mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 23:41:48 +01:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d78ca6352 | ||
|
|
b82ad80d1c | ||
|
|
eece2ce7dc | ||
|
|
3bfd7dd7ab | ||
|
|
b0f3dffc3c | ||
|
|
f5234d0c11 | ||
|
|
8a12ccb01e | ||
|
|
fac2e86240 | ||
|
|
39b49bb9de | ||
|
|
406ccfa2ce | ||
|
|
16e6b96cc7 | ||
|
|
1e37c8a742 | ||
|
|
5f0389b36f | ||
|
|
8762d85d57 | ||
|
|
018bbd43f1 | ||
|
|
756e3fc556 | ||
|
|
7ac5f5dbcb | ||
|
|
65c613fd24 | ||
|
|
e0ad9007a4 | ||
|
|
c3cda5f547 | ||
|
|
12f379c03d | ||
|
|
e760c34ede | ||
|
|
187a15a55d | ||
|
|
3e4751a6b5 | ||
|
|
b05d87196a | ||
|
|
b8297848f6 | ||
|
|
de0f004a48 | ||
|
|
0bc9c1d4d2 | ||
|
|
3fccb59544 | ||
|
|
ea76aabf91 | ||
|
|
dc1c900bd2 | ||
|
|
b1e59d8f2a | ||
|
|
006148dbd0 | ||
|
|
974ba258f5 | ||
|
|
29d833e9d1 | ||
|
|
eea72405bb | ||
|
|
e500f1ed0b | ||
|
|
6c3bd2e6a1 | ||
|
|
e54d8968e8 | ||
|
|
9dca2e989a | ||
|
|
610c5d6ef5 | ||
|
|
7797b34852 | ||
|
|
99e9f326f7 | ||
|
|
0f2d73239b | ||
|
|
497b489ea9 | ||
|
|
51581f106a | ||
|
|
43e9aa02e0 | ||
|
|
089a41cc2b | ||
|
|
279ab101cc | ||
|
|
c0f278652e | ||
|
|
ac02cc78bc | ||
|
|
d42ac3af6b | ||
|
|
20cfe76091 | ||
|
|
b68170b489 | ||
|
|
21273fa9ca | ||
|
|
fe9dd29964 | ||
|
|
eece2bcc0f | ||
|
|
f19c6b8dd0 | ||
|
|
44599b2d33 | ||
|
|
613f75fd25 | ||
|
|
6f68e6cb62 | ||
|
|
43ac412efd | ||
|
|
7149c98564 | ||
|
|
0f4e46a758 | ||
|
|
23fb927cd6 | ||
|
|
d055a260ab | ||
|
|
d55fa5f7c0 | ||
|
|
f4fd658c36 | ||
|
|
0e80d5612c | ||
|
|
9302ebc667 | ||
|
|
2265866f58 | ||
|
|
8ec6ee5ef0 | ||
|
|
a03d250bdb | ||
|
|
a0ebac41ea |
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -119,6 +119,7 @@ jobs:
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: macos
|
||||
args: "--dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
|
||||
|
||||
- name: Decode Keystore
|
||||
if: inputs.build_android
|
||||
@@ -168,7 +169,7 @@ jobs:
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: ios
|
||||
args: "--export-options-plist ios/ExportOptions.plist"
|
||||
args: "--export-options-plist ios/ExportOptions.plist --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
|
||||
|
||||
- name: Prepare App Store authentication key
|
||||
if: inputs.build_ios || inputs.build_mac
|
||||
@@ -186,7 +187,7 @@ jobs:
|
||||
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
packageName: de.jonasbark.swiftcontrol
|
||||
releaseFiles: build/app/outputs/bundle/release/app-release.aab
|
||||
track: production
|
||||
track: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }}
|
||||
whatsNewDirectory: whatsnew
|
||||
|
||||
- name: Upload to macOS App Store
|
||||
@@ -334,7 +335,9 @@ jobs:
|
||||
run: msstore reconfigure --tenantId $ --clientId $ --clientSecret $ --sellerId $
|
||||
|
||||
- name: Create MSIX package
|
||||
run: dart run msix:create
|
||||
run: |
|
||||
$version = "${{ env.VERSION }}" -replace '\+', '.'
|
||||
dart run msix:create --version $version
|
||||
|
||||
- name: Publish MSIX to the Microsoft Store
|
||||
if: false
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,3 +52,4 @@ lib/gen/
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
lib/generated
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,3 +1,17 @@
|
||||
### 4.2.0 (20-12-2025)
|
||||
|
||||
BikeControl now offers a free trial period of 5 days for all features, so you can test everything before deciding to purchase a license. Please contact the support if you experience any issues!
|
||||
|
||||
**Features**:
|
||||
- support for SRAM AXS/eTap
|
||||
- only single or double click is supported (no individual button mapping possible, yet)
|
||||
- use your phone/tablet for steering by attaching your device on your handlebar!
|
||||
- App is now available in Polish (thanks to Wandrocet)
|
||||
|
||||
**Fixes**:
|
||||
- You will now be notified when a connection to your controller is lost
|
||||
- improved UI of the Keymap customization screen
|
||||
|
||||
### 4.1.0 (16-12-2025)
|
||||
|
||||
**Features**:
|
||||
|
||||
15
README.md
15
README.md
@@ -6,7 +6,7 @@
|
||||
|
||||
With BikeControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, Shimano Di2, or other similar devices. Here's what you can do with it, depending on your configuration:
|
||||
- Virtual Gear shifting
|
||||
- Steering/turning
|
||||
- Steering / navigation
|
||||
- adjust workout intensity
|
||||
- control music on your device
|
||||
- more? If you can do it via keyboard, mouse, or touch, you can do it with BikeControl
|
||||
@@ -17,8 +17,8 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
|
||||
|
||||
## Downloads
|
||||
Best follow our landing page and the "Get Started" button: [bikecontrol.app](https://bikecontrol.app/) to understand on which platform you want to run BikeControl.
|
||||
## Download
|
||||
Best follow our landing page and the "Get Started" button: [bikecontrol.app](https://bikecontrol.app/) to understand on which platform you want to run BikeControl. A testing period is available, allowing you to try out the full functionality of BikeControl:
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
|
||||
|
||||
@@ -45,13 +45,19 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
- Zwift Play
|
||||
- Shimano Di2
|
||||
- Configure your levers to use D-Fly channels with Shimano E-Tube app
|
||||
- SRAM AXS/eTap
|
||||
- only single or double click is supported (no individual button mapping possible, yet)
|
||||
- Wahoo Kickr Bike Shift
|
||||
- Wahoo Kickr Bike Pro
|
||||
- CYCPLUS BC2 Virtual Shifter
|
||||
- Elite Sterzo Smart (for steering support)
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Your Phone!
|
||||
- Mount your phone on the handlebar to detect e.g. steering
|
||||
- Available on Android and iOS
|
||||
- Gamepads
|
||||
- Keyboard input
|
||||
- like a Companion App
|
||||
- some trainers do not support keyboard input for all functions - now they do!
|
||||
- useful when remapping keys from other devices using e.g. AutoHotkey
|
||||
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
|
||||
@@ -89,9 +95,6 @@ The app connects to your Controller devices (such as Zwift ones) automatically.
|
||||
- Connect to supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
|
||||
- available on Android, iOS, macOS, Windows
|
||||
|
||||
## Alternatives
|
||||
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app). This can be useful if your trainer app does not support virtual shifting.
|
||||
|
||||
## Donate
|
||||
Please consider donating to support the development of this app :)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
|
||||
<uses-permission android:name="android.permission.BILLING"/>
|
||||
|
||||
<!-- legacy for Android 9 or lower -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" tools:replace="android:maxSdkVersion" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
|
||||
platform :ios, '15.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -7,14 +7,22 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- gamepads_ios (0.1.1):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- in_app_purchase_storekit (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- in_app_review (2.0.0):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- ios_receipt (0.0.1):
|
||||
- Flutter
|
||||
- media_key_detector_ios (0.0.1):
|
||||
- Flutter
|
||||
- nsd_ios (0.0.1):
|
||||
@@ -28,6 +36,8 @@ PODS:
|
||||
- Flutter
|
||||
- restart_app (0.0.1):
|
||||
- Flutter
|
||||
- sensors_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -44,16 +54,20 @@ DEPENDENCIES:
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- ios_receipt (from `.symlinks/plugins/ios_receipt/ios`)
|
||||
- media_key_detector_ios (from `.symlinks/plugins/media_key_detector_ios/ios`)
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- restart_app (from `.symlinks/plugins/restart_app/ios`)
|
||||
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
@@ -68,14 +82,20 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_secure_storage_darwin:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||
gamepads_ios:
|
||||
:path: ".symlinks/plugins/gamepads_ios/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
in_app_purchase_storekit:
|
||||
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
ios_receipt:
|
||||
:path: ".symlinks/plugins/ios_receipt/ios"
|
||||
media_key_detector_ios:
|
||||
:path: ".symlinks/plugins/media_key_detector_ios/ios"
|
||||
nsd_ios:
|
||||
@@ -88,6 +108,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
restart_app:
|
||||
:path: ".symlinks/plugins/restart_app/ios"
|
||||
sensors_plus:
|
||||
:path: ".symlinks/plugins/sensors_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
universal_ble:
|
||||
@@ -102,21 +124,25 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d
|
||||
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
|
||||
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
ios_receipt: c2d5b4c36953c377a024992393976214ce6951e6
|
||||
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
|
||||
nsd_ios: 8c37babdc6538e3350dbed3a52674d2edde98173
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
|
||||
sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
PODFILE CHECKSUM: 7ebd5c9b932b3af79d5c67e3af873118b74e970f
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DFFDC4B9C4D6EF6A3BDE2E73 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
F0D040E82EEF2560009B19C0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -152,6 +153,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F0D040E82EEF2560009B19C0 /* Runner.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
@@ -487,12 +489,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -673,12 +677,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -699,12 +705,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -8,6 +8,8 @@ import UIKit
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<string>BikeControl uses Bluetooth to connect to accessories.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app connects to your trainer app on your local network.</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>Access your accelerometer and gyroscope for steering support via your phone.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_wahoo-fitness-tnp._tcp</string>
|
||||
|
||||
8
ios/Runner/Runner.entitlements
Normal file
8
ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
2
ios_receipt/.gitattributes
vendored
Normal file
2
ios_receipt/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
30
ios_receipt/.gitignore
vendored
Normal file
30
ios_receipt/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
30
ios_receipt/.metadata
Normal file
30
ios_receipt/.metadata
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
- platform: ios
|
||||
create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
7
ios_receipt/CHANGELOG.md
Normal file
7
ios_receipt/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## 1.1.0
|
||||
|
||||
* Migrate to StoreKit2
|
||||
|
||||
## 1.0.0
|
||||
|
||||
* Implement main method
|
||||
21
ios_receipt/LICENSE
Normal file
21
ios_receipt/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Dmytro O. Kut'ko
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
32
ios_receipt/README.md
Normal file
32
ios_receipt/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# IosReceipt
|
||||
|
||||
The IosReceipt package allows you to easily fetch the App Store receipt in your Flutter application on the iOS platform.
|
||||
|
||||
## Installation
|
||||
|
||||
Add the following dependency to your `pubspec.yaml` file:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
ios_receipt: ^0.0.1 # Use the latest version of the package
|
||||
```
|
||||
Then, run flutter pub get to install the package.
|
||||
|
||||
## Usage
|
||||
Method for get transactions with StoreKit2
|
||||
```dart
|
||||
final transactions = await IosReceipt.getAllTransactions();
|
||||
```
|
||||
|
||||
## Based on
|
||||
This package is based on the [appStoreReceiptURL](https://developer.apple.com/documentation/foundation/nsbundle/1407276-appstorereceipturl) from the official Apple documentation.
|
||||
|
||||
## Note
|
||||
- The receipt isn't necessary if you use AppTransaction to validate the app download, or Transaction to validate in-app purchases.
|
||||
- If the receipt is invalid or missing in your app, use SKReceiptRefreshRequest to request a new receipt.
|
||||
|
||||
## Testing Environments
|
||||
Keep in mind that receipts aren't initially present in iOS and iPadOS apps in the sandbox environment and in Xcode. Apps receive a receipt after the tester completes the first in-app purchase.
|
||||
|
||||
## License
|
||||
This project is licensed under the MIT License.
|
||||
4
ios_receipt/analysis_options.yaml
Normal file
4
ios_receipt/analysis_options.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
38
ios_receipt/ios/.gitignore
vendored
Normal file
38
ios_receipt/ios/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
.idea/
|
||||
.vagrant/
|
||||
.sconsign.dblite
|
||||
.svn/
|
||||
|
||||
.DS_Store
|
||||
*.swp
|
||||
profile
|
||||
|
||||
DerivedData/
|
||||
build/
|
||||
GeneratedPluginRegistrant.h
|
||||
GeneratedPluginRegistrant.m
|
||||
|
||||
.generated/
|
||||
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
|
||||
!default.pbxuser
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.perspectivev3
|
||||
|
||||
xcuserdata
|
||||
|
||||
*.moved-aside
|
||||
|
||||
*.pyc
|
||||
*sync/
|
||||
Icon?
|
||||
.tags*
|
||||
|
||||
/Flutter/Generated.xcconfig
|
||||
/Flutter/ephemeral/
|
||||
/Flutter/flutter_export_environment.sh
|
||||
0
ios_receipt/ios/Assets/.gitkeep
Normal file
0
ios_receipt/ios/Assets/.gitkeep
Normal file
73
ios_receipt/ios/Classes/IosReceiptPlugin.swift
Normal file
73
ios_receipt/ios/Classes/IosReceiptPlugin.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import StoreKit
|
||||
|
||||
public class IosReceiptPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
private func getAppleReceipt() -> String? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
let data = try? Data(contentsOf: url, options: .alwaysMapped)
|
||||
return data?.base64EncodedString()
|
||||
}
|
||||
|
||||
private func isSandbox() -> Bool {
|
||||
guard let path = Bundle.main.appStoreReceiptURL?.path else {
|
||||
return false
|
||||
}
|
||||
return path.contains("CoreSimulator") || path.contains("sandboxReceipt")
|
||||
}
|
||||
|
||||
private func getAllTransactions() async -> [[String: Any]] {
|
||||
var list: [[String: Any]] = []
|
||||
if #available(iOS 15.0, *) {
|
||||
for await result in Transaction.all {
|
||||
switch result {
|
||||
case .verified(let tx):
|
||||
var item: [String: Any] = [
|
||||
"productId": tx.productID,
|
||||
"transactionId": String(tx.id),
|
||||
"originalTransactionId": String(tx.originalID),
|
||||
"purchaseDate": ISO8601DateFormatter().string(from: tx.purchaseDate)
|
||||
]
|
||||
if let revocationDate = tx.revocationDate {
|
||||
item["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate)
|
||||
}
|
||||
if let reason = tx.revocationReason {
|
||||
item["revocationReason"] = reason.rawValue
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
item["jws"] = result.jwsRepresentation
|
||||
}
|
||||
list.append(item)
|
||||
|
||||
case .unverified(_, _):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "ios_receipt", binaryMessenger: registrar.messenger())
|
||||
let instance = IosReceiptPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
|
||||
case "getAppleReceipt":
|
||||
result(getAppleReceipt())
|
||||
case "isSandbox":
|
||||
result(isSandbox())
|
||||
case "getAllTransactions":
|
||||
Task { result(await self.getAllTransactions()) }
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ios_receipt/ios/ios_receipt.podspec
Normal file
23
ios_receipt/ios/ios_receipt.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint ios_receipt.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ios_receipt'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '15.0'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
end
|
||||
22
ios_receipt/lib/ios_receipt.dart
Normal file
22
ios_receipt/lib/ios_receipt.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:ios_receipt/models/transaction.dart';
|
||||
|
||||
import 'ios_receipt_platform_interface.dart';
|
||||
|
||||
class IosReceipt {
|
||||
static Future<String?> getAppleReceipt() {
|
||||
return IosReceiptPlatform.instance.getAppleReceipt();
|
||||
}
|
||||
|
||||
static Future<bool> isSandbox() {
|
||||
return IosReceiptPlatform.instance.isSandbox();
|
||||
}
|
||||
|
||||
static Future<List<Transaction>> getAllTransactions() async {
|
||||
final list = await IosReceiptPlatform.instance.getAllTransactions();
|
||||
final result = <Transaction>[];
|
||||
for (var data in list) {
|
||||
result.add(Transaction.fromMap(data));
|
||||
}
|
||||
return result..sort((a, b) => a.purchaseDate.compareTo(b.purchaseDate));
|
||||
}
|
||||
}
|
||||
29
ios_receipt/lib/ios_receipt_method_channel.dart
Normal file
29
ios_receipt/lib/ios_receipt_method_channel.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'ios_receipt_platform_interface.dart';
|
||||
|
||||
/// An implementation of [IosReceiptPlatform] that uses method channels.
|
||||
class MethodChannelIosReceipt extends IosReceiptPlatform {
|
||||
/// The method channel used to interact with the native platform.
|
||||
@visibleForTesting
|
||||
final methodChannel = const MethodChannel('ios_receipt');
|
||||
|
||||
@override
|
||||
Future<String?> getAppleReceipt() async {
|
||||
final version = await methodChannel.invokeMethod<String>('getAppleReceipt');
|
||||
return version;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> getAllTransactions() async {
|
||||
final list = await methodChannel.invokeMethod('getAllTransactions');
|
||||
return (list as List).map((e) => Map<String, dynamic>.from(e)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isSandbox() async {
|
||||
final isSandbox = await methodChannel.invokeMethod<bool>('isSandbox');
|
||||
return isSandbox ?? false;
|
||||
}
|
||||
}
|
||||
37
ios_receipt/lib/ios_receipt_platform_interface.dart
Normal file
37
ios_receipt/lib/ios_receipt_platform_interface.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import 'ios_receipt_method_channel.dart';
|
||||
|
||||
abstract class IosReceiptPlatform extends PlatformInterface {
|
||||
/// Constructs a IosReceiptPlatform.
|
||||
IosReceiptPlatform() : super(token: _token);
|
||||
|
||||
static final Object _token = Object();
|
||||
|
||||
static IosReceiptPlatform _instance = MethodChannelIosReceipt();
|
||||
|
||||
/// The default instance of [IosReceiptPlatform] to use.
|
||||
///
|
||||
/// Defaults to [MethodChannelIosReceipt].
|
||||
static IosReceiptPlatform get instance => _instance;
|
||||
|
||||
/// Platform-specific implementations should set this with their own
|
||||
/// platform-specific class that extends [IosReceiptPlatform] when
|
||||
/// they register themselves.
|
||||
static set instance(IosReceiptPlatform instance) {
|
||||
PlatformInterface.verifyToken(instance, _token);
|
||||
_instance = instance;
|
||||
}
|
||||
|
||||
Future<String?> getAppleReceipt() {
|
||||
throw UnimplementedError('platformVersion() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAllTransactions() {
|
||||
throw UnimplementedError('getAllTransactions() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<bool> isSandbox() async {
|
||||
throw UnimplementedError('isSandbox() has not been implemented.');
|
||||
}
|
||||
}
|
||||
41
ios_receipt/lib/models/transaction.dart
Normal file
41
ios_receipt/lib/models/transaction.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
class Transaction {
|
||||
const Transaction({
|
||||
required this.jws,
|
||||
required this.productId,
|
||||
required this.transactionId,
|
||||
required this.purchaseDate,
|
||||
required this.originalTransactionId,
|
||||
});
|
||||
|
||||
final String? jws;
|
||||
final String productId;
|
||||
final String transactionId;
|
||||
final DateTime purchaseDate;
|
||||
final String originalTransactionId;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Transaction other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other.jws == jws &&
|
||||
other.productId == productId &&
|
||||
other.transactionId == transactionId &&
|
||||
other.purchaseDate == purchaseDate &&
|
||||
other.originalTransactionId == originalTransactionId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
jws.hashCode ^
|
||||
productId.hashCode ^
|
||||
transactionId.hashCode ^
|
||||
purchaseDate.hashCode ^
|
||||
originalTransactionId.hashCode;
|
||||
|
||||
factory Transaction.fromMap(Map<String, dynamic> map) => Transaction(
|
||||
jws: map['jws'] as String?,
|
||||
productId: map['productId'] as String,
|
||||
transactionId: map['transactionId'] as String,
|
||||
purchaseDate: DateTime.parse(map['purchaseDate'] as String),
|
||||
originalTransactionId: map['originalTransactionId'] as String,
|
||||
);
|
||||
}
|
||||
38
ios_receipt/macos/.gitignore
vendored
Normal file
38
ios_receipt/macos/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
.idea/
|
||||
.vagrant/
|
||||
.sconsign.dblite
|
||||
.svn/
|
||||
|
||||
.DS_Store
|
||||
*.swp
|
||||
profile
|
||||
|
||||
DerivedData/
|
||||
build/
|
||||
GeneratedPluginRegistrant.h
|
||||
GeneratedPluginRegistrant.m
|
||||
|
||||
.generated/
|
||||
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
|
||||
!default.pbxuser
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.perspectivev3
|
||||
|
||||
xcuserdata
|
||||
|
||||
*.moved-aside
|
||||
|
||||
*.pyc
|
||||
*sync/
|
||||
Icon?
|
||||
.tags*
|
||||
|
||||
/Flutter/Generated.xcconfig
|
||||
/Flutter/ephemeral/
|
||||
/Flutter/flutter_export_environment.sh
|
||||
0
ios_receipt/macos/Assets/.gitkeep
Normal file
0
ios_receipt/macos/Assets/.gitkeep
Normal file
73
ios_receipt/macos/Classes/IosReceiptPlugin.swift
Normal file
73
ios_receipt/macos/Classes/IosReceiptPlugin.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
|
||||
import FlutterMacOS
|
||||
import StoreKit
|
||||
|
||||
public class IosReceiptPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
private func isSandbox() -> Bool {
|
||||
guard let path = Bundle.main.appStoreReceiptURL?.path else {
|
||||
return false
|
||||
}
|
||||
return path.contains("CoreSimulator") || path.contains("sandboxReceipt")
|
||||
}
|
||||
|
||||
private func getAppleReceipt() -> String? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
let data = try? Data(contentsOf: url, options: .alwaysMapped)
|
||||
return data?.base64EncodedString()
|
||||
}
|
||||
|
||||
private func getAllTransactions() async -> [[String: Any]] {
|
||||
var list: [[String: Any]] = []
|
||||
if #available(iOS 15.0, *) {
|
||||
for await result in Transaction.all {
|
||||
switch result {
|
||||
case .verified(let tx):
|
||||
var item: [String: Any] = [
|
||||
"productId": tx.productID,
|
||||
"transactionId": String(tx.id),
|
||||
"originalTransactionId": String(tx.originalID),
|
||||
"purchaseDate": ISO8601DateFormatter().string(from: tx.purchaseDate)
|
||||
]
|
||||
if let revocationDate = tx.revocationDate {
|
||||
item["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate)
|
||||
}
|
||||
if let reason = tx.revocationReason {
|
||||
item["revocationReason"] = reason.rawValue
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
item["jws"] = result.jwsRepresentation
|
||||
}
|
||||
list.append(item)
|
||||
|
||||
case .unverified(_, _):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "ios_receipt", binaryMessenger: registrar.messenger)
|
||||
let instance = IosReceiptPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
|
||||
case "getAppleReceipt":
|
||||
result(getAppleReceipt())
|
||||
case "isSandbox":
|
||||
result(isSandbox())
|
||||
case "getAllTransactions":
|
||||
Task { result(await self.getAllTransactions()) }
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ios_receipt/macos/ios_receipt.podspec
Normal file
23
ios_receipt/macos/ios_receipt.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint ios_receipt.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ios_receipt'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'FlutterMacOS'
|
||||
s.platform = :osx, '12.00'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
end
|
||||
26
ios_receipt/pubspec.yaml
Normal file
26
ios_receipt/pubspec.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: ios_receipt
|
||||
description: The IosReceipt package allows you to easily fetch the App Store receipt in your Flutter application on the iOS platform.
|
||||
version: 1.1.0
|
||||
homepage: https://github.com/DimaKutko/ios_receipt
|
||||
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.32.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
plugin_platform_interface: ^2.1.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
ios:
|
||||
pluginClass: IosReceiptPlugin
|
||||
macos:
|
||||
pluginClass: IosReceiptPlugin
|
||||
@@ -1,22 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gamepad/gamepad_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import 'devices/base_device.dart';
|
||||
@@ -28,10 +28,12 @@ class Connection {
|
||||
|
||||
List<BluetoothDevice> get bluetoothDevices => devices.whereType<BluetoothDevice>().toList();
|
||||
List<GamepadDevice> get gamepadDevices => devices.whereType<GamepadDevice>().toList();
|
||||
List<GyroscopeSteering> get gyroscopeDevices => devices.whereType<GyroscopeSteering>().toList();
|
||||
List<WahooKickrHeadwind> get accessories => devices.whereType<WahooKickrHeadwind>().toList();
|
||||
List<BaseDevice> get controllerDevices => [
|
||||
...bluetoothDevices.where((d) => d is! WahooKickrHeadwind),
|
||||
...gamepadDevices,
|
||||
...gyroscopeDevices,
|
||||
...devices.whereType<HidDevice>(),
|
||||
];
|
||||
|
||||
@@ -87,7 +89,7 @@ class Connection {
|
||||
_lastScanResult.add(result);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Scan result: ${result.name} - ${result.deviceId}');
|
||||
debugPrint('Scan result: ${result.name} - ${result.deviceId}');
|
||||
}
|
||||
|
||||
final scanResult = BluetoothDevice.fromScanResult(result);
|
||||
@@ -154,6 +156,9 @@ class Connection {
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
if (core.settings.getPhoneSteeringEnabled()) {
|
||||
toggleGyroscopeSteering(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +169,10 @@ class Connection {
|
||||
isScanning.value = true;
|
||||
_actionStreams.add(LogNotification('Scanning for devices...'));
|
||||
|
||||
if (screenshotMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// does not work on web, may not work on Windows
|
||||
if (!kIsWeb && !Platform.isWindows) {
|
||||
UniversalBle.getSystemDevices(
|
||||
@@ -206,7 +215,7 @@ class Connection {
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
// start foreground service only when app is in foreground
|
||||
NotificationRequirement.setup().catchError((e) {
|
||||
NotificationRequirement.addPersistentNotification().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
@@ -248,6 +257,18 @@ class Connection {
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
}
|
||||
|
||||
void toggleGyroscopeSteering(bool enable) {
|
||||
final existing = gyroscopeDevices.firstOrNull;
|
||||
if (existing != null && !enable) {
|
||||
// Remove gyroscope steering
|
||||
disconnect(existing, forget: true, persistForget: false);
|
||||
} else if (enable) {
|
||||
// Add gyroscope steering
|
||||
final gyroDevice = GyroscopeSteering();
|
||||
addDevices([gyroDevice]);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnectionQueue() {
|
||||
// windows apparently has issues when connecting to multiple devices at once, so don't
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue && !screenshotMode) {
|
||||
@@ -287,9 +308,18 @@ class Connection {
|
||||
_actionStreams.add(data);
|
||||
});
|
||||
if (device is BluetoothDevice) {
|
||||
final connectionStateSubscription = UniversalBle.connectionStream(device.device.deviceId).listen((state) {
|
||||
final connectionStateSubscription = device.device.connectionStream.listen((state) {
|
||||
device.isConnected = state;
|
||||
_connectionStreams.add(device);
|
||||
core.flutterLocalNotificationsPlugin.show(
|
||||
1338,
|
||||
'${device.name} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}',
|
||||
!state ? AppLocalizations.current.tryingToConnectAgain : null,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails('Connection', 'Connection Status'),
|
||||
iOS: DarwinNotificationDetails(presentAlert: true),
|
||||
),
|
||||
);
|
||||
if (!device.isConnected) {
|
||||
disconnect(device, forget: false, persistForget: false);
|
||||
// try reconnect
|
||||
@@ -302,20 +332,7 @@ class Connection {
|
||||
await device.connect();
|
||||
signalChange(device);
|
||||
|
||||
final newButtons = device.availableButtons.filter(
|
||||
(button) => core.actionHandler.supportedApp?.keymap.getKeyPair(button) == null,
|
||||
);
|
||||
for (final button in newButtons) {
|
||||
core.actionHandler.supportedApp?.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
core.actionHandler.supportedApp?.keymap.addNewButtons(device.availableButtons);
|
||||
|
||||
_streamSubscriptions[device] = actionSubscription;
|
||||
} catch (e, backtrace) {
|
||||
@@ -383,6 +400,16 @@ class Connection {
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
|
||||
// Remove device from the list
|
||||
devices.remove(device);
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
} else if (device is GyroscopeSteering) {
|
||||
// Clean up subscriptions
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
|
||||
// Remove device from the list
|
||||
devices.remove(device);
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart' show LogLevel;
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
@@ -47,9 +51,26 @@ abstract class BaseDevice {
|
||||
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> handleButtonsClickedWithoutLongPressSupport(List<ControllerButton> clickedButtons) async {
|
||||
await handleButtonsClicked(clickedButtons, longPress: true);
|
||||
if (clickedButtons.length == 1) {
|
||||
final keyPair = core.actionHandler.supportedApp?.keymap.getKeyPair(clickedButtons.single);
|
||||
if (keyPair != null && (keyPair.isLongPress || keyPair.inGameAction?.isLongPress == true)) {
|
||||
// simulate release after click
|
||||
_longPressTimer?.cancel();
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await handleButtonsClicked([], longPress: true);
|
||||
} else {
|
||||
await handleButtonsClicked([], longPress: true);
|
||||
}
|
||||
} else {
|
||||
await handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked, {bool longPress = false}) async {
|
||||
try {
|
||||
await _handleButtonsClickedInternal(buttonsClicked);
|
||||
await _handleButtonsClickedInternal(buttonsClicked, longPress: longPress);
|
||||
} catch (e, st) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification('Error handling button clicks: $e\n$st'),
|
||||
@@ -57,7 +78,7 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked, {required bool longPress}) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
@@ -67,8 +88,9 @@ abstract class BaseDevice {
|
||||
// Handle release events for long press keys
|
||||
final buttonsReleased = _previouslyPressedButtons.toList();
|
||||
final isLongPress =
|
||||
longPress ||
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && isLongPress) {
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
@@ -79,15 +101,17 @@ abstract class BaseDevice {
|
||||
// Handle release events for buttons that are no longer pressed
|
||||
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
|
||||
final wasLongPress =
|
||||
longPress ||
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && wasLongPress) {
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
|
||||
final isLongPress =
|
||||
longPress ||
|
||||
buttonsClicked.singleOrNull != null &&
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
|
||||
if (!isLongPress &&
|
||||
!(buttonsClicked.singleOrNull == ZwiftButtons.onOffLeft ||
|
||||
@@ -109,16 +133,46 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
String _getCommandLimitMessage() {
|
||||
return AppLocalizations.current.dailyCommandLimitReachedNotification;
|
||||
}
|
||||
|
||||
String _getCommandLimitTitle() {
|
||||
return AppLocalizations.current
|
||||
.dailyLimitReached(IAPManager.dailyCommandLimit, IAPManager.dailyCommandLimit)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit} / ${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
//actionStreamInternal.add(AlertNotification(LogLevel.LOGLEVEL_ERROR, _getCommandLimitMessage()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: false);
|
||||
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
_showCommandLimitAlert();
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: true);
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
}
|
||||
@@ -126,6 +180,12 @@ abstract class BaseDevice {
|
||||
|
||||
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
_showCommandLimitAlert();
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: false, isKeyUp: true);
|
||||
actionStreamInternal.add(LogNotification(result.message));
|
||||
}
|
||||
@@ -144,6 +204,9 @@ abstract class BaseDevice {
|
||||
Widget showInformation(BuildContext context);
|
||||
|
||||
ControllerButton getOrAddButton(String key, ControllerButton Function() creator) {
|
||||
if (core.actionHandler.supportedApp == null) {
|
||||
return creator();
|
||||
}
|
||||
if (core.actionHandler.supportedApp is! CustomApp) {
|
||||
final currentProfile = core.actionHandler.supportedApp!.name;
|
||||
// should we display this to the user?
|
||||
@@ -157,4 +220,26 @@ abstract class BaseDevice {
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
void _showCommandLimitAlert() {
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_ERROR,
|
||||
_getCommandLimitMessage(),
|
||||
buttonTitle: AppLocalizations.current.purchase,
|
||||
onTap: () {
|
||||
IAPManager.instance.purchaseFullVersion();
|
||||
},
|
||||
),
|
||||
);
|
||||
core.flutterLocalNotificationsPlugin.show(
|
||||
1337,
|
||||
_getCommandLimitTitle(),
|
||||
_getCommandLimitMessage(),
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails('Limit', 'Limit reached'),
|
||||
iOS: DarwinNotificationDetails(presentAlert: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/sram/sram_axs.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
@@ -13,11 +14,11 @@ import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/device_info.dart';
|
||||
import 'package:bike_control/widgets/ui/loading_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
@@ -72,6 +73,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SRAM') => SramAxs(scanResult),
|
||||
_ => null,
|
||||
};
|
||||
} else {
|
||||
@@ -93,6 +95,9 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE.toLowerCase()) => ShimanoDi2(
|
||||
scanResult,
|
||||
),
|
||||
_ when scanResult.services.contains(SramAxsConstants.SERVICE_UUID.toLowerCase()) => SramAxs(
|
||||
scanResult,
|
||||
),
|
||||
_ when scanResult.services.contains(OpenBikeControlConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
OpenBikeControlDevice(scanResult),
|
||||
_ when scanResult.services.contains(WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
@@ -273,113 +278,72 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Basic(
|
||||
title: Text(context.i18n.connection).xSmall,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: Icon(switch (isConnected) {
|
||||
true => Icons.bluetooth_connected_outlined,
|
||||
false => Icons.bluetooth_disabled_outlined,
|
||||
}),
|
||||
subtitle: Text(
|
||||
isConnected ? context.i18n.connected : context.i18n.disconnected,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
DeviceInfo(
|
||||
title: context.i18n.connection,
|
||||
icon: switch (isConnected) {
|
||||
true => Icons.bluetooth_connected_outlined,
|
||||
false => Icons.bluetooth_disabled_outlined,
|
||||
},
|
||||
value: isConnected ? context.i18n.connected : context.i18n.disconnected,
|
||||
),
|
||||
|
||||
if (batteryLevel != null)
|
||||
SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Basic(
|
||||
title: Text(context.i18n.battery).xSmall,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: Icon(switch (batteryLevel!) {
|
||||
>= 80 => Icons.battery_full,
|
||||
>= 60 => Icons.battery_6_bar,
|
||||
>= 50 => Icons.battery_5_bar,
|
||||
>= 25 => Icons.battery_4_bar,
|
||||
>= 10 => Icons.battery_2_bar,
|
||||
_ => Icons.battery_alert,
|
||||
}),
|
||||
subtitle: Text(
|
||||
'$batteryLevel%',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
DeviceInfo(
|
||||
title: context.i18n.battery,
|
||||
icon: switch (batteryLevel!) {
|
||||
>= 80 => Icons.battery_full,
|
||||
>= 60 => Icons.battery_6_bar,
|
||||
>= 50 => Icons.battery_5_bar,
|
||||
>= 25 => Icons.battery_4_bar,
|
||||
>= 10 => Icons.battery_2_bar,
|
||||
_ => Icons.battery_alert,
|
||||
},
|
||||
value: '$batteryLevel%',
|
||||
),
|
||||
if (firmwareVersion != null)
|
||||
SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
padding: EdgeInsets.all(12),
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
child: Basic(
|
||||
title: Text(context.i18n.firmware).xSmall,
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$firmwareVersion',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
if (this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion)
|
||||
Text(
|
||||
' (${context.i18n.latestVersion((this as ZwiftDevice).latestFirmwareVersion)})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.destructive, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion
|
||||
? Icon(Icons.warning, color: Theme.of(context).colorScheme.destructive)
|
||||
: Icon(Icons.text_fields_sharp),
|
||||
),
|
||||
),
|
||||
DeviceInfo(
|
||||
title: context.i18n.signal,
|
||||
icon: this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion
|
||||
? Icons.warning
|
||||
: Icons.text_fields_sharp,
|
||||
value: firmwareVersion!,
|
||||
additionalInfo: (this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion)
|
||||
? Text(
|
||||
' (${context.i18n.latestVersion((this as ZwiftDevice).latestFirmwareVersion)})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.destructive, fontSize: 12),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
if (rssi != null)
|
||||
SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
padding: EdgeInsets.all(12),
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
child: Basic(
|
||||
title: Text(context.i18n.signal).xSmall,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: Icon(
|
||||
switch (rssi!) {
|
||||
>= -50 => Icons.signal_cellular_4_bar,
|
||||
>= -60 => Icons.signal_cellular_alt_2_bar,
|
||||
>= -70 => Icons.signal_cellular_alt_1_bar,
|
||||
_ => Icons.signal_cellular_alt,
|
||||
},
|
||||
size: 18,
|
||||
),
|
||||
subtitle: Text(
|
||||
'$rssi dBm',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
DeviceInfo(
|
||||
title: context.i18n.signal,
|
||||
icon: switch (rssi!) {
|
||||
>= -50 => Icons.signal_cellular_4_bar,
|
||||
>= -60 => Icons.signal_cellular_alt_2_bar,
|
||||
>= -70 => Icons.signal_cellular_alt_1_bar,
|
||||
_ => Icons.signal_cellular_alt,
|
||||
},
|
||||
value: '$rssi dBm',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void debugSubscribeToAll(List<BleService> services) {
|
||||
for (final service in services) {
|
||||
for (final characteristic in service.characteristics) {
|
||||
if (characteristic.properties.contains(CharacteristicProperty.indicate)) {
|
||||
debugPrint('Subscribing to indications for ${service.uuid} / ${characteristic.uuid}');
|
||||
UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
if (characteristic.properties.contains(CharacteristicProperty.notify)) {
|
||||
debugPrint('Subscribing to notifications for ${service.uuid} / ${characteristic.uuid}');
|
||||
UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
315
lib/bluetooth/devices/gyroscope/gyroscope_steering.dart
Normal file
315
lib/bluetooth/devices/gyroscope/gyroscope_steering.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/steering_estimator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/device_info.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
/// Gyroscope and Accelerometer based steering device
|
||||
/// Detects handlebar movement when the phone is mounted on the handlebar
|
||||
class GyroscopeSteering extends BaseDevice {
|
||||
GyroscopeSteering()
|
||||
: super(
|
||||
'Phone Steering',
|
||||
availableButtons: GyroscopeSteeringButtons.values,
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
StreamSubscription<GyroscopeEvent>? _gyroscopeSubscription;
|
||||
StreamSubscription<AccelerometerEvent>? _accelerometerSubscription;
|
||||
|
||||
// Calibration state
|
||||
final SteeringEstimator _estimator = SteeringEstimator();
|
||||
bool _isCalibrated = false;
|
||||
ControllerButton? _lastSteeringButton;
|
||||
|
||||
// Accelerometer raw data
|
||||
bool _hasAccelData = false;
|
||||
|
||||
// Time tracking for integration
|
||||
DateTime? _lastGyroUpdate;
|
||||
|
||||
// Last rounded angle for change detection
|
||||
int? _lastRoundedAngle;
|
||||
|
||||
// Debounce timer for PWM-like keypress behavior
|
||||
Timer? _keypressTimer;
|
||||
bool _isProcessingKeypresses = false;
|
||||
|
||||
// Configuration (can be made customizable later)
|
||||
static const double STEERING_THRESHOLD = 5.0; // degrees
|
||||
static const double LEVEL_DEGREE_STEP = 10.0; // degrees per level
|
||||
static const int MAX_LEVELS = 5;
|
||||
static const int KEY_REPEAT_INTERVAL_MS = 40;
|
||||
static const double COMPLEMENTARY_FILTER_ALPHA = 0.98; // Weight for gyroscope
|
||||
static const double LOW_PASS_FILTER_ALPHA = 0.9; // Smoothing factor
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
if (isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Start listening to sensors
|
||||
_gyroscopeSubscription = gyroscopeEventStream().listen(
|
||||
_handleGyroscopeEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Gyroscope error: $error'));
|
||||
},
|
||||
);
|
||||
|
||||
_accelerometerSubscription = accelerometerEventStream().listen(
|
||||
_handleAccelerometerEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Accelerometer error: $error'));
|
||||
},
|
||||
);
|
||||
|
||||
isConnected = true;
|
||||
actionStreamInternal.add(LogNotification('Gyroscope Steering: Connected - Calibrating...'));
|
||||
|
||||
// Reset calibration/estimator
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_lastGyroUpdate = null;
|
||||
_lastRoundedAngle = null;
|
||||
_lastSteeringButton = null;
|
||||
} catch (e) {
|
||||
actionStreamInternal.add(LogNotification('Failed to connect Gyroscope Steering: $e'));
|
||||
isConnected = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleGyroscopeEvent(GyroscopeEvent event) {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (!_hasAccelData) {
|
||||
_lastGyroUpdate = now;
|
||||
return;
|
||||
}
|
||||
|
||||
final dt = _lastGyroUpdate != null ? (now.difference(_lastGyroUpdate!).inMicroseconds / 1000000.0) : 0.0;
|
||||
_lastGyroUpdate = now;
|
||||
|
||||
if (dt <= 0 || dt >= 1.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS drift fix:
|
||||
// - integrate bias-corrected gyro z (yaw) into an estimator
|
||||
// - learn bias while the device is still
|
||||
final angleDeg = _estimator.updateGyro(wz: event.z, dt: dt);
|
||||
|
||||
if (!_isCalibrated) {
|
||||
// Consider calibration complete once we have a bit of stillness and sensor data.
|
||||
// This gives the bias estimator time to settle.
|
||||
if (_estimator.stillTimeSec >= 0.6) {
|
||||
_estimator.calibrate(seedBiasZRadPerSec: _estimator.biasZRadPerSec);
|
||||
_isCalibrated = true;
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Calibration complete.'),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_processSteeringAngle(angleDeg);
|
||||
}
|
||||
|
||||
void _handleAccelerometerEvent(AccelerometerEvent event) {
|
||||
_hasAccelData = true;
|
||||
_estimator.updateAccel(x: event.x, y: event.y, z: event.z);
|
||||
}
|
||||
|
||||
void _processSteeringAngle(double steeringAngleDeg) {
|
||||
final roundedAngle = steeringAngleDeg.round();
|
||||
|
||||
if (_lastRoundedAngle != roundedAngle) {
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Steering angle: $roundedAngle° (biasZ=${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s)',
|
||||
),
|
||||
);
|
||||
}
|
||||
_lastRoundedAngle = roundedAngle;
|
||||
_applyPWMSteering(roundedAngle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies PWM-like steering behavior with repeated keypresses proportional to angle magnitude
|
||||
void _applyPWMSteering(int roundedAngle) {
|
||||
// Cancel any pending keypress timer
|
||||
_keypressTimer?.cancel();
|
||||
|
||||
// Determine if we're steering
|
||||
if (roundedAngle.abs() > core.settings.getPhoneSteeringThreshold()) {
|
||||
// Determine direction
|
||||
final button = roundedAngle < 0 ? GyroscopeSteeringButtons.rightSteer : GyroscopeSteeringButtons.leftSteer;
|
||||
|
||||
if (_lastSteeringButton != button) {
|
||||
// New steering direction - reset any previous state
|
||||
_lastSteeringButton = button;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
handleButtonsClicked([button]);
|
||||
} else {
|
||||
_lastSteeringButton = null;
|
||||
// Center position - release any held buttons
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
await _gyroscopeSubscription?.cancel();
|
||||
await _accelerometerSubscription?.cancel();
|
||||
_gyroscopeSubscription = null;
|
||||
_accelerometerSubscription = null;
|
||||
_keypressTimer?.cancel();
|
||||
isConnected = false;
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
actionStreamInternal.add(LogNotification('Gyroscope Steering: Disconnected'));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (c, setState) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
DeviceInfo(
|
||||
title: 'Calibration',
|
||||
icon: BootstrapIcons.wrenchAdjustable,
|
||||
value: _isCalibrated ? 'Complete' : 'In Progress',
|
||||
),
|
||||
DeviceInfo(
|
||||
title: 'Steering Angle',
|
||||
icon: RadixIcons.angle,
|
||||
value: _isCalibrated ? '${_estimator.angleDeg.toStringAsFixed(2)}°' : 'Calibrating...',
|
||||
),
|
||||
if (kDebugMode)
|
||||
DeviceInfo(
|
||||
title: 'Gyro Bias',
|
||||
icon: BootstrapIcons.speedometer,
|
||||
value: '${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s',
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
leading: !_isCalibrated ? SmallProgressIndicator() : null,
|
||||
onPressed: !_isCalibrated
|
||||
? null
|
||||
: () {
|
||||
// Reset calibration
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_lastGyroUpdate = null;
|
||||
_lastRoundedAngle = null;
|
||||
_lastSteeringButton = null;
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Calibrating the sensors now.'),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(_isCalibrated ? 'Calibrate' : 'Calibrating...'),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
trailing: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('${core.settings.getPhoneSteeringThreshold().toInt()}°'),
|
||||
),
|
||||
onPressed: () {
|
||||
final values = [for (var i = 3; i <= 12; i += 1) i];
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (b) => DropdownMenu(
|
||||
children: values
|
||||
.map(
|
||||
(v) => MenuButton(
|
||||
child: Text('$v°'),
|
||||
onPressed: (c) {
|
||||
core.settings.setPhoneSteeringThreshold(v);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Trigger Threshold:'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!_isCalibrated)
|
||||
Text(
|
||||
'Calibrating the sensors now. Attach your phone/tablet on your handlebar and keep it still for a second.',
|
||||
).xSmall,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GyroscopeSteeringButtons {
|
||||
static final ControllerButton leftSteer = ControllerButton(
|
||||
'gyroLeftSteer',
|
||||
action: InGameAction.steerLeft,
|
||||
);
|
||||
static final ControllerButton rightSteer = ControllerButton(
|
||||
'gyroRightSteer',
|
||||
action: InGameAction.steerRight,
|
||||
);
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
leftSteer,
|
||||
rightSteer,
|
||||
];
|
||||
}
|
||||
200
lib/bluetooth/devices/gyroscope/steering_estimator.dart
Normal file
200
lib/bluetooth/devices/gyroscope/steering_estimator.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Pure-Dart steering estimator for phone-on-handlebar steering.
|
||||
///
|
||||
/// Design goals:
|
||||
/// - Avoid long-term drift on platforms like iOS by continuously estimating
|
||||
/// and subtracting gyro bias.
|
||||
/// - Keep it testable (no Flutter/sensors dependencies).
|
||||
///
|
||||
/// NOTE: This is not a full AHRS. It uses bias-corrected integration and
|
||||
/// a "stillness" detector to learn gyro bias and optionally auto-recenter.
|
||||
class SteeringEstimator {
|
||||
SteeringEstimator({
|
||||
this.biasLearningRate = 0.02,
|
||||
this.gyroStillThresholdRadPerSec = 0.03,
|
||||
this.accelStillThresholdMS2 = 0.6,
|
||||
this.minStillTimeForBiasSec = 0.35,
|
||||
this.biasLearningDeadbandDeg = 3.0,
|
||||
this.minStillTimeForRecenterSec = double.infinity,
|
||||
this.recenterHalfLifeSec = 0.7,
|
||||
this.recenterDeadbandDeg = 2.0,
|
||||
this.maxAngleAbsDeg = 60,
|
||||
this.lowPassAlpha = 0.9,
|
||||
|
||||
// Responsiveness / smoothing tuning.
|
||||
// When steering changes quickly we reduce smoothing, but keep more
|
||||
// smoothing when stable to avoid jitter.
|
||||
this.lowPassAlphaStable = 0.9,
|
||||
this.lowPassAlphaMoving = 0.55,
|
||||
this.motionAngleRateDegPerSecForMinAlpha = 90.0,
|
||||
|
||||
// Cap dt to avoid "freezing" the estimator on occasional long frames.
|
||||
this.maxDtSec = 0.05,
|
||||
});
|
||||
|
||||
// Tunables
|
||||
final double biasLearningRate;
|
||||
final double gyroStillThresholdRadPerSec;
|
||||
final double accelStillThresholdMS2;
|
||||
final double minStillTimeForBiasSec;
|
||||
final double biasLearningDeadbandDeg;
|
||||
final double minStillTimeForRecenterSec;
|
||||
final double recenterHalfLifeSec;
|
||||
final double recenterDeadbandDeg;
|
||||
final double maxAngleAbsDeg;
|
||||
|
||||
/// Backwards-compatible, kept as-is.
|
||||
///
|
||||
/// If you set `lowPassAlpha = 0.0`, filtering is disabled.
|
||||
final double lowPassAlpha;
|
||||
|
||||
/// Smoothing used when the angle is stable.
|
||||
///
|
||||
/// Default mirrors the original behavior (`0.9`).
|
||||
final double lowPassAlphaStable;
|
||||
|
||||
/// Smoothing used when the angle is changing quickly.
|
||||
///
|
||||
/// Lower alpha => faster response.
|
||||
final double lowPassAlphaMoving;
|
||||
|
||||
/// Angle rate (deg/s) at which we reach `lowPassAlphaMoving`.
|
||||
final double motionAngleRateDegPerSecForMinAlpha;
|
||||
|
||||
/// Maximum timestep used for integration/bias learning.
|
||||
final double maxDtSec;
|
||||
|
||||
// State
|
||||
double _accelX = 0, _accelY = 0, _accelZ = 0;
|
||||
bool _hasAccel = false;
|
||||
|
||||
double _biasZ = 0.0; // rad/s
|
||||
double _yawDeg = 0.0;
|
||||
double _filteredYawDeg = 0.0;
|
||||
|
||||
double _stillTimeSec = 0.0;
|
||||
|
||||
/// Resets the estimator state.
|
||||
void reset() {
|
||||
_biasZ = 0.0;
|
||||
_yawDeg = 0.0;
|
||||
_filteredYawDeg = 0.0;
|
||||
_stillTimeSec = 0.0;
|
||||
_hasAccel = false;
|
||||
_accelX = _accelY = _accelZ = 0;
|
||||
}
|
||||
|
||||
/// One-time calibration: assume device is held still and centered.
|
||||
///
|
||||
/// This resets yaw and also seeds the bias to the current z gyro rate.
|
||||
void calibrate({double? seedBiasZRadPerSec}) {
|
||||
_yawDeg = 0.0;
|
||||
_filteredYawDeg = 0.0;
|
||||
_stillTimeSec = 0.0;
|
||||
if (seedBiasZRadPerSec != null) {
|
||||
_biasZ = seedBiasZRadPerSec;
|
||||
}
|
||||
}
|
||||
|
||||
void updateAccel({required double x, required double y, required double z}) {
|
||||
_accelX = x;
|
||||
_accelY = y;
|
||||
_accelZ = z;
|
||||
_hasAccel = true;
|
||||
}
|
||||
|
||||
/// Update with gyro z-rate (rad/s) and dt (seconds).
|
||||
///
|
||||
/// Returns the current filtered steering angle in degrees.
|
||||
double updateGyro({required double wz, required double dt}) {
|
||||
if (dt <= 0) {
|
||||
return angleDeg;
|
||||
}
|
||||
|
||||
// If dt spikes (app paused/jank), cap it instead of bailing out.
|
||||
// This keeps the estimator responsive and avoids "stuck" output.
|
||||
final usedDt = dt > maxDtSec ? maxDtSec : dt;
|
||||
|
||||
final still = _isStill(wz);
|
||||
if (still) {
|
||||
_stillTimeSec += usedDt;
|
||||
|
||||
// Learn gyro bias only when we're still AND near our calibrated center.
|
||||
// Otherwise, if the user holds a steady steering angle, wz≈0 and we'd
|
||||
// incorrectly move bias towards 0 and cause the angle to be wrong when
|
||||
// they return to center.
|
||||
final nearCenter = _yawDeg.abs() <= biasLearningDeadbandDeg;
|
||||
if (nearCenter && _stillTimeSec >= minStillTimeForBiasSec) {
|
||||
// Exponential moving average towards the observed rate.
|
||||
_biasZ = (1.0 - biasLearningRate) * _biasZ + biasLearningRate * wz;
|
||||
}
|
||||
|
||||
// IMPORTANT: only auto-recenter when we're already close to center.
|
||||
// Users may hold a constant steering angle for several seconds.
|
||||
final canRecenter = _stillTimeSec >= minStillTimeForRecenterSec && _yawDeg.abs() <= recenterDeadbandDeg;
|
||||
if (canRecenter) {
|
||||
_applyRecenter(usedDt);
|
||||
}
|
||||
} else {
|
||||
_stillTimeSec = 0.0;
|
||||
}
|
||||
|
||||
final correctedWz = wz - _biasZ;
|
||||
_yawDeg += correctedWz * usedDt * (180.0 / pi);
|
||||
|
||||
// Clamp to avoid runaway if something goes wrong.
|
||||
_yawDeg = _yawDeg.clamp(-maxAngleAbsDeg, maxAngleAbsDeg).toDouble();
|
||||
|
||||
// Low-pass filter for noise smoothing.
|
||||
//
|
||||
// Make it adaptive: when the angle is changing fast, we reduce smoothing
|
||||
// (more responsive). When stable, we keep stronger smoothing.
|
||||
//
|
||||
// If user forces lowPassAlpha=0.0 (existing tests do), we keep behavior
|
||||
// equivalent (no filtering).
|
||||
if (lowPassAlpha <= 0.0) {
|
||||
_filteredYawDeg = _yawDeg;
|
||||
} else {
|
||||
final stableAlpha = ((lowPassAlphaStable.isFinite ? lowPassAlphaStable : lowPassAlpha)).clamp(0.0, 0.999);
|
||||
final movingAlpha = lowPassAlphaMoving.clamp(0.0, stableAlpha);
|
||||
|
||||
// Use a rate estimate derived from the filtered-vs-raw divergence.
|
||||
final rateDegPerSec = ((_yawDeg - _filteredYawDeg).abs()) / usedDt;
|
||||
final t = (rateDegPerSec / motionAngleRateDegPerSecForMinAlpha).clamp(0.0, 1.0);
|
||||
|
||||
final alpha = stableAlpha + (movingAlpha - stableAlpha) * t;
|
||||
_filteredYawDeg = alpha * _filteredYawDeg + (1 - alpha) * _yawDeg;
|
||||
}
|
||||
|
||||
return angleDeg;
|
||||
}
|
||||
|
||||
double get angleDeg => _filteredYawDeg;
|
||||
|
||||
double get biasZRadPerSec => _biasZ;
|
||||
|
||||
double get stillTimeSec => _stillTimeSec;
|
||||
|
||||
bool _isStill(double wz) {
|
||||
// If we don't have accel yet, be conservative: don't learn bias.
|
||||
if (!_hasAccel) return false;
|
||||
|
||||
final gyroOk = wz.abs() < gyroStillThresholdRadPerSec;
|
||||
|
||||
// Check accel magnitude close to gravity (device not being bumped).
|
||||
final aMag = sqrt(_accelX * _accelX + _accelY * _accelY + _accelZ * _accelZ);
|
||||
const g = 9.80665;
|
||||
final accelOk = (aMag - g).abs() < accelStillThresholdMS2;
|
||||
|
||||
return gyroOk && accelOk;
|
||||
}
|
||||
|
||||
void _applyRecenter(double dt) {
|
||||
// Exponential decay towards 0 with given half-life.
|
||||
// decay = 0.5^(dt/halfLife)
|
||||
if (recenterHalfLifeSec <= 0) return;
|
||||
final decay = pow(0.5, dt / recenterHalfLifeSec).toDouble();
|
||||
_yawDeg *= decay;
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,11 @@ class WhooshLink extends TrainerConnection {
|
||||
Socket? _socket;
|
||||
ServerSocket? _server;
|
||||
|
||||
static const String connectionTitle = 'MyWhoosh Link';
|
||||
|
||||
WhooshLink()
|
||||
: super(
|
||||
title: 'MyWhoosh Link',
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
|
||||
@@ -24,9 +24,11 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
|
||||
late GATTCharacteristic _buttonCharacteristic;
|
||||
|
||||
static const String connectionTitle = 'OpenBikeControl BLE Emulator';
|
||||
|
||||
OpenBikeControlBluetoothEmulator()
|
||||
: super(
|
||||
title: 'OpenBikeControl BLE Emulator',
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
|
||||
@@ -18,13 +18,15 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _server;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
static const String connectionTitle = 'OpenBikeControl mDNS Emulator';
|
||||
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier(null);
|
||||
|
||||
Socket? _socket;
|
||||
|
||||
OpenBikeControlMdnsEmulator()
|
||||
: super(
|
||||
title: 'OpenBikeControl mDNS Emulator',
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) {
|
||||
final channels = bytes.sublist(1);
|
||||
|
||||
@@ -67,8 +67,7 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
});
|
||||
|
||||
if (clickedButtons.isNotEmpty) {
|
||||
handleButtonsClicked(clickedButtons);
|
||||
handleButtonsClicked([]);
|
||||
await handleButtonsClickedWithoutLongPressSupport(clickedButtons);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
|
||||
171
lib/bluetooth/devices/sram/sram_axs.dart
Normal file
171
lib/bluetooth/devices/sram/sram_axs.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class SramAxs extends BluetoothDevice {
|
||||
SramAxs(super.scanResult) : super(availableButtons: [], isBeta: true);
|
||||
|
||||
Timer? _singleClickTimer;
|
||||
int _tapCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_tapCount = 0;
|
||||
await super.disconnect();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == SramAxsConstants.SERVICE_UUID_RELEVANT.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${SramAxsConstants.SERVICE_UUID_RELEVANT}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == SramAxsConstants.TRIGGER_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${SramAxsConstants.TRIGGER_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
|
||||
// add both buttons
|
||||
_singleClickButton();
|
||||
_doubleClickButton();
|
||||
}
|
||||
|
||||
ControllerButton _singleClickButton() => getOrAddButton(
|
||||
'SRAM Tap',
|
||||
() => const ControllerButton('SRAM Tap', action: InGameAction.shiftUp),
|
||||
);
|
||||
|
||||
ControllerButton _doubleClickButton() => getOrAddButton(
|
||||
'SRAM Double Tap',
|
||||
() => const ControllerButton('SRAM Double Tap', action: InGameAction.shiftDown),
|
||||
);
|
||||
|
||||
void _emitClick(ControllerButton button) {
|
||||
// Use the common pipeline so long-press handling and app action execution stays consistent.
|
||||
handleButtonsClickedWithoutLongPressSupport([button]);
|
||||
}
|
||||
|
||||
void _registerTap() {
|
||||
final windowMs = core.settings.getSramAxsDoubleClickWindowMs();
|
||||
|
||||
_tapCount++;
|
||||
|
||||
// First tap: start a timer. If no second tap arrives in time => single click.
|
||||
if (_tapCount == 1) {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = Timer(Duration(milliseconds: windowMs), () {
|
||||
if (_tapCount == 1) {
|
||||
_emitClick(_singleClickButton());
|
||||
}
|
||||
_tapCount = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Second tap within window: double click.
|
||||
if (_tapCount == 2) {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_emitClick(_doubleClickButton());
|
||||
_tapCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we get more than two taps fast, treat as a double click and restart counting.
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_emitClick(_doubleClickButton());
|
||||
_tapCount = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode) {
|
||||
debugPrint('SramAxs: Received data on characteristic $characteristic: ${bytesToHex(bytes)}');
|
||||
}
|
||||
|
||||
if (characteristic.toLowerCase() == SramAxsConstants.TRIGGER_UUID.toLowerCase()) {
|
||||
// At the moment we can only detect "some button pressed". We therefore interpret each
|
||||
// notification as a tap and provide two logical buttons (single & double click).
|
||||
_registerTap();
|
||||
}
|
||||
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
final windowMs = core.settings.getSramAxsDoubleClickWindowMs();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
"Unfortunately, at the moment it's not possible to determine which physical button was pressed on your SRAM AXS device. Let us know if you have a contact at SRAM who can help :)\n\n"
|
||||
'So the app exposes two logical buttons:\n'
|
||||
'• SRAM Tap, assigned to Shift Up\n'
|
||||
'• SRAM Double Tap, assigned to Shift Down\n\n'
|
||||
'You can assign an action to each in the app settings.',
|
||||
).xSmall,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('${windowMs}ms'),
|
||||
),
|
||||
onPressed: () {
|
||||
final values = [
|
||||
for (var v = 150; v <= 600; v += 50) v,
|
||||
];
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (b) => DropdownMenu(
|
||||
children: values
|
||||
.map(
|
||||
(v) => MenuButton(
|
||||
child: Text('${v}ms'),
|
||||
onPressed: (c) async {
|
||||
await core.settings.setSramAxsDoubleClickWindowMs(v);
|
||||
// Force rebuild to show new value.
|
||||
(context as Element).markNeedsBuild();
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Double-click window:'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SramAxsConstants {
|
||||
static const String SERVICE_UUID = "0000fe51-0000-1000-8000-00805f9b34fb";
|
||||
static const String SERVICE_UUID_RELEVANT = "d9050053-90aa-4c7c-b036-1e01fb8eb7ee";
|
||||
|
||||
static const String TRIGGER_UUID = "d9050054-90aa-4c7c-b036-1e01fb8eb7ee";
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ZwiftConstants {
|
||||
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
|
||||
@@ -101,7 +101,11 @@ class ZwiftButtons {
|
||||
// right controller
|
||||
static const ControllerButton a = ControllerButton('a', action: InGameAction.select, color: Colors.lightGreen);
|
||||
static const ControllerButton b = ControllerButton('b', action: InGameAction.back, color: Colors.pinkAccent);
|
||||
static const ControllerButton z = ControllerButton('z', action: null, color: Colors.deepOrangeAccent);
|
||||
static const ControllerButton z = ControllerButton(
|
||||
'z',
|
||||
action: InGameAction.rideOnBomb,
|
||||
color: Colors.deepOrangeAccent,
|
||||
);
|
||||
static const ControllerButton y = ControllerButton('y', action: null, color: Colors.lightBlue);
|
||||
static const ControllerButton onOffRight = ControllerButton('onOffRight', action: InGameAction.toggleUi);
|
||||
static const ControllerButton sideButtonRight = ControllerButton('sideButtonRight', action: InGameAction.shiftUp);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
@@ -14,17 +11,22 @@ import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
|
||||
class FtmsMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _tcpServer;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
static const String connectionTitle = 'Zwift Network Emulator';
|
||||
|
||||
Socket? _socket;
|
||||
var lastMessageId = 0;
|
||||
|
||||
FtmsMdnsEmulator()
|
||||
: super(
|
||||
title: 'Zwift Network Emulator',
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
@@ -378,7 +380,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
|
||||
_write(_socket!, zero);
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Sent action ${keyPair.inGameAction!.title} to Zwift Emulator');
|
||||
print('Sent action $isKeyUp vs $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator');
|
||||
}
|
||||
return Success('Sent action: ${keyPair.inGameAction!.title}');
|
||||
}
|
||||
@@ -462,8 +464,8 @@ extension on List<int> {
|
||||
}
|
||||
}
|
||||
|
||||
String bytesToHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
String bytesToHex(List<int> bytes, {bool spaced = false}) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : '');
|
||||
}
|
||||
|
||||
String bytesToReadableHex(List<int> bytes) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
@@ -9,6 +7,8 @@ import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/single_line_exception.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@@ -152,10 +152,10 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked, {bool longPress = false}) async {
|
||||
// the same messages are sent multiple times, so ignore
|
||||
if (_lastButtonsClicked == null || _lastButtonsClicked?.contentEquals(buttonsClicked ?? []) == false) {
|
||||
super.handleButtonsClicked(buttonsClicked);
|
||||
super.handleButtonsClicked(buttonsClicked, longPress: longPress);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
class ZwiftEmulator extends TrainerConnection {
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
static const String connectionTitle = 'Zwift BLE Emulator';
|
||||
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
@@ -32,7 +34,7 @@ class ZwiftEmulator extends TrainerConnection {
|
||||
|
||||
ZwiftEmulator()
|
||||
: super(
|
||||
title: 'Zwift BLE Emulator',
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
@@ -306,7 +308,7 @@ class ZwiftEmulator extends TrainerConnection {
|
||||
|
||||
Future<void> _sendKeepAlive() async {
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
if (isConnected.value) {
|
||||
if (isConnected.value && _central != null) {
|
||||
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
|
||||
_peripheralManager.notifyCharacteristic(_central!, _syncTxCharacteristic!, value: zero);
|
||||
_sendKeepAlive();
|
||||
@@ -459,4 +461,15 @@ class ZwiftEmulator extends TrainerConnection {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
_peripheralManager.stopAdvertising();
|
||||
_peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isSubscribedToEvents = false;
|
||||
_central = null;
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class BaseNotification {}
|
||||
|
||||
@@ -68,8 +68,10 @@ class ActionNotification extends BaseNotification {
|
||||
class AlertNotification extends LogNotification {
|
||||
final LogLevel level;
|
||||
final String alertMessage;
|
||||
final VoidCallback? onTap;
|
||||
final String? buttonTitle;
|
||||
|
||||
AlertNotification(this.level, this.alertMessage) : super(alertMessage);
|
||||
AlertNotification(this.level, this.alertMessage, {this.onTap, this.buttonTitle}) : super(alertMessage);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
@@ -12,6 +9,9 @@ import 'package:bike_control/utils/actions/remote.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../utils/keymap/keymap.dart';
|
||||
|
||||
@@ -26,9 +26,11 @@ class RemotePairing extends TrainerConnection {
|
||||
Central? _central;
|
||||
GATTCharacteristic? _inputReport;
|
||||
|
||||
static const String connectionTitle = 'Remote Control';
|
||||
|
||||
RemotePairing()
|
||||
: super(
|
||||
title: 'Remote Control',
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
"accessibilityUsageGestures": "• Wenn Du die Tasten auf Deinem Zwift Click-, Zwift Ride- oder Zwift Play-Geräten drückst, simuliert BikeControl Berührungsgesten an bestimmten Bildschirmpositionen.",
|
||||
"accessibilityUsageMonitor": "• Die App überwacht, welches Trainings-App-Fenster aktiv ist, um sicherzustellen, dass Gesten an die richtige App gesendet werden.",
|
||||
"accessibilityUsageNoData": "• Über diesen Dienst werden keine personenbezogenen Daten abgerufen oder erfasst.",
|
||||
"accessories": "Zubehör",
|
||||
"action": "Aktion",
|
||||
"adjust": "Anpassen",
|
||||
"adjustControllerButtons": "Controller-Tasten anpassen",
|
||||
"allow": "Erlauben",
|
||||
"allowAccessibilityService": "Barrierefreiheitsdienst zulassen",
|
||||
"allowBluetoothConnections": "Bluetooth-Verbindungen zulassen",
|
||||
"allowBluetoothScan": "Bluetooth-Scan zulassen",
|
||||
"allowLocationForBluetooth": "Standortzugriff erlauben, damit Bluetooth-Scan funktioniert",
|
||||
"allowPersistentNotification": "Dauerhafte Benachrichtigung zulassen",
|
||||
"allowPersistentNotification": "Benachrichtigungen zulassen",
|
||||
"allowsRunningInBackground": "Ermöglicht es BikeControl, im Hintergrund weiterzulaufen.",
|
||||
"appIdActions": "{appId} Aktionen",
|
||||
"@appIdActions": {
|
||||
@@ -28,42 +28,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"appNameOnTargetName": "{appName} auf {targetName}",
|
||||
"@appNameOnTargetName": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appStartError": "Beim Starten der App ist ein Fehler aufgetreten. Bitte kontaktiere den Support.\n{error}",
|
||||
"@appStartError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachLogFile": "Bitte füge auch die Datei „ {logPath} “ bei, falls vorhanden.\nBitte diese Informationen nicht entfernen.",
|
||||
"@attachLogFile": {
|
||||
"placeholders": {
|
||||
"logPath": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery": "Batterie",
|
||||
"bikeControlPlatform": "BikeControl {platform}",
|
||||
"@bikeControlPlatform": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bluetoothAdvertiseAccess": "Bluetooth-Zugriff",
|
||||
"bluetoothTurnedOn": "Bluetooth ist eingeschaltet",
|
||||
"browserNotSupported": "Dieser Browser unterstützt kein Web-Bluetooth und die Plattform wird nicht unterstützt :(",
|
||||
@@ -73,9 +38,11 @@
|
||||
"checkMyWhooshConnectionScreen": "Überprüfe den Verbindungsbildschirm in MyWhoosh, um zu sehen, ob „Link“ verbunden ist.",
|
||||
"chooseAnotherScreenshot": "Wähle einen anderen Screenshot aus",
|
||||
"chooseBikeControlInConnectionScreen": "Wähle im Verbindungsbildschirm BikeControl aus.",
|
||||
"choosePreferredConnectionMethod": "Wähle Deine bevorzugte Verbindungsmethode.",
|
||||
"clickAButtonOnYourController": "Klicke eine Controller-Taste, um deren Aktion zu bearbeiten, oder tippe auf das Bearbeitungssymbol.",
|
||||
"clickV2EventInfo": "Dein Click V2 sendet möglicherweise keine Tastenereignisse mehr. Probier mal ein paar Tasten aus und schau, ob sie in BikeControl angezeigt werden.",
|
||||
"clickV2Instructions": "Damit dein Zwift Click V2 optimal funktioniert, solltest du vor jeder Trainings-Session in der Zwift-App verbinden.\nWenn du das nicht machst, funktioniert der Click V2 nach einer Minute nicht mehr.\n\n1. Öffne die Zwift-App.\n2. Melde dich an (kein Abonnement nötig) und öffne den Bildschirm für die Geräteverbindung.\n3. Verbinde deinen Trainer und dann den Zwift Click V2.\n4. Schließe die Zwift-App wieder und verbinde dich erneut in BikeControl.",
|
||||
"close": "Schließen",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} verbleibende Befehle heute",
|
||||
"configuration": "Konfiguration",
|
||||
"connectControllerToPreview": "Schließe ein Controller-Gerät an, um die Tastaturbelegung in der Vorschau anzuzeigen und anzupassen.",
|
||||
"connectControllers": "Controller verbinden",
|
||||
@@ -107,6 +74,7 @@
|
||||
}
|
||||
},
|
||||
"controllers": "Controller",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "{button} konnte nicht ausgeführt werden: Keine Konfiguration festgelegt",
|
||||
"create": "Erstellen",
|
||||
"createNewKeymap": "Neue Tastaturbelegung erstellen",
|
||||
"createNewProfileByDuplicating": "Erstelle ein neues benutzerdefiniertes Profil, indem „{profileName} “ dupliziert wird.",
|
||||
@@ -125,7 +93,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": "Benutzerdefiniert",
|
||||
"customizeControllerButtons": "Controller-Tasten anpassen für {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
@@ -135,6 +102,8 @@
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Passe die Tastaturbelegung an, falls Probleme auftreten (z. B. falsche Tastatureingaben oder falsch ausgerichtete Touch-Positionen).",
|
||||
"dailyCommandLimitReachedNotification": "Das tägliche Befehlslimit wurde für heute erreicht. Führe ein Upgrade durch, um die Vollversion mit unbegrenzten Befehlen freizuschalten.",
|
||||
"dailyLimitReached": "Tageslimit erreicht ({dailyCommandCount} / {dailyCommandLimit} verbraucht)",
|
||||
"delete": "Löschen",
|
||||
"deleteProfile": "Profil löschen",
|
||||
"deleteProfileConfirmation": "„{profileName} “ wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
@@ -165,7 +134,7 @@
|
||||
"enableAutoRotation": "Aktiviere die automatische Drehung auf Ihrem Gerät, um sicherzustellen, dass die App ordnungsgemäß funktioniert.",
|
||||
"enableBluetooth": "Bluetooth aktivieren",
|
||||
"enableKeyboardAccessMessage": "Aktiviere im folgenden Bildschirm die Tastatursteuerung für BikeControl. Falls BikeControl nicht angezeigt wird, füge es bitte manuell hinzu.",
|
||||
"enableKeyboardMouseControl": "Aktiviere die Tastatur- und Maussteuerung für eine bessere Interaktion mit {appName}.",
|
||||
"enableKeyboardMouseControl": "Aktiviere die Tastatur- und Maussteuerung für eine bessere Interaktion mit {appName}. Sobald die Funktion aktiviert ist, sind keine weiteren Aktionen oder Verbindungen erforderlich. BikeControl sendet die Maus- oder Tastatureingaben direkt an {appName} weiter.",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
@@ -176,6 +145,7 @@
|
||||
"enableMediaKeyDetection": "Medientastenerkennung aktivieren",
|
||||
"enablePairingProcess": "Kopplungsprozess aktivieren",
|
||||
"enablePermissions": "Berechtigungen aktivieren",
|
||||
"enableSteeringWithPhone": "Sensoren Ihres Telefons aktivieren z.B. zum Lenken",
|
||||
"enableVibrationFeedback": "Vibrationsfeedback beim Gangwechsel aktivieren",
|
||||
"enableZwiftControllerBluetooth": "Zwift Controller aktivieren (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Zwift Controller aktivieren (Netzwerk)",
|
||||
@@ -194,6 +164,8 @@
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Schließen die App, um die neue Version zu verwenden.",
|
||||
"fullVersion": "Vollversion",
|
||||
"fullVersionDescription": "Die Vollversion beinhaltet: \n- Unbegrenzte Befehle pro Tag \n- Zugriff auf alle zukünftigen Updates \n- Kein Abonnement! Nur eine einmalige Gebühr :)",
|
||||
"getSupport": "Unterstützung erhalten",
|
||||
"gotIt": "Verstanden!",
|
||||
"grant": "Gewähren",
|
||||
@@ -239,6 +211,7 @@
|
||||
}
|
||||
},
|
||||
"license": "Lizenz",
|
||||
"licenseStatus": "Lizenzstatus",
|
||||
"loadScreenshotForPlacement": "Lade einen Screenshot aus dem Spiel zur Platzierung hoch.",
|
||||
"logViewer": "Protokollanzeige",
|
||||
"logs": "Protokolle",
|
||||
@@ -249,6 +222,7 @@
|
||||
"mailSupportExplanation": "Die individuelle Unterstützung per E-Mail ist für mich sehr aufwendig.\n\nBitte nutze daher Reddit, Facebook oder GitHub für Fragen und Probleme, damit die gesamte Community davon profitieren kann.",
|
||||
"manageIgnoredDevices": "Ignorierte Geräte verwalten",
|
||||
"manageProfile": "Profil verwalten",
|
||||
"manualyControllingButton": "{trainerApp} manuell steuern!",
|
||||
"mediaKeyDetectionTooltip": "Aktiviere diese Option, damit BikeControl Bluetooth-Fernbedienungen erkennt. \nDazu muss BikeControl als Mediaplayer fungieren.",
|
||||
"miuiDeviceDetected": "MIUI-Gerät erkannt",
|
||||
"miuiDisableBatteryOptimization": "• Batterieoptimierung für BikeControl deaktivieren",
|
||||
@@ -262,11 +236,10 @@
|
||||
"myWhooshDirectConnection": " z. B. mit MyWhoosh „Link”.",
|
||||
"myWhooshLinkConnected": "MyWhoosh „Link“ verbunden",
|
||||
"myWhooshLinkDescriptionLocal": "Verbinde dich direkt mit MyWhoosh über die „Link”-Methode. Unterstützte Aktionen sind unter anderem Schalten, Emotes und Richtungswechsel. Die MyWhoosh Link-Begleit-App darf dabei NICHT gleichzeitig laufen.",
|
||||
"myWhooshLinkDescriptionRemote": "Du kannst dich über das Netzwerk mit MyWhoosh verbinden, indem du die „Link”-Verbindung nutzt. Die MyWhoosh Link-Begleit-App darf dabei NICHT gleichzeitig laufen.",
|
||||
"myWhooshLinkInfo": "Schau mal im Abschnitt zur Fehlerbehebung nach, wenn du Probleme hast. Eine deutlich zuverlässigere Verbindungsmethode kommt bald!",
|
||||
"nameChangeNotice": "SwiftControl heißt jetzt BikeControl! Es ist Teil des OpenBikeControl-Projekts, das sich für offene Standards bei intelligenten Fahrradtrainern einsetzt und erschwingliche Hardware-Controller entwickelt!",
|
||||
"needHelpClickHelp": "Hilfe benötigt? Klicke auf",
|
||||
"needHelpDontHesitate": "den Button oben und zögere nicht, uns zu kontaktieren.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp} wird in Kürze deutlich bessere und zuverlässigere Verbindungsmethoden unterstützen!",
|
||||
"newCustomProfile": "Neues benutzerdefiniertes Profil",
|
||||
"newProfileName": "Neuer Profilname",
|
||||
"newVersionAvailable": "Neue Version verfügbar",
|
||||
@@ -280,16 +253,18 @@
|
||||
},
|
||||
"next": "Nächste",
|
||||
"noActionAssigned": "Keine Maßnahmen zugewiesen",
|
||||
"noButtonAssigned": "Für Ihr angeschlossenes Gerät ist keine Taste zugewiesen.",
|
||||
"noActionAssignedForButton": "{button} konnte nicht ausgeführt werden: Keine Aktion zugewiesen",
|
||||
"noConnectionMethodIsConnectedOrActive": "Es ist keine Verbindungsmethode verbunden oder aktiv.",
|
||||
"noConnectionMethodSelected": "Keine Verbindungsmethode ausgewählt",
|
||||
"noControllerConnected": "Keine Verbindung",
|
||||
"noControllerUseCompanionMode": "Kein Controller? Nutze den Companion Mode",
|
||||
"noIgnoredDevices": "Keine ignorierten Geräte.",
|
||||
"noPredefinedActionsAvailable": "Keine vordefinierten Aktionen verfügbar",
|
||||
"noTrainerSelected": "Kein Trainer ausgewählt",
|
||||
"notConnected": "Nicht verbunden",
|
||||
"notificationDescription": "Dadurch bleibt die App im Hintergrund aktiv.",
|
||||
"notificationDescription": "Dadurch bleibt die App im Hintergrund aktiv und informiert, wenn sich die Verbindung zu Geräten ändert.",
|
||||
"ok": "OK",
|
||||
"openBikeControlActions": "OpenBikeControl-Aktionen",
|
||||
"openBikeControlAnnouncement": "Tolle Neuigkeiten! {trainerApp} unterstützt das OpenBikeControl-Protokoll für eine bestmögliche Benutzererfahrung.",
|
||||
"openBikeControlConnection": " z. B. durch Verwendung einer OpenBikeControl-Verbindung",
|
||||
"otherConnectionMethods": "Andere Verbindungsmethoden",
|
||||
"pairingDescription": "Die Kopplung ermöglicht volle Anpassungsmöglichkeiten, funktioniert aber möglicherweise nicht auf allen Geräten.",
|
||||
@@ -304,7 +279,7 @@
|
||||
"pairingInstructionsIOS": "Gehe auf Deinem iPad zu „Einstellungen“ > „Bedienungshilfen“ > „Berühren“ > „AssistiveTouch“ > „Zeigergeräte“ > „Geräte“ und koppel Dein Gerät. Stelle sicher, dass AssistiveTouch aktiviert ist.",
|
||||
"pasteExportedJsonData": "Füge die exportierten JSON-Daten unten ein:",
|
||||
"pathCopiedToClipboard": "Der Pfad wurde in die Zwischenablage kopiert.",
|
||||
"permissionsRequired": "Damit BikeControl nach Geräten in der Nähe suchen kann, aktiviere bitte die folgenden Berechtigungen:",
|
||||
"permissionsRequired": "Damit BikeControl nach Geräten in der Nähe suchen und bei Verbindungsänderungen informieren kann, aktiviere bitte die folgenden Berechtigungen:",
|
||||
"platformNotSupported": "{platform} wird nicht unterstützt :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
@@ -352,15 +327,7 @@
|
||||
},
|
||||
"profileImportedSuccessfully": "Profil erfolgreich importiert",
|
||||
"profileName": "Profilname",
|
||||
"provideFeedback": "Feedback geben",
|
||||
"recommendDownloadBikeControl": "Wir empfehlen dringend, BikeControl auf dieses {platform} -Gerät herunterzuladen und zu verwenden.",
|
||||
"@recommendDownloadBikeControl": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"purchase": "Kaufen",
|
||||
"recommendedConnectionMethods": "Empfohlene Verbindungsmethoden",
|
||||
"removeFromIgnoredList": "Aus der Ignorierliste entfernen",
|
||||
"rename": "Umbenennen",
|
||||
@@ -394,16 +361,6 @@
|
||||
"scan": "SCAN",
|
||||
"scanningForDevices": "Suche nach Geräten... Stelle sicher, dass diese eingeschaltet und in Reichweite sind und nicht mit einem anderen Gerät verbunden sind.",
|
||||
"selectKeymap": "Tastaturbelegung auswählen",
|
||||
"selectOtherDeviceWhereAppRuns": "Wähle das andere Gerät aus, auf dem {appName} läuft auf",
|
||||
"@selectOtherDeviceWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTargetDevice": "Zielgerät auswählen",
|
||||
"selectTargetDeviceDescription": "Wähle das Zielgerät aus, auf dem die Trainer-App ausgeführt werden soll.",
|
||||
"selectTargetWhereAppRuns": "Ziel auswählen, auf dem {appName} läuft",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
@@ -412,14 +369,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectThisDeviceWarning": "Wähle „Dieses Gerät“, es sei denn, Du möchtest ein anderes {platform} -Gerät steuern. Sicher?",
|
||||
"@selectThisDeviceWarning": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Trainer-App auswählen",
|
||||
"selectTrainerAppAndTarget": "Trainer-App und Zielgerät auswählen",
|
||||
"selectTrainerAppPlaceholder": "Trainer-App auswählen",
|
||||
@@ -429,36 +378,26 @@
|
||||
"showDonation": "Zeige Deine Wertschätzung durch eine Spende.",
|
||||
"showSupportedControllers": "Unterstützte Controller anzeigen",
|
||||
"showTroubleshootingGuide": "Anleitung zur Fehlerbehebung anzeigen",
|
||||
"sideloaded": "(per Sideloading)",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Tasten simulieren",
|
||||
"simulateButtons": "Trainersteuerung",
|
||||
"simulateKeyboardShortcut": "Tastenkombination simulieren",
|
||||
"simulateMediaKey": "Medientaste simulieren",
|
||||
"simulateTouch": "Berührung simulieren",
|
||||
"stop": "Stoppen",
|
||||
"targetAndroid": "Android-Gerät",
|
||||
"targetIOS": "iPhone / iPad / Apple TV",
|
||||
"targetMacOS": "Mac",
|
||||
"targetOtherDevice": "Anderes Gerät",
|
||||
"targetThisDevice": "Dieses Gerät",
|
||||
"targetWindows": "Windows-PC",
|
||||
"theFollowingPermissionsRequired": "Folgende Berechtigungen sind erforderlich:",
|
||||
"touchAreaInstructions": "1. Erstelle einen Screenshot Ihrer App (z. B. innerhalb von MyWhoosh) im Querformat.\n2. Lade den Screenshot über die Schaltfläche unten.\n3. Die App wird automatisch auf Querformat eingestellt, um eine genaue Zuordnung zu gewährleisten.\n4. Drücke eine Taste auf Ihrem Click-Gerät, um einen Touch-Bereich zu erstellen.\n5. Ziehe die Touch-Bereiche an die gewünschte Position auf dem Screenshot.\n6. Speicher und schließe diesen Bildschirm.",
|
||||
"touchSimulationForegroundMessage": "Um Berührungen zu simulieren, muss die App im Vordergrund bleiben.",
|
||||
"trainer": "Trainer",
|
||||
"trainerSetup": "Trainer-Setup: {appName} auf {targetName}",
|
||||
"@trainerSetup": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trialDaysRemaining": "{trialDaysRemaining} verbleibende Tage",
|
||||
"trialExpired": "Testphase abgelaufen. Befehle beschränkt auf {dailyCommandLimit} pro Tag.",
|
||||
"trialPeriodActive": "Testphase aktiv - {trialDaysRemaining} verbleibende Tage",
|
||||
"trialPeriodDescription": "Während der Testphase stehen unbegrenzt viele Befehle zur Verfügung. Nach Ablauf der Testphase sind die Befehle auf {dailyCommandLimit} pro Tag eingeschränkt.",
|
||||
"troubleshootingGuide": "Leitfaden zur Fehlerbehebung",
|
||||
"tryingToConnectAgain": "Verbinde erneut...",
|
||||
"unassignAction": "Zuweisung aufheben",
|
||||
"unlockFullVersion": "Vollversion freischalten",
|
||||
"update": "Aktualisieren",
|
||||
"useCustomKeymapForButton": "Verwende eine benutzerdefinierte Tastaturbelegung, um die",
|
||||
"version": "Version {version}",
|
||||
@@ -469,7 +408,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"videoInstructions": "Videoanleitung",
|
||||
"viewDetailedInstructions": "Detaillierte Anweisungen ansehen",
|
||||
"volumeDown": "Lautstärke verringern",
|
||||
"volumeUp": "Lautstärke erhöhen",
|
||||
|
||||
@@ -1,462 +1,427 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
|
||||
"controllers": "Controllers",
|
||||
"trainer": "Trainer",
|
||||
"configuration": "Configuration",
|
||||
"logs": "Logs",
|
||||
|
||||
"connectControllers": "Connect Controllers",
|
||||
"connectedControllers": "Connected Controllers",
|
||||
|
||||
"scanningForDevices": "Scanning for devices... Make sure they are powered on and in range and not connected to another device.",
|
||||
"scan": "SCAN",
|
||||
"enablePermissions": "Enable Permissions",
|
||||
"permissionsRequired": "In order for BikeControl to search for nearby devices, please enable the following permissions:",
|
||||
"showTroubleshootingGuide": "Show Troubleshooting Guide",
|
||||
"showSupportedControllers": "Show Supported Controllers",
|
||||
"troubleshootingGuide": "Troubleshooting Guide",
|
||||
"enableMediaKeyDetection": "Enable Media Key Detection",
|
||||
"mediaKeyDetectionTooltip": "Enable this option to allow BikeControl to detect bluetooth remotes.\nIn order to do so BikeControl needs to act as a media player.",
|
||||
|
||||
"nameChangeNotice": "SwiftControl is now BikeControl!\nIt is part of the OpenBikeControl project, advocating for open standards in smart bike trainers - and building affordable hardware controllers!",
|
||||
"moreInformation": "More Information",
|
||||
|
||||
"manageIgnoredDevices": "Manage Ignored Devices",
|
||||
"ignoredDevices": "Ignored Devices",
|
||||
"noIgnoredDevices": "No ignored devices.",
|
||||
"removeFromIgnoredList": "Remove from ignored list",
|
||||
"close": "Close",
|
||||
|
||||
"connectToTrainerApp": "Connect to Trainer app",
|
||||
|
||||
"trainerSetup": "Trainer setup: {appName} on {targetName}",
|
||||
"@trainerSetup": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" },
|
||||
"targetName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"setupTrainer": "Setup Trainer",
|
||||
"adjust": "Adjust",
|
||||
|
||||
"selectTrainerApp": "Select Trainer App",
|
||||
"selectTrainerAppPlaceholder": "Select Trainer app",
|
||||
"selectTargetDevice": "Select Target device",
|
||||
"selectTargetWhereAppRuns": "Select Target where {appName} runs on",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"selectOtherDeviceWhereAppRuns": "Select the other device where {appName} runs on",
|
||||
"@selectOtherDeviceWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"targetThisDevice": "This Device",
|
||||
"targetOtherDevice": "Other Device",
|
||||
"targetIOS": "iPhone / iPad / Apple TV",
|
||||
"targetAndroid": "Android Device",
|
||||
"targetMacOS": "Mac",
|
||||
"targetWindows": "Windows PC",
|
||||
|
||||
"runAppOnThisDevice": "Run {appName} on this device.",
|
||||
"@runAppOnThisDevice": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"runAppOnPlatformRemotely": "Run {appName} on {platform} and control it remotely from this device{preferredConnection}.",
|
||||
"@runAppOnPlatformRemotely": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" },
|
||||
"platform": { "type": "String" },
|
||||
"preferredConnection": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"platformRestrictionOtherDevicesOnly": "Due to platform restrictions only controlling {appName} on other devices is supported.",
|
||||
"@platformRestrictionOtherDevicesOnly": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"platformRestrictionNotSupported": "Due to platform restrictions this scenario is not supported.",
|
||||
|
||||
"selectThisDeviceWarning": "Select 'This device' unless you want to control another {platform} device. Are you sure?",
|
||||
"@selectThisDeviceWarning": {
|
||||
"placeholders": {
|
||||
"platform": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"recommendDownloadBikeControl": "We highly recommended to download and use BikeControl on that {platform} device.",
|
||||
"@recommendDownloadBikeControl": {
|
||||
"placeholders": {
|
||||
"platform": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"errorStartingOpenBikeControlServer": "Error starting OpenBikeControl server.",
|
||||
"errorStartingOpenBikeControlBluetoothServer": "Error starting OpenBikeControl Bluetooth server.",
|
||||
|
||||
"enableAutoRotation": "Enable auto-rotation on your device to make sure the app works correctly.",
|
||||
"miuiDeviceDetected": "MIUI Device Detected",
|
||||
"miuiWarningDescription": "Your device is running MIUI, which is known to aggressively kill background services and accessibility services.",
|
||||
"miuiEnsureProperWorking": "To ensure BikeControl works properly:",
|
||||
"miuiDisableBatteryOptimization": "• Disable battery optimization for BikeControl",
|
||||
"miuiEnableAutostart": "• Enable autostart for BikeControl",
|
||||
"miuiLockInRecentApps": "• Lock the app in recent apps",
|
||||
"viewDetailedInstructions": "View Detailed Instructions",
|
||||
|
||||
"recommendedConnectionMethods": "Recommended Connection Methods",
|
||||
"otherConnectionMethods": "Other Connection Methods",
|
||||
|
||||
"connectUsingBluetooth": "Connect using Bluetooth",
|
||||
"connectedTo": "Connected to {appId}",
|
||||
"@connectedTo": {
|
||||
"placeholders": {
|
||||
"appId": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"chooseBikeControlInConnectionScreen": "Choose BikeControl in the connection screen.",
|
||||
"letsAppConnectOverBluetooth": "Lets {appName} connect to BikeControl over Bluetooth.",
|
||||
"@letsAppConnectOverBluetooth": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"connectDirectlyOverNetwork": "Connect directly over Network",
|
||||
"letsAppConnectOverNetwork": "Lets {appName} connect directly over the Network. Choose BikeControl in the connection screen.",
|
||||
"@letsAppConnectOverNetwork": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"connectUsingMyWhooshLink": "Connect using MyWhoosh \"Link\"",
|
||||
"myWhooshLinkConnected": "MyWhoosh \"Link\" connected",
|
||||
"checkMyWhooshConnectionScreen": "Check the connection screen in MyWhoosh to see if \"Link\" is connected.",
|
||||
"myWhooshLinkDescriptionRemote": "Allows you to connect to MyWhoosh over the network, using the \"Link\" connection. The MyWhoosh Link companion app must NOT be running at the same time.",
|
||||
"myWhooshLinkDescriptionLocal": "Optional - allows you to do some additional features such as Emotes and turn directions. The MyWhoosh Link companion app must NOT be running at the same time.",
|
||||
"errorStartingMyWhooshLink": "Error starting MyWhoosh Link server. Please make sure the \"MyWhoosh Link\" app is not already running on this device.",
|
||||
|
||||
"enableZwiftControllerBluetooth": "Enable Zwift Controller (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Enable Zwift Controller (Network)",
|
||||
"zwiftControllerDescription": "Enables BikeControl to act as a Zwift-compatible controller.",
|
||||
"connected": "Connected",
|
||||
"waitingForConnectionKickrBike": "Waiting for connection. Choose KICKR BIKE PRO in {appName}'s controller pairing menu.",
|
||||
"@waitingForConnectionKickrBike": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"controlAppUsingModes": "Control {appName} using {modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" },
|
||||
"modes": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"enableKeyboardMouseControl": "Enable keyboard and mouse control for better interaction with {appName}.",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"accessibilityDescription": "BikeControl needs accessibility permission to control your training apps.",
|
||||
"accessibilityDisclaimer": "BikeControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.",
|
||||
"accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices",
|
||||
"accessibilityReasonTouch": "• To simulate touch gestures on your screen for controlling trainer apps",
|
||||
"accessibilityReasonWindow": "• To detect which training app window is currently active",
|
||||
"accessibilityServiceExplanation": "BikeControl needs to use Android's AccessibilityService API to function properly.",
|
||||
"accessibilityServiceNotRunning": "Accessibility Service is not running.\nFollow instructions at",
|
||||
|
||||
"enablePairingProcess": "Enable Pairing Process",
|
||||
"pairingDescription": "Pairing allows full customizability, but may not work on all devices.",
|
||||
"pairingInstructionsIOS": "On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.",
|
||||
"pairingInstructions": "On your {targetName} go into Bluetooth settings and look for BikeControl or your machines name. Pairing is required if you want to use the remote control feature.",
|
||||
"@pairingInstructions": {
|
||||
"placeholders": {
|
||||
"targetName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"adjustControllerButtons": "Adjust Controller Buttons",
|
||||
|
||||
"customizeControllerButtons": "Customize Controller buttons for {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"selectKeymap": "Select Keymap",
|
||||
"createNewKeymap": "Create new keymap",
|
||||
"customizeKeymapHint": "Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)",
|
||||
"connectControllerToPreview": "Connect a controller device to preview and customize the keymap.",
|
||||
"enableVibrationFeedback": "Enable vibration feedback when shifting gears",
|
||||
|
||||
"deviceButton": "{deviceName} button",
|
||||
"@deviceButton": {
|
||||
"placeholders": {
|
||||
"deviceName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"accessibilityServicePermissionRequired": "Accessibility Service Permission Required",
|
||||
"accessibilityUsageGestures": "• When you press buttons on your Zwift Click, Zwift Ride, or Zwift Play devices, BikeControl simulates touch gestures at specific screen locations",
|
||||
"accessibilityUsageMonitor": "• The app monitors which training app window is active to ensure gestures are sent to the correct app",
|
||||
"accessibilityUsageNoData": "• No personal data is accessed or collected through this service",
|
||||
"accessories": "Accessories",
|
||||
"action": "Action",
|
||||
"noButtonAssigned": "No button assigned for your connected device",
|
||||
"noActionAssigned": "No action assigned",
|
||||
|
||||
"openBikeControlActions": "OpenBikeControl actions",
|
||||
"adjustControllerButtons": "Adjust Controller Buttons",
|
||||
"allow": "Allow",
|
||||
"allowAccessibilityService": "Allow Accessibility Service",
|
||||
"allowBluetoothConnections": "Allow Bluetooth Connections",
|
||||
"allowBluetoothScan": "Allow Bluetooth Scan",
|
||||
"allowLocationForBluetooth": "Allow Location so Bluetooth scan works",
|
||||
"allowPersistentNotification": "Allow Notifications",
|
||||
"allowsRunningInBackground": "Allows BikeControl to keep running in background",
|
||||
"appIdActions": "{appId} actions",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
"appId": { "type": "String" }
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"myWhooshDirectConnectAction": "MyWhoosh Direct Connect Action",
|
||||
"zwiftControllerAction": "Zwift Controller Action",
|
||||
"custom": "Custom",
|
||||
"predefinedAction": "Predefined {appName} action",
|
||||
"@predefinedAction": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"noPredefinedActionsAvailable": "No predefined actions available",
|
||||
"simulateKeyboardShortcut": "Simulate Keyboard shortcut",
|
||||
"simulateTouch": "Simulate Touch",
|
||||
"simulateMediaKey": "Simulate Media key",
|
||||
"setting": "Setting",
|
||||
"longPressMode": "Long Press Mode (vs. repeating)",
|
||||
"unassignAction": "Unassign action",
|
||||
|
||||
"playPause": "Play/Pause",
|
||||
"stop": "Stop",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"volumeUp": "Volume Up",
|
||||
"volumeDown": "Volume Down",
|
||||
|
||||
"createdNewCustomProfile": "Created a new custom profile: {profileName}",
|
||||
"@createdNewCustomProfile": {
|
||||
"placeholders": {
|
||||
"profileName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"manageProfile": "Manage Profile",
|
||||
"rename": "Rename",
|
||||
"duplicate": "Duplicate",
|
||||
"importAction": "Import",
|
||||
"exportAction": "Export",
|
||||
"delete": "Delete",
|
||||
"battery": "Battery",
|
||||
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
|
||||
"bluetoothTurnedOn": "Bluetooth turned on",
|
||||
"browserNotSupported": "This Browser does not support Web Bluetooth and platform is not supported :(",
|
||||
"button": "button.",
|
||||
"cancel": "Cancel",
|
||||
"changelog": "Changelog",
|
||||
"checkMyWhooshConnectionScreen": "Check the connection screen in MyWhoosh to see if \"Link\" is connected.",
|
||||
"chooseAnotherScreenshot": "Choose another screenshot",
|
||||
"chooseBikeControlInConnectionScreen": "Choose BikeControl in the connection screen.",
|
||||
"clickAButtonOnYourController": "Click a button on your controller to edit its action or tap the edit icon.",
|
||||
"clickV2EventInfo": "Your Click V2 may no longer send button events. Please check by tapping a few buttons and see if they are visible in BikeControl.",
|
||||
"clickV2Instructions": "To make your Zwift Click V2 work best, you should connect it in the Zwift app before each training session.\nIf you don't do that, the Click V2 will stop working after a minute.\n\n1. Open Zwift app\n2. Log in (subscription not required) and open the device connection screen\n3. Connect your Trainer, then connect the Zwift Click V2\n4. Close the Zwift app again and connect again in BikeControl",
|
||||
"close": "Close",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} commands remaining today",
|
||||
"configuration": "Configuration",
|
||||
"connectControllerToPreview": "Connect a controller device to preview and customize the keymap.",
|
||||
"connectControllers": "Connect Controllers",
|
||||
"connectDirectlyOverNetwork": "Connect directly over Network",
|
||||
"connectToTrainerApp": "Connect to Trainer app",
|
||||
"connectUsingBluetooth": "Connect using Bluetooth",
|
||||
"connectUsingMyWhooshLink": "Connect using MyWhoosh \"Link\"",
|
||||
"connected": "Connected",
|
||||
"connectedControllers": "Connected Controllers",
|
||||
"connectedTo": "Connected to {appId}",
|
||||
"@connectedTo": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connection": "Connection",
|
||||
"continueAction": "Continue",
|
||||
"controlAppUsingModes": "Control {appName} using {modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"modes": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllers": "Controllers",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Could not perform {button}: No keymap set",
|
||||
"create": "Create",
|
||||
|
||||
"newCustomProfile": "New Custom Profile",
|
||||
"profileName": "Profile name",
|
||||
"renameProfile": "Rename Profile",
|
||||
"createNewKeymap": "Create new keymap",
|
||||
"createNewProfileByDuplicating": "Create new custom profile by duplicating \"{profileName}\"",
|
||||
"@createNewProfileByDuplicating": {
|
||||
"placeholders": {
|
||||
"profileName": { "type": "String" }
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"newProfileName": "New Profile Name",
|
||||
"createdNewCustomProfile": "Created a new custom profile: {profileName}",
|
||||
"@createdNewCustomProfile": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeControllerButtons": "Customize Controller buttons for {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)",
|
||||
"dailyCommandLimitReachedNotification": "Daily command limit reached for today. Upgrade to unlock the full version with unlimited commands.",
|
||||
"dailyLimitReached": "Daily limit reached ({dailyCommandCount}/{dailyCommandLimit} used)",
|
||||
"delete": "Delete",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirmation": "Are you sure you want to delete \"{profileName}\"? This action cannot be undone.",
|
||||
"@deleteProfileConfirmation": {
|
||||
"placeholders": {
|
||||
"profileName": { "type": "String" }
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"importProfile": "Import Profile",
|
||||
"pasteExportedJsonData": "Paste the exported JSON data below:",
|
||||
"jsonData": "JSON Data",
|
||||
"profileImportedSuccessfully": "Profile imported successfully",
|
||||
"failedToImportProfile": "Failed to import profile. Invalid format.",
|
||||
"profileExportedToClipboard": "Profile \"{profileName}\" exported to clipboard",
|
||||
"@profileExportedToClipboard": {
|
||||
"deny": "Deny",
|
||||
"deviceButton": "{deviceName} button",
|
||||
"@deviceButton": {
|
||||
"placeholders": {
|
||||
"profileName": { "type": "String" }
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"pressButtonOnClickDevice": "Press a button on your Click device",
|
||||
"pressKeyToAssign": "Press a key on your keyboard to assign to {buttonName}",
|
||||
"@pressKeyToAssign": {
|
||||
"placeholders": {
|
||||
"buttonName": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"ok": "OK",
|
||||
"waiting": "Waiting...",
|
||||
|
||||
"touchAreaInstructions": "1. Create an in-game screenshot of your app (e.g. within MyWhoosh) in landscape orientation\n2. Load the screenshot with the button below\n3. The app is automatically set to landscape orientation for accurate mapping\n4. Press a button on your Click device to create a touch area\n5. Drag the touch areas to the desired position on the screenshot\n6. Save and close this screen",
|
||||
"loadScreenshotForPlacement": "Load in-game screenshot for placement",
|
||||
"save": "Save",
|
||||
"chooseAnotherScreenshot": "Choose another screenshot",
|
||||
"reset": "Reset",
|
||||
"dragToReposition": "Drag to reposition",
|
||||
"longPress": "long\npress",
|
||||
|
||||
"videoInstructions": "Video Instructions",
|
||||
"theFollowingPermissionsRequired": "The following permissions are required:",
|
||||
"granted": "Granted",
|
||||
"grant": "Grant",
|
||||
"choosePreferredConnectionMethod": "Choose your preferred connection method",
|
||||
|
||||
"showDonation": "Show your appreciation by donating",
|
||||
"donateViaCreditCard": "via Credit Card, Google Pay, Apple Pay and others",
|
||||
"disconnectDevices": "Disconnect Devices",
|
||||
"disconnected": "Disconnected",
|
||||
"donateByBuyingFromPlayStore": "by buying the app from Play Store",
|
||||
"donateViaCreditCard": "via Credit Card, Google Pay, Apple Pay and others",
|
||||
"donateViaPaypal": "via PayPal",
|
||||
"leaveAReview": "Leave a Review",
|
||||
|
||||
"provideFeedback": "Provide Feedback",
|
||||
"download": "Download",
|
||||
"dragToReposition": "Drag to reposition",
|
||||
"duplicate": "Duplicate",
|
||||
"enableAutoRotation": "Enable auto-rotation on your device to make sure the app works correctly.",
|
||||
"enableBluetooth": "Enable Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Enable keyboard access in the following screen for BikeControl. If you don't see BikeControl, please add it manually.",
|
||||
"enableKeyboardMouseControl": "Enable keyboard and mouse control for better interaction with {appName}. Once active, no further action or connection is needed. BikeControl will directly send the mouse or keyboard input to {appName}.",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableMediaKeyDetection": "Enable Media Key Detection",
|
||||
"enablePairingProcess": "Enable Pairing Process",
|
||||
"enablePermissions": "Enable Permissions",
|
||||
"enableSteeringWithPhone": "Enable Phones' sensors to enable e.g. steering",
|
||||
"enableVibrationFeedback": "Enable vibration feedback when shifting gears",
|
||||
"enableZwiftControllerBluetooth": "Enable Zwift Controller (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Enable Zwift Controller (Network)",
|
||||
"errorStartingMyWhooshLink": "Error starting MyWhoosh Link server. Please make sure the \"MyWhoosh Link\" app is not already running on this device.",
|
||||
"errorStartingOpenBikeControlBluetoothServer": "Error starting OpenBikeControl Bluetooth server.",
|
||||
"errorStartingOpenBikeControlServer": "Error starting OpenBikeControl server.",
|
||||
"exportAction": "Export",
|
||||
"failedToImportProfile": "Failed to import profile. Invalid format.",
|
||||
"failedToUpdate": "Failed to update: {error}",
|
||||
"@failedToUpdate": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Force-close the app to use the new version",
|
||||
"fullVersion": "Full Version",
|
||||
"fullVersionDescription": "The full version includes:\n- Unlimited commands per day\n- Access to all future updates\n- No subscription! A one-time fee only :)",
|
||||
"getSupport": "Get Support",
|
||||
"gotIt": "Got it!",
|
||||
"grant": "Grant",
|
||||
"granted": "Granted",
|
||||
"helpRequested": "Help requested for BikeControl v{version}",
|
||||
"@helpRequested": {
|
||||
"placeholders": {
|
||||
"version": { "type": "String" }
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachLogFile": "Please also attach the file {logPath}, if it exists.\nPlease don't remove this information, it helps me to assist you better.",
|
||||
"@attachLogFile": {
|
||||
"howBikeControlUsesPermission": "How does BikeControl use this permission?",
|
||||
"ignoredDevices": "Ignored Devices",
|
||||
"importAction": "Import",
|
||||
"importProfile": "Import Profile",
|
||||
"instructions": "Instructions",
|
||||
"jsonData": "JSON Data",
|
||||
"keyboardAccess": "Keyboard access",
|
||||
"latestVersion": "latest: {version}",
|
||||
"@latestVersion": {
|
||||
"placeholders": {
|
||||
"logPath": { "type": "String" }
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveAReview": "Leave a Review",
|
||||
"letsAppConnectOverBluetooth": "Lets {appName} connect to BikeControl over Bluetooth.",
|
||||
"@letsAppConnectOverBluetooth": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsAppConnectOverNetwork": "Lets {appName} connect directly over the Network. Choose BikeControl in the connection screen.",
|
||||
"@letsAppConnectOverNetwork": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"simulateButtons": "Simulate buttons",
|
||||
"continueAction": "Continue",
|
||||
|
||||
"changelog": "Changelog",
|
||||
"license": "License",
|
||||
|
||||
"whatsNew": "What's New",
|
||||
"version": "Version {version}",
|
||||
"@version": {
|
||||
"placeholders": {
|
||||
"version": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"gotIt": "Got it!",
|
||||
|
||||
"licenseStatus": "License Status",
|
||||
"loadScreenshotForPlacement": "Load in-game screenshot for placement",
|
||||
"logViewer": "Log Viewer",
|
||||
"share": "Share",
|
||||
"logsHaveBeenCopiedToClipboard": "Logs have been copied to clipboard",
|
||||
"logs": "Logs",
|
||||
"logsAreAlsoAt": "Logs are also at",
|
||||
"pathCopiedToClipboard": "Path has been copied to clipboard",
|
||||
|
||||
"appStartError": "There was an error starting the App. Please contact support:\n{error}",
|
||||
"@appStartError": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
}
|
||||
},
|
||||
|
||||
"sideloaded": "(sideloaded)",
|
||||
"logsHaveBeenCopiedToClipboard": "Logs have been copied to clipboard",
|
||||
"longPress": "long\npress",
|
||||
"longPressMode": "Long Press Mode (vs. repeating)",
|
||||
"mailSupportExplanation": "Providing individual support via email is a lot of work for me.\n\nPlease consider using Reddit, Facebook or GitHub for questions and issues so that the whole community can benefit from it.",
|
||||
"manageIgnoredDevices": "Manage Ignored Devices",
|
||||
"manageProfile": "Manage Profile",
|
||||
"manualyControllingButton": "Control {trainerApp} manually!",
|
||||
"mediaKeyDetectionTooltip": "Enable this option to allow BikeControl to detect bluetooth remotes.\nIn order to do so BikeControl needs to act as a media player.",
|
||||
"miuiDeviceDetected": "MIUI Device Detected",
|
||||
"miuiDisableBatteryOptimization": "• Disable battery optimization for BikeControl",
|
||||
"miuiEnableAutostart": "• Enable autostart for BikeControl",
|
||||
"miuiEnsureProperWorking": "To ensure BikeControl works properly:",
|
||||
"miuiLockInRecentApps": "• Lock the app in recent apps",
|
||||
"miuiWarningDescription": "Your device is running MIUI, which is known to aggressively kill background services and accessibility services.",
|
||||
"moreInformation": "More Information",
|
||||
"mustChooseAllowOrDeny": "You must choose to either Allow or Deny this permission to continue.",
|
||||
"myWhooshDirectConnectAction": "MyWhoosh \"Link\" Action",
|
||||
"myWhooshDirectConnection": " e.g. by using MyWhoosh \"Link\"",
|
||||
"myWhooshLinkConnected": "MyWhoosh \"Link\" connected",
|
||||
"myWhooshLinkDescriptionLocal": "Connect directly to MyWhoosh via the \"Link\" method. Supported actions are shifting, Emotes, turn directions, among others. The MyWhoosh Link companion app must NOT be running at the same time.",
|
||||
"myWhooshLinkInfo": "Please check the troubleshooting section if you encounter any issues. A much more reliable connection method is coming soon!",
|
||||
"needHelpClickHelp": "Need help? Click on the",
|
||||
"needHelpDontHesitate": "button on top and don't hesitate to contact us.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp} will soon support much better, reliable connection methods - stay tuned for updates!",
|
||||
"newCustomProfile": "New Custom Profile",
|
||||
"newProfileName": "New Profile Name",
|
||||
"newVersionAvailable": "New version available",
|
||||
"newVersionAvailableWithVersion": "New version available: {version}",
|
||||
"@newVersionAvailableWithVersion": {
|
||||
"placeholders": {
|
||||
"version": { "type": "String" }
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"update": "Update",
|
||||
"download": "Download",
|
||||
"forceCloseToUpdate": "Force-close the app to use the new version",
|
||||
"restart": "Restart",
|
||||
"failedToUpdate": "Failed to update: {error}",
|
||||
"@failedToUpdate": {
|
||||
"next": "Next",
|
||||
"noActionAssigned": "No action assigned",
|
||||
"noActionAssignedForButton": "Could not perform {button}: No action assigned",
|
||||
"noConnectionMethodIsConnectedOrActive": "No connection method is connected or active.",
|
||||
"noConnectionMethodSelected": "No Connection Method selected",
|
||||
"noControllerConnected": "None connected",
|
||||
"noControllerUseCompanionMode": "No Controller? Use Companion Mode.",
|
||||
"noIgnoredDevices": "No ignored devices.",
|
||||
"noTrainerSelected": "No Trainer selected",
|
||||
"notConnected": "Not connected",
|
||||
"notificationDescription": "This keeps the app alive in background and updates you when the connection to your devices changes.",
|
||||
"ok": "OK",
|
||||
"openBikeControlActions": "OpenBikeControl actions",
|
||||
"openBikeControlAnnouncement": "Great news - {trainerApp} supports the OpenBikeControl Protocol, so you'll have the best possible experience!",
|
||||
"openBikeControlConnection": " e.g. by using OpenBikeControl connection",
|
||||
"otherConnectionMethods": "Other Connection Methods",
|
||||
"pairingDescription": "Pairing allows full customizability, but may not work on all devices.",
|
||||
"pairingInstructions": "On your {targetName} go into Bluetooth settings and look for BikeControl or your machines name. Pairing is required if you want to use the remote control feature.",
|
||||
"@pairingInstructions": {
|
||||
"placeholders": {
|
||||
"error": { "type": "String" }
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"connection": "Connection",
|
||||
"disconnected": "Disconnected",
|
||||
"battery": "Battery",
|
||||
"firmware": "Firmware",
|
||||
"latestVersion": "latest: {version}",
|
||||
"@latestVersion": {
|
||||
"placeholders": {
|
||||
"version": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"signal": "Signal",
|
||||
|
||||
"accessibilityServicePermissionRequired": "Accessibility Service Permission Required",
|
||||
"accessibilityServiceExplanation": "BikeControl needs to use Android's AccessibilityService API to function properly.",
|
||||
"whyPermissionNeeded": "Why is this permission needed?",
|
||||
"accessibilityReasonTouch": "• To simulate touch gestures on your screen for controlling trainer apps",
|
||||
"accessibilityReasonWindow": "• To detect which training app window is currently active",
|
||||
"accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices",
|
||||
"howBikeControlUsesPermission": "How does BikeControl use this permission?",
|
||||
"accessibilityUsageGestures": "• When you press buttons on your Zwift Click, Zwift Ride, or Zwift Play devices, BikeControl simulates touch gestures at specific screen locations",
|
||||
"accessibilityUsageMonitor": "• The app monitors which training app window is active to ensure gestures are sent to the correct app",
|
||||
"accessibilityUsageNoData": "• No personal data is accessed or collected through this service",
|
||||
"accessibilityDisclaimer": "BikeControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.",
|
||||
"mustChooseAllowOrDeny": "You must choose to either Allow or Deny this permission to continue.",
|
||||
"deny": "Deny",
|
||||
"allow": "Allow",
|
||||
|
||||
"allowAccessibilityService": "Allow Accessibility Service",
|
||||
"accessibilityDescription": "BikeControl needs accessibility permission to control your training apps.",
|
||||
"allowBluetoothScan": "Allow Bluetooth Scan",
|
||||
"allowLocationForBluetooth": "Allow Location so Bluetooth scan works",
|
||||
"allowBluetoothConnections": "Allow Bluetooth Connections",
|
||||
"allowPersistentNotification": "Allow persistent Notification",
|
||||
"notificationDescription": "This keeps the app alive in background",
|
||||
"allowsRunningInBackground": "Allows BikeControl to keep running in background",
|
||||
"disconnectDevices": "Disconnect Devices",
|
||||
|
||||
"keyboardAccess": "Keyboard access",
|
||||
"enableKeyboardAccessMessage": "Enable keyboard access in the following screen for BikeControl. If you don't see BikeControl, please add it manually.",
|
||||
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
|
||||
"bluetoothTurnedOn": "Bluetooth turned on",
|
||||
"enableBluetooth": "Enable Bluetooth",
|
||||
"pairingInstructionsIOS": "On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.",
|
||||
"pasteExportedJsonData": "Paste the exported JSON data below:",
|
||||
"pathCopiedToClipboard": "Path has been copied to clipboard",
|
||||
"permissionsRequired": "In order for BikeControl to search for nearby devices and update you when the connection changes, please enable the following permissions:",
|
||||
"platformNotSupported": "This {platform} is not supported :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
"platform": { "type": "String" }
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"browserNotSupported": "This Browser does not support Web Bluetooth and platform is not supported :(",
|
||||
"selectTrainerAppAndTarget": "Select Trainer App & Target Device",
|
||||
"selectTargetDeviceDescription": "Select your Target Device where you want to run your trainer app on",
|
||||
"requirement": "Requirement",
|
||||
|
||||
"needHelpClickHelp": "Need help? Click on the",
|
||||
"needHelpDontHesitate": "button on top and don't hesitate to contact us.",
|
||||
|
||||
"touchSimulationForegroundMessage": "To simulate touches the app needs to stay in the foreground.",
|
||||
|
||||
"useCustomKeymapForButton": "Use a custom keymap to support the",
|
||||
"button": "button.",
|
||||
|
||||
"openBikeControlConnection": " e.g. by using OpenBikeControl connection",
|
||||
"myWhooshDirectConnection": " e.g. by using MyWhoosh Direct Connect",
|
||||
|
||||
"appNameOnTargetName": "{appName} on {targetName}",
|
||||
"@appNameOnTargetName": {
|
||||
"platformRestrictionNotSupported": "Due to platform restrictions this scenario is not supported.",
|
||||
"platformRestrictionOtherDevicesOnly": "Due to platform restrictions only controlling {appName} on other devices is supported.",
|
||||
"@platformRestrictionOtherDevicesOnly": {
|
||||
"placeholders": {
|
||||
"appName": { "type": "String" },
|
||||
"targetName": { "type": "String" }
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"bikeControlPlatform": "BikeControl {platform}",
|
||||
"@bikeControlPlatform": {
|
||||
"placeholders": {
|
||||
"platform": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"clickV2Instructions": "To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that the Click V2 will stop working after a minute.\n\n1. Open Zwift app\n2. Log in (subscription not required) and open the device connection screen\n3. Connect your Trainer, then connect the Zwift Click V2\n4. Close the Zwift app again and connect again in BikeControl",
|
||||
"playPause": "Play/Pause",
|
||||
"pleaseSelectAConnectionMethodFirst": "Please select a connection method in the Trainer settings, first.",
|
||||
"noConnectionMethodSelected": "No Connection Method selected",
|
||||
"noControllerConnected": "None connected",
|
||||
"notConnected": "Not connected",
|
||||
"noTrainerSelected": "No Trainer selected",
|
||||
"instructions": "Instructions",
|
||||
"mailSupportExplanation": "Providing individual support via email is a lot of work for me.\n\nPlease consider using Reddit, Facebook or GitHub for questions and issues so that the whole community can benefit from it.",
|
||||
"myWhooshLinkInfo": "Please check the troubleshooting section if you encounter any issues. A much more reliable connection method is coming soon!",
|
||||
"clickV2EventInfo": "Your Click V2 may no longer send button events. Please check by tapping a few buttons and see if they are visible in BikeControl."
|
||||
}
|
||||
"predefinedAction": "Predefined {appName} action",
|
||||
"@predefinedAction": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressButtonOnClickDevice": "Press a button on your Click device",
|
||||
"pressKeyToAssign": "Press a key on your keyboard to assign to {buttonName}",
|
||||
"@pressKeyToAssign": {
|
||||
"placeholders": {
|
||||
"buttonName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": "Previous",
|
||||
"profileExportedToClipboard": "Profile \"{profileName}\" exported to clipboard",
|
||||
"@profileExportedToClipboard": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileImportedSuccessfully": "Profile imported successfully",
|
||||
"profileName": "Profile name",
|
||||
"purchase": "Purchase",
|
||||
"recommendedConnectionMethods": "Recommended Connection Methods",
|
||||
"removeFromIgnoredList": "Remove from ignored list",
|
||||
"rename": "Rename",
|
||||
"renameProfile": "Rename Profile",
|
||||
"requirement": "Requirement",
|
||||
"reset": "Reset",
|
||||
"restart": "Restart",
|
||||
"runAppOnPlatformRemotely": "Run {appName} on {platform} and control it remotely from this device{preferredConnection}.",
|
||||
"@runAppOnPlatformRemotely": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"platform": {
|
||||
"type": "String"
|
||||
},
|
||||
"preferredConnection": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runAppOnThisDevice": "Run {appName} on this device.",
|
||||
"@runAppOnThisDevice": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": "Save",
|
||||
"scan": "SCAN",
|
||||
"scanningForDevices": "Scanning for devices... Make sure they are powered on and in range and not connected to another device.",
|
||||
"selectKeymap": "Select Keymap",
|
||||
"selectTargetWhereAppRuns": "Select Target where {appName} runs on",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Select Trainer App",
|
||||
"selectTrainerAppAndTarget": "Select Trainer App & Target Device",
|
||||
"selectTrainerAppPlaceholder": "Select Trainer app",
|
||||
"setting": "Setting",
|
||||
"setupTrainer": "Setup Trainer",
|
||||
"share": "Share",
|
||||
"showDonation": "Show your appreciation by donating",
|
||||
"showSupportedControllers": "Show Supported Controllers",
|
||||
"showTroubleshootingGuide": "Show Troubleshooting Guide",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Trainer Controls",
|
||||
"simulateKeyboardShortcut": "Simulate Keyboard shortcut",
|
||||
"simulateMediaKey": "Simulate Media key",
|
||||
"simulateTouch": "Simulate Touch",
|
||||
"stop": "Stop",
|
||||
"targetOtherDevice": "Other Device",
|
||||
"targetThisDevice": "This Device",
|
||||
"theFollowingPermissionsRequired": "The following permissions are required:",
|
||||
"touchAreaInstructions": "1. Create an in-game screenshot of your app (e.g. within MyWhoosh) in landscape orientation\n2. Load the screenshot with the button below\n3. The app is automatically set to landscape orientation for accurate mapping\n4. Press a button on your Click device to create a touch area\n5. Drag the touch areas to the desired position on the screenshot\n6. Save and close this screen",
|
||||
"touchSimulationForegroundMessage": "To simulate touches the app needs to stay in the foreground.",
|
||||
"trainer": "Trainer",
|
||||
"trialDaysRemaining": "{trialDaysRemaining} days remaining",
|
||||
"trialExpired": "Trial expired. Commands limited to {dailyCommandLimit} per day.",
|
||||
"trialPeriodActive": "Trial Period Active - {trialDaysRemaining} days remaining",
|
||||
"trialPeriodDescription": "Enjoy unlimited commands during your trial period. After the trial, commands will be limited to {dailyCommandLimit} per day.",
|
||||
"troubleshootingGuide": "Troubleshooting Guide",
|
||||
"tryingToConnectAgain": "Trying to connect again...",
|
||||
"unassignAction": "Unassign action",
|
||||
"unlockFullVersion": "Unlock Full Version",
|
||||
"update": "Update",
|
||||
"useCustomKeymapForButton": "Use a custom keymap to support the",
|
||||
"version": "Version {version}",
|
||||
"@version": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewDetailedInstructions": "View Detailed Instructions",
|
||||
"volumeDown": "Volume Down",
|
||||
"volumeUp": "Volume Up",
|
||||
"waiting": "Waiting...",
|
||||
"waitingForConnectionKickrBike": "Waiting for connection. Choose KICKR BIKE PRO in {appName}'s controller pairing menu.",
|
||||
"@waitingForConnectionKickrBike": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whatsNew": "What's New",
|
||||
"whyPermissionNeeded": "Why is this permission needed?",
|
||||
"zwiftControllerAction": "Zwift Controller Action",
|
||||
"zwiftControllerDescription": "Enables BikeControl to act as a Zwift-compatible controller."
|
||||
}
|
||||
@@ -10,15 +10,15 @@
|
||||
"accessibilityUsageGestures": "• Lorsque vous appuyez sur les boutons de vos appareils Zwift Click, Zwift Ride ou Zwift Play, BikeControl simule des gestes tactiles à des emplacements spécifiques de l'écran.",
|
||||
"accessibilityUsageMonitor": "• L'application surveille quelle fenêtre de l'application d'entraînement est active afin de s'assurer que les gestes sont envoyés à la bonne application.",
|
||||
"accessibilityUsageNoData": "• Aucune donnée personnelle n'est consultée ou collectée par le biais de ce service.",
|
||||
"accessories": "Accessoires",
|
||||
"action": "Action",
|
||||
"adjust": "Ajuster",
|
||||
"adjustControllerButtons": "Ajuster les boutons de la manette",
|
||||
"allow": "Permettre",
|
||||
"allowAccessibilityService": "Autoriser le service d'accessibilité",
|
||||
"allowBluetoothConnections": "Autoriser les connexions Bluetooth",
|
||||
"allowBluetoothScan": "Autoriser la recherche Bluetooth",
|
||||
"allowLocationForBluetooth": "Autoriser la localisation pour que la recherche Bluetooth fonctionne",
|
||||
"allowPersistentNotification": "Autoriser les notifications persistantes",
|
||||
"allowPersistentNotification": "Autoriser les notifications",
|
||||
"allowsRunningInBackground": "Permet à BikeControl de continuer à fonctionner en arrière-plan",
|
||||
"appIdActions": "{appId} actions",
|
||||
"@appIdActions": {
|
||||
@@ -28,42 +28,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"appNameOnTargetName": "{appName} sur {targetName}",
|
||||
"@appNameOnTargetName": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appStartError": "Une erreur s'est produite lors du démarrage de l'application. Veuillez contacter le service d'assistance :\n{error}",
|
||||
"@appStartError": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"attachLogFile": "Veuillez également joindre le fichier {logPath}, s'il existe.\nVeuillez ne pas supprimer ces informations, elles m'aident à mieux vous aider.",
|
||||
"@attachLogFile": {
|
||||
"placeholders": {
|
||||
"logPath": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery": "Batterie",
|
||||
"bikeControlPlatform": "BikeControl {platform}",
|
||||
"@bikeControlPlatform": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bluetoothAdvertiseAccess": "Accès à la publicité Bluetooth",
|
||||
"bluetoothTurnedOn": "Bluetooth activé",
|
||||
"browserNotSupported": "Ce navigateur ne prend pas en charge Web Bluetooth et la plateforme n'est pas prise en charge :(",
|
||||
@@ -73,9 +38,11 @@
|
||||
"checkMyWhooshConnectionScreen": "Vérifiez l'écran de connexion dans MyWhoosh pour voir si « Link » est connecté.",
|
||||
"chooseAnotherScreenshot": "Choisissez une autre capture d'écran",
|
||||
"chooseBikeControlInConnectionScreen": "Sélectionnez BikeControl dans l'écran de connexion.",
|
||||
"choosePreferredConnectionMethod": "Choisissez votre méthode de connexion préférée",
|
||||
"clickAButtonOnYourController": "Cliquez sur un bouton de votre manette pour modifier son action ou appuyez sur l'icône de modification.",
|
||||
"clickV2EventInfo": "Votre Click V2 n'envoie peut-être plus les événements liés aux boutons. Vérifiez en appuyant sur quelques boutons et voyez s'ils apparaissent dans BikeControl.",
|
||||
"clickV2Instructions": "Pour que ton Zwift Click V2 marche super bien, tu dois le connecter à l'appli Zwift une fois par jour.\nSi tu ne le fais pas, le Click V2 s'arrêtera de fonctionner au bout d'une minute.\n\n1. Ouvre l'appli Zwift.\n2. Connecte-toi (pas besoin d'abonnement) et ouvre l'écran de connexion des appareils.\n3. Connecte ton home trainer, puis connecte le Zwift Click V2.\n4. Ferme l'appli Zwift et reconnecte-toi dans BikeControl.",
|
||||
"close": "Fermer",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} commandes restantes aujourd'hui",
|
||||
"configuration": "Configuration",
|
||||
"connectControllerToPreview": "Connectez un périphérique de contrôle pour prévisualiser et personnaliser la configuration des touches.",
|
||||
"connectControllers": "Connectez les contrôleurs",
|
||||
@@ -107,6 +74,7 @@
|
||||
}
|
||||
},
|
||||
"controllers": "Contrôleurs",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Impossible d'effectuer {button}: Aucun clavier défini",
|
||||
"create": "Créer",
|
||||
"createNewKeymap": "Créer un nouveau clavier",
|
||||
"createNewProfileByDuplicating": "Créer un nouveau profil personnalisé en dupliquant «{profileName} ».",
|
||||
@@ -125,7 +93,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"custom": "Coutume",
|
||||
"customizeControllerButtons": "Personnalisez les boutons de la manette pour {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
@@ -135,6 +102,8 @@
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Personnalisez la configuration des touches si vous rencontrez des problèmes (par exemple, une sortie clavier incorrecte ou un placement des touches mal aligné).",
|
||||
"dailyCommandLimitReachedNotification": "Limite de commandes journalières atteinte pour aujourd'hui. Passez à la version supérieure pour débloquer la version complète avec commandes illimitées.",
|
||||
"dailyLimitReached": "Limite journalière atteinte ({dailyCommandCount} /{dailyCommandLimit} utilisé)",
|
||||
"delete": "Supprimer",
|
||||
"deleteProfile": "Supprimer le profil",
|
||||
"deleteProfileConfirmation": "Êtes-vous sûr de vouloir supprimer «{profileName} » ? Cette action ne peut pas être annulée.",
|
||||
@@ -165,7 +134,7 @@
|
||||
"enableAutoRotation": "Activez la rotation automatique sur votre appareil pour vous assurer que l'application fonctionne correctement.",
|
||||
"enableBluetooth": "Activer le Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Activez l'accès au clavier dans l'écran suivant pour BikeControl. Si vous ne voyez pas BikeControl, veuillez l'ajouter manuellement.",
|
||||
"enableKeyboardMouseControl": "Activez le contrôle du clavier et de la souris pour une meilleure interaction avec {appName}.",
|
||||
"enableKeyboardMouseControl": "Activez le contrôle du clavier et de la souris pour une meilleure interaction avec {appName} Une fois activé, aucune autre action ni connexion n'est nécessaire. BikeControl enverra directement les entrées de la souris ou du clavier à {appName} .",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
@@ -176,6 +145,7 @@
|
||||
"enableMediaKeyDetection": "Activer la détection des touches multimédias",
|
||||
"enablePairingProcess": "Activer le processus d'appairage",
|
||||
"enablePermissions": "Activer les autorisations",
|
||||
"enableSteeringWithPhone": "Activez les capteurs du téléphone pour permettre, par exemple, la direction.",
|
||||
"enableVibrationFeedback": "Activer le retour haptique par vibration lors du changement de vitesse",
|
||||
"enableZwiftControllerBluetooth": "Activer le contrôleur Zwift (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Activer le contrôleur Zwift (réseau)",
|
||||
@@ -194,6 +164,8 @@
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Fermez de force l'application pour utiliser la nouvelle version.",
|
||||
"fullVersion": "Version complète",
|
||||
"fullVersionDescription": "La version complète comprend:\n- Commandes illimitées par jour\n- Accès à toutes les mises à jour futures \n- Aucun abonnement ! Un paiement unique :)",
|
||||
"getSupport": "Obtenir de l'aide",
|
||||
"gotIt": "Compris !",
|
||||
"grant": "Accorder",
|
||||
@@ -239,6 +211,7 @@
|
||||
}
|
||||
},
|
||||
"license": "Licence",
|
||||
"licenseStatus": "Statut de la licence",
|
||||
"loadScreenshotForPlacement": "Charger une capture d'écran du jeu pour le placement",
|
||||
"logViewer": "Visionneuse de journaux",
|
||||
"logs": "Journaux",
|
||||
@@ -249,6 +222,7 @@
|
||||
"mailSupportExplanation": "Répondre à tout le monde individuellement par e-mail, ça me prend beaucoup de temps.\n\nSi t'as des questions ou des problèmes, pense à utiliser Reddit, Facebook ou GitHub pour que tout le monde puisse en profiter.",
|
||||
"manageIgnoredDevices": "Gérer les périphériques ignorés",
|
||||
"manageProfile": "Gérer mon profil",
|
||||
"manualyControllingButton": "Contrôle {trainerApp} manuellement!",
|
||||
"mediaKeyDetectionTooltip": "Activez cette option pour permettre à BikeControl de détecter les télécommandes Bluetooth. Pour ce faire, BikeControl doit fonctionner comme un lecteur multimédia.",
|
||||
"miuiDeviceDetected": "Appareil MIUI détecté",
|
||||
"miuiDisableBatteryOptimization": "• Désactivez l'optimisation de la batterie pour BikeControl.",
|
||||
@@ -262,11 +236,10 @@
|
||||
"myWhooshDirectConnection": " par exemple en utilisant MyWhoosh «Link».",
|
||||
"myWhooshLinkConnected": "MyWhoosh « Link » connecté",
|
||||
"myWhooshLinkDescriptionLocal": "Connecte-toi directement à MyWhoosh avec la méthode « Link ». Tu peux faire des trucs comme changer de vitesse, utiliser des émoticônes, indiquer la direction à prendre, et plein d'autres choses. L'appli MyWhoosh Link ne doit PAS être ouverte en même temps.",
|
||||
"myWhooshLinkDescriptionRemote": "Ça te permet de te connecter à MyWhoosh via le réseau, en utilisant la connexion « Link ». L'appli MyWhoosh Link ne doit PAS être ouverte en même temps.",
|
||||
"myWhooshLinkInfo": "Si tu rencontres des problèmes, jette un œil à la section dépannage. Une méthode de connexion bien plus fiable sera bientôt disponible !",
|
||||
"nameChangeNotice": "SwiftControl devient BikeControl ! Ce logiciel fait partie du projet OpenBikeControl, qui promeut les standards ouverts pour les home trainers connectés et conçoit des contrôleurs matériels abordables !",
|
||||
"needHelpClickHelp": "Besoin d'aide ? Cliquez sur le",
|
||||
"needHelpDontHesitate": "bouton en haut et n'hésitez pas à nous contacter.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp} prendra bientôt en charge des méthodes de connexion bien meilleures et plus fiables - restez à l'écoute pour les mises à jour !",
|
||||
"newCustomProfile": "Nouveau profil personnalisé",
|
||||
"newProfileName": "Nouveau nom de profil",
|
||||
"newVersionAvailable": "Nouvelle version disponible",
|
||||
@@ -280,16 +253,18 @@
|
||||
},
|
||||
"next": "Suivant",
|
||||
"noActionAssigned": "Aucune action assignée",
|
||||
"noButtonAssigned": "Aucun bouton n'est attribué à votre appareil connecté.",
|
||||
"noActionAssignedForButton": "Impossible d'effectuer {button}: Aucune action assignée",
|
||||
"noConnectionMethodIsConnectedOrActive": "Aucune méthode de connexion n'est établie ou active.",
|
||||
"noConnectionMethodSelected": "Aucune méthode de connexion choisie",
|
||||
"noControllerConnected": "Aucun connecté",
|
||||
"noControllerUseCompanionMode": "Pas de manette ? Utilisez le mode compagnon.",
|
||||
"noIgnoredDevices": "Aucun appareil ignoré.",
|
||||
"noPredefinedActionsAvailable": "Aucune action prédéfinie disponible",
|
||||
"noTrainerSelected": "Aucun Trainer sélectionné",
|
||||
"notConnected": "Non connecté",
|
||||
"notificationDescription": "Cela permet à l'application de rester active en arrière-plan.",
|
||||
"notificationDescription": "Cela permet à l'application de rester active en arrière-plan et de vous informer lorsque la connexion à vos appareils change.",
|
||||
"ok": "OK",
|
||||
"openBikeControlActions": "Actions OpenBikeControl",
|
||||
"openBikeControlAnnouncement": "Excellente nouvelle! {trainerApp} Il prend en charge le protocole OpenBikeControl, vous bénéficierez donc de la meilleure expérience possible !",
|
||||
"openBikeControlConnection": " par exemple en utilisant la connexion OpenBikeControl",
|
||||
"otherConnectionMethods": "Autres méthodes de connexion",
|
||||
"pairingDescription": "Le jumelage permet une personnalisation complète, mais peut ne pas fonctionner sur tous les appareils.",
|
||||
@@ -304,7 +279,7 @@
|
||||
"pairingInstructionsIOS": "Sur votre iPad, allez dans Réglages > Accessibilité > Toucher > AssistiveTouch > Périphériques de pointage > Périphériques et appairez votre appareil. Assurez-vous que la fonction AssistiveTouch est activée.",
|
||||
"pasteExportedJsonData": "Collez ci-dessous les données JSON exportées :",
|
||||
"pathCopiedToClipboard": "Le chemin a été copié dans le presse-papiers.",
|
||||
"permissionsRequired": "Pour que BikeControl puisse rechercher les appareils à proximité, veuillez activer les autorisations suivantes :",
|
||||
"permissionsRequired": "Pour que BikeControl puisse rechercher les appareils à proximité et vous informer en cas de changement de connexion, veuillez activer les autorisations suivantes:",
|
||||
"platformNotSupported": "Cette {platform} n'est pas prise en charge :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
@@ -352,15 +327,7 @@
|
||||
},
|
||||
"profileImportedSuccessfully": "Profil importé avec succès",
|
||||
"profileName": "Nom du profil",
|
||||
"provideFeedback": "Donnez votre avis",
|
||||
"recommendDownloadBikeControl": "Nous vous recommandons vivement de télécharger et d'utiliser BikeControl sur cet appareil {platform}.",
|
||||
"@recommendDownloadBikeControl": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"purchase": "Achat",
|
||||
"recommendedConnectionMethods": "Méthodes de connexion recommandées",
|
||||
"removeFromIgnoredList": "Supprimer de la liste ignorée",
|
||||
"rename": "Rebaptiser",
|
||||
@@ -394,16 +361,6 @@
|
||||
"scan": "BALAYAGE",
|
||||
"scanningForDevices": "Recherche d'appareils en cours... Assurez-vous qu'ils sont allumés, à portée et non connectés à un autre appareil.",
|
||||
"selectKeymap": "Sélectionner le clavier",
|
||||
"selectOtherDeviceWhereAppRuns": "Sélectionnez l'autre appareil sur lequel {appName} fonctionne.",
|
||||
"@selectOtherDeviceWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTargetDevice": "Sélectionner l'appareil cible",
|
||||
"selectTargetDeviceDescription": "Sélectionnez l'appareil cible sur lequel vous souhaitez exécuter votre application d'entraînement.",
|
||||
"selectTargetWhereAppRuns": "Sélectionnez la cible où {appName} fonctionne sur",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
@@ -412,14 +369,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectThisDeviceWarning": "Sélectionnez « Cet appareil » sauf si vous souhaitez contrôler un autre appareil {platform}. Êtes-vous sûr ?",
|
||||
"@selectThisDeviceWarning": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Sélectionner l'application Trainer",
|
||||
"selectTrainerAppAndTarget": "Sélectionnez l'application Trainer et l'appareil cible",
|
||||
"selectTrainerAppPlaceholder": "Sélectionner l'application Trainer",
|
||||
@@ -429,36 +378,26 @@
|
||||
"showDonation": "Exprimez votre reconnaissance en faisant un don",
|
||||
"showSupportedControllers": "Afficher les manettes compatibles",
|
||||
"showTroubleshootingGuide": "Afficher le guide de dépannage",
|
||||
"sideloaded": "(transféré)",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Simuler des boutons",
|
||||
"simulateButtons": "Commandes de l'entraîneur",
|
||||
"simulateKeyboardShortcut": "Simuler un raccourci clavier",
|
||||
"simulateMediaKey": "Simuler la touche média",
|
||||
"simulateTouch": "Simuler le toucher",
|
||||
"stop": "Arrêt",
|
||||
"targetAndroid": "Appareil Android",
|
||||
"targetIOS": "iPhone / iPad / Apple TV",
|
||||
"targetMacOS": "Mac",
|
||||
"targetOtherDevice": "Autre appareil",
|
||||
"targetThisDevice": "Cet appareil",
|
||||
"targetWindows": "PC Windows",
|
||||
"theFollowingPermissionsRequired": "Les autorisations suivantes sont requises :",
|
||||
"touchAreaInstructions": "1. Créez une capture d'écran de votre application (par exemple dans MyWhoosh) en mode paysage.\n2. Chargez la capture d'écran à l'aide du bouton ci-dessous.\n3. L'application est automatiquement réglée en mode paysage pour un mappage précis.\n4. Appuyez sur un bouton de votre appareil Click pour créer une zone tactile.\n5. Faites glisser les zones tactiles vers la position souhaitée sur la capture d'écran.\n6. Enregistrez et fermez cet écran.",
|
||||
"touchSimulationForegroundMessage": "Pour simuler les touches, l'application doit rester au premier plan.",
|
||||
"trainer": "Trainer",
|
||||
"trainerSetup": "Configuration du home trainer: {appName} sur {targetName}",
|
||||
"@trainerSetup": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trialDaysRemaining": "{trialDaysRemaining} jours restants",
|
||||
"trialExpired": "Période d'essai expirée. Commandes limitées à {dailyCommandLimit} par jour.",
|
||||
"trialPeriodActive": "Période d'essai active -{trialDaysRemaining} jours restants",
|
||||
"trialPeriodDescription": "Profitez de commandes illimitées pendant votre période d'essai. Après la période d'essai, le nombre de commandes sera limité à {dailyCommandLimit} par jour.",
|
||||
"troubleshootingGuide": "Guide de dépannage",
|
||||
"tryingToConnectAgain": "Tentative de reconnexion...",
|
||||
"unassignAction": "Action de désaffectation",
|
||||
"unlockFullVersion": "Débloquer la version complète",
|
||||
"update": "Mise à jour",
|
||||
"useCustomKeymapForButton": "Utilisez une configuration de touches personnalisée pour prendre en charge",
|
||||
"version": "Version {version}",
|
||||
@@ -469,7 +408,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"videoInstructions": "Instructions vidéo",
|
||||
"viewDetailedInstructions": "Consultez les instructions détaillées",
|
||||
"volumeDown": "Baisser le volume",
|
||||
"volumeUp": "Augmenter le volume",
|
||||
|
||||
427
lib/i10n/intl_pl.arb
Normal file
427
lib/i10n/intl_pl.arb
Normal file
@@ -0,0 +1,427 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl potrzebuje uprawnień dostępu, aby móc sterować aplikacjami treningowymi.",
|
||||
"accessibilityDisclaimer": "BikeControl będzie miał dostęp do Twojego ekranu wyłącznie w celu wykonania skonfigurowanych przez Ciebie gestów. Nie będzie miał dostępu do żadnych innych funkcji ułatwień dostępu ani danych osobowych.",
|
||||
"accessibilityReasonControl": "• Aby umożliwić Ci sterowanie aplikacjami takimi jak MyWhoosh, IndieVelo i innymi za pomocą urządzeń Zwift",
|
||||
"accessibilityReasonTouch": "• Aby symulować gesty dotykowe na ekranie w celu sterowania aplikacją treningową",
|
||||
"accessibilityReasonWindow": "• Aby wykryć, które okno aplikacji treningowej jest aktualnie aktywne",
|
||||
"accessibilityServiceExplanation": "Aby działać prawidłowo, BikeControl musi korzystać z interfejsu API AccessibilityService systemu Android.",
|
||||
"accessibilityServiceNotRunning": "Usługa ułatwień dostępu nie działa.\nPostępuj zgodnie z instrukcjami na",
|
||||
"accessibilityServicePermissionRequired": "Wymagane uprawnienie usługi ułatwień dostępu",
|
||||
"accessibilityUsageGestures": "• Gdy naciskasz przyciski na urządzeniach Zwift Click, Zwift Ride lub Zwift Play, BikeControl symuluje gesty dotykowe w określonych miejscach ekranu",
|
||||
"accessibilityUsageMonitor": "• Aplikacja monitoruje, które okno aplikacji treningowej jest aktywne, aby zapewnić, że gesty są wysyłane do właściwej aplikacji",
|
||||
"accessibilityUsageNoData": "• Ta usługa nie uzyskuje dostępu do danych osobowych, ani ich nie gromadzi",
|
||||
"accessories": "Akcesoria",
|
||||
"action": "Działanie",
|
||||
"adjustControllerButtons": "Dostosuj przyciski kontrolera",
|
||||
"allow": "Zezwól",
|
||||
"allowAccessibilityService": "Zezwól na usługę ułatwień dostępu",
|
||||
"allowBluetoothConnections": "Zezwól na połączenia Bluetooth",
|
||||
"allowBluetoothScan": "Zezwól na skanowanie Bluetooth",
|
||||
"allowLocationForBluetooth": "Zezwól na dostęp do lokalizacji, aby umożliwić skanowanie Bluetooth",
|
||||
"allowPersistentNotification": "Zezwól na powiadomienia",
|
||||
"allowsRunningInBackground": "Umożliwia działanie BikeControl w tle",
|
||||
"appIdActions": "{appId} działania",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery": "Bateria",
|
||||
"bluetoothAdvertiseAccess": "Dostęp do reklamy Bluetooth",
|
||||
"bluetoothTurnedOn": "Włączono Bluetooth",
|
||||
"browserNotSupported": "Ta przeglądarka nie obsługuje technologii Web Bluetooth i platforma nie jest obsługiwana :(",
|
||||
"button": "przycisk.",
|
||||
"cancel": "Anuluj",
|
||||
"changelog": "Changelog",
|
||||
"checkMyWhooshConnectionScreen": "Sprawdź ekran połączenia w MyWhoosh, aby zobaczyć, czy „Link” jest połączony.",
|
||||
"chooseAnotherScreenshot": "Wybierz inny zrzut ekranu",
|
||||
"chooseBikeControlInConnectionScreen": "Wybierz BikeControl na ekranie połączenia.",
|
||||
"clickAButtonOnYourController": "Kliknij przycisk na kontrolerze, aby edytować jego akcję lub naciśnij ikonę edycji.",
|
||||
"clickV2EventInfo": "Twóje Click V2 może już nie wysyłać zdarzeń przycisków. Sprawdź, naciskając kilka przycisków, czy są one widoczne w BikeControl.",
|
||||
"clickV2Instructions": "Aby Twoje Zwift Click V2 działały optymalnie, należy połączyć je z aplikacją Zwift przed każdym treningiem.\nJeśli tego nie zrobisz, Click V2 przestanie działać po minucie.\n\n1. Otwórz aplikację Zwift\n2. Zaloguj się (nie wymaga subskrypcji) i otwórz ekran połączeń z urządzeniami\n3. Połącz się z trenażerem, a następnie z Zwift Click V2\n4. Zamknij aplikację Zwift i ponownie połącz się z BikeControl",
|
||||
"close": "Zamknij",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} dzisiejsze pozostałe polecenia",
|
||||
"configuration": "Konfiguracja",
|
||||
"connectControllerToPreview": "Podłącz urządzenie sterujące, aby wyświetlić podgląd i dostosować mapę klawiszy.",
|
||||
"connectControllers": "Połącz kontrolery",
|
||||
"connectDirectlyOverNetwork": "Połącz bezpośrednio przez sieć",
|
||||
"connectToTrainerApp": "Połącz z aplikacją treningową",
|
||||
"connectUsingBluetooth": "Połącz przez Bluetooth",
|
||||
"connectUsingMyWhooshLink": "Połącz się za pomocą MyWhoosh „Link”",
|
||||
"connected": "Połączono",
|
||||
"connectedControllers": "Połączone kontrolery",
|
||||
"connectedTo": "Połączono z {appId}",
|
||||
"@connectedTo": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connection": "Połączenie",
|
||||
"continueAction": "Kontynuuj",
|
||||
"controlAppUsingModes": "Kontroluj {appName} za pomocą {modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"modes": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllers": "Kontrolery",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Nie można wykonać {button}: Nie zmapowano klawisza",
|
||||
"create": "Utwórz",
|
||||
"createNewKeymap": "Utwórz nową mapę klawiszy",
|
||||
"createNewProfileByDuplicating": "Utwórz nowy profil niestandardowy, kopiując „{profileName}\"",
|
||||
"@createNewProfileByDuplicating": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"createdNewCustomProfile": "Utworzono nowy profil niestandardowy: {profileName}",
|
||||
"@createdNewCustomProfile": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeControllerButtons": "Dostosuj przyciski kontrolera dla {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Jeśli występują jakiekolwiek problemy (np. nieprawidłowe działanie klawiatury lub nieprawidłowe rozmieszczenie przycisków dotykowych), dostosuj mapę klawiszy.",
|
||||
"dailyCommandLimitReachedNotification": "Dzienny limit poleceń został osiągnięty. Zaktualizuj do pełnej wersji z nieograniczoną liczbą poleceń.",
|
||||
"dailyLimitReached": "Osiągnięto dzienny limit ({dailyCommandCount} /{dailyCommandLimit} wykorzystanych)",
|
||||
"delete": "Usuń",
|
||||
"deleteProfile": "Usuń profil",
|
||||
"deleteProfileConfirmation": "Czy na pewno chcesz usunąć „{profileName}\"? Tej czynności nie można cofnąć.",
|
||||
"@deleteProfileConfirmation": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deny": "Odmów",
|
||||
"deviceButton": "{deviceName} przycisk",
|
||||
"@deviceButton": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disconnectDevices": "Odłącz urządzenia",
|
||||
"disconnected": "Rozłączony",
|
||||
"donateByBuyingFromPlayStore": "kupując aplikację w Play Store",
|
||||
"donateViaCreditCard": "za pomocą karty kredytowej, Google Pay, Apple Pay i innych",
|
||||
"donateViaPaypal": "przez PayPal",
|
||||
"download": "Pobierz",
|
||||
"dragToReposition": "Przeciągnij, aby zmienić położenie",
|
||||
"duplicate": "Duplikuj",
|
||||
"enableAutoRotation": "Włącz funkcję automatycznego obracania na urządzeniu, aby mieć pewność, że aplikacja będzie działać prawidłowo.",
|
||||
"enableBluetooth": "Włącz Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Włącz dostęp do klawiatury dla BikeControl na poniższym ekranie. Jeśli nie widzisz BikeControl, dodaj go ręcznie.",
|
||||
"enableKeyboardMouseControl": "Włącz sterowanie klawiaturą i myszą, aby zapewnić lepszą interakcję z {appName}. Po aktywacji nie jest wymagane żadne dalsze działanie, ani połączenie. BikeControl będzie bezpośrednio wysyłał dane z myszy lub klawiatury do {appName}.",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableMediaKeyDetection": "Włącz rozpoznawanie klawiszy multimedialnych",
|
||||
"enablePairingProcess": "Włącz proces parowania",
|
||||
"enablePermissions": "Nadaj uprawnienia",
|
||||
"enableSteeringWithPhone": "Włącz czujniki telefonów, aby umożliwić np. sterowanie",
|
||||
"enableVibrationFeedback": "Włącz wibracje podczas zmiany biegów",
|
||||
"enableZwiftControllerBluetooth": "Włącz kontroler Zwift (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Włącz kontroler Zwift (sieć)",
|
||||
"errorStartingMyWhooshLink": "Błąd podczas uruchamiania serwera MyWhoosh Link. Upewnij się, że aplikacja „MyWhoosh Link” nie jest już uruchomiona na tym urządzeniu.",
|
||||
"errorStartingOpenBikeControlBluetoothServer": "Błąd podczas uruchamiania serwera Bluetooth OpenBikeControl.",
|
||||
"errorStartingOpenBikeControlServer": "Błąd podczas uruchamiania serwera OpenBikeControl.",
|
||||
"exportAction": "Eksport",
|
||||
"failedToImportProfile": "Nie udało się zaimportować profilu. Nieprawidłowy format.",
|
||||
"failedToUpdate": "Nie udało się zaktualizować: {error}",
|
||||
"@failedToUpdate": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Wymuś zamknięcie aplikacji, aby móc korzystać z nowej wersji",
|
||||
"fullVersion": "Pełna wersja",
|
||||
"fullVersionDescription": "Pełna wersja zawiera: \n- Nielimitowane polecenia dziennie \n- Dostęp do wszystkich przyszłych aktualizacji \n- Brak subskrypcji! Opłata jednorazowa :)",
|
||||
"getSupport": "Uzyskaj wsparcie",
|
||||
"gotIt": "Zrozumiałem!",
|
||||
"grant": "Nadaj",
|
||||
"granted": "Nadano",
|
||||
"helpRequested": "Prośba o pomoc dotyczącą BikeControl v{version}",
|
||||
"@helpRequested": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"howBikeControlUsesPermission": "W jaki sposób BikeControl wykorzystuje to uprawnienie?",
|
||||
"ignoredDevices": "Zignorowane urządzenia",
|
||||
"importAction": "Import",
|
||||
"importProfile": "Importuj profil",
|
||||
"instructions": "Instrukcje",
|
||||
"jsonData": "Dane JSON",
|
||||
"keyboardAccess": "Dostęp do klawiatury",
|
||||
"latestVersion": "najnowszy: {version}",
|
||||
"@latestVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveAReview": "Zostaw recenzję",
|
||||
"letsAppConnectOverBluetooth": "Pozwala {appName} połączyć się z BikeControl przez Bluetooth.",
|
||||
"@letsAppConnectOverBluetooth": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsAppConnectOverNetwork": "Pozwala {appName} połączyć się bezpośrednio przez sieć. Wybierz BikeControl na ekranie połączenia.",
|
||||
"@letsAppConnectOverNetwork": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"license": "Licencja",
|
||||
"licenseStatus": "Status licencji",
|
||||
"loadScreenshotForPlacement": "Prześlij zrzut ekranu z gry w celu ustalenia miejsca",
|
||||
"logViewer": "Przeglądarka dziennika zdarzeń",
|
||||
"logs": "Dziennik zdarzeń",
|
||||
"logsAreAlsoAt": "Dziennik zdarzeń jest dostępny również na",
|
||||
"logsHaveBeenCopiedToClipboard": "Dziennik zdarzeń został skopiowany do schowka",
|
||||
"longPress": "długie\nnaciśnięcie",
|
||||
"longPressMode": "Tryb długiego naciśnięcia (zamiast powtarzania)",
|
||||
"mailSupportExplanation": "Udzielanie indywidualnego wsparcia przez e-mail to dla mnie dużo pracy.\n\nProszę rozważyć korzystanie z Reddita, Facebooka lub GitHuba w celu zadawania pytań i zgłaszania problemów, aby cała społeczność mogła z tego skorzystać.",
|
||||
"manageIgnoredDevices": "Zarządzaj ignorowanymi urządzeniami",
|
||||
"manageProfile": "Zarządzaj profilem",
|
||||
"manualyControllingButton": "Steruj {trainerApp} manualnie!",
|
||||
"mediaKeyDetectionTooltip": "Włącz tę opcję, aby umożliwić BikeControl wykrywanie pilotów Bluetooth.\nW tym celu BikeControl musi działać jako odtwarzacz multimedialny.",
|
||||
"miuiDeviceDetected": "Wykryto urządzenie MIUI",
|
||||
"miuiDisableBatteryOptimization": "• Wyłącz optymalizację baterii dla BikeControl",
|
||||
"miuiEnableAutostart": "• Włącz autostart dla BikeControl",
|
||||
"miuiEnsureProperWorking": "Aby mieć pewność, że BikeControl działa prawidłowo:",
|
||||
"miuiLockInRecentApps": "• Zablokuj aplikację w ostatnio używanych aplikacjach",
|
||||
"miuiWarningDescription": "Na Twoim urządzeniu działa oprogramowanie MIUI, który słynie z agresywnego wyłączania usług działających w tle i usług ułatwień dostępu.",
|
||||
"moreInformation": "Więcej informacji",
|
||||
"mustChooseAllowOrDeny": "Aby kontynuować, musisz zezwolić lub odmówić tego uprawnienia.",
|
||||
"myWhooshDirectConnectAction": "Akcja „Link” MyWhoosh",
|
||||
"myWhooshDirectConnection": " np. za pomocą MyWhoosh „Link”",
|
||||
"myWhooshLinkConnected": "Połączono MyWhoosh „Link”",
|
||||
"myWhooshLinkDescriptionLocal": "Połącz się bezpośrednio z MyWhoosh za pomocą „Link”. Obsługiwane akcje to m.in. zmiana biegów, emotikony i zmiana kierunku. Aplikacja MyWhoosh Link NIE może być uruchomiona w tym samym czasie.",
|
||||
"myWhooshLinkInfo": "W razie napotkania błędów prosimy o sprawdzenie sekcji rozwiązywania problemów. Wkrótce pojawi się znacznie bardziej niezawodna metoda połączenia!",
|
||||
"needHelpClickHelp": "Potrzebujesz pomocy? Kliknij",
|
||||
"needHelpDontHesitate": "przycisk na górze i prosimy o kontakt.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będziemy wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądźcie na bieżąco z aktualizacjami!",
|
||||
"newCustomProfile": "Nowy profil niestandardowy",
|
||||
"newProfileName": "Nowa nazwa profilu",
|
||||
"newVersionAvailable": "Dostępna jest nowa wersja",
|
||||
"newVersionAvailableWithVersion": "Dostępna jest nowa wersja: {version}",
|
||||
"@newVersionAvailableWithVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"next": "Dalej",
|
||||
"noActionAssigned": "Brak przypisanej akcji",
|
||||
"noActionAssignedForButton": "Nie można wykonać {button}: Brak przypisanej akcji",
|
||||
"noConnectionMethodIsConnectedOrActive": "Żadna metoda połączenia nie jest podłączona lub aktywna.",
|
||||
"noConnectionMethodSelected": "Nie wybrano metody połączenia",
|
||||
"noControllerConnected": "Brak połączenia",
|
||||
"noControllerUseCompanionMode": "Nie masz kontrolera? Użyj Companion Mode.",
|
||||
"noIgnoredDevices": "Brak ignorowanych urządzeń.",
|
||||
"noTrainerSelected": "Nie wybrano aplikacji treningowej",
|
||||
"notConnected": "Nie połączono",
|
||||
"notificationDescription": "Dzięki temu aplikacja działa w tle i powiadamia Cię o każdej zmianie połączenia z Twoim urządzeniem.",
|
||||
"ok": "OK",
|
||||
"openBikeControlActions": "Akcje OpenBikeControl",
|
||||
"openBikeControlAnnouncement": "Świetna wiadomość - {trainerApp} obsługuje protokół OpenBikeControl, dzięki czemu uzyskasz najlepsze doświadczenia!",
|
||||
"openBikeControlConnection": " np. za pomocą połączenia OpenBikeControl",
|
||||
"otherConnectionMethods": "Inne metody połączenia",
|
||||
"pairingDescription": "Parowanie umożliwia pełną personalizację, jednak może nie działać na wszystkich urządzeniach.",
|
||||
"pairingInstructions": "Przejdź do ustawień Bluetooth na twoim {targetName} i wyszukaj BikeControl lub nazwę swojego urządzenia. Parowanie jest wymagane, jeśli chcesz korzystać z funkcji zdalnego sterowania.",
|
||||
"@pairingInstructions": {
|
||||
"placeholders": {
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pairingInstructionsIOS": "Na iPadzie przejdź do Ustawienia > Ułatwienia dostępu > Dotyk > AssistiveTouch > Urządzenia wskazujące > Urządzenia i sparuj twoje urządzenie. Upewnij się, że funkcja AssistiveTouch jest włączona.",
|
||||
"pasteExportedJsonData": "Wklej wyeksportowane dane JSON poniżej:",
|
||||
"pathCopiedToClipboard": "Ścieżka została skopiowana do schowka",
|
||||
"permissionsRequired": "Aby aplikacja BikeControl mogła wyszukiwać urządzenia w pobliżu i informować o zmianie połączenia, włącz następujące uprawnienia:",
|
||||
"platformNotSupported": "Platforma {platform} nie jest obsługiwana :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"platformRestrictionNotSupported": "Ze względu na ograniczenia platformy ten scenariusz nie jest obsługiwany.",
|
||||
"platformRestrictionOtherDevicesOnly": "Ze względu na ograniczenia platformy obsługiwane jest tylko sterowanie {appName} na innych urządzeniach.",
|
||||
"@platformRestrictionOtherDevicesOnly": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playPause": "Start/Pauza",
|
||||
"pleaseSelectAConnectionMethodFirst": "Najpierw wybierz metodę połączenia w ustawieniach aplikacji treningowej.",
|
||||
"predefinedAction": "Predefiniowana akcja {appName}",
|
||||
"@predefinedAction": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressButtonOnClickDevice": "Wciśnij przycisk na swoim urządzeniu Click",
|
||||
"pressKeyToAssign": "Naciśnij klawisz na klawiaturze, aby go przypisać do {buttonName}",
|
||||
"@pressKeyToAssign": {
|
||||
"placeholders": {
|
||||
"buttonName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": "Poprzedni",
|
||||
"profileExportedToClipboard": "Wyeksportowano profil „{profileName}\" do schowka",
|
||||
"@profileExportedToClipboard": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileImportedSuccessfully": "Pomyślnie zaimportowano profil",
|
||||
"profileName": "Nazwa profilu",
|
||||
"purchase": "Zakup",
|
||||
"recommendedConnectionMethods": "Zalecane metody połączenia",
|
||||
"removeFromIgnoredList": "Usuń z listy ignorowanych",
|
||||
"rename": "Zmiana nazwy",
|
||||
"renameProfile": "Zmień nazwę profilu",
|
||||
"requirement": "Wymóg",
|
||||
"reset": "Reset",
|
||||
"restart": "Uruchom ponownie",
|
||||
"runAppOnPlatformRemotely": "Uruchom {appName} na {platform} i steruj nim zdalnie z tego urządzenia {preferredConnection}.",
|
||||
"@runAppOnPlatformRemotely": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"platform": {
|
||||
"type": "String"
|
||||
},
|
||||
"preferredConnection": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runAppOnThisDevice": "Uruchom {appName} na tym urządzeniu.",
|
||||
"@runAppOnThisDevice": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": "Zapisz",
|
||||
"scan": "SKANUJ",
|
||||
"scanningForDevices": "Skanowanie urządzeń... Upewnij się, że są włączone, znajdują się w zasięgu i nie są podłączone do innego urządzenia.",
|
||||
"selectKeymap": "Wybierz mapę klawiszy",
|
||||
"selectTargetWhereAppRuns": "Wybierz urządzenie docelowe, na którym działa {appName}",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Wybierz aplikację treningową",
|
||||
"selectTrainerAppAndTarget": "Wybierz aplikację treningową i docelowe urządzenie",
|
||||
"selectTrainerAppPlaceholder": "Wybierz aplikację treningową",
|
||||
"setting": "Ustawienie",
|
||||
"setupTrainer": "Konfiguracja trenażera",
|
||||
"share": "Udostępnij",
|
||||
"showDonation": "Wyraź swoją wdzięczność poprzez darowiznę",
|
||||
"showSupportedControllers": "Pokaż wspierane kontrolery",
|
||||
"showTroubleshootingGuide": "Pokaż instrukcję rozwiązywania problemów",
|
||||
"signal": "Sygnał",
|
||||
"simulateButtons": "Sterowanie trenażerem",
|
||||
"simulateKeyboardShortcut": "Symuluj skrót klawiaturowy",
|
||||
"simulateMediaKey": "Symuluj przycisk multimedialny",
|
||||
"simulateTouch": "Symuluj dotyk",
|
||||
"stop": "Stop",
|
||||
"targetOtherDevice": "Inne urządzenie",
|
||||
"targetThisDevice": "To urządzenie",
|
||||
"theFollowingPermissionsRequired": "Wymagane są następujące uprawnienia:",
|
||||
"touchAreaInstructions": "1. Utwórz zrzut ekranu w grze swojej aplikacji (np. w MyWhoosh) w orientacji poziomej\n2. Załaduj zrzut ekranu za pomocą poniższego przycisku\n3. Aplikacja automatycznie ustawi się w orientacji poziomej, aby zapewnić dokładne odwzorowanie\n4. Naciśnij przycisk na urządzeniu Click, aby utworzyć obszar dotykowy\n5. Przeciągnij obszary dotykowe do żądanej pozycji na zrzucie ekranu\n6. Zapisz i zamknij ten ekran.",
|
||||
"touchSimulationForegroundMessage": "Aby symulować dotyk, aplikacja musi pozostać na pierwszym planie.",
|
||||
"trainer": "Trenażer",
|
||||
"trialDaysRemaining": "Pozostało {trialDaysRemaining} dni",
|
||||
"trialExpired": "Wersja próbna wygasła. Liczba poleceń ograniczona do {dailyCommandLimit} na dzień.",
|
||||
"trialPeriodActive": "Okres próbny jest aktywny - pozostało {trialDaysRemaining} dni",
|
||||
"trialPeriodDescription": "Korzystaj z nieograniczonej liczby poleceń w okresie próbnym. Po zakończeniu okresu próbnego polecenia będą ograniczone do {dailyCommandLimit} na dzień.",
|
||||
"troubleshootingGuide": "Instrukcja rozwiązywania problemów",
|
||||
"tryingToConnectAgain": "Próba ponownego połączenia...",
|
||||
"unassignAction": "Anuluj przypisanie akcji",
|
||||
"unlockFullVersion": "Odblokuj pełną wersję",
|
||||
"update": "Aktualizacja",
|
||||
"useCustomKeymapForButton": "Użyj niestandardowej mapy klawiszy, aby obsługiwać",
|
||||
"version": "Wersja {version}",
|
||||
"@version": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewDetailedInstructions": "Zobacz szczegółowe instrukcje",
|
||||
"volumeDown": "Zmniejsz głośność",
|
||||
"volumeUp": "Zwiększ głośność",
|
||||
"waiting": "Czekam...",
|
||||
"waitingForConnectionKickrBike": "Czekam na połączenie. Wybierz KICKR BIKE PRO w menu parowania kontrolera w {appName}.",
|
||||
"@waitingForConnectionKickrBike": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whatsNew": "Co nowego",
|
||||
"whyPermissionNeeded": "Dlaczego to uprawnienie jest potrzebne?",
|
||||
"zwiftControllerAction": "Akcja kontrolera Zwift",
|
||||
"zwiftControllerDescription": "Umożliwia BikeControl działanie jako kontroler kompatybilny ze Zwift."
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
@@ -120,7 +121,7 @@ Future<void> _persistCrash({
|
||||
}
|
||||
|
||||
await file.writeAsString(crashData.toString(), mode: FileMode.append);
|
||||
core.connection.lastLogEntries.add((date: DateTime.now(), entry: 'App crashed: $error'));
|
||||
core.connection.signalNotification(LogNotification('App crashed: $error'));
|
||||
} catch (_) {
|
||||
// Avoid throwing from the crash logger
|
||||
}
|
||||
@@ -165,9 +166,10 @@ void initializeActions(ConnectionType connectionType) {
|
||||
}
|
||||
|
||||
class BikeControlApp extends StatelessWidget {
|
||||
final Widget? customChild;
|
||||
final BCPage page;
|
||||
final String? error;
|
||||
const BikeControlApp({super.key, this.error, this.page = BCPage.devices});
|
||||
const BikeControlApp({super.key, this.error, this.page = BCPage.devices, this.customChild});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -180,6 +182,7 @@ class BikeControlApp extends StatelessWidget {
|
||||
localizationsDelegates: [
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
ShadcnLocalizations.delegate,
|
||||
AppLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
@@ -209,7 +212,7 @@ class BikeControlApp extends StatelessWidget {
|
||||
padding: isMobile ? EdgeInsets.only(bottom: 60, left: 24, right: 24, top: 60) : null,
|
||||
child: Stack(
|
||||
children: [
|
||||
Navigation(page: page),
|
||||
customChild ?? Navigation(page: page),
|
||||
Positioned.fill(child: Testbed()),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/touch_area.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
@@ -16,26 +20,69 @@ import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ButtonEditPage extends StatefulWidget {
|
||||
final Keymap keymap;
|
||||
final KeyPair keyPair;
|
||||
final VoidCallback onUpdate;
|
||||
const ButtonEditPage({super.key, required this.keyPair, required this.onUpdate});
|
||||
const ButtonEditPage({super.key, required this.keyPair, required this.onUpdate, required this.keymap});
|
||||
|
||||
@override
|
||||
State<ButtonEditPage> createState() => _ButtonEditPageState();
|
||||
}
|
||||
|
||||
class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
late KeyPair _keyPair;
|
||||
late final ScrollController _scrollController = ScrollController();
|
||||
final double baseHeight = 46;
|
||||
bool _bumped = false;
|
||||
|
||||
void _triggerBump() async {
|
||||
setState(() {
|
||||
_bumped = true;
|
||||
});
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bumped = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyPair = widget.keyPair;
|
||||
_actionSubscription = core.connection.actionStream.listen((data) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (data is ButtonNotification && data.buttonsClicked.length == 1) {
|
||||
final clickedButton = data.buttonsClicked.first;
|
||||
final keyPair = widget.keymap.keyPairs.firstOrNullWhere(
|
||||
(kp) => kp.buttons.contains(clickedButton),
|
||||
);
|
||||
if (keyPair != null) {
|
||||
setState(() {
|
||||
_keyPair = keyPair;
|
||||
});
|
||||
_triggerBump();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_actionSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keyPair = widget.keyPair;
|
||||
final trainerApp = core.settings.getTrainerApp();
|
||||
|
||||
final actionsWithInGameAction = trainerApp?.keymap.keyPairs
|
||||
@@ -53,7 +100,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
padding: const EdgeInsets.only(right: 26.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
spacing: 8,
|
||||
children: [
|
||||
SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -63,7 +110,15 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('Editing').h3,
|
||||
ButtonWidget(button: widget.keyPair.buttons.first),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOut,
|
||||
width: _keyPair.buttons.first.color != null ? baseHeight : null,
|
||||
height: _keyPair.buttons.first.color != null ? baseHeight : null,
|
||||
padding: EdgeInsets.all(_bumped ? 0 : 6.0),
|
||||
constraints: BoxConstraints(maxWidth: 120),
|
||||
child: ButtonWidget(button: _keyPair.buttons.first),
|
||||
),
|
||||
Expanded(child: SizedBox()),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close),
|
||||
@@ -85,137 +140,29 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
),
|
||||
if (core.logic.showObpActions) ...[
|
||||
ColoredTitle(text: context.i18n.openBikeControlActions),
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.link,
|
||||
title: Text(
|
||||
core.logic.obpConnectedApp == null
|
||||
? 'Please connect to ${core.settings.getTrainerApp()?.name}, first.'
|
||||
: context.i18n.appIdActions(core.logic.obpConnectedApp!.appId),
|
||||
),
|
||||
isActive: core.logic.obpConnectedApp != null && keyPair.inGameAction != null,
|
||||
onPressed: core.logic.obpConnectedApp == null
|
||||
? null
|
||||
: () {
|
||||
showDropdown(
|
||||
builder: (c) => DropdownMenu(
|
||||
children: core.logic.obpConnectedApp!.supportedActions
|
||||
.map(
|
||||
(action) => MenuButton(
|
||||
child: Text(action.name),
|
||||
onPressed: (_) {
|
||||
keyPair.touchPosition = Offset.zero;
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
keyPair.inGameAction = action;
|
||||
keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
context: context,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (core.logic.obpConnectedApp == null)
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
core.logic.obpConnectedApp == null
|
||||
? 'Please connect to ${core.settings.getTrainerApp()?.name}, first.'
|
||||
: context.i18n.appIdActions(core.logic.obpConnectedApp!.appId),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
..._buildTrainerConnectionActions(core.logic.obpConnectedApp!.supportedActions),
|
||||
],
|
||||
|
||||
if (core.settings.getMyWhooshLinkEnabled() && core.logic.showMyWhooshLink) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.myWhooshDirectConnectAction),
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.link,
|
||||
title: Text(context.i18n.myWhooshDirectConnectAction),
|
||||
isActive:
|
||||
keyPair.inGameAction != null &&
|
||||
core.whooshLink.supportedActions.contains(keyPair.inGameAction),
|
||||
value: [keyPair.inGameAction.toString(), ?keyPair.inGameActionValue?.toString()].join(' '),
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: core.whooshLink.supportedActions.map(
|
||||
(ingame) {
|
||||
return MenuButton(
|
||||
subMenu: ingame.possibleValues
|
||||
?.map(
|
||||
(value) => MenuButton(
|
||||
child: Text(value.toString()),
|
||||
onPressed: (_) {
|
||||
keyPair.inGameAction = ingame;
|
||||
keyPair.inGameActionValue = value;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Text(ingame.toString()),
|
||||
onPressed: (_) {
|
||||
keyPair.inGameAction = ingame;
|
||||
keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
..._buildTrainerConnectionActions(core.whooshLink.supportedActions),
|
||||
],
|
||||
if (core.logic.isZwiftBleEnabled || core.logic.isZwiftMdnsEnabled) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.zwiftControllerAction),
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.link,
|
||||
title: Text(context.i18n.zwiftControllerAction),
|
||||
isActive:
|
||||
keyPair.inGameAction != null &&
|
||||
core.zwiftEmulator.supportedActions.contains(keyPair.inGameAction),
|
||||
value: [keyPair.inGameAction.toString(), ?keyPair.inGameActionValue?.toString()].join(' '),
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: core.zwiftEmulator.supportedActions.map(
|
||||
(ingame) {
|
||||
return MenuButton(
|
||||
subMenu: ingame.possibleValues
|
||||
?.map(
|
||||
(value) => MenuButton(
|
||||
child: Text(value.toString()),
|
||||
onPressed: (_) {
|
||||
keyPair.inGameAction = ingame;
|
||||
keyPair.inGameActionValue = value;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Text(ingame.toString()),
|
||||
onPressed: (_) {
|
||||
keyPair.inGameAction = ingame;
|
||||
keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
..._buildTrainerConnectionActions(core.zwiftEmulator.supportedActions),
|
||||
],
|
||||
|
||||
if (core.logic.showLocalRemoteOptions) ...[
|
||||
@@ -233,38 +180,31 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
builder: (c) => DropdownMenu(
|
||||
children: actionsWithInGameAction!.map((keyPairAction) {
|
||||
return MenuButton(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(_formatActionDescription(keyPairAction).split(' = ').first),
|
||||
Text(
|
||||
_formatActionDescription(keyPairAction).split(' = ').last,
|
||||
style: TextStyle(fontSize: 12, color: Colors.gray),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: keyPairAction.inGameAction?.icon != null
|
||||
? Icon(keyPairAction.inGameAction!.icon)
|
||||
: null,
|
||||
onPressed: (_) {
|
||||
// Copy all properties from the selected predefined action
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) {
|
||||
keyPair.physicalKey = keyPairAction.physicalKey;
|
||||
keyPair.logicalKey = keyPairAction.logicalKey;
|
||||
keyPair.modifiers = List.of(keyPairAction.modifiers);
|
||||
_keyPair.physicalKey = keyPairAction.physicalKey;
|
||||
_keyPair.logicalKey = keyPairAction.logicalKey;
|
||||
_keyPair.modifiers = List.of(keyPairAction.modifiers);
|
||||
} else {
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
keyPair.modifiers = [];
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.modifiers = [];
|
||||
}
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.touch)) {
|
||||
keyPair.touchPosition = keyPairAction.touchPosition;
|
||||
_keyPair.touchPosition = keyPairAction.touchPosition;
|
||||
} else {
|
||||
keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
}
|
||||
keyPair.isLongPress = keyPairAction.isLongPress;
|
||||
keyPair.inGameAction = keyPairAction.inGameAction;
|
||||
keyPair.inGameActionValue = keyPairAction.inGameActionValue;
|
||||
_keyPair.isLongPress = keyPairAction.isLongPress;
|
||||
_keyPair.inGameAction = keyPairAction.inGameAction;
|
||||
_keyPair.inGameActionValue = keyPairAction.inGameActionValue;
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(keyPairAction.toString()),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -275,17 +215,17 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
],
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
SelectableCard(
|
||||
icon: Icons.keyboard_alt_outlined,
|
||||
icon: RadixIcons.keyboard,
|
||||
title: Text(context.i18n.simulateKeyboardShortcut),
|
||||
isActive: keyPair.physicalKey != null && !keyPair.isSpecialKey,
|
||||
value: 'Key: $keyPair',
|
||||
isActive: _keyPair.physicalKey != null && !_keyPair.isSpecialKey,
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder: (c) => HotKeyListenerDialog(
|
||||
customApp: core.actionHandler.supportedApp! as CustomApp,
|
||||
keyPair: keyPair,
|
||||
keyPair: _keyPair,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
@@ -295,20 +235,19 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.touch))
|
||||
SelectableCard(
|
||||
title: Text(context.i18n.simulateTouch),
|
||||
icon: Icons.touch_app_outlined,
|
||||
isActive: keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero,
|
||||
value:
|
||||
'Coordinates: X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}',
|
||||
icon: core.actionHandler is AndroidActions ? Icons.touch_app_outlined : BootstrapIcons.mouse,
|
||||
isActive: _keyPair.physicalKey == null && _keyPair.touchPosition != Offset.zero,
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () async {
|
||||
if (keyPair.touchPosition == Offset.zero) {
|
||||
keyPair.touchPosition = Offset(50, 50);
|
||||
if (_keyPair.touchPosition == Offset.zero) {
|
||||
_keyPair.touchPosition = Offset(50, 50);
|
||||
}
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
await Navigator.of(context).push<bool?>(
|
||||
MaterialPageRoute(
|
||||
builder: (c) => TouchAreaSetupPage(
|
||||
keyPair: keyPair,
|
||||
keyPair: _keyPair,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -321,9 +260,9 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.music_note_outlined,
|
||||
isActive: keyPair.isSpecialKey,
|
||||
isActive: _keyPair.isSpecialKey,
|
||||
title: Text(context.i18n.simulateMediaKey),
|
||||
value: keyPair.toString(),
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
@@ -332,8 +271,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
MenuButton(
|
||||
child: Text(context.i18n.playPause),
|
||||
onPressed: (c) {
|
||||
keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
|
||||
keyPair.logicalKey = null;
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
@@ -342,8 +281,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
MenuButton(
|
||||
child: Text(context.i18n.stop),
|
||||
onPressed: (c) {
|
||||
keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
|
||||
keyPair.logicalKey = null;
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
@@ -353,8 +292,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
child: Text(context.i18n.previous),
|
||||
|
||||
onPressed: (c) {
|
||||
keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
|
||||
keyPair.logicalKey = null;
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
@@ -363,8 +302,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
MenuButton(
|
||||
child: Text(context.i18n.next),
|
||||
onPressed: (c) {
|
||||
keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext;
|
||||
keyPair.logicalKey = null;
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
@@ -372,8 +311,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
),
|
||||
MenuButton(
|
||||
onPressed: (c) {
|
||||
keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
|
||||
keyPair.logicalKey = null;
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
@@ -383,8 +322,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
MenuButton(
|
||||
child: Text(context.i18n.volumeDown),
|
||||
onPressed: (c) {
|
||||
keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
|
||||
keyPair.logicalKey = null;
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
@@ -406,11 +345,11 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
icon: Icons.air,
|
||||
title: Text('KICKR Headwind'),
|
||||
isActive:
|
||||
keyPair.inGameAction != null &&
|
||||
(keyPair.inGameAction == InGameAction.headwindSpeed ||
|
||||
keyPair.inGameAction == InGameAction.headwindHeartRateMode),
|
||||
value: keyPair.inGameAction != null
|
||||
? '${keyPair.inGameAction} ${keyPair.inGameActionValue ?? ""}'.trim()
|
||||
_keyPair.inGameAction != null &&
|
||||
(_keyPair.inGameAction == InGameAction.headwindSpeed ||
|
||||
_keyPair.inGameAction == InGameAction.headwindHeartRateMode),
|
||||
value: _keyPair.inGameAction != null
|
||||
? '${_keyPair.inGameAction} ${_keyPair.inGameActionValue ?? ""}'.trim()
|
||||
: null,
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
@@ -423,8 +362,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
(value) => MenuButton(
|
||||
child: Text('Set Speed to $value%'),
|
||||
onPressed: (_) {
|
||||
keyPair.inGameAction = InGameAction.headwindSpeed;
|
||||
keyPair.inGameActionValue = value;
|
||||
_keyPair.inGameAction = InGameAction.headwindSpeed;
|
||||
_keyPair.inGameActionValue = value;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -436,8 +375,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
MenuButton(
|
||||
child: Text('Set to Heart Rate Mode'),
|
||||
onPressed: (_) {
|
||||
keyPair.inGameAction = InGameAction.headwindHeartRateMode;
|
||||
keyPair.inGameActionValue = null;
|
||||
_keyPair.inGameAction = InGameAction.headwindHeartRateMode;
|
||||
_keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -453,11 +392,11 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.setting),
|
||||
SelectableCard(
|
||||
icon: keyPair.isLongPress ? Icons.check_box : Icons.check_box_outline_blank,
|
||||
icon: _keyPair.isLongPress ? Icons.check_box : Icons.check_box_outline_blank,
|
||||
title: Text(context.i18n.longPressMode),
|
||||
isActive: keyPair.isLongPress,
|
||||
isActive: _keyPair.isLongPress,
|
||||
onPressed: () {
|
||||
keyPair.isLongPress = !keyPair.isLongPress;
|
||||
_keyPair.isLongPress = !_keyPair.isLongPress;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -465,13 +404,13 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
SizedBox(height: 8),
|
||||
DestructiveButton(
|
||||
onPressed: () {
|
||||
keyPair.isLongPress = false;
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
keyPair.modifiers = [];
|
||||
keyPair.touchPosition = Offset.zero;
|
||||
keyPair.inGameAction = null;
|
||||
keyPair.inGameActionValue = null;
|
||||
_keyPair.isLongPress = false;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.modifiers = [];
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.inGameAction = null;
|
||||
_keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -486,33 +425,54 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatActionDescription(KeyPair keyPairAction) {
|
||||
final parts = <String>[];
|
||||
|
||||
if (keyPairAction.inGameAction != null) {
|
||||
parts.add(keyPairAction.inGameAction!.toString());
|
||||
if (keyPairAction.inGameActionValue != null) {
|
||||
parts.add('(${keyPairAction.inGameActionValue})');
|
||||
}
|
||||
}
|
||||
|
||||
// Use KeyPair's toString() which formats the key with modifiers (e.g., "Ctrl+Alt+R")
|
||||
final keyLabel = keyPairAction.toString();
|
||||
if (keyLabel != 'Not assigned') {
|
||||
parts.add('Key: $keyLabel');
|
||||
}
|
||||
|
||||
if (keyPairAction.touchPosition != Offset.zero) {
|
||||
parts.add(
|
||||
'Touch: ${keyPairAction.touchPosition.dx.toInt()}, ${keyPairAction.touchPosition.dy.toInt()}',
|
||||
List<Widget> _buildTrainerConnectionActions(List<InGameAction> supportedActions) {
|
||||
return supportedActions.map((action) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return SelectableCard(
|
||||
icon: action.icon,
|
||||
title: Text(action.title),
|
||||
subtitle: (action.possibleValues != null && action == _keyPair.inGameAction)
|
||||
? Text(_keyPair.inGameActionValue!.toString())
|
||||
: null,
|
||||
isActive: _keyPair.inGameAction == action && supportedActions.contains(_keyPair.inGameAction),
|
||||
onPressed: () {
|
||||
if (action.possibleValues?.isNotEmpty == true) {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: action.possibleValues!.map(
|
||||
(ingame) {
|
||||
return MenuButton(
|
||||
child: Text(ingame.toString()),
|
||||
onPressed: (_) {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.inGameAction = action;
|
||||
_keyPair.inGameActionValue = ingame;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.inGameAction = action;
|
||||
_keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (keyPairAction.isLongPress) {
|
||||
parts.add('[Long Press]');
|
||||
}
|
||||
|
||||
return parts.isNotEmpty ? [parts.first, ' = ', parts.skip(1).join(' • ')].join() : 'Action';
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/mywhoosh/link.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/remote_pairing.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/touch_area.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/zwift_mdns_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/zwift_tile.dart';
|
||||
import 'package:bike_control/widgets/pair_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
@@ -65,7 +81,11 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
'm',
|
||||
];
|
||||
|
||||
static const Duration _keyPressDuration = Duration(milliseconds: 100);
|
||||
static const Duration _keyPressDuration = Duration(milliseconds: 200);
|
||||
|
||||
InGameAction? _pressedAction;
|
||||
|
||||
DateTime? _lastDown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -86,7 +106,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
|
||||
// If no saved hotkeys, initialize with defaults
|
||||
if (savedHotkeys.isEmpty) {
|
||||
final connectedTrainers = core.logic.connectedTrainerConnections;
|
||||
final connectedTrainers = core.logic.enabledTrainerConnections;
|
||||
final allActions = <InGameAction>[];
|
||||
|
||||
for (final connection in connectedTrainers) {
|
||||
@@ -126,6 +146,9 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
|
||||
if (action == null) return KeyEventResult.ignored;
|
||||
|
||||
_pressedAction = action;
|
||||
setState(() {});
|
||||
|
||||
// Find the connection that supports this action
|
||||
final connectedTrainers = core.logic.connectedTrainerConnections;
|
||||
final connection = connectedTrainers.firstOrNullWhere((c) => c.supportedActions.contains(action));
|
||||
@@ -137,11 +160,17 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
_keyPressDuration,
|
||||
() {
|
||||
if (mounted) {
|
||||
_pressedAction = null;
|
||||
setState(() {});
|
||||
_sendKey(context, down: false, action: action, connection: connection);
|
||||
}
|
||||
},
|
||||
);
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
_pressedAction = null;
|
||||
setState(() {});
|
||||
buildToast(context, title: 'No connected trainer.');
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
@@ -149,7 +178,9 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectedTrainers = core.logic.connectedTrainerConnections;
|
||||
final connectedTrainers = core.logic.enabledTrainerConnections;
|
||||
|
||||
final isMobile = MediaQuery.sizeOf(context).width < 600;
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
@@ -178,9 +209,28 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
if (connectedTrainers.isEmpty)
|
||||
Warning(
|
||||
children: [
|
||||
Text('No connected trainers found. Connect a trainer to simulate button presses.'),
|
||||
Text('No suitable connection method activated. Connect a trainer to simulate button presses.'),
|
||||
],
|
||||
),
|
||||
for (final connectedTrainer in connectedTrainers)
|
||||
if (!screenshotMode)
|
||||
switch (connectedTrainer.title) {
|
||||
WhooshLink.connectionTitle => MyWhooshLinkTile(),
|
||||
ZwiftEmulator.connectionTitle => ZwiftTile(
|
||||
onUpdate: () {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
FtmsMdnsEmulator.connectionTitle => ZwiftMdnsTile(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
OpenBikeControlMdnsEmulator.connectionTitle => OpenBikeControlMdnsTile(),
|
||||
OpenBikeControlBluetoothEmulator.connectionTitle => OpenBikeControlBluetoothTile(),
|
||||
RemotePairing.connectionTitle => RemotePairingWidget(),
|
||||
_ => SizedBox.shrink(),
|
||||
},
|
||||
...connectedTrainers.map(
|
||||
(connection) {
|
||||
final supportedActions = connection.supportedActions;
|
||||
@@ -188,58 +238,93 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
final actionGroups = {
|
||||
if (supportedActions.contains(InGameAction.shiftUp) &&
|
||||
supportedActions.contains(InGameAction.shiftDown))
|
||||
'Shifting': [InGameAction.shiftUp, InGameAction.shiftDown],
|
||||
'Shifting': [InGameAction.shiftDown, InGameAction.shiftUp],
|
||||
'Other': supportedActions
|
||||
.where((action) => action != InGameAction.shiftUp && action != InGameAction.shiftDown)
|
||||
.where(
|
||||
(action) =>
|
||||
action != InGameAction.shiftUp &&
|
||||
action != InGameAction.shiftDown &&
|
||||
action != InGameAction.steerLeft &&
|
||||
action != InGameAction.steerRight,
|
||||
)
|
||||
.toList(),
|
||||
if (supportedActions.contains(InGameAction.steerLeft) &&
|
||||
supportedActions.contains(InGameAction.steerRight))
|
||||
'Steering': [InGameAction.steerLeft, InGameAction.steerRight],
|
||||
};
|
||||
|
||||
return [
|
||||
GradientText(connection.title).bold.large,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
for (final group in actionGroups.entries) ...[
|
||||
Text(group.key).bold,
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: group.value.map(
|
||||
(action) {
|
||||
final hotkey = _hotkeys[action];
|
||||
return PrimaryButton(
|
||||
size: ButtonSize(1.6),
|
||||
leading: hotkey != null
|
||||
? KeyWidget(
|
||||
label: hotkey.toUpperCase(),
|
||||
)
|
||||
: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(action.title),
|
||||
if (action.alternativeTitle != null)
|
||||
Text(
|
||||
action.alternativeTitle!,
|
||||
style: TextStyle(fontSize: 12, color: Colors.gray),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () {},
|
||||
onTapDown: (c) async {
|
||||
_sendKey(context, down: true, action: action, connection: connection);
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
for (final group in actionGroups.entries) ...[
|
||||
Text(group.key.toUpperCase()).bold.muted,
|
||||
if (group.value.length == 2)
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: group.value.map(
|
||||
(action) {
|
||||
final hotkey = _hotkeys[action];
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
child: _buildButton(action, group, connection, isMobile),
|
||||
),
|
||||
if (hotkey != null)
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: KeyWidget(
|
||||
label: hotkey.toUpperCase(),
|
||||
invert: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onTapUp: (c) async {
|
||||
_sendKey(context, down: false, action: action, connection: connection);
|
||||
).toList(),
|
||||
)
|
||||
else
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisCount: min(group.value.length, 3),
|
||||
childAspectRatio: isMobile ? 1 : 2.4,
|
||||
children: group.value.map(
|
||||
(action) {
|
||||
final hotkey = _hotkeys[action];
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildButton(action, group, connection, isMobile),
|
||||
|
||||
if (hotkey != null)
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: KeyWidget(
|
||||
label: hotkey.toUpperCase(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
).toList(),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
@@ -295,12 +380,70 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButton(
|
||||
InGameAction action,
|
||||
MapEntry<String, List<InGameAction>> group,
|
||||
TrainerConnection connection,
|
||||
bool isMobile,
|
||||
) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return Button(
|
||||
style: _pressedAction == action
|
||||
? ButtonStyle.outline()
|
||||
: group.key == 'Other'
|
||||
? ButtonStyle.outline()
|
||||
: ButtonStyle.primary(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (action.icon != null) ...[
|
||||
Icon(action.icon),
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
action.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(height: 1),
|
||||
maxLines: 2,
|
||||
).bold,
|
||||
if (action.alternativeTitle != null)
|
||||
Text(
|
||||
action.alternativeTitle!.toUpperCase(),
|
||||
style: TextStyle(fontSize: 10, color: Colors.gray),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () {},
|
||||
onTapDown: (c) async {
|
||||
_sendKey(context, down: true, action: action, connection: connection);
|
||||
/*final device = HidDevice('Simulator');
|
||||
final button = ControllerButton('action', action: InGameAction.openActionBar);
|
||||
device.getOrAddButton(button.name, () => button);
|
||||
device.handleButtonsClickedWithoutLongPressSupport([button]);*/
|
||||
},
|
||||
onTapUp: (c) async {
|
||||
_sendKey(context, down: false, action: action, connection: connection);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendKey(
|
||||
BuildContext context, {
|
||||
required bool down,
|
||||
required InGameAction action,
|
||||
required TrainerConnection connection,
|
||||
}) async {
|
||||
if (!connection.isConnected.value) {
|
||||
if (down) {
|
||||
buildToast(context, title: 'No connected trainer.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (action.possibleValues != null) {
|
||||
if (down) return;
|
||||
showDropdown(
|
||||
@@ -330,6 +473,16 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
if (!down && _lastDown != null) {
|
||||
final timeSinceLastDown = DateTime.now().difference(_lastDown!);
|
||||
if (timeSinceLastDown < Duration(milliseconds: 400)) {
|
||||
// wait a bit so actions actually get applied correctly for some trainer apps
|
||||
await Future.delayed(Duration(milliseconds: 800) - timeSinceLastDown);
|
||||
}
|
||||
} else if (down) {
|
||||
_lastDown = DateTime.now();
|
||||
}
|
||||
|
||||
final result = await connection.sendAction(
|
||||
KeyPair(
|
||||
buttons: [],
|
||||
@@ -340,6 +493,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
isKeyDown: down,
|
||||
isKeyUp: !down,
|
||||
);
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
if (result is! Success) {
|
||||
buildToast(context, title: result.message);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
@@ -29,21 +31,6 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: '${context.i18n.needHelpClickHelp} '),
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Icon(Icons.help_outline),
|
||||
),
|
||||
),
|
||||
TextSpan(text: ' ${context.i18n.needHelpDontHesitate}'),
|
||||
],
|
||||
),
|
||||
).small.muted,
|
||||
SizedBox(height: 4),
|
||||
ColoredTitle(text: context.i18n.setupTrainer),
|
||||
Card(
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
@@ -52,7 +39,6 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
borderColor: Theme.of(context).colorScheme.border,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final isMobile = MediaQuery.sizeOf(context).width < 600;
|
||||
return StatefulBuilder(
|
||||
builder: (c, setState) => Column(
|
||||
spacing: 8,
|
||||
@@ -125,38 +111,42 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
},
|
||||
),
|
||||
if (core.settings.getTrainerApp() != null) ...[
|
||||
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true)
|
||||
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true && !screenshotMode)
|
||||
Text(
|
||||
'Great news - ${core.settings.getTrainerApp()!.name} supports the OpenBikeControl Protocol, so you\'ll the best possible experience!',
|
||||
AppLocalizations.of(context).openBikeControlAnnouncement(core.settings.getTrainerApp()!.name),
|
||||
).xSmall,
|
||||
SizedBox(height: 8),
|
||||
SizedBox(height: 0),
|
||||
Text(
|
||||
context.i18n.selectTargetWhereAppRuns(
|
||||
screenshotMode ? 'Trainer app' : core.settings.getTrainerApp()?.name ?? 'the Trainer app',
|
||||
),
|
||||
).small,
|
||||
Flex(
|
||||
direction: isMobile ? Axis.vertical : Axis.horizontal,
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [Target.thisDevice, Target.otherDevice]
|
||||
.map(
|
||||
(target) => SelectableCard(
|
||||
title: Text(target.getTitle(context)),
|
||||
icon: target.icon,
|
||||
isActive: target == core.settings.getLastTarget(),
|
||||
subtitle: !target.isCompatible
|
||||
? Text(context.i18n.platformRestrictionNotSupported)
|
||||
: null,
|
||||
onPressed: !target.isCompatible
|
||||
? null
|
||||
: () async {
|
||||
await _setTarget(context, target);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
(target) => Expanded(
|
||||
child: SelectableCard(
|
||||
title: Center(child: Icon(target.icon)),
|
||||
isActive: target == core.settings.getLastTarget(),
|
||||
subtitle: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(target.getTitle(context)),
|
||||
if (!target.isCompatible) Text(context.i18n.platformRestrictionNotSupported),
|
||||
],
|
||||
),
|
||||
),
|
||||
onPressed: !target.isCompatible
|
||||
? null
|
||||
: () async {
|
||||
await _setTarget(context, target);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.map((e) => !isMobile ? Expanded(child: e) : e)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
@@ -172,6 +162,21 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
],
|
||||
),
|
||||
],
|
||||
if (core.settings.getTrainerApp()?.star == true && !screenshotMode)
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(Icons.star),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).newConnectionMethodAnnouncement(core.settings.getTrainerApp()!.name),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).xSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:bike_control/widgets/iap_status_widget.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class CustomizePage extends StatefulWidget {
|
||||
const CustomizePage({super.key});
|
||||
@@ -21,10 +22,6 @@ class CustomizePage extends StatefulWidget {
|
||||
class _CustomizeState extends State<CustomizePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canVibrate = core.connection.bluetoothDevices.any(
|
||||
(device) => device.isConnected && device is ZwiftDevice && device.canVibrate,
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -32,6 +29,10 @@ class _CustomizeState extends State<CustomizePage> {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: true),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
@@ -56,7 +57,10 @@ class _CustomizeState extends State<CustomizePage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: Text(a.name)),
|
||||
if (a is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
if (a is CustomApp)
|
||||
BetaPill(text: 'CUSTOM')
|
||||
else if (a.supportsOpenBikeProtocol)
|
||||
Icon(Icons.star, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -82,12 +86,6 @@ class _CustomizeState extends State<CustomizePage> {
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
/*DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),*/
|
||||
placeholder: Text(context.i18n.selectKeymap),
|
||||
|
||||
onChanged: (app) async {
|
||||
@@ -99,10 +97,11 @@ class _CustomizeState extends State<CustomizePage> {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
core.actionHandler.init(customApp);
|
||||
await core.settings.setKeyMap(customApp);
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
core.actionHandler.supportedApp = app;
|
||||
core.actionHandler.init(app);
|
||||
await core.settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_simulator.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/widgets/iap_status_widget.dart';
|
||||
import 'package:bike_control/widgets/scan.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
import '../widgets/ignored_devices_dialog.dart';
|
||||
|
||||
class DevicePage extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
@@ -27,13 +22,11 @@ class DevicePage extends StatefulWidget {
|
||||
|
||||
class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
late StreamSubscription<BaseDevice> _connectionStateSubscription;
|
||||
bool _showNameChangeWarning = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_showNameChangeWarning = !core.settings.knowsAboutNameChange();
|
||||
_connectionStateSubscription = core.connection.connectionStream.listen((state) async {
|
||||
setState(() {});
|
||||
});
|
||||
@@ -49,39 +42,32 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
Widget build(BuildContext context) {
|
||||
return Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
primary: true,
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (_showNameChangeWarning && !screenshotMode)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(context.i18n.nameChangeNotice),
|
||||
SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showNameChangeWarning = false;
|
||||
});
|
||||
launchUrlString('https://openbikecontrol.org');
|
||||
},
|
||||
child: Text(context.i18n.moreInformation),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(
|
||||
text: core.connection.controllerDevices.isEmpty
|
||||
? context.i18n.connectControllers
|
||||
: context.i18n.connectedControllers,
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: false),
|
||||
),
|
||||
|
||||
if (core.connection.controllerDevices.isEmpty || kIsWeb) ScanWidget(),
|
||||
if (core.connection.controllerDevices.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(text: context.i18n.connectControllers),
|
||||
),
|
||||
|
||||
// leave it in for the extra scanning options
|
||||
ScanWidget(),
|
||||
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(text: context.i18n.connectedControllers),
|
||||
),
|
||||
|
||||
...core.connection.controllerDevices.map(
|
||||
(device) => Card(
|
||||
filled: true,
|
||||
@@ -95,9 +81,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
if (core.connection.accessories.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(
|
||||
text: 'Accessories',
|
||||
),
|
||||
child: ColoredTitle(text: AppLocalizations.of(context).accessories),
|
||||
),
|
||||
...core.connection.accessories.map(
|
||||
(device) => Card(
|
||||
@@ -110,22 +94,11 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
],
|
||||
|
||||
if (core.settings.getIgnoredDevices().isNotEmpty)
|
||||
OutlineButton(
|
||||
child: Text(context.i18n.manageIgnoredDevices),
|
||||
onPressed: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => IgnoredDevicesDialog(),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(),
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
child: Text(context.i18n.connectToTrainerApp),
|
||||
@@ -134,37 +107,6 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
PrimaryButton(
|
||||
child: Text(
|
||||
'No Controller? Control ${core.settings.getTrainerApp()?.name ?? 'your trainer'} manually!',
|
||||
),
|
||||
onPressed: () {
|
||||
if (core.settings.getTrainerApp() == null) {
|
||||
buildToast(
|
||||
context,
|
||||
level: LogLevel.LOGLEVEL_WARNING,
|
||||
title: context.i18n.selectTrainerApp,
|
||||
);
|
||||
widget.onUpdate();
|
||||
} else if (core.logic.connectedTrainerConnections.isEmpty) {
|
||||
buildToast(
|
||||
context,
|
||||
level: LogLevel.LOGLEVEL_WARNING,
|
||||
title:
|
||||
'Please connect to ${core.settings.getTrainerApp()?.name ?? 'your trainer'} with ${core.logic.trainerConnections.joinToString(transform: (t) => t.title, separator: ' or ')}, first.',
|
||||
);
|
||||
widget.onUpdate();
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => ButtonSimulator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/customize.dart';
|
||||
@@ -16,6 +11,11 @@ import 'package:bike_control/widgets/logviewer.dart';
|
||||
import 'package:bike_control/widgets/menu.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../widgets/changelog_dialog.dart';
|
||||
|
||||
@@ -135,6 +135,7 @@ class _NavigationState extends State<Navigation> {
|
||||
backgroundColor: Theme.of(context).colorScheme.background,
|
||||
trailing: buildMenuButtons(
|
||||
context,
|
||||
_selectedPage,
|
||||
_isMobile
|
||||
? () {
|
||||
setState(() {
|
||||
@@ -249,7 +250,7 @@ class _NavigationState extends State<Navigation> {
|
||||
reverseDuration: Duration(seconds: 1),
|
||||
start: 10,
|
||||
end: 12,
|
||||
mode: RepeatMode.pingPong,
|
||||
mode: LoopingMode.pingPong,
|
||||
builder: (context, value, child) {
|
||||
return Container(
|
||||
width: value,
|
||||
|
||||
@@ -8,8 +8,6 @@ import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/widgets/testbed.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -18,7 +16,6 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../utils/actions/base_actions.dart';
|
||||
import '../utils/keymap/keymap.dart';
|
||||
|
||||
final touchAreaSize = 42.0;
|
||||
@@ -393,53 +390,24 @@ class KeypairExplanation extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (withKey)
|
||||
Row(
|
||||
children: keyPair.buttons.map((b) => ButtonWidget(button: b, big: true)).toList(),
|
||||
)
|
||||
else
|
||||
Icon(keyPair.icon),
|
||||
if (keyPair.inGameAction != null && core.logic.emulatorEnabled)
|
||||
KeyWidget(
|
||||
label: [
|
||||
keyPair.inGameAction!.title,
|
||||
if (keyPair.inGameActionValue != null) '${keyPair.inGameActionValue}',
|
||||
].joinToString(separator: ': '),
|
||||
)
|
||||
else if (keyPair.isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
KeyWidget(
|
||||
label: switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaStop => 'Stop',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => 'Previous',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Next',
|
||||
PhysicalKeyboardKey.audioVolumeUp => 'Volume Up',
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
|
||||
_ => 'Unknown',
|
||||
},
|
||||
)
|
||||
else if (keyPair.physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
|
||||
KeyWidget(
|
||||
label: keyPair.toString(),
|
||||
),
|
||||
] else ...[
|
||||
if (!withKey && keyPair.touchPosition != Offset.zero && core.logic.showLocalRemoteOptions)
|
||||
KeyWidget(label: 'X:${keyPair.touchPosition.dx.toInt()}, Y:${keyPair.touchPosition.dy.toInt()}'),
|
||||
],
|
||||
if (keyPair.isLongPress) Text(context.i18n.longPress, style: TextStyle(fontSize: 10)),
|
||||
],
|
||||
return Basic(
|
||||
leading: withKey
|
||||
? Row(
|
||||
children: keyPair.buttons.map((b) => ButtonWidget(button: b, big: true)).toList(),
|
||||
)
|
||||
: Icon(keyPair.icon),
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
contentSpacing: 10,
|
||||
subtitle: keyPair.isLongPress ? Text(context.i18n.longPress.replaceAll('\n', ' ')).muted.xSmall : null,
|
||||
title: Text(keyPair.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class KeyWidget extends StatelessWidget {
|
||||
final String label;
|
||||
const KeyWidget({super.key, required this.label});
|
||||
final bool invert;
|
||||
const KeyWidget({super.key, required this.label, this.invert = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -448,7 +416,7 @@ class KeyWidget extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
constraints: BoxConstraints(minWidth: 30),
|
||||
decoration: BoxDecoration(
|
||||
color: BKColor.main,
|
||||
color: invert ? Colors.white : Colors.black,
|
||||
border: Border.all(color: Theme.of(context).colorScheme.border, width: 2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
@@ -458,7 +426,7 @@ class KeyWidget extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontFamily: screenshotMode ? null : 'monospace',
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
color: invert ? Colors.black : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_simulator.dart';
|
||||
import 'package:bike_control/pages/configuration.dart';
|
||||
import 'package:bike_control/pages/navigation.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/widgets/apps/local_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart';
|
||||
@@ -11,6 +14,7 @@ import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/zwift_mdns_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/zwift_tile.dart';
|
||||
import 'package:bike_control/widgets/iap_status_widget.dart';
|
||||
import 'package:bike_control/widgets/pair_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
@@ -19,6 +23,8 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
|
||||
class TrainerPage extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
final VoidCallback goToNextPage;
|
||||
@@ -87,6 +93,7 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
final showWhooshLinkAsOther =
|
||||
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showMyWhooshLink;
|
||||
|
||||
final isMobile = MediaQuery.sizeOf(context).width < 800;
|
||||
return Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
@@ -97,6 +104,10 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: true),
|
||||
),
|
||||
ConfigurationPage(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
@@ -132,14 +143,31 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
if (core.logic.showZwiftBleEmulator)
|
||||
ZwiftTile(
|
||||
onUpdate: () {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
|
||||
);
|
||||
setState(() {});
|
||||
if (mounted) {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
|
||||
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
|
||||
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: '${context.i18n.needHelpClickHelp} '),
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Icon(Icons.help_outline),
|
||||
),
|
||||
),
|
||||
TextSpan(text: ' ${context.i18n.needHelpDontHesitate}'),
|
||||
],
|
||||
),
|
||||
).small.muted,
|
||||
if (core.logic.showRemote || showLocalAsOther || showWhooshLinkAsOther) ...[
|
||||
SizedBox(height: 16),
|
||||
Accordion(
|
||||
@@ -158,15 +186,44 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
),
|
||||
],
|
||||
|
||||
SizedBox(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
SizedBox(height: 4),
|
||||
Flex(
|
||||
direction: isMobile ? Axis.vertical : Axis.horizontal,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
child: Text(context.i18n.adjustControllerButtons),
|
||||
leading: Icon(Icons.computer_outlined),
|
||||
child: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).manualyControllingButton(core.settings.getTrainerApp()?.name ?? 'your trainer'),
|
||||
),
|
||||
onPressed: () {
|
||||
if (core.settings.getTrainerApp() == null) {
|
||||
buildToast(
|
||||
context,
|
||||
level: LogLevel.LOGLEVEL_WARNING,
|
||||
title: context.i18n.selectTrainerApp,
|
||||
);
|
||||
widget.onUpdate();
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => ButtonSimulator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
PrimaryButton(
|
||||
leading: Icon(BCPage.customization.icon),
|
||||
onPressed: () {
|
||||
widget.goToNextPage();
|
||||
},
|
||||
child: Text(context.i18n.adjustControllerButtons),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../keymap/apps/supported_app.dart';
|
||||
import '../single_line_exception.dart';
|
||||
@@ -47,6 +48,7 @@ class AndroidActions extends BaseActions {
|
||||
Future<ActionResult> performAction(ControllerButton button, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final superResult = await super.performAction(button, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
|
||||
if (superResult is! NotHandled) {
|
||||
// Increment command count after successful execution
|
||||
return superResult;
|
||||
}
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(button)!;
|
||||
@@ -59,6 +61,8 @@ class AndroidActions extends BaseActions {
|
||||
PhysicalKeyboardKey.audioVolumeDown => MediaAction.volumeDown,
|
||||
_ => throw SingleLineException("No action for key: ${keyPair.physicalKey}"),
|
||||
});
|
||||
// Increment command count after successful execution
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
return Success("Key pressed: ${keyPair.toString()}");
|
||||
}
|
||||
|
||||
@@ -69,6 +73,8 @@ class AndroidActions extends BaseActions {
|
||||
} on PlatformException catch (e) {
|
||||
return Error("Accessibility Service not working. Follow instructions at https://dontkillmyapp.com/");
|
||||
}
|
||||
// Increment command count after successful execution
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
return Success(
|
||||
"Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
|
||||
? "click"
|
||||
|
||||
@@ -2,11 +2,13 @@ import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
@@ -52,23 +54,8 @@ abstract class BaseActions {
|
||||
debugPrint('Supported app: ${supportedApp?.name ?? "None"}');
|
||||
|
||||
if (supportedApp != null) {
|
||||
final allButtons = core.connection.devices.map((e) => e.availableButtons).flatten().distinct();
|
||||
|
||||
final newButtons = allButtons.filter(
|
||||
(button) => supportedApp.keymap.getKeyPair(button) == null,
|
||||
);
|
||||
for (final button in newButtons) {
|
||||
supportedApp.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
inGameAction: button.action,
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
final allButtons = core.connection.devices.map((e) => e.availableButtons).flatten().distinct().toList();
|
||||
supportedApp.keymap.addNewButtons(allButtons);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,19 +107,23 @@ abstract class BaseActions {
|
||||
|
||||
Future<ActionResult> performAction(ControllerButton button, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
if (supportedApp == null) {
|
||||
return Error("Could not perform ${button.name.splitByUpperCase()}: No keymap set");
|
||||
return Error(
|
||||
AppLocalizations.current.couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet(button.name.splitByUpperCase()),
|
||||
);
|
||||
}
|
||||
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(button);
|
||||
|
||||
if (core.logic.hasNoConnectionMethod) {
|
||||
return Error(AppLocalizations.current.pleaseSelectAConnectionMethodFirst);
|
||||
if (GyroscopeSteeringButtons.values.contains(button)) {
|
||||
return Ignored('Too many messages from gyroscope steering');
|
||||
} else {
|
||||
return Error(AppLocalizations.current.pleaseSelectAConnectionMethodFirst);
|
||||
}
|
||||
} else if (!(await core.logic.isTrainerConnected())) {
|
||||
return Error('No connection method is connected or active.');
|
||||
} else if (keyPair == null) {
|
||||
return Error("Could not perform ${button.name.splitByUpperCase()}: No action assigned");
|
||||
} else if (keyPair.hasNoAction) {
|
||||
return Error('No action assigned for ${button.toString().splitByUpperCase()}');
|
||||
return Error(AppLocalizations.current.noConnectionMethodIsConnectedOrActive);
|
||||
} else if (keyPair == null || keyPair.hasNoAction) {
|
||||
return Error(AppLocalizations.current.noActionAssignedForButton(button.name.splitByUpperCase()));
|
||||
}
|
||||
|
||||
// Handle Headwind actions
|
||||
@@ -143,12 +134,17 @@ abstract class BaseActions {
|
||||
return Error('No Headwind connected');
|
||||
}
|
||||
|
||||
// Increment command count after successful execution
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
return await headwind.handleKeypair(keyPair, isKeyDown: isKeyDown);
|
||||
}
|
||||
|
||||
final directConnectHandled = await _handleDirectConnect(keyPair, button, isKeyUp: isKeyUp, isKeyDown: isKeyDown);
|
||||
if (directConnectHandled is NotHandled && directConnectHandled.message.isNotEmpty) {
|
||||
core.connection.signalNotification(LogNotification(directConnectHandled.message));
|
||||
} else if (directConnectHandled is! NotHandled) {
|
||||
// Increment command count after successful execution
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
}
|
||||
return directConnectHandled;
|
||||
}
|
||||
@@ -185,6 +181,6 @@ class StubActions extends BaseActions {
|
||||
@override
|
||||
Future<ActionResult> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
performedActions.add(button);
|
||||
return Future.value(Success('${button.name.splitByUpperCase()} clicked'));
|
||||
return Future.value(Ignored('${button.name.splitByUpperCase()} clicked'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
|
||||
class DesktopActions extends BaseActions {
|
||||
DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]});
|
||||
@@ -20,6 +21,8 @@ class DesktopActions extends BaseActions {
|
||||
|
||||
if (core.settings.getLocalEnabled()) {
|
||||
if (keyPair.physicalKey != null) {
|
||||
// Increment command count after successful execution
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
|
||||
@@ -34,6 +37,8 @@ class DesktopActions extends BaseActions {
|
||||
} else {
|
||||
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: null);
|
||||
if (point != Offset.zero) {
|
||||
// Increment command count after successful execution
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
// slight move to register clicks on some apps, see issue #116
|
||||
|
||||
@@ -55,7 +55,9 @@ class Core {
|
||||
class Permissions {
|
||||
Future<List<PlatformRequirement>> getScanRequirements() async {
|
||||
final List<PlatformRequirement> list;
|
||||
if (kIsWeb) {
|
||||
if (screenshotMode) {
|
||||
list = [];
|
||||
} else if (kIsWeb) {
|
||||
final availablity = await UniversalBle.getBluetoothAvailabilityState();
|
||||
if (availablity == AvailabilityState.unsupported) {
|
||||
list = [UnsupportedPlatform()];
|
||||
@@ -63,10 +65,14 @@ class Permissions {
|
||||
list = [BluetoothTurnedOn()];
|
||||
}
|
||||
} else if (Platform.isMacOS) {
|
||||
list = [BluetoothTurnedOn()];
|
||||
list = [
|
||||
BluetoothTurnedOn(),
|
||||
NotificationRequirement(),
|
||||
];
|
||||
} else if (Platform.isIOS) {
|
||||
list = [
|
||||
BluetoothTurnedOn(),
|
||||
NotificationRequirement(),
|
||||
];
|
||||
} else if (Platform.isWindows) {
|
||||
list = [
|
||||
@@ -101,6 +107,8 @@ class Permissions {
|
||||
return [
|
||||
BluetoothTurnedOn(),
|
||||
if (Platform.isAndroid) ...[
|
||||
BluetoothScanRequirement(),
|
||||
BluetoothConnectRequirement(),
|
||||
BluetoothAdvertiseRequirement(),
|
||||
],
|
||||
];
|
||||
@@ -231,6 +239,15 @@ class CoreLogic {
|
||||
if (isRemoteControlEnabled) core.remotePairing,
|
||||
].filter((e) => e.isConnected.value).toList();
|
||||
|
||||
List<TrainerConnection> get enabledTrainerConnections => [
|
||||
if (isMyWhooshLinkEnabled) core.whooshLink,
|
||||
if (isObpMdnsEnabled) core.obpMdnsEmulator,
|
||||
if (isObpBleEnabled) core.obpBluetoothEmulator,
|
||||
if (isZwiftBleEnabled) core.zwiftEmulator,
|
||||
if (isZwiftMdnsEnabled) core.zwiftMdnsEmulator,
|
||||
if (isRemoteControlEnabled) core.remotePairing,
|
||||
];
|
||||
|
||||
List<TrainerConnection> get trainerConnections => [
|
||||
if (showMyWhooshLink) core.whooshLink,
|
||||
if (showObpMdnsEmulator) core.obpMdnsEmulator,
|
||||
@@ -399,7 +416,6 @@ class MediaKeyHandler {
|
||||
core.connection.addDevices([hidDevice]);
|
||||
availableDevice = hidDevice;
|
||||
}
|
||||
availableDevice.handleButtonsClicked([button]);
|
||||
availableDevice.handleButtonsClicked([]);
|
||||
availableDevice.handleButtonsClickedWithoutLongPressSupport([button]);
|
||||
}
|
||||
}
|
||||
|
||||
167
lib/utils/iap/iap_manager.dart
Normal file
167
lib/utils/iap/iap_manager.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/iap/iap_service.dart';
|
||||
import 'package:bike_control/utils/iap/windows_iap_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Unified IAP manager that handles platform-specific IAP services
|
||||
class IAPManager {
|
||||
static IAPManager? _instance;
|
||||
static IAPManager get instance {
|
||||
_instance ??= IAPManager._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static int dailyCommandLimit = 15;
|
||||
IAPService? _iapService;
|
||||
WindowsIAPService? _windowsIapService;
|
||||
ValueNotifier<bool> isPurchased = ValueNotifier<bool>(false);
|
||||
|
||||
IAPManager._();
|
||||
|
||||
/// Initialize the IAP manager
|
||||
Future<void> initialize() async {
|
||||
final prefs = FlutterSecureStorage(aOptions: AndroidOptions());
|
||||
|
||||
if (kIsWeb || screenshotMode) {
|
||||
// Web doesn't support IAP
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
_windowsIapService = WindowsIAPService(prefs);
|
||||
await _windowsIapService!.initialize();
|
||||
} else if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
|
||||
_iapService = IAPService(prefs);
|
||||
await _iapService!.initialize();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error initializing IAP manager: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the trial period has started
|
||||
bool get hasTrialStarted {
|
||||
if (_iapService != null) {
|
||||
return _iapService!.hasTrialStarted;
|
||||
} else if (_windowsIapService != null) {
|
||||
return _windowsIapService!.hasTrialStarted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Start the trial period
|
||||
Future<void> startTrial() async {
|
||||
if (_iapService != null) {
|
||||
await _iapService!.startTrial();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of days remaining in the trial
|
||||
int get trialDaysRemaining {
|
||||
if (_iapService != null) {
|
||||
return _iapService!.trialDaysRemaining;
|
||||
} else if (_windowsIapService != null) {
|
||||
return _windowsIapService!.trialDaysRemaining;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Check if the trial has expired
|
||||
bool get isTrialExpired {
|
||||
if (_iapService != null) {
|
||||
return _iapService!.isTrialExpired;
|
||||
} else if (_windowsIapService != null) {
|
||||
return _windowsIapService!.isTrialExpired;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Check if the user can execute a command
|
||||
bool get canExecuteCommand {
|
||||
// If IAP is not initialized or not available, allow commands
|
||||
if (_iapService == null && _windowsIapService == null) return true;
|
||||
|
||||
if (_iapService != null) {
|
||||
return _iapService!.canExecuteCommand;
|
||||
} else if (_windowsIapService != null) {
|
||||
return _windowsIapService!.canExecuteCommand;
|
||||
}
|
||||
return true; // Default to true for platforms without IAP
|
||||
}
|
||||
|
||||
/// Get the number of commands remaining today (for free tier after trial)
|
||||
int get commandsRemainingToday {
|
||||
if (_iapService != null) {
|
||||
return _iapService!.commandsRemainingToday;
|
||||
} else if (_windowsIapService != null) {
|
||||
return _windowsIapService!.commandsRemainingToday;
|
||||
}
|
||||
return -1; // Unlimited
|
||||
}
|
||||
|
||||
/// Get the daily command count
|
||||
int get dailyCommandCount {
|
||||
if (_iapService != null) {
|
||||
return _iapService!.dailyCommandCount;
|
||||
} else if (_windowsIapService != null) {
|
||||
return _windowsIapService!.dailyCommandCount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Increment the daily command count
|
||||
Future<void> incrementCommandCount() async {
|
||||
if (_iapService != null) {
|
||||
await _iapService!.incrementCommandCount();
|
||||
} else if (_windowsIapService != null) {
|
||||
await _windowsIapService!.incrementCommandCount();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a status message for the user
|
||||
String getStatusMessage() {
|
||||
/// Get a status message for the user
|
||||
if (IAPManager.instance.isPurchased.value) {
|
||||
return AppLocalizations.current.fullVersion;
|
||||
} else if (!hasTrialStarted) {
|
||||
return '${_iapService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available';
|
||||
} else if (!isTrialExpired) {
|
||||
return AppLocalizations.current.trialDaysRemaining(trialDaysRemaining);
|
||||
} else {
|
||||
return AppLocalizations.current.commandsRemainingToday(commandsRemainingToday, dailyCommandLimit);
|
||||
}
|
||||
}
|
||||
|
||||
/// Purchase the full version
|
||||
Future<void> purchaseFullVersion() async {
|
||||
if (_iapService != null) {
|
||||
return await _iapService!.purchaseFullVersion();
|
||||
} else if (_windowsIapService != null) {
|
||||
return await _windowsIapService!.purchaseFullVersion();
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore previous purchases
|
||||
Future<void> restorePurchases() async {
|
||||
if (_iapService != null) {
|
||||
await _iapService!.restorePurchases();
|
||||
}
|
||||
// Windows doesn't have a separate restore mechanism in the stub
|
||||
}
|
||||
|
||||
/// Dispose the manager
|
||||
void dispose() {
|
||||
_iapService?.dispose();
|
||||
_windowsIapService?.dispose();
|
||||
}
|
||||
|
||||
void reset(bool fullReset) {
|
||||
_windowsIapService?.reset();
|
||||
_iapService?.reset(fullReset);
|
||||
}
|
||||
}
|
||||
398
lib/utils/iap/iap_service.dart
Normal file
398
lib/utils/iap/iap_service.dart
Normal file
@@ -0,0 +1,398 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:ios_receipt/ios_receipt.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
/// Service to handle in-app purchase functionality and trial period management
|
||||
class IAPService {
|
||||
static const int trialDays = 5;
|
||||
|
||||
static const String _trialStartDateKey = 'iap_trial_start_date';
|
||||
static const String _purchaseStatusKey = 'iap_purchase_status';
|
||||
static const String _dailyCommandCountKey = 'iap_daily_command_count';
|
||||
static const String _lastCommandDateKey = 'iap_last_command_date';
|
||||
|
||||
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
|
||||
final FlutterSecureStorage _prefs;
|
||||
|
||||
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
||||
bool _isInitialized = false;
|
||||
String? _trialStartDate;
|
||||
String? _lastCommandDate;
|
||||
int? _dailyCommandCount;
|
||||
|
||||
IAPService(this._prefs);
|
||||
|
||||
/// Initialize the IAP service
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
// Skip IAP initialization on web
|
||||
if (kIsWeb) {
|
||||
debugPrint('IAP not supported on web');
|
||||
_isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if IAP is available on this platform
|
||||
final available = await _inAppPurchase.isAvailable();
|
||||
if (!available) {
|
||||
debugPrint('IAP not available on this platform -');
|
||||
// Set as purchased to allow unlimited access when IAP is not available
|
||||
IAPManager.instance.isPurchased.value = false;
|
||||
_isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for purchase updates
|
||||
_subscription = _inAppPurchase.purchaseStream.listen(
|
||||
_onPurchaseUpdate,
|
||||
onDone: () => _subscription?.cancel(),
|
||||
onError: (error) {
|
||||
debugPrint('IAP Error: $error');
|
||||
core.connection.signalNotification(
|
||||
LogNotification('There was an error with in-app purchases: ${error.toString()}'),
|
||||
);
|
||||
// On error, default to allowing access
|
||||
IAPManager.instance.isPurchased.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
_trialStartDate = await _prefs.read(key: _trialStartDateKey);
|
||||
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
|
||||
|
||||
final commandCount = await _prefs.read(key: _dailyCommandCountKey) ?? '0';
|
||||
_dailyCommandCount = int.tryParse(commandCount);
|
||||
// Check if already purchased
|
||||
await _checkExistingPurchase();
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
if (!isTrialExpired && Platform.isAndroid) {
|
||||
IAPManager.dailyCommandLimit = 80;
|
||||
}
|
||||
} catch (e, s) {
|
||||
recordError(e, s, context: 'Initializing IAP Service');
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'There was an error checking purchase status: ${e.toString()}'),
|
||||
);
|
||||
debugPrint('Failed to initialize IAP: $e');
|
||||
// On initialization failure, default to allowing access
|
||||
IAPManager.instance.isPurchased.value = false;
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the user has already purchased the app
|
||||
Future<void> _checkExistingPurchase() async {
|
||||
// First check if we have a stored purchase status
|
||||
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
|
||||
if (storedStatus == "true") {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Platform-specific checks for existing paid app purchases
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
// On iOS/macOS, check if the app was previously purchased (has a receipt)
|
||||
await _checkAppleReceipt();
|
||||
} else if (Platform.isAndroid) {
|
||||
// On Android, check if user had the paid version before
|
||||
await _checkAndroidPreviousPurchase();
|
||||
}
|
||||
|
||||
// Also check for IAP purchase
|
||||
if (!IAPManager.instance.isPurchased.value) {
|
||||
await restorePurchases();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for Apple receipt (iOS/macOS)
|
||||
Future<void> _checkAppleReceipt() async {
|
||||
try {
|
||||
final receiptContent = await IosReceipt.getAppleReceipt();
|
||||
if (receiptContent != null) {
|
||||
debugPrint('Existing Apple user detected - validating receipt $receiptContent');
|
||||
final sharedSecret =
|
||||
Platform.environment['VERIFYING_SHARED_SECRET'] ?? String.fromEnvironment("VERIFYING_SHARED_SECRET");
|
||||
|
||||
if (sharedSecret.isEmpty) {
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Shared Secret is empty'));
|
||||
}
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Using shared secret: ${sharedSecret.characters.take(15).join()}'),
|
||||
);
|
||||
await validateReceipt(
|
||||
base64Receipt: receiptContent,
|
||||
sharedSecret: sharedSecret,
|
||||
);
|
||||
} else {
|
||||
debugPrint('No Apple receipt found');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error checking Apple receipt: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> validateReceipt({
|
||||
required String base64Receipt,
|
||||
required String sharedSecret,
|
||||
bool isSandbox = false,
|
||||
}) async {
|
||||
final Uri url = Uri.parse(
|
||||
isSandbox ? 'https://sandbox.itunes.apple.com/verifyReceipt' : 'https://buy.itunes.apple.com/verifyReceipt',
|
||||
);
|
||||
|
||||
final Map<String, dynamic> requestData = {
|
||||
'receipt-data': base64Receipt,
|
||||
'password': sharedSecret,
|
||||
'exclude-old-transactions': false,
|
||||
};
|
||||
|
||||
final HttpClient client = HttpClient();
|
||||
|
||||
try {
|
||||
final HttpClientRequest request = await client.postUrl(url);
|
||||
request.headers.set(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
'application/json',
|
||||
);
|
||||
request.add(utf8.encode(jsonEncode(requestData)));
|
||||
|
||||
final HttpClientResponse response = await request.close();
|
||||
final String responseBody = await response.transform(utf8.decoder).join();
|
||||
|
||||
final Map<String, dynamic> json = jsonDecode(responseBody) as Map<String, dynamic>;
|
||||
|
||||
if (json['status'] == 21007) {
|
||||
// Receipt is from sandbox, retry with sandbox URL
|
||||
debugPrint('Receipt is from sandbox, retrying with sandbox URL');
|
||||
return validateReceipt(
|
||||
base64Receipt: base64Receipt,
|
||||
sharedSecret: sharedSecret,
|
||||
isSandbox: true,
|
||||
);
|
||||
} else if (json['status'] != 0) {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Apple receipt validation failed with status: ${json['status']}'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final purchasedVersion = json['receipt']["original_application_version"];
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Apple receipt validated for version: $purchasedVersion'),
|
||||
);
|
||||
IAPManager.instance.isPurchased.value = Version.parse(purchasedVersion) < Version(4, 2, 0);
|
||||
if (IAPManager.instance.isPurchased.value) {
|
||||
debugPrint('Apple receipt validation successful - granting full access');
|
||||
await _prefs.write(key: _purchaseStatusKey, value: "true");
|
||||
} else {
|
||||
debugPrint('Apple receipt validation failed - no full access');
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if Android user had the paid app before
|
||||
Future<void> _checkAndroidPreviousPurchase() async {
|
||||
try {
|
||||
// On Android, we use the last seen version to determine if they had the paid version
|
||||
// IMPORTANT: This assumes the app is currently paid and this update will be released
|
||||
// while the app is still paid. Only users who downloaded the paid version will have
|
||||
// a last_seen_version. After changing the app to free, new users won't have this set.
|
||||
final lastSeenVersion = core.settings.getLastSeenVersion();
|
||||
if (lastSeenVersion != null && lastSeenVersion.isNotEmpty) {
|
||||
Version lastVersion = Version.parse(lastSeenVersion);
|
||||
// If they had a previous version, they're an existing paid user
|
||||
IAPManager.instance.isPurchased.value = lastVersion < Version(4, 2, 0);
|
||||
if (IAPManager.instance.isPurchased.value) {
|
||||
await _prefs.write(key: _purchaseStatusKey, value: "true");
|
||||
}
|
||||
debugPrint('Existing Android user detected - granting full access');
|
||||
}
|
||||
} catch (e, s) {
|
||||
debugPrint('Error checking Android previous purchase: $e');
|
||||
recordError(e, s, context: 'Checking Android previous purchase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore previous purchases
|
||||
Future<void> restorePurchases() async {
|
||||
try {
|
||||
await _inAppPurchase.restorePurchases();
|
||||
// The purchase stream will be called with restored purchases
|
||||
} catch (e, s) {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'There was an error restoring purchases: ${e.toString()}'),
|
||||
);
|
||||
recordError(e, s, context: 'Restore Purchases');
|
||||
debugPrint('Error restoring purchases: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle purchase updates
|
||||
Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) async {
|
||||
for (final purchase in purchaseDetailsList) {
|
||||
core.connection.signalNotification(
|
||||
LogNotification('Purchase found: ${purchase.productID} - ${purchase.status}'),
|
||||
);
|
||||
if (purchase.status == PurchaseStatus.purchased || purchase.status == PurchaseStatus.restored) {
|
||||
IAPManager.instance.isPurchased.value = !kDebugMode;
|
||||
await _prefs.write(key: _purchaseStatusKey, value: IAPManager.instance.isPurchased.value.toString());
|
||||
debugPrint('Purchase successful or restored');
|
||||
}
|
||||
|
||||
// Complete the purchase
|
||||
if (purchase.pendingCompletePurchase) {
|
||||
_inAppPurchase.completePurchase(purchase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Purchase the full version
|
||||
Future<void> purchaseFullVersion() async {
|
||||
try {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
final available = await _inAppPurchase.isAvailable();
|
||||
if (!available) {
|
||||
debugPrint('IAP not available');
|
||||
return;
|
||||
}
|
||||
|
||||
final productId = 'full_access_unlock';
|
||||
|
||||
// Query product details
|
||||
final response = await _inAppPurchase.queryProductDetails({productId});
|
||||
if (response.error != null) {
|
||||
debugPrint('Error querying products: ${response.error}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.productDetails.isEmpty) {
|
||||
debugPrint('Product not found: $productId');
|
||||
return;
|
||||
}
|
||||
|
||||
final product = response.productDetails.first;
|
||||
final purchaseParam = PurchaseParam(productDetails: product);
|
||||
|
||||
await _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
|
||||
} catch (e, s) {
|
||||
debugPrint('Error purchasing: $e');
|
||||
recordError(e, s, context: 'Error purchasing');
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'There was an error during purchasing: ${e.toString()}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the trial period has started
|
||||
bool get hasTrialStarted {
|
||||
return _trialStartDate != null;
|
||||
}
|
||||
|
||||
/// Start the trial period
|
||||
Future<void> startTrial() async {
|
||||
if (!hasTrialStarted) {
|
||||
await _prefs.write(key: _trialStartDateKey, value: DateTime.now().toIso8601String());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of days remaining in the trial
|
||||
int get trialDaysRemaining {
|
||||
if (IAPManager.instance.isPurchased.value) return 0;
|
||||
|
||||
final trialStart = _trialStartDate;
|
||||
if (trialStart == null) return trialDays;
|
||||
|
||||
final startDate = DateTime.parse(trialStart);
|
||||
final now = DateTime.now();
|
||||
final daysPassed = now.difference(startDate).inDays;
|
||||
final remaining = trialDays - daysPassed;
|
||||
|
||||
return remaining > 0 ? remaining : 0;
|
||||
}
|
||||
|
||||
/// Check if the trial has expired
|
||||
bool get isTrialExpired {
|
||||
return (!IAPManager.instance.isPurchased.value && hasTrialStarted && trialDaysRemaining <= 0);
|
||||
}
|
||||
|
||||
/// Get the number of commands executed today
|
||||
int get dailyCommandCount {
|
||||
final lastDate = _lastCommandDate;
|
||||
final today = DateTime.now().toIso8601String().split('T')[0];
|
||||
|
||||
if (lastDate != today) {
|
||||
// Reset counter for new day
|
||||
_lastCommandDate = today;
|
||||
_dailyCommandCount = 0;
|
||||
}
|
||||
|
||||
return _dailyCommandCount ?? 0;
|
||||
}
|
||||
|
||||
/// Increment the daily command count
|
||||
Future<void> incrementCommandCount() async {
|
||||
final today = DateTime.now().toIso8601String().split('T')[0];
|
||||
final lastDate = await _prefs.read(key: _lastCommandDateKey);
|
||||
|
||||
if (lastDate != today) {
|
||||
// Reset counter for new day
|
||||
_lastCommandDate = today;
|
||||
_dailyCommandCount = 1;
|
||||
await _prefs.write(key: _lastCommandDateKey, value: today);
|
||||
await _prefs.write(key: _dailyCommandCountKey, value: '1');
|
||||
} else {
|
||||
final count = _dailyCommandCount ?? 0;
|
||||
_dailyCommandCount = count + 1;
|
||||
await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the user can execute a command
|
||||
bool get canExecuteCommand {
|
||||
if (IAPManager.instance.isPurchased.value) return true;
|
||||
if (!isTrialExpired && !Platform.isAndroid) return true;
|
||||
return dailyCommandCount < IAPManager.dailyCommandLimit;
|
||||
}
|
||||
|
||||
/// Get the number of commands remaining today (for free tier after trial)
|
||||
int get commandsRemainingToday {
|
||||
if (IAPManager.instance.isPurchased.value || (!isTrialExpired && !Platform.isAndroid)) return -1; // Unlimited
|
||||
final remaining = IAPManager.dailyCommandLimit - dailyCommandCount;
|
||||
return remaining > 0 ? remaining : 0; // Never return negative
|
||||
}
|
||||
|
||||
/// Dispose the service
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
}
|
||||
|
||||
void reset(bool fullReset) {
|
||||
if (fullReset) {
|
||||
_prefs.deleteAll();
|
||||
} else {
|
||||
_prefs.delete(key: _purchaseStatusKey);
|
||||
_isInitialized = false;
|
||||
initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
150
lib/utils/iap/windows_iap_service.dart
Normal file
150
lib/utils/iap/windows_iap_service.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:windows_iap/windows_iap.dart';
|
||||
|
||||
/// Windows-specific IAP service
|
||||
/// Note: This is a stub implementation. For actual Windows Store integration,
|
||||
/// you would need to use the Windows Store APIs through platform channels.
|
||||
class WindowsIAPService {
|
||||
static const String productId = '9NP42GS03Z26';
|
||||
static const int trialDays = 7;
|
||||
static const int dailyCommandLimit = 15;
|
||||
|
||||
static const String _purchaseStatusKey = 'iap_purchase_status';
|
||||
static const String _dailyCommandCountKey = 'iap_daily_command_count';
|
||||
static const String _lastCommandDateKey = 'iap_last_command_date';
|
||||
|
||||
final FlutterSecureStorage _prefs;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
String? _lastCommandDate;
|
||||
int? _dailyCommandCount;
|
||||
|
||||
final _windowsIapPlugin = WindowsIap();
|
||||
|
||||
WindowsIAPService(this._prefs);
|
||||
|
||||
/// Initialize the Windows IAP service
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
// Check if already purchased
|
||||
await _checkExistingPurchase();
|
||||
|
||||
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
|
||||
_dailyCommandCount = int.tryParse(await _prefs.read(key: _dailyCommandCountKey) ?? '0');
|
||||
_isInitialized = true;
|
||||
} catch (e, s) {
|
||||
recordError(e, s, context: 'Initializing');
|
||||
debugPrint('Failed to initialize Windows IAP: $e');
|
||||
_isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the user has already purchased the app
|
||||
Future<void> _checkExistingPurchase() async {
|
||||
// First check if we have a stored purchase status
|
||||
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
|
||||
if (storedStatus == "true") {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
return;
|
||||
}
|
||||
final trial = await _windowsIapPlugin.getTrialStatusAndRemainingDays();
|
||||
trialDaysRemaining = trial.remainingDays;
|
||||
if (!trial.isTrial && trial.remainingDays <= 0) {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
await _prefs.write(key: _purchaseStatusKey, value: "true");
|
||||
} else {
|
||||
IAPManager.instance.isPurchased.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Purchase the full version
|
||||
/// TODO: Implement actual Windows Store purchase flow
|
||||
Future<void> purchaseFullVersion() async {
|
||||
try {
|
||||
final status = await _windowsIapPlugin.makePurchase(productId);
|
||||
if (status == StorePurchaseStatus.succeeded || status == StorePurchaseStatus.alreadyPurchased) {
|
||||
/*buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'Purchase Successful',
|
||||
subtitle: 'Thank you for your purchase! You now have unlimited access.',
|
||||
);*/
|
||||
}
|
||||
} catch (e, s) {
|
||||
recordError(e, s, context: 'Purchasing on Windows');
|
||||
debugPrint('Error purchasing on Windows: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the trial period has started
|
||||
bool get hasTrialStarted => trialDaysRemaining > 0;
|
||||
|
||||
/// Get the number of days remaining in the trial
|
||||
int trialDaysRemaining = 0;
|
||||
|
||||
/// Check if the trial has expired
|
||||
bool get isTrialExpired {
|
||||
return !IAPManager.instance.isPurchased.value && hasTrialStarted && trialDaysRemaining <= 0;
|
||||
}
|
||||
|
||||
/// Get the number of commands executed today
|
||||
int get dailyCommandCount {
|
||||
final lastDate = _lastCommandDate;
|
||||
final today = DateTime.now().toIso8601String().split('T')[0];
|
||||
|
||||
if (lastDate != today) {
|
||||
// Reset counter for new day
|
||||
return 0;
|
||||
}
|
||||
|
||||
return _dailyCommandCount ?? 0;
|
||||
}
|
||||
|
||||
/// Increment the daily command count
|
||||
Future<void> incrementCommandCount() async {
|
||||
final today = DateTime.now().toIso8601String().split('T')[0];
|
||||
final lastDate = _lastCommandDate;
|
||||
|
||||
if (lastDate != today) {
|
||||
// Reset counter for new day
|
||||
_lastCommandDate = today;
|
||||
_dailyCommandCount = 1;
|
||||
await _prefs.write(key: _lastCommandDateKey, value: today);
|
||||
await _prefs.write(key: _dailyCommandCountKey, value: "1");
|
||||
} else {
|
||||
final count = _dailyCommandCount ?? 0;
|
||||
_dailyCommandCount = count + 1;
|
||||
await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the user can execute a command
|
||||
bool get canExecuteCommand {
|
||||
if (IAPManager.instance.isPurchased.value) return true;
|
||||
if (!isTrialExpired) return true;
|
||||
return dailyCommandCount < dailyCommandLimit;
|
||||
}
|
||||
|
||||
/// Get the number of commands remaining today (for free tier after trial)
|
||||
int get commandsRemainingToday {
|
||||
if (IAPManager.instance.isPurchased.value || !isTrialExpired) return -1; // Unlimited
|
||||
final remaining = dailyCommandLimit - dailyCommandCount;
|
||||
return remaining > 0 ? remaining : 0; // Never return negative
|
||||
}
|
||||
|
||||
/// Dispose the service
|
||||
void dispose() {
|
||||
// Nothing to dispose for Windows
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_prefs.deleteAll();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class MyWhoosh extends SupportedApp {
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
supportsOpenBikeProtocol: screenshotMode,
|
||||
star: true,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
...ControllerButton.values
|
||||
|
||||
@@ -6,7 +6,7 @@ import '../keymap.dart';
|
||||
class OpenBikeControl extends SupportedApp {
|
||||
OpenBikeControl()
|
||||
: super(
|
||||
name: 'OpenBikeControl compatible app',
|
||||
name: 'OpenBikeControl Compatible',
|
||||
packageName: "org.openbikecontrol",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
@@ -17,6 +17,7 @@ class Rouvy extends SupportedApp {
|
||||
packageName: "eu.virtualtraining.rouvy.android",
|
||||
compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values,
|
||||
supportsZwiftEmulation: !kIsWeb && Platform.isAndroid,
|
||||
star: true,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
// https://support.rouvy.com/hc/de/articles/32452137189393-Virtuelles-Schalten#h_01K5GMVG4KVYZ0Y6W7RBRZC9MA
|
||||
|
||||
@@ -16,6 +16,7 @@ abstract class SupportedApp {
|
||||
final Keymap keymap;
|
||||
final bool supportsZwiftEmulation;
|
||||
final bool supportsOpenBikeProtocol;
|
||||
final bool star;
|
||||
|
||||
const SupportedApp({
|
||||
required this.name,
|
||||
@@ -24,6 +25,7 @@ abstract class SupportedApp {
|
||||
required this.compatibleTargets,
|
||||
required this.supportsZwiftEmulation,
|
||||
this.supportsOpenBikeProtocol = false,
|
||||
this.star = false,
|
||||
});
|
||||
|
||||
static final List<SupportedApp> supportedApps = [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
@@ -83,6 +83,7 @@ class Zwift extends SupportedApp {
|
||||
physicalKey: PhysicalKeyboardKey.space,
|
||||
logicalKey: LogicalKeyboardKey.space,
|
||||
inGameAction: InGameAction.usePowerUp,
|
||||
isLongPress: true,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.a],
|
||||
@@ -101,6 +102,7 @@ class Zwift extends SupportedApp {
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: InGameAction.rideOnBomb,
|
||||
isLongPress: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:bike_control/bluetooth/devices/elite/elite_sterzo.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
enum InGameAction {
|
||||
shiftUp('Shift Up'),
|
||||
shiftDown('Shift Down'),
|
||||
uturn('U-Turn', alternativeTitle: 'Down'),
|
||||
steerLeft('Steer Left', alternativeTitle: 'Left'),
|
||||
steerRight('Steer Right', alternativeTitle: 'Right'),
|
||||
shiftUp('Shift Up', icon: BootstrapIcons.patchPlus),
|
||||
shiftDown('Shift Down', icon: BootstrapIcons.patchMinus),
|
||||
uturn('U-Turn', alternativeTitle: 'Down', icon: BootstrapIcons.arrowDownUp),
|
||||
steerLeft('Steer Left', alternativeTitle: 'Left', icon: RadixIcons.doubleArrowLeft),
|
||||
steerRight('Steer Right', alternativeTitle: 'Right', icon: RadixIcons.doubleArrowRight),
|
||||
|
||||
// mywhoosh
|
||||
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
||||
emote('Emote', possibleValues: [1, 2, 3, 4, 5, 6]),
|
||||
toggleUi('Toggle UI'),
|
||||
navigateLeft('Navigate Left'),
|
||||
navigateRight('Navigate Right'),
|
||||
increaseResistance('Increase Resistance'),
|
||||
decreaseResistance('Decrease Resistance'),
|
||||
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], icon: BootstrapIcons.cameraReels),
|
||||
emote('Emote', possibleValues: [1, 2, 3, 4, 5, 6], icon: BootstrapIcons.emojiSmile),
|
||||
toggleUi('Toggle UI', icon: RadixIcons.iconSwitch),
|
||||
navigateLeft('Navigate Left', icon: BootstrapIcons.signTurnLeft),
|
||||
navigateRight('Navigate Right', icon: BootstrapIcons.signTurnRight),
|
||||
increaseResistance('Increase Resistance', icon: LucideIcons.chartNoAxesColumnIncreasing),
|
||||
decreaseResistance('Decrease Resistance', icon: LucideIcons.chartNoAxesColumnDecreasing),
|
||||
|
||||
// zwift
|
||||
openActionBar('Open Action Bar', alternativeTitle: 'Up'),
|
||||
usePowerUp('Use Power-Up'),
|
||||
select('Select'),
|
||||
back('Back'),
|
||||
rideOnBomb('Ride On Bomb'),
|
||||
openActionBar('Open Action Bar', alternativeTitle: 'Up', icon: BootstrapIcons.menuApp, isLongPress: true),
|
||||
usePowerUp('Use Power-Up', icon: Icons.flash_on_outlined, isLongPress: true),
|
||||
select('Select', icon: LucideIcons.mousePointerClick),
|
||||
back('Back', icon: BootstrapIcons.arrowLeft),
|
||||
rideOnBomb('Ride On Bomb', icon: LucideIcons.bomb, isLongPress: true),
|
||||
|
||||
// headwind
|
||||
headwindSpeed('Headwind Speed', possibleValues: [0, 25, 50, 75, 100]),
|
||||
headwindHeartRateMode('Headwind HR Mode');
|
||||
|
||||
final String title;
|
||||
final bool isLongPress;
|
||||
final IconData? icon;
|
||||
final String? alternativeTitle;
|
||||
final List<int>? possibleValues;
|
||||
|
||||
const InGameAction(this.title, {this.possibleValues, this.alternativeTitle});
|
||||
const InGameAction(this.title, {this.possibleValues, this.alternativeTitle, this.icon, this.isLongPress = false});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -82,6 +85,7 @@ class ControllerButton {
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
...SterzoButtons.values,
|
||||
...GyroscopeSteeringButtons.values,
|
||||
...ZwiftButtons.values,
|
||||
...EliteSquareButtons.values,
|
||||
...WahooKickrShiftButtons.values,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../actions/base_actions.dart';
|
||||
import 'apps/custom_app.dart';
|
||||
@@ -70,7 +73,8 @@ class Keymap {
|
||||
buttons: [newButton],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
inGameAction: newButton.action,
|
||||
isLongPress: newButton.action?.isLongPress ?? false,
|
||||
),
|
||||
);
|
||||
return newButton;
|
||||
@@ -78,6 +82,21 @@ class Keymap {
|
||||
return allButtons.firstWhere((b) => b.name == name);
|
||||
}
|
||||
}
|
||||
|
||||
void addNewButtons(List<ControllerButton> availableButtons) {
|
||||
final newButtons = availableButtons.filter((button) => getKeyPair(button) == null);
|
||||
for (final button in newButtons) {
|
||||
addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
@@ -111,7 +130,8 @@ class KeyPair {
|
||||
|
||||
IconData? get icon {
|
||||
return switch (physicalKey) {
|
||||
_ when inGameAction != null && core.logic.emulatorEnabled => Icons.link,
|
||||
//_ when inGameAction != null && core.logic.emulatorEnabled => Icons.link,
|
||||
_ when inGameAction != null && inGameAction!.icon != null => inGameAction!.icon,
|
||||
|
||||
PhysicalKeyboardKey.mediaPlayPause ||
|
||||
PhysicalKeyboardKey.mediaStop ||
|
||||
@@ -120,8 +140,13 @@ class KeyPair {
|
||||
PhysicalKeyboardKey.audioVolumeUp ||
|
||||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
|
||||
_ when physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard) =>
|
||||
Icons.keyboard,
|
||||
_ when touchPosition != Offset.zero && core.logic.showLocalRemoteOptions => Icons.touch_app,
|
||||
RadixIcons.keyboard,
|
||||
_
|
||||
when touchPosition != Offset.zero &&
|
||||
core.logic.showLocalRemoteOptions &&
|
||||
core.actionHandler is AndroidActions =>
|
||||
Icons.touch_app,
|
||||
_ when touchPosition != Offset.zero && core.logic.showLocalRemoteOptions => BootstrapIcons.mouse,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -130,6 +155,7 @@ class KeyPair {
|
||||
logicalKey == null && physicalKey == null && touchPosition == Offset.zero && inGameAction == null;
|
||||
|
||||
bool get hasActiveAction =>
|
||||
screenshotMode ||
|
||||
(physicalKey != null &&
|
||||
core.logic.showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
@@ -155,23 +181,36 @@ class KeyPair {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final baseKey =
|
||||
logicalKey?.keyLabel ??
|
||||
switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Next Track',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => 'Previous Track',
|
||||
PhysicalKeyboardKey.mediaStop => 'Stop',
|
||||
PhysicalKeyboardKey.audioVolumeUp => 'Volume Up',
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
|
||||
_ => 'Not assigned',
|
||||
};
|
||||
final text = (inGameAction != null && core.logic.emulatorEnabled)
|
||||
? [
|
||||
inGameAction!.title,
|
||||
if (inGameActionValue != null) '$inGameActionValue',
|
||||
].joinToString(separator: ': ')
|
||||
: (isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
? switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => AppLocalizations.current.playPause,
|
||||
PhysicalKeyboardKey.mediaStop => AppLocalizations.current.stop,
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => AppLocalizations.current.previous,
|
||||
PhysicalKeyboardKey.mediaTrackNext => AppLocalizations.current.next,
|
||||
PhysicalKeyboardKey.audioVolumeUp => AppLocalizations.current.volumeUp,
|
||||
PhysicalKeyboardKey.audioVolumeDown => AppLocalizations.current.volumeDown,
|
||||
_ => 'Unknown',
|
||||
}
|
||||
: (physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
? null
|
||||
: (touchPosition != Offset.zero && core.logic.showLocalRemoteOptions)
|
||||
? 'X:${touchPosition.dx.toInt()}, Y:${touchPosition.dy.toInt()}'
|
||||
: '';
|
||||
if (text != null && text.isNotEmpty) {
|
||||
return text;
|
||||
}
|
||||
final baseKey = logicalKey?.keyLabel ?? text ?? 'Not assigned';
|
||||
|
||||
if (modifiers.isEmpty || baseKey == 'Not assigned') {
|
||||
if (baseKey.trim().isEmpty) {
|
||||
return 'Space';
|
||||
}
|
||||
return baseKey;
|
||||
return baseKey + (inGameAction != null ? ' (${inGameAction!.title})' : '');
|
||||
}
|
||||
|
||||
// Format modifiers + key (e.g., "Ctrl+Alt+R")
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class AccessibilityRequirement extends PlatformRequirement {
|
||||
@@ -112,24 +113,58 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
AppLocalizations.current.allowPersistentNotification,
|
||||
description: AppLocalizations.current.notificationDescription,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
if (Platform.isAndroid) {
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
} else if (Platform.isIOS) {
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: false,
|
||||
sound: false,
|
||||
);
|
||||
} else if (Platform.isMacOS) {
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermissions(
|
||||
alert: true,
|
||||
badge: false,
|
||||
sound: false,
|
||||
);
|
||||
}
|
||||
await getStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getStatus() async {
|
||||
final bool granted =
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.areNotificationsEnabled() ??
|
||||
false;
|
||||
status = granted;
|
||||
if (Platform.isAndroid) {
|
||||
final bool granted =
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.areNotificationsEnabled() ??
|
||||
false;
|
||||
status = granted;
|
||||
} else if (Platform.isIOS) {
|
||||
final permissions = await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
|
||||
?.checkPermissions();
|
||||
status = permissions?.isEnabled == true;
|
||||
} else if (Platform.isMacOS) {
|
||||
final permissions = await core.flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>()
|
||||
?.checkPermissions();
|
||||
status = permissions?.isEnabled == true;
|
||||
} else {
|
||||
status = true;
|
||||
}
|
||||
if (status) {
|
||||
await setup();
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -139,13 +174,23 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
);
|
||||
|
||||
await core.flutterLocalNotificationsPlugin.initialize(
|
||||
InitializationSettings(android: initializationSettingsAndroid),
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
iOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
),
|
||||
macOS: DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
),
|
||||
),
|
||||
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
|
||||
onDidReceiveNotificationResponse: (n) {
|
||||
notificationTapBackground(n);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> addPersistentNotification() async {
|
||||
const String channelGroupId = 'BikeControl';
|
||||
// create the group first
|
||||
await core.flutterLocalNotificationsPlugin
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider_windows/path_provider_windows.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:shared_preferences_windows/shared_preferences_windows.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
@@ -31,6 +33,15 @@ class Settings {
|
||||
|
||||
final app = getKeyMap();
|
||||
core.actionHandler.init(app);
|
||||
|
||||
// Initialize IAP manager
|
||||
await IAPManager.instance.initialize();
|
||||
|
||||
// Start trial if this is the first launch
|
||||
if (!IAPManager.instance.hasTrialStarted && !IAPManager.instance.isPurchased.value) {
|
||||
await IAPManager.instance.startTrial();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e, s) {
|
||||
if (!retried) {
|
||||
@@ -58,7 +69,8 @@ class Settings {
|
||||
|
||||
Future<void> reset() async {
|
||||
await prefs.clear();
|
||||
core.actionHandler.init(null);
|
||||
IAPManager.instance.reset(true);
|
||||
init();
|
||||
}
|
||||
|
||||
void setTrainerApp(SupportedApp app) {
|
||||
@@ -73,12 +85,6 @@ class Settings {
|
||||
return SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
|
||||
}
|
||||
|
||||
bool knowsAboutNameChange() {
|
||||
final knows = prefs.getBool('name_change') == true;
|
||||
prefs.setBool('name_change', true);
|
||||
return knows;
|
||||
}
|
||||
|
||||
Future<void> setKeyMap(SupportedApp app) async {
|
||||
if (app is CustomApp) {
|
||||
await prefs.setStringList('customapp_${app.profileName}', app.encodeKeymap());
|
||||
@@ -337,4 +343,35 @@ class Settings {
|
||||
hotkeys.remove(action);
|
||||
await setButtonSimulatorHotkeys(hotkeys);
|
||||
}
|
||||
|
||||
void setPhoneSteeringEnabled(bool value) {
|
||||
prefs.setBool('phone_steering_enabled', value);
|
||||
}
|
||||
|
||||
bool getPhoneSteeringEnabled() {
|
||||
return prefs.getBool('phone_steering_enabled') ?? false;
|
||||
}
|
||||
|
||||
void setPhoneSteeringThreshold(int value) {
|
||||
prefs.setInt('phone_steering_threshold', value);
|
||||
}
|
||||
|
||||
double getPhoneSteeringThreshold() {
|
||||
return prefs.getInt('phone_steering_threshold')?.toDouble() ?? GyroscopeSteering.STEERING_THRESHOLD;
|
||||
}
|
||||
|
||||
// SRAM AXS Settings
|
||||
static const int _sramAxsDoubleClickWindowDefaultMs = 350;
|
||||
static const int _sramAxsDoubleClickWindowMinMs = 150;
|
||||
static const int _sramAxsDoubleClickWindowMaxMs = 800;
|
||||
|
||||
int getSramAxsDoubleClickWindowMs() {
|
||||
final v = prefs.getInt('sram_axs_double_click_window_ms') ?? _sramAxsDoubleClickWindowDefaultMs;
|
||||
return v.clamp(_sramAxsDoubleClickWindowMinMs, _sramAxsDoubleClickWindowMaxMs);
|
||||
}
|
||||
|
||||
Future<void> setSramAxsDoubleClickWindowMs(int ms) async {
|
||||
final v = ms.clamp(_sramAxsDoubleClickWindowMinMs, _sramAxsDoubleClickWindowMaxMs);
|
||||
await prefs.setInt('sram_axs_double_click_window_ms', v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,103 @@ class _LocalTileState extends State<LocalTile> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = [
|
||||
// show warning only for android when using local accessibility service
|
||||
if (_showAutoRotationWarning)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(context.i18n.enableAutoRotation),
|
||||
],
|
||||
),
|
||||
if (_showMiuiWarning)
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(context.i18n.miuiDeviceDetected).bold,
|
||||
),
|
||||
IconButton.destructive(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () async {
|
||||
await core.settings.setMiuiWarningDismissed(true);
|
||||
setState(() {
|
||||
_showMiuiWarning = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.miuiWarningDescription,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.miuiEnsureProperWorking,
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiDisableBatteryOptimization,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiEnableAutostart,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiLockInRecentApps,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
OutlineButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://dontkillmyapp.com/xiaomi');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
leading: Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.viewDetailedInstructions),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isRunningAndroidService == false)
|
||||
Warning(
|
||||
children: [
|
||||
Text(context.i18n.accessibilityServiceNotRunning).xSmall,
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlineButton(
|
||||
child: Text('dontkillmyapp.com'),
|
||||
onPressed: () {
|
||||
launchUrlString('https://dontkillmyapp.com/');
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton.secondary(
|
||||
onPressed: () {
|
||||
core.logic.isAndroidServiceRunning().then((isRunning) {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $isRunning'));
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
return ConnectionMethod(
|
||||
isEnabled: core.settings.getLocalEnabled(),
|
||||
type: ConnectionMethodType.local,
|
||||
@@ -116,105 +213,11 @@ class _LocalTileState extends State<LocalTile> {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $value'));
|
||||
}
|
||||
},
|
||||
additionalChild: Column(
|
||||
children: [
|
||||
// show warning only for android when using local accessibility service
|
||||
if (_showAutoRotationWarning)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(context.i18n.enableAutoRotation),
|
||||
],
|
||||
),
|
||||
if (_showMiuiWarning)
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(context.i18n.miuiDeviceDetected).bold,
|
||||
),
|
||||
IconButton.destructive(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () async {
|
||||
await core.settings.setMiuiWarningDismissed(true);
|
||||
setState(() {
|
||||
_showMiuiWarning = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.miuiWarningDescription,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.miuiEnsureProperWorking,
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiDisableBatteryOptimization,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiEnableAutostart,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiLockInRecentApps,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
OutlineButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://dontkillmyapp.com/xiaomi');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
leading: Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.viewDetailedInstructions),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isRunningAndroidService == false)
|
||||
Warning(
|
||||
children: [
|
||||
Text(context.i18n.accessibilityServiceNotRunning).xSmall,
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlineButton(
|
||||
child: Text('dontkillmyapp.com'),
|
||||
onPressed: () {
|
||||
launchUrlString('https://dontkillmyapp.com/');
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton.secondary(
|
||||
onPressed: () {
|
||||
core.logic.isAndroidServiceRunning().then((isRunning) {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $isRunning'));
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
additionalChild: children.isNotEmpty
|
||||
? Column(
|
||||
children: children,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
@@ -43,8 +44,17 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
|
||||
buildToast(
|
||||
context,
|
||||
title: AppLocalizations.of(context).myWhooshLinkInfo,
|
||||
level: LogLevel.LOGLEVEL_WARNING,
|
||||
duration: Duration(seconds: 12),
|
||||
level: LogLevel.LOGLEVEL_INFO,
|
||||
duration: Duration(seconds: 6),
|
||||
closeTitle: 'Open',
|
||||
onClose: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'INSTRUCTIONS_MYWHOOSH_LINK.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
core.connection.startMyWhooshServer().catchError((e, s) {
|
||||
recordError(e, s, context: 'MyWhoosh Link Server');
|
||||
|
||||
@@ -39,6 +39,7 @@ class _ZwiftTileState extends State<ZwiftTile> {
|
||||
} else if (value) {
|
||||
core.zwiftEmulator.startAdvertising(widget.onUpdate).catchError((e, s) {
|
||||
recordError(e, s, context: 'Zwift BLE Emulator');
|
||||
core.zwiftEmulator.cleanup();
|
||||
core.zwiftEmulator.isStarted.value = false;
|
||||
core.settings.setZwiftBleEmulatorEnabled(false);
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString()));
|
||||
|
||||
220
lib/widgets/iap_status_widget.dart
Normal file
220
lib/widgets/iap_status_widget.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
/// Widget to display IAP status and allow purchases
|
||||
class IAPStatusWidget extends StatefulWidget {
|
||||
final bool small;
|
||||
const IAPStatusWidget({super.key, required this.small});
|
||||
|
||||
@override
|
||||
State<IAPStatusWidget> createState() => _IAPStatusWidgetState();
|
||||
}
|
||||
|
||||
class _IAPStatusWidgetState extends State<IAPStatusWidget> {
|
||||
bool _isPurchasing = false;
|
||||
bool _isSmall = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isSmall = widget.small;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant IAPStatusWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.small != widget.small) {
|
||||
setState(() {
|
||||
_isSmall = widget.small;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iapManager = IAPManager.instance;
|
||||
final isTrialExpired = iapManager.isTrialExpired;
|
||||
if (isTrialExpired) {
|
||||
_isSmall = false;
|
||||
}
|
||||
final trialDaysRemaining = iapManager.trialDaysRemaining;
|
||||
final commandsRemaining = iapManager.commandsRemainingToday;
|
||||
final dailyCommandCount = iapManager.dailyCommandCount;
|
||||
|
||||
return Button(
|
||||
onPressed: _isSmall
|
||||
? () {
|
||||
setState(() {
|
||||
_isSmall = false;
|
||||
});
|
||||
}
|
||||
: _handlePurchase,
|
||||
style: ButtonStyle.card().withBackgroundColor(
|
||||
color: Theme.of(context).colorScheme.muted,
|
||||
hoverColor: Theme.of(context).colorScheme.primaryForeground,
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 700),
|
||||
width: double.infinity,
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, isPurchased, child) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isPurchased) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
AppLocalizations.of(context).fullVersion,
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (!isTrialExpired) ...[
|
||||
if (!Platform.isAndroid)
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.access_time, color: Colors.blue),
|
||||
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
|
||||
subtitle: _isSmall
|
||||
? null
|
||||
: Text(AppLocalizations.of(context).trialPeriodDescription(IAPManager.dailyCommandLimit)),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
)
|
||||
else
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.lock),
|
||||
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
SizedBox(),
|
||||
Text(
|
||||
commandsRemaining >= 0
|
||||
? context.i18n
|
||||
.commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
)
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
|
||||
).small,
|
||||
if (commandsRemaining >= 0)
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: LinearProgressIndicator(
|
||||
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
|
||||
backgroundColor: Colors.gray[300],
|
||||
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
),
|
||||
] else ...[
|
||||
Basic(
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
leading: Icon(Icons.lock),
|
||||
title: Text(AppLocalizations.of(context).trialExpired(IAPManager.dailyCommandLimit)),
|
||||
trailing: _isSmall ? Icon(Icons.expand_more) : null,
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 6,
|
||||
children: [
|
||||
SizedBox(),
|
||||
Text(
|
||||
commandsRemaining >= 0
|
||||
? context.i18n.commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit)
|
||||
: AppLocalizations.of(
|
||||
context,
|
||||
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
|
||||
).small,
|
||||
if (commandsRemaining >= 0)
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: LinearProgressIndicator(
|
||||
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
|
||||
backgroundColor: Colors.gray[300],
|
||||
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!IAPManager.instance.isPurchased.value && !_isSmall) ...[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 42.0),
|
||||
child: PrimaryButton(
|
||||
onPressed: _isPurchasing ? null : _handlePurchase,
|
||||
leading: Icon(Icons.star),
|
||||
child: _isPurchasing
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SmallProgressIndicator(),
|
||||
const SizedBox(width: 8),
|
||||
Text('Processing...'),
|
||||
],
|
||||
)
|
||||
: Text(AppLocalizations.of(context).unlockFullVersion),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 42.0, top: 8.0),
|
||||
child: Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase() async {
|
||||
setState(() {
|
||||
_isPurchasing = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await IAPManager.instance.purchaseFullVersion();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
buildToast(
|
||||
context,
|
||||
title: 'Error',
|
||||
subtitle: 'An error occurred: $e',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPurchasing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
@@ -12,7 +9,10 @@ import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
import '../pages/touch_area.dart';
|
||||
|
||||
class KeymapExplanation extends StatefulWidget {
|
||||
@@ -27,12 +27,37 @@ class KeymapExplanation extends StatefulWidget {
|
||||
class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
late StreamSubscription<void> _updateStreamListener;
|
||||
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
|
||||
bool _isDrawerOpen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateStreamListener = widget.keymap.updateStream.listen((_) {
|
||||
setState(() {});
|
||||
});
|
||||
_actionSubscription = core.connection.actionStream.listen((data) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (data is ButtonNotification && data.buttonsClicked.length == 1) {
|
||||
final clickedButton = data.buttonsClicked.first;
|
||||
final keyPair = widget.keymap.keyPairs.firstOrNullWhere(
|
||||
(kp) => kp.buttons.contains(clickedButton),
|
||||
);
|
||||
if (keyPair != null && !_isDrawerOpen) {
|
||||
await _openKeyPairEditor(keyPair);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_updateStreamListener.cancel();
|
||||
_actionSubscription.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,12 +71,6 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_updateStreamListener.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allAvailableButtons = IterableFlatMap(core.connection.devices).flatMap((d) => d.availableButtons);
|
||||
@@ -61,64 +80,44 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
)
|
||||
.sortedBy((k) => k.buttons.first.color != null ? 0 : 1);
|
||||
|
||||
final isMobile = MediaQuery.sizeOf(context).width < 600;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Table(
|
||||
columnWidths: {
|
||||
0: FlexTableSize(flex: isMobile ? 1.5 : 1),
|
||||
1: FlexTableSize(flex: 3),
|
||||
},
|
||||
theme: TableTheme(
|
||||
cellTheme: TableCellTheme(
|
||||
border: WidgetStatePropertyAll(
|
||||
Border.all(
|
||||
color: Theme.of(context).colorScheme.border,
|
||||
strokeAlign: BorderSide.strokeAlignCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
// rounded border
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.border,
|
||||
strokeAlign: BorderSide.strokeAlignCenter,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
rows: [
|
||||
TableHeader(
|
||||
cells: [
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: OverflowMarquee(
|
||||
duration: Duration(seconds: 3),
|
||||
child: Text(
|
||||
core.connection.devices.isEmpty
|
||||
? context.i18n.deviceButton('Device')
|
||||
: core.connection.devices.joinToString(transform: (d) => d.name.screenshot),
|
||||
).small,
|
||||
),
|
||||
),
|
||||
),
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(context.i18n.action).small,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final keyPair in availableKeypairs) ...[
|
||||
TableRow(
|
||||
cells: [
|
||||
TableCell(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 52),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
Text(
|
||||
AppLocalizations.of(context).clickAButtonOnYourController,
|
||||
style: TextStyle(fontSize: 12),
|
||||
).muted,
|
||||
|
||||
for (final keyPair in availableKeypairs) ...[
|
||||
Button.card(
|
||||
style: ButtonStyle.card().withBackgroundColor(color: Theme.of(context).colorScheme.background),
|
||||
onPressed: () async {
|
||||
if (core.actionHandler.supportedApp is! CustomApp) {
|
||||
final currentProfile = core.actionHandler.supportedApp!.name;
|
||||
final newName = await KeymapManager().duplicate(
|
||||
context,
|
||||
currentProfile,
|
||||
skipName: '$currentProfile (Copy)',
|
||||
);
|
||||
if (newName != null && context.mounted) {
|
||||
buildToast(context, title: context.i18n.createdNewCustomProfile(newName));
|
||||
final selectedKeyPair = core.actionHandler.supportedApp!.keymap.keyPairs.firstWhere(
|
||||
(e) => e == keyPair,
|
||||
);
|
||||
_openKeyPairEditor(selectedKeyPair);
|
||||
}
|
||||
} else {
|
||||
_openKeyPairEditor(keyPair);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Basic(
|
||||
leading: SizedBox(
|
||||
width: 68,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
@@ -127,82 +126,59 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
children: [
|
||||
if (core.actionHandler.supportedApp is! CustomApp)
|
||||
for (final button in keyPair.buttons.filter((b) => allAvailableButtons.contains(b)))
|
||||
IntrinsicWidth(child: ButtonWidget(button: button))
|
||||
IntrinsicWidth(
|
||||
child: ButtonWidget(
|
||||
button: button,
|
||||
big: true,
|
||||
),
|
||||
)
|
||||
else
|
||||
for (final button in keyPair.buttons) IntrinsicWidth(child: ButtonWidget(button: button)),
|
||||
for (final button in keyPair.buttons)
|
||||
IntrinsicWidth(
|
||||
child: ButtonWidget(
|
||||
button: button,
|
||||
big: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
content: (keyPair.buttons.isNotEmpty && keyPair.hasActiveAction)
|
||||
? KeypairExplanation(
|
||||
keyPair: keyPair,
|
||||
)
|
||||
: Text(
|
||||
core.logic.hasNoConnectionMethod
|
||||
? AppLocalizations.of(context).noConnectionMethodSelected
|
||||
: context.i18n.noActionAssigned,
|
||||
style: TextStyle(height: 1),
|
||||
).muted,
|
||||
),
|
||||
TableCell(
|
||||
child: _ButtonEditor(keyPair: keyPair, onUpdate: widget.onUpdate),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.edit_outlined, size: 26),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ButtonEditor extends StatelessWidget {
|
||||
final KeyPair keyPair;
|
||||
final VoidCallback onUpdate;
|
||||
const _ButtonEditor({required this.onUpdate, super.key, required this.keyPair});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: () async {
|
||||
if (core.actionHandler.supportedApp is! CustomApp) {
|
||||
final currentProfile = core.actionHandler.supportedApp!.name;
|
||||
final newName = await KeymapManager().duplicate(
|
||||
context,
|
||||
currentProfile,
|
||||
skipName: '$currentProfile (Copy)',
|
||||
);
|
||||
if (newName != null && context.mounted) {
|
||||
buildToast(context, title: context.i18n.createdNewCustomProfile(newName));
|
||||
final selectedKeyPair = core.actionHandler.supportedApp!.keymap.keyPairs.firstWhere(
|
||||
(e) => e == keyPair,
|
||||
);
|
||||
await openDrawer(
|
||||
context: context,
|
||||
builder: (c) => ButtonEditPage(
|
||||
keyPair: selectedKeyPair,
|
||||
onUpdate: () {},
|
||||
),
|
||||
position: OverlayPosition.end,
|
||||
);
|
||||
}
|
||||
onUpdate();
|
||||
} else {
|
||||
await openDrawer(
|
||||
context: context,
|
||||
|
||||
builder: (c) => ButtonEditPage(
|
||||
keyPair: keyPair,
|
||||
onUpdate: () {},
|
||||
),
|
||||
position: OverlayPosition.end,
|
||||
);
|
||||
onUpdate();
|
||||
}
|
||||
},
|
||||
trailing: Icon(Icons.edit, size: 18),
|
||||
child: (keyPair.buttons.isNotEmpty && keyPair.hasActiveAction)
|
||||
? KeypairExplanation(
|
||||
keyPair: keyPair,
|
||||
)
|
||||
: Text(
|
||||
core.logic.hasNoConnectionMethod
|
||||
? AppLocalizations.of(context).noConnectionMethodSelected
|
||||
: context.i18n.noActionAssigned,
|
||||
style: TextStyle(height: 1),
|
||||
).muted.xSmall,
|
||||
Future<void> _openKeyPairEditor(KeyPair selectedKeyPair) async {
|
||||
_isDrawerOpen = true;
|
||||
await openDrawer(
|
||||
context: context,
|
||||
builder: (c) => ButtonEditPage(
|
||||
keyPair: selectedKeyPair,
|
||||
keymap: widget.keymap,
|
||||
onUpdate: () {
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
position: OverlayPosition.end,
|
||||
);
|
||||
widget.onUpdate();
|
||||
_isDrawerOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show SelectionArea;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:flutter/material.dart' show SelectionArea;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
|
||||
@@ -112,23 +110,6 @@ class _LogviewerState extends State<LogViewer> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!kIsWeb) ...[
|
||||
Text(context.i18n.logsAreAlsoAt).muted.small,
|
||||
CodeSnippet(
|
||||
code: SelectableText(File('${Directory.current.path}/app.logs').path),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy),
|
||||
variance: ButtonVariance.outline,
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: File('${Directory.current.path}/app.logs').path));
|
||||
buildToast(context, title: context.i18n.pathCopiedToClipboard);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/pages/navigation.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show showLicensePage;
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
List<Widget> buildMenuButtons(BuildContext context, VoidCallback? openLogs) {
|
||||
import '../utils/iap/iap_manager.dart';
|
||||
|
||||
List<Widget> buildMenuButtons(BuildContext context, BCPage currentPage, VoidCallback? openLogs) {
|
||||
return [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
@@ -210,7 +213,7 @@ List<Widget> buildMenuButtons(BuildContext context, VoidCallback? openLogs) {
|
||||
},
|
||||
),
|
||||
Gap(4),
|
||||
BKMenuButton(openLogs: openLogs),
|
||||
BKMenuButton(openLogs: openLogs, currentPage: currentPage),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -231,7 +234,8 @@ ${core.connection.lastLogEntries.reversed.joinToString(separator: '\n', transfor
|
||||
|
||||
class BKMenuButton extends StatelessWidget {
|
||||
final VoidCallback? openLogs;
|
||||
const BKMenuButton({super.key, this.openLogs});
|
||||
final BCPage currentPage;
|
||||
const BKMenuButton({super.key, this.openLogs, required this.currentPage});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -267,6 +271,16 @@ class BKMenuButton extends StatelessWidget {
|
||||
),
|
||||
MenuDivider(),
|
||||
],
|
||||
if (currentPage == BCPage.logs) ...[
|
||||
MenuButton(
|
||||
child: Text('Reset IAP State'),
|
||||
onPressed: (c) async {
|
||||
IAPManager.instance.reset(false);
|
||||
core.settings.init();
|
||||
},
|
||||
),
|
||||
MenuDivider(),
|
||||
],
|
||||
if (openLogs != null)
|
||||
MenuButton(
|
||||
leading: Icon(Icons.article_outlined),
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_simulator.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ignored_devices_dialog.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:bike_control/widgets/ui/wifi_animation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ScanWidget extends StatefulWidget {
|
||||
@@ -40,7 +44,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.i18n.permissionsRequired),
|
||||
Text(context.i18n.permissionsRequired).xSmall,
|
||||
..._needsPermissions!.map((e) => Text(e.name).li),
|
||||
],
|
||||
),
|
||||
@@ -77,6 +81,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(),
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows))
|
||||
ValueListenableBuilder(
|
||||
valueListenable: core.mediaKeyHandler.isMediaKeyDetectionEnabled,
|
||||
@@ -87,7 +92,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
),
|
||||
child: Checkbox(
|
||||
state: value ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
trailing: Text(context.i18n.enableMediaKeyDetection),
|
||||
trailing: Expanded(child: Text(context.i18n.enableMediaKeyDetection)),
|
||||
onChanged: (change) {
|
||||
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = change == CheckboxState.checked;
|
||||
},
|
||||
@@ -95,26 +100,71 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS))
|
||||
Checkbox(
|
||||
state: core.settings.getPhoneSteeringEnabled()
|
||||
? CheckboxState.checked
|
||||
: CheckboxState.unchecked,
|
||||
trailing: Expanded(child: Text(AppLocalizations.of(context).enableSteeringWithPhone)),
|
||||
onChanged: (change) {
|
||||
core.settings.setPhoneSteeringEnabled(change == CheckboxState.checked);
|
||||
core.connection.toggleGyroscopeSteering(change == CheckboxState.checked);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
SizedBox(),
|
||||
if (core.connection.controllerDevices.isEmpty) ...[
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
);
|
||||
},
|
||||
child: Text(context.i18n.showTroubleshootingGuide),
|
||||
if (!screenshotMode)
|
||||
Column(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
);
|
||||
},
|
||||
leading: Icon(Icons.help_outline),
|
||||
child: Text(context.i18n.showTroubleshootingGuide),
|
||||
),
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices',
|
||||
);
|
||||
},
|
||||
leading: Icon(Icons.gamepad_outlined),
|
||||
child: Text(context.i18n.showSupportedControllers),
|
||||
),
|
||||
if (core.settings.getIgnoredDevices().isNotEmpty)
|
||||
OutlineButton(
|
||||
leading: Icon(Icons.block_outlined),
|
||||
onPressed: () async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => IgnoredDevicesDialog(),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(context.i18n.manageIgnoredDevices),
|
||||
),
|
||||
|
||||
if (core.connection.controllerDevices.isEmpty)
|
||||
PrimaryButton(
|
||||
leading: Icon(Icons.computer_outlined),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => ButtonSimulator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context).noControllerUseCompanionMode),
|
||||
),
|
||||
],
|
||||
),
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices',
|
||||
);
|
||||
},
|
||||
child: Text(context.i18n.showSupportedControllers),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -139,6 +139,8 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
location: ToastLocation.bottomRight,
|
||||
level: data.level,
|
||||
title: data.alertMessage,
|
||||
closeTitle: data.buttonTitle ?? 'Close',
|
||||
onClose: data.onTap,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:in_app_update/in_app_update.dart';
|
||||
@@ -8,12 +15,6 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:restart_app/restart_app.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:shorebird_code_push/shorebird_code_push.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
@@ -142,7 +143,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
GradientText('BikeControl', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22)),
|
||||
if (packageInfoValue != null)
|
||||
Text(
|
||||
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' ${AppLocalizations.current.sideloaded}' : ''}',
|
||||
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''} - ${IAPManager.instance.getStatusMessage()}',
|
||||
style: TextStyle(fontSize: 12),
|
||||
).mono.muted
|
||||
else
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ButtonWidget extends StatelessWidget {
|
||||
final ControllerButton button;
|
||||
@@ -23,8 +22,8 @@ class ButtonWidget extends StatelessWidget {
|
||||
color: button.color != null ? Colors.black.getContrastColor(0.3) : Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
shape: button.color != null || button.icon != null ? BoxShape.circle : BoxShape.rectangle,
|
||||
borderRadius: button.color != null || button.icon != null ? null : BorderRadius.circular(4),
|
||||
color: button.color ?? BKColor.main,
|
||||
borderRadius: button.color != null || button.icon != null ? null : BorderRadius.circular(8),
|
||||
color: button.color ?? Colors.black,
|
||||
),
|
||||
child: Center(
|
||||
child: button.icon != null
|
||||
@@ -35,6 +34,7 @@ class ButtonWidget extends StatelessWidget {
|
||||
)
|
||||
: Text(
|
||||
button.name.splitByUpperCase(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: screenshotMode ? null : 'monospace',
|
||||
fontSize: big && button.color != null ? 20 : 12,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
@@ -9,6 +6,9 @@ import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
enum ConnectionMethodType {
|
||||
bluetooth,
|
||||
@@ -147,7 +147,7 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (widget.isEnabled) ?widget.additionalChild,
|
||||
if (widget.isEnabled && widget.additionalChild != null) widget.additionalChild!,
|
||||
if (widget.instructionLink != null || widget.showTroubleshooting) SizedBox(height: 8),
|
||||
if (widget.instructionLink != null)
|
||||
Button(
|
||||
@@ -247,9 +247,9 @@ class _PermissionListState extends State<_PermissionList> with WidgetsBindingObs
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16),
|
||||
height: 120 + widget.requirements.length * 70,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 18,
|
||||
children: [
|
||||
Text(
|
||||
|
||||
44
lib/widgets/ui/device_info.dart
Normal file
44
lib/widgets/ui/device_info.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
|
||||
class DeviceInfo extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final Widget? additionalInfo;
|
||||
final IconData icon;
|
||||
|
||||
const DeviceInfo({super.key, required this.title, required this.icon, required this.value, this.additionalInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: screenshotMode ? 160 : null,
|
||||
height: screenshotMode ? 70 : null,
|
||||
child: Card(
|
||||
filled: true,
|
||||
padding: EdgeInsets.all(12),
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
child: Basic(
|
||||
title: Text(title).xSmall,
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
?additionalInfo,
|
||||
],
|
||||
),
|
||||
trailingAlignment: Alignment.centerRight,
|
||||
trailing: Icon(
|
||||
icon,
|
||||
color: icon == Icons.warning || icon == Icons.battery_alert
|
||||
? Theme.of(context).colorScheme.destructive
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
void buildToast(
|
||||
BuildContext context, {
|
||||
@@ -18,9 +18,9 @@ void buildToast(
|
||||
location: location,
|
||||
showDuration: switch (level) {
|
||||
LogLevel.LOGLEVEL_DEBUG => const Duration(seconds: 2),
|
||||
LogLevel.LOGLEVEL_INFO => const Duration(seconds: 3),
|
||||
LogLevel.LOGLEVEL_WARNING => const Duration(seconds: 5),
|
||||
LogLevel.LOGLEVEL_ERROR => const Duration(seconds: 7),
|
||||
LogLevel.LOGLEVEL_INFO => duration ?? const Duration(seconds: 3),
|
||||
LogLevel.LOGLEVEL_WARNING => duration ?? const Duration(seconds: 5),
|
||||
LogLevel.LOGLEVEL_ERROR => duration ?? const Duration(seconds: 7),
|
||||
_ => duration ?? const Duration(seconds: 3),
|
||||
},
|
||||
builder: (context, overlay) => SurfaceCard(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <bluetooth_low_energy_linux/bluetooth_low_energy_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <gamepads_linux/gamepads_linux_plugin.h>
|
||||
#include <gtk/gtk_plugin.h>
|
||||
#include <media_key_detector_linux/media_key_detector_plugin.h>
|
||||
@@ -23,6 +24,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) gamepads_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "GamepadsLinuxPlugin");
|
||||
gamepads_linux_plugin_register_with_registrar(gamepads_linux_registrar);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
bluetooth_low_energy_linux
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
gamepads_linux
|
||||
gtk
|
||||
media_key_detector_linux
|
||||
|
||||
@@ -9,8 +9,11 @@ import bluetooth_low_energy_darwin
|
||||
import device_info_plus
|
||||
import file_selector_macos
|
||||
import flutter_local_notifications
|
||||
import flutter_secure_storage_darwin
|
||||
import gamepads_darwin
|
||||
import in_app_purchase_storekit
|
||||
import in_app_review
|
||||
import ios_receipt
|
||||
import keypress_simulator_macos
|
||||
import media_key_detector_macos
|
||||
import nsd_macos
|
||||
@@ -28,8 +31,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
|
||||
GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin"))
|
||||
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
|
||||
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
|
||||
IosReceiptPlugin.register(with: registry.registrar(forPlugin: "IosReceiptPlugin"))
|
||||
KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin"))
|
||||
MediaKeyDetectorPlugin.register(with: registry.registrar(forPlugin: "MediaKeyDetectorPlugin"))
|
||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
platform :osx, '10.15'
|
||||
platform :osx, '12.00'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
@@ -38,5 +38,8 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_macos_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.00'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,11 +8,19 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- FlutterMacOS
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- gamepads_darwin (0.1.1):
|
||||
- FlutterMacOS
|
||||
- in_app_purchase_storekit (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- in_app_review (2.0.0):
|
||||
- FlutterMacOS
|
||||
- ios_receipt (0.0.1):
|
||||
- FlutterMacOS
|
||||
- keypress_simulator_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- media_key_detector_macos (0.0.1):
|
||||
@@ -44,9 +52,12 @@ DEPENDENCIES:
|
||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||
- flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`)
|
||||
- in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
|
||||
- ios_receipt (from `Flutter/ephemeral/.symlinks/plugins/ios_receipt/macos`)
|
||||
- keypress_simulator_macos (from `Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos`)
|
||||
- media_key_detector_macos (from `Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos`)
|
||||
- nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`)
|
||||
@@ -68,12 +79,18 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
||||
flutter_local_notifications:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
|
||||
flutter_secure_storage_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
gamepads_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos
|
||||
in_app_purchase_storekit:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin
|
||||
in_app_review:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
|
||||
ios_receipt:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/ios_receipt/macos
|
||||
keypress_simulator_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos
|
||||
media_key_detector_macos:
|
||||
@@ -102,9 +119,12 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
|
||||
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
|
||||
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
|
||||
flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
gamepads_darwin: 07af6c60c282902b66574c800e20b2b26e68fda8
|
||||
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
|
||||
in_app_review: 866c9b17c87a7b46a395bda43f5d3ca02deb585a
|
||||
ios_receipt: 8741a75f39e6ca0866313b73c69a5b674cf5c98c
|
||||
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
|
||||
media_key_detector_macos: a93757a483b4b47283ade432b1af9e427c47329f
|
||||
nsd_macos: 1a38a38a33adbb396b4c6f303bc076073514cadc
|
||||
@@ -117,6 +137,6 @@ SPEC CHECKSUMS:
|
||||
wakelock_plus: 9d63063ffb7af1c215209769067c57103bde719d
|
||||
window_manager: e25faf20d88283a0d46e7b1a759d07261ca27575
|
||||
|
||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||
PODFILE CHECKSUM: 0fb45929fd801f111c1e68cc98ccc2d06ac5e49e
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -585,6 +585,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = BikeControl;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -726,6 +727,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = BikeControl;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -756,6 +758,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = BikeControl;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -14,5 +14,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -12,5 +12,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
141
pubspec.lock
141
pubspec.lock
@@ -40,6 +40,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
animation_kit:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: animation_kit
|
||||
sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -375,10 +383,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "7ed76be64e8a7d01dfdf250b8434618e2a028c9dfa2a3c41dc9b531d4b3fc8a5"
|
||||
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "19.4.2"
|
||||
version: "19.5.0"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -432,6 +440,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
flutter_secure_storage_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_darwin
|
||||
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -656,6 +712,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
in_app_purchase:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: in_app_purchase
|
||||
sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.3"
|
||||
in_app_purchase_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: in_app_purchase_android
|
||||
sha256: abb254ae159a5a9d4f867795ecb076864faeba59ce015ab81d4cca380f23df45
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0+8"
|
||||
in_app_purchase_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: in_app_purchase_platform_interface
|
||||
sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
in_app_purchase_storekit:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: in_app_purchase_storekit
|
||||
sha256: f7cbbd7fb47ab5a4fb736fc3f20ae81a4f6def0af9297b3c525ca727761e2589
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.7"
|
||||
in_app_review:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -701,6 +789,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.8.12"
|
||||
ios_receipt:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: ios_receipt
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.1.0"
|
||||
jovial_misc:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1155,6 +1250,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubghost:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: pubghost
|
||||
sha256: feb4c76c873b9c8cbbf53a45085fc5ff1705dd88e63554bb0de10ecb96ec1ba9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
quiver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1220,14 +1323,31 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
sensors_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sensors_plus
|
||||
sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
sensors_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sensors_plus_platform_interface
|
||||
sha256: "58815d2f5e46c0c41c40fb39375d3f127306f7742efe3b891c0b1c87e2b5cd5d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
shadcn_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shadcn_flutter
|
||||
sha256: "1fd4f798c39d6308dc8f7e94d9e870b5db39fbf417ea95c423c7555ce8227a1c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.47"
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "3e49867dadd504ccf5c036adc967ef9229643111"
|
||||
url: "https://github.com/sunarya-thito/shadcn_flutter.git"
|
||||
source: git
|
||||
version: "0.0.48"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1570,6 +1690,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
windows_iap:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: windows_iap
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
16
pubspec.yaml
16
pubspec.yaml
@@ -1,7 +1,7 @@
|
||||
name: bike_control
|
||||
description: "BikeControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 4.1.1+53
|
||||
version: 4.2.0+59
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.0
|
||||
@@ -11,7 +11,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
|
||||
url_launcher: ^6.3.1
|
||||
flutter_local_notifications: ^19.4.1
|
||||
flutter_local_notifications: ^19.5.0
|
||||
|
||||
# fork to allow iOS background BLE connections
|
||||
universal_ble:
|
||||
@@ -32,6 +32,12 @@ dependencies:
|
||||
nsd: ^4.0.3
|
||||
image_picker: ^1.1.2
|
||||
in_app_review: ^2.0.11
|
||||
ios_receipt:
|
||||
path: ios_receipt
|
||||
flutter_secure_storage: ^10.0.0
|
||||
in_app_purchase: ^3.2.1
|
||||
windows_iap:
|
||||
path: windows_iap
|
||||
window_manager: ^0.5.1
|
||||
device_info_plus: ^12.1.0
|
||||
keypress_simulator:
|
||||
@@ -41,6 +47,7 @@ dependencies:
|
||||
path: media_key_detector/media_key_detector
|
||||
accessibility:
|
||||
path: accessibility
|
||||
sensors_plus: ^7.0.0
|
||||
|
||||
device_auto_rotate_checker:
|
||||
git:
|
||||
@@ -55,7 +62,9 @@ dependencies:
|
||||
package_info_plus: ^9.0.0
|
||||
in_app_update: ^4.2.5
|
||||
http: ^1.3.0
|
||||
shadcn_flutter: ^0.0.47
|
||||
shadcn_flutter:
|
||||
git:
|
||||
url: https://github.com/sunarya-thito/shadcn_flutter.git
|
||||
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
@@ -66,6 +75,7 @@ dev_dependencies:
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
|
||||
pubghost: ^1.0.7
|
||||
intl_utils: ^2.8.12
|
||||
golden_screenshot: ^8.1.1
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user