Compare commits

..

61 Commits

Author SHA1 Message Date
Jonas Bark
aa8089556a Merge branch 'login' of github.com:OpenBikeControl/bikecontrol into login 2026-02-16 17:17:57 +01:00
Jonas Bark
f572a5c300 macOS login 2026-02-16 17:17:52 +01:00
jonas.bark@gmx.de
dcd11e6c4b Windows login 2026-02-16 17:14:38 +01:00
Jonas Bark
3f1a920888 sign in with google, apple 2026-02-16 16:26:22 +01:00
Jonas Bark
ceeca9dd02 training peaks BLE workaround 2026-02-16 12:10:05 +01:00
Jonas Bark
ab379cf74b shorebird cleanup 2026-02-16 11:02:59 +01:00
Jonas Bark
ad7bd646f3 cleanup 2026-02-16 11:00:20 +01:00
Jonas Bark
7ed1ba4397 fix import 2026-02-15 21:49:28 +01:00
Jonas Bark
a9cb929b01 cleanup ui 2026-02-15 21:48:04 +01:00
Jonas Bark
8d056b526e Di2: resolve issue #233 2026-02-15 21:44:02 +01:00
Jonas Bark
7a77acaf94 local connection tile always show as suggested 2026-02-15 13:40:33 +01:00
Jonas Bark
216dc97517 zwift click: send 0xff command again 2026-02-15 12:20:17 +01:00
Jonas Bark
746a680449 cleanup 2026-02-15 10:11:43 +01:00
Jonas Bark
b1385e70cc actions 2026-02-14 10:34:17 +01:00
Jonas Bark
aec24bba61 enable audio button capture on iOS 2026-02-14 10:11:25 +01:00
Jonas Bark
226824c14a actions 2026-02-14 10:06:53 +01:00
Jonas Bark
8c4816ffd0 actions 2026-02-14 10:06:29 +01:00
Jonas Bark
07423fc0f6 fix unit test 2026-02-14 09:54:32 +01:00
Jonas Bark
812a4efe13 Merge remote-tracking branch 'origin/copilot/fix-vs200-double-shifting'
# Conflicts:
#	test/thinkrider_vs200_test.dart
2026-02-14 09:51:29 +01:00
Jonas Bark
8dadc07e07 fix unit tests 2026-02-14 09:50:36 +01:00
copilot-swe-agent[bot]
15dc34b2ea Add clarifying comments for two-call pattern in handleButtonsClickedWithoutLongPressSupport
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-02-14 07:51:10 +00:00
copilot-swe-agent[bot]
d8a528017d Add comprehensive tests for VS200 single-click behavior
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-02-14 07:50:25 +00:00
copilot-swe-agent[bot]
d2a41fc2fa Fix VS200 double shifting by handling non-long-press buttons correctly
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-02-14 07:49:25 +00:00
copilot-swe-agent[bot]
7b2f16772d Initial plan 2026-02-14 07:42:09 +00:00
Jonas Bark
edf19e3ffa fix MyWhoosh Link issue 2026-02-12 18:23:14 +01:00
Jonas Bark
ec4e4fc375 fix public compilation 2026-02-11 09:00:00 +01:00
Jonas Bark
59141c81af support Zwift Ride with old firmware 2026-02-11 08:56:10 +01:00
Jonas Bark
732bb4a150 version++ 2026-02-10 15:15:18 +01:00
Jonas Bark
84d9d1e312 accessories should not be displayed in keymap customize tab 2026-02-10 15:15:01 +01:00
Jonas Bark
3b6f9f6f29 version++ 2026-02-10 09:44:40 +01:00
Jonas Bark
21f7636cee patch it 2026-02-10 09:13:44 +01:00
Jonas Bark
3eda3b590a update submodule 2026-02-10 09:12:38 +01:00
Jonas Bark
f844681f4c kickr headwind adjustments #11 2026-02-10 09:11:39 +01:00
Jonas Bark
5c22851d66 Merge branch 'test' 2026-02-10 08:56:49 +01:00
jonasbark
fb2068a08a Merge pull request #296 from OpenBikeControl/copilot/fix-button-press-headwind-issue
Add delay between mode change and speed commands for Wahoo KICKR Headwind
2026-02-10 08:55:43 +01:00
copilot-swe-agent[bot]
f7a0b8dca8 Fix headwind first button press issue by adding delay between mode and speed commands
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-02-10 07:44:37 +00:00
copilot-swe-agent[bot]
76c59537c1 Initial plan 2026-02-10 07:42:07 +00:00
Jonas Bark
81f14f16fd openbikecontrol via dircon 2026-02-08 11:28:48 +01:00
Jonas Bark
c4a8d1ef9c remove BLE services when disconnecting 2026-02-08 10:02:12 +01:00
Jonas Bark
a1cfe43ef9 cleanup toast handling 2026-02-08 10:01:54 +01:00
Jonas Bark
14f5486ab6 fix potential crash 2026-02-07 09:56:35 +01:00
jonasbark
a7e2b5bc26 Update Windows Store version from 4.6.2 to 4.7.2 2026-02-06 19:26:03 +01:00
Jonas Bark
8bea3b36cc fix patch yaml 2026-02-06 14:23:45 +01:00
Jonas Bark
2802ead254 missing methods in shared public 2026-02-06 13:20:50 +01:00
Jonas Bark
5c2ae38951 fix module 2026-02-06 13:17:23 +01:00
Jonas Bark
0dc6ea7fd4 fix module 2026-02-06 13:16:10 +01:00
Jonas Bark
288fbed819 logic fixes 2026-02-06 13:02:21 +01:00
Jonas Bark
497528c75b iOS: keep app alive in background 2026-02-06 12:51:47 +01:00
Jonas Bark
d8ceea9c63 purchasing the app on Android is now finally possible 2026-02-06 10:27:21 +01:00
Jonas Bark
bed3dac98e update submodule 2026-02-05 17:33:39 +01:00
Jonas Bark
9eaa9c53f9 add new remote keyboard connection method 2026-02-05 17:22:21 +01:00
Jonas Bark
ccd1d46128 reenable shorebird, fix button editor for remote setting 2026-02-05 14:45:21 +01:00
Jonas Bark
79edebc8f9 cleanup 2026-02-05 14:30:15 +01:00
Jonas Bark
dbf148c41f fix messages 2026-02-05 14:28:15 +01:00
Jonas Bark
fe898cefda fix #246 2026-02-05 14:23:50 +01:00
Jonas Bark
f662d0a36a Merge branch 'main' of github.com:OpenBikeControl/bikecontrol 2026-02-04 15:28:17 +01:00
jonasbark
5c8a5934f2 Merge pull request #290 from OpenBikeControl/copilot/save-enable-media-key-setting
Persist media key detection setting across app restarts
2026-02-04 15:27:49 +01:00
copilot-swe-agent[bot]
c7e845086a Simplify assignment per code review feedback
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-02-04 14:08:10 +00:00
copilot-swe-agent[bot]
67a4144ab0 Implement media key detection persistence
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-02-04 14:07:07 +00:00
copilot-swe-agent[bot]
bfeb72a775 Initial plan 2026-02-04 14:03:54 +00:00
Jonas Bark
799234c323 set long press by default for steering actions 2026-02-04 12:44:27 +01:00
101 changed files with 2280 additions and 700 deletions

View File

@@ -31,8 +31,7 @@ on:
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
FLUTTER_VERSION_MAC: 3.41.0-0.0.pre
FLUTTER_VERSION: 3.38.7
FLUTTER_VERSION: 3.41.0
jobs:
build:
@@ -52,6 +51,14 @@ jobs:
submodules: recursive
token: ${{ secrets.PAT_TOKEN }}
- name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
run: |
if [ -f pubspec_overrides_ci.yaml ]; then
mv pubspec_overrides_ci.yaml pubspec_overrides.yaml
else
echo "No pubspec_overrides_ci.yaml found, skipping rename."
fi
- name: Install certificates
if: inputs.build_mac || inputs.build_ios
env:
@@ -105,8 +112,8 @@ jobs:
- name: Set Up Flutter maCOS
uses: subosito/flutter-action@v2
with:
channel: 'beta'
flutter-version: ${{ env.FLUTTER_VERSION_MAC }}
channel: 'stable'
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Generate translation files
run: |
@@ -126,12 +133,6 @@ jobs:
run:
flutter build macos --release --obfuscate --split-debug-info=symbols --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}
- name: Set Up Flutter Rest
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Decode Keystore
if: inputs.build_android
run: |
@@ -244,6 +245,15 @@ jobs:
submodules: recursive
token: ${{ secrets.PAT_TOKEN }}
- name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
shell: pwsh
run: |
if (Test-Path pubspec_overrides_ci.yaml) {
Rename-Item -Path pubspec_overrides_ci.yaml -NewName pubspec_overrides.yaml
} else {
Write-Output "No pubspec_overrides_ci.yaml found, skipping rename."
}
- name: Extract version from pubspec.yaml (Windows)
shell: pwsh
run: |

View File

@@ -25,6 +25,14 @@ jobs:
submodules: recursive
token: ${{ secrets.PAT_TOKEN }}
- name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
run: |
if [ -f pubspec_overrides_ci.yaml ]; then
mv pubspec_overrides_ci.yaml pubspec_overrides.yaml
else
echo "No pubspec_overrides_ci.yaml found, skipping rename."
fi
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:
@@ -104,7 +112,6 @@ jobs:
args: '--allow-asset-diffs --allow-native-diffs -- --obfuscate --split-debug-info=symbols --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}'
- name: 🚀 Shorebird Patch iOS
if: false
uses: shorebirdtech/shorebird-patch@v1
with:
platform: ios
@@ -113,7 +120,6 @@ jobs:
windows:
name: Patch Windows
if: false
runs-on: windows-latest
steps:
@@ -124,6 +130,15 @@ jobs:
submodules: recursive
token: ${{ secrets.PAT_TOKEN }}
- name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
shell: pwsh
run: |
if (Test-Path pubspec_overrides_ci.yaml) {
Rename-Item -Path pubspec_overrides_ci.yaml -NewName pubspec_overrides.yaml
} else {
Write-Output "No pubspec_overrides_ci.yaml found, skipping rename."
}
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:

View File

@@ -1,10 +1,23 @@
### Unreleased
### 4.8.0 (15-02-2026)
**Features**:
- Windows: Add device source detection for media hotkeys
- Distinguish between multiple bluetooth media controllers
- Configure different actions for each controller even when they have the same buttons
- Uses Windows Raw Input API to identify device sources
- Bluetooth media buttons are now supported on iOS
- Shimano Di2: long press and double clicks are now supported:
- perform steering using long presses
- gear changes are now reflected properly without losing any button presses
### 4.7.0 (04-02-2026)
**Features**:
- new connection method: act as Bluetooth Keyboard:
Your device can now act as Bluetooth keyboard, allowing you to send keyboard shortcuts (e.g. for virtual shifting) directly to your connected device. Especially useful for tablets / iPads.
- added new keyboard shortcuts for Rouvy (Kudos, Pause workout)
**Fixes**:
- you can now finally buy the full version on Android :)
- save "Enable Media Key detection" setting across app restarts
- UI adjustments and fixes in the controller configuration screen
- iOS: Remote pairing now works again
### 4.6.0 (28-01-2026)

View File

@@ -1 +1 @@
4.6.2
4.7.2

View File

@@ -1 +0,0 @@
.

View File

@@ -73,7 +73,7 @@ class AccessibilityService : AccessibilityService(), Listener {
override fun onKeyEvent(event: KeyEvent): Boolean {
val keyString = KeyEvent.keyCodeToString(event.keyCode)
// if currently active app is BikeControl => handle it, so keymap can be created
if (!Observable.ignoreHidDevices && isBleRemote(event) && (rootInActiveWindow.packageName == "de.jonasbark.swiftcontrol" || Observable.handledKeys.contains(keyString))) {
if (!Observable.ignoreHidDevices && isBleRemote(event) && (rootInActiveWindow?.packageName == "de.jonasbark.swiftcontrol" || Observable.handledKeys.contains(keyString))) {
// Handle keys that have a keymap defined
Log.d(
"AccessibilityService",

View File

@@ -51,6 +51,13 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->
<data android:scheme="bikecontrol" />
</intent-filter>
</activity>
<receiver

BIN
assets/silence.mp3 Normal file

Binary file not shown.

View File

@@ -20,7 +20,5 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -1,4 +1,18 @@
PODS:
- app_links (6.4.1):
- Flutter
- AppAuth (2.0.0):
- AppAuth/Core (= 2.0.0)
- AppAuth/ExternalUserAgent (= 2.0.0)
- AppAuth/Core (2.0.0)
- AppAuth/ExternalUserAgent (2.0.0):
- AppAuth/Core
- AppCheckCore (11.2.0):
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- audio_session (0.0.1):
- Flutter
- bluetooth_low_energy_darwin (0.0.1):
- Flutter
- FlutterMacOS
@@ -14,6 +28,33 @@ PODS:
- Flutter
- gamepads_ios (0.1.1):
- Flutter
- google_sign_in_ios (0.0.1):
- Flutter
- FlutterMacOS
- GoogleSignIn (~> 9.0)
- GTMSessionFetcher (>= 3.4.0)
- GoogleSignIn (9.1.0):
- AppAuth (~> 2.0)
- AppCheckCore (~> 11.0)
- GTMAppAuth (~> 5.0)
- GTMSessionFetcher/Core (~> 3.3)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (5.0.0):
- AppAuth/Core (~> 2.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher (3.5.0):
- GTMSessionFetcher/Full (= 3.5.0)
- GTMSessionFetcher/Core (3.5.0)
- GTMSessionFetcher/Full (3.5.0):
- GTMSessionFetcher/Core
- image_picker_ios (0.0.1):
- Flutter
- in_app_purchase_storekit (0.0.1):
@@ -25,6 +66,9 @@ PODS:
- Flutter
- ios_receipt (0.0.1):
- Flutter
- just_audio (0.0.1):
- Flutter
- FlutterMacOS
- media_key_detector_ios (0.0.1):
- Flutter
- nsd_ios (0.0.1):
@@ -33,6 +77,7 @@ PODS:
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
- purchases_flutter (9.10.6):
- Flutter
- PurchasesHybridCommon (= 17.27.1)
@@ -54,6 +99,8 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sign_in_with_apple (0.0.1):
- Flutter
- universal_ble (0.0.1):
- Flutter
- FlutterMacOS
@@ -63,6 +110,8 @@ PODS:
- Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- bluetooth_low_energy_darwin (from `.symlinks/plugins/bluetooth_low_energy_darwin/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
@@ -70,11 +119,13 @@ DEPENDENCIES:
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`)
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
- google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`)
- 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`)
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
- 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`)
@@ -84,18 +135,30 @@ DEPENDENCIES:
- 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`)
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- AppAuth
- AppCheckCore
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- PromisesObjC
- PurchasesHybridCommon
- PurchasesHybridCommonUI
- RevenueCat
- RevenueCatUI
EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
bluetooth_low_energy_darwin:
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
device_info_plus:
@@ -110,6 +173,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_volume_controller/ios"
gamepads_ios:
:path: ".symlinks/plugins/gamepads_ios/ios"
google_sign_in_ios:
:path: ".symlinks/plugins/google_sign_in_ios/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_purchase_storekit:
@@ -120,6 +185,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/integration_test/ios"
ios_receipt:
:path: ".symlinks/plugins/ios_receipt/ios"
just_audio:
:path: ".symlinks/plugins/just_audio/darwin"
media_key_detector_ios:
:path: ".symlinks/plugins/media_key_detector_ios/ios"
nsd_ios:
@@ -138,6 +205,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sensors_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sign_in_with_apple:
:path: ".symlinks/plugins/sign_in_with_apple/ios"
universal_ble:
:path: ".symlinks/plugins/universal_ble/darwin"
url_launcher_ios:
@@ -146,6 +215,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
app_links: 585674be3c6661708e6cd794ab4f39fb9d8356f9
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe
bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
@@ -153,15 +226,22 @@ SPEC CHECKSUMS:
flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d
flutter_volume_controller: e4d5832f08008180f76e30faf671ffd5a425e529
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
google_sign_in_ios: 7336a3372ea93ea56a21e126a0055ffca3723601
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
ios_receipt: c2d5b4c36953c377a024992393976214ce6951e6
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
nsd_ios: 8c37babdc6538e3350dbed3a52674d2edde98173
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
purchases_flutter: b3c0792197f69cd7af4c2449b71df6ac6378aace
purchases_ui_flutter: caae6d62ea23c6fe964992a28353211cc74b244a
PurchasesHybridCommon: 027f03312519c51056457eb2e4f7ee1c91b61b8f
@@ -171,6 +251,7 @@ SPEC CHECKSUMS:
RevenueCatUI: ac7492873928e9e7f297e5e27a7c4f23f9008326
sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e

View File

@@ -22,6 +22,19 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2</string>
</array>
</dict>
</array>
<key>GIDClientID</key>
<string>709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2.apps.googleusercontent.com</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -30,21 +43,22 @@
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>BikeControl uses Bluetooth to connect to accessories.</string>
<key>NSBonjourServices</key>
<array>
<string>_wahoo-fitness-tnp._tcp</string>
<string>_openbikecontrol._tcp</string>
</array>
<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>
<string>_openbikecontrol._tcp</string>
</array>
<string>Access your accelerometer and gyroscope for steering support via your phone.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-peripheral</string>
<string>bluetooth-central</string>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>keychain-access-groups</key>
<array/>
</dict>

View File

@@ -64,8 +64,10 @@ class Connection {
lastLogEntries = lastLogEntries.takeLast(kIsWeb ? 1000 : 60).toList();
});
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isIOS)) {
core.mediaKeyHandler.initialize();
// Load saved media key detection state
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = core.settings.getMediaKeyDetectionEnabled();
}
UniversalBle.onAvailabilityChange = (available) {

View File

@@ -69,18 +69,24 @@ abstract class BaseDevice {
Future<void> connect();
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
// For long press actions: perform down, wait, then release
await handleButtonsClicked(clickedButtons, longPress: true);
_longPressTimer?.cancel();
await Future.delayed(const Duration(milliseconds: 800));
await handleButtonsClicked([], longPress: true);
} else {
await handleButtonsClicked([], longPress: true);
// For non-long-press actions: perform a single click
// First call performs the click action (isKeyDown: true, isKeyUp: true)
await handleButtonsClicked(clickedButtons);
// Second call cleans up state (clears timer, logs release, clears _previouslyPressedButtons)
// but doesn't perform a release action since longPress: false
await handleButtonsClicked([]);
}
} else {
await handleButtonsClicked(clickedButtons);
await handleButtonsClicked([]);
}
}

View File

@@ -15,7 +15,6 @@ 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';
@@ -62,6 +61,7 @@ abstract class BluetoothDevice extends BaseDevice {
static List<String> servicesToScan = [
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_SHORT_UUID,
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
SquareConstants.SERVICE_UUID,
WahooKickrBikeShiftConstants.SERVICE_UUID,
@@ -141,6 +141,7 @@ abstract class BluetoothDevice extends BaseDevice {
return device;
} else if (scanResult.services.containsAny([
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(),
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_SHORT_UUID.toLowerCase(),
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(),
])) {
// otherwise use the manufacturer data to identify the device
@@ -169,13 +170,10 @@ abstract class BluetoothDevice extends BaseDevice {
device == null &&
core.connection.controllerDevices.none((d) => d is ZwiftRide)) {
// Fallback for Zwift Ride if nothing else matched => old firmware
if (navigatorKey.currentContext?.mounted ?? false) {
buildToast(
navigatorKey.currentContext!,
title: 'You may need to update your Zwift Ride firmware.',
duration: Duration(seconds: 6),
);
}
buildToast(
title: 'You may need to update your Zwift Ride firmware.',
duration: Duration(seconds: 6),
);
}
return device;
}

View File

@@ -32,6 +32,7 @@ class HidDevice extends BaseDevice {
(core.actionHandler as AndroidActions).ignoreHidDevices();
} else if (core.mediaKeyHandler.isMediaKeyDetectionEnabled.value) {
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = false;
core.settings.setMediaKeyDetectionEnabled(false);
}
},
),

View File

@@ -2,7 +2,6 @@ import 'dart:convert';
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:prop/prop.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
@@ -11,6 +10,7 @@ import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/keymap/keymap.dart';
import 'package:bike_control/utils/requirements/multi.dart';
import 'package:flutter/foundation.dart';
import 'package:prop/prop.dart';
class WhooshLink extends TrainerConnection {
Socket? _socket;
@@ -66,16 +66,17 @@ class WhooshLink extends TrainerConnection {
// Accept connection
_server!.listen(
(Socket socket) {
(Socket socket) async {
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
}
SharedLogic.keepAlive();
_socket = socket;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.myWhooshLinkConnected),
);
isConnected.value = true;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
}
// Listen for data from the client
socket.listen(
(List<int> data) {
@@ -89,6 +90,8 @@ class WhooshLink extends TrainerConnection {
},
onDone: () {
print('Client disconnected: $socket');
SharedLogic.stopKeepAlive();
isConnected.value = false;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'MyWhoosh Link disconnected'),

View File

@@ -7,6 +7,7 @@ import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/messages/notification.dart' show AlertNotification, LogNotification;
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/apps/training_peaks.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/keymap/keymap.dart';
import 'package:bike_control/widgets/title.dart';
@@ -104,17 +105,28 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
);
});
Uint8List? firstAppInfoMessage;
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
final characteristic = eventArgs.characteristic;
final request = eventArgs.request;
final value = request.value;
print(
'Write request for characteristic: ${characteristic.uuid}',
);
var value = request.value;
if (kDebugMode) {
print('Write request for characteristic: ${characteristic.uuid}: ${bytesToReadableHex(value)}');
}
switch (eventArgs.characteristic.uuid.toString().toLowerCase()) {
case OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID:
try {
if (core.settings.getTrainerApp() is TrainingPeaks) {
if (firstAppInfoMessage == null) {
firstAppInfoMessage = value;
return;
} else {
value = Uint8List.fromList([...firstAppInfoMessage!, ...value]);
}
}
final appInfo = OpenBikeProtocolParser.parseAppInfo(value);
isConnected.value = true;
connectedApp.value = appInfo;
@@ -228,6 +240,8 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
if (kDebugMode) {
print('Stopping OpenBikeControl BLE server...');
}
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;

View File

@@ -0,0 +1,39 @@
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
import 'package:prop/emulators/dircon/dircon.dart';
import 'package:universal_ble/universal_ble.dart';
abstract class OnMessage {
void onMessage(List<int> message);
}
class ObcDircon extends DirCon {
final OnMessage onMessageCallback;
ObcDircon({required super.socket, required this.onMessageCallback});
@override
List<BleCharacteristic> getCharacteristics(String serviceUUID) {
if (serviceUUID.toLowerCase() == OpenBikeControlConstants.SERVICE_UUID) {
return [
BleCharacteristic(
OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID,
[CharacteristicProperty.notify],
),
BleCharacteristic(
OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID,
[CharacteristicProperty.writeWithoutResponse, CharacteristicProperty.write],
),
];
}
return [];
}
@override
void processWriteCallback(String characteristicUUID, List<int> characteristicData) {
if (characteristicUUID.toLowerCase() == OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID) {
onMessageCallback.onMessage(characteristicData);
}
}
@override
List<String> get serviceUUIDs => [OpenBikeControlConstants.SERVICE_UUID];
}

View File

@@ -1,11 +1,13 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_dircon.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/keymap/keymap.dart';
import 'package:dartx/dartx.dart';
@@ -13,7 +15,7 @@ import 'package:flutter/foundation.dart';
import 'package:nsd/nsd.dart';
import 'package:prop/prop.dart';
class OpenBikeControlMdnsEmulator extends TrainerConnection {
class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage {
ServerSocket? _server;
Registration? _mdnsRegistration;
@@ -22,6 +24,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier(null);
Socket? _socket;
ObcDircon? _dirCon;
OpenBikeControlMdnsEmulator()
: super(
@@ -29,6 +32,9 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
supportedActions: InGameAction.values,
);
bool get _useDirCon =>
core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.dircon) ?? false;
Future<void> startServer() async {
print('Starting mDNS server...');
isStarted.value = true;
@@ -64,18 +70,23 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
_mdnsRegistration = await register(
Service(
name: 'BikeControl',
type: '_openbikecontrol._tcp',
type: _useDirCon ? '_wahoo-fitness-tnp._tcp' : '_openbikecontrol._tcp',
port: 36867,
//hostName: 'KICKR BIKE SHIFT B84D.local',
addresses: [localIP],
txt: {
'version': Uint8List.fromList([0x01]),
'id': Uint8List.fromList('1337'.codeUnits),
'name': Uint8List.fromList('BikeControl'.codeUnits),
'service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits),
'manufacturer': Uint8List.fromList('OpenBikeControl'.codeUnits),
'model': Uint8List.fromList('BikeControl app'.codeUnits),
},
txt: _useDirCon
? {
'ble-service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits),
'mac-address': Uint8List.fromList('00:11:22:33:44:55'.codeUnits),
'serial-number': Uint8List.fromList('1234567890'.codeUnits),
}
: {
'version': Uint8List.fromList([0x01]),
'id': Uint8List.fromList('1337'.codeUnits),
'name': Uint8List.fromList('BikeControl'.codeUnits),
'service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits),
'manufacturer': Uint8List.fromList('OpenBikeControl'.codeUnits),
'model': Uint8List.fromList('BikeControl app'.codeUnits),
},
),
);
print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration');
@@ -104,7 +115,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
Future<void> _createTcpServer() async {
try {
_server = await ServerSocket.bind(
InternetAddress.anyIPv6,
InternetAddress.anyIPv4,
36867,
shared: true,
v6Only: false,
@@ -119,40 +130,33 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
// Accept connection
_server!.listen(
(Socket socket) {
(Socket socket) async {
SharedLogic.keepAlive();
_socket = socket;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
}
if (_useDirCon) {
_dirCon = ObcDircon(socket: socket, onMessageCallback: this);
}
// Listen for data from the client
socket.listen(
(List<int> data) {
if (kDebugMode) {
print('Received message: ${bytesToHex(data)}');
}
final messageType = data[0];
switch (messageType) {
case OpenBikeProtocolParser.MSG_TYPE_APP_INFO:
try {
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data));
isConnected.value = true;
connectedApp.value = appInfo;
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
);
} catch (e) {
core.connection.signalNotification(LogNotification('Failed to parse app info: $e'));
}
break;
default:
print('Unknown message type: $messageType');
if (_dirCon != null) {
_dirCon!.handleIncomingData(data);
return;
}
onMessage(data);
},
onDone: () {
_dirCon = null;
SharedLogic.stopKeepAlive();
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'),
);
@@ -205,6 +209,40 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
void _write(Socket socket, List<int> responseData) {
debugPrint('Sending response: ${bytesToHex(responseData)}');
socket.add(responseData);
if (_dirCon != null) {
_dirCon!.sendCharacteristicNotification(OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID, responseData);
return;
} else {
socket.add(responseData);
}
}
@override
void onMessage(List<int> message) {
if (kDebugMode) {
print('Received message from OBC: ${bytesToHex(message)}');
}
final messageType = message[0];
switch (messageType) {
case OpenBikeProtocolParser.MSG_TYPE_APP_INFO:
try {
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(message));
isConnected.value = true;
connectedApp.value = appInfo;
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
);
} catch (e) {
core.connection.signalNotification(LogNotification('Failed to parse app info: $e'));
}
break;
case OpenBikeProtocolParser.MSG_TYPE_HAPTIC_FEEDBACK:
// noop
break;
default:
print('Unknown message type: $messageType');
}
}
}

View File

@@ -4,6 +4,7 @@ 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:prop/prop.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth_device.dart';
@@ -25,7 +26,7 @@ class ShimanoDi2 extends BluetoothDevice {
await UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
}
final _lastButtons = <int, int>{};
final _lastButtons = <int, ({int value, _Di2State type})>{};
bool _isInitialized = false;
@override
@@ -33,6 +34,7 @@ class ShimanoDi2 extends BluetoothDevice {
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
Logger.info('Received data from $characteristic: ${bytesToReadableHex(bytes)}');
if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) {
final channels = bytes.sublist(1);
@@ -40,7 +42,7 @@ class ShimanoDi2 extends BluetoothDevice {
if (!_isInitialized) {
channels.forEachIndexed((int value, int index) {
final readableIndex = index + 1;
_lastButtons[index] = value;
_lastButtons[index] = (value: value, type: _Di2State.released);
getOrAddButton(
'D-Fly Channel $readableIndex',
@@ -51,28 +53,61 @@ class ShimanoDi2 extends BluetoothDevice {
return Future.value();
}
final clickedButtons = <ControllerButton>[];
var actualChange = false;
channels.forEachIndexed((int value, int index) {
final didChange = _lastButtons[index] != value;
_lastButtons[index] = value;
final didChange = _lastButtons[index]?.value != value;
final readableIndex = index + 1;
final button = getOrAddButton(
'D-Fly Channel $readableIndex',
() => ControllerButton('D-Fly Channel $readableIndex', sourceDeviceId: device.deviceId),
);
if (didChange) {
clickedButtons.add(button);
if ((value & 0x10) != 0) {
if (_lastButtons[index]?.type == _Di2State.longPress || _lastButtons[index]?.type == _Di2State.keep) {
// short press is triggered after long press, until it's released later on
_lastButtons[index] = (value: value, type: _Di2State.keep);
Logger.info('Button $readableIndex still long pressed');
} else {
_lastButtons[index] = (value: value, type: _Di2State.shortPress);
actualChange = true;
Logger.info('Button $readableIndex short pressed');
}
} else if ((value & 0x20) != 0) {
_lastButtons[index] = (value: value, type: _Di2State.longPress);
actualChange = true;
Logger.info('Button $readableIndex long pressed');
} else if ((value & 0x40) != 0) {
_lastButtons[index] = (value: value, type: _Di2State.doublePress);
actualChange = true;
Logger.info('Button $readableIndex double pressed');
} else {
_lastButtons[index] = (value: value, type: _Di2State.released);
actualChange = true;
Logger.info('Button $readableIndex released');
}
}
});
if (clickedButtons.isNotEmpty) {
await handleButtonsClickedWithoutLongPressSupport(clickedButtons);
if (actualChange) {
final buttonsToTrigger = _lastButtons.entries
.where((entry) {
final type = entry.value.type;
return type != _Di2State.released;
})
.map((entry) => availableButtons.firstWhere((button) => button.name == 'D-Fly Channel ${entry.key + 1}'))
.toList();
Logger.debug('Buttons to trigger: ${buttonsToTrigger.map((b) => b.name).join(', ')}');
handleButtonsClicked(buttonsToTrigger);
final doublePress = _lastButtons.entries
.filter((entry) => entry.value.type == _Di2State.doublePress)
.map((entry) => availableButtons.firstWhere((button) => button.name == 'D-Fly Channel ${entry.key + 1}'))
.toList();
if (doublePress.isNotEmpty) {
Logger.debug('Buttons to still trigger: ${doublePress.map((b) => b.name).join(', ')}');
handleButtonsClicked(doublePress);
}
}
}
return Future.value();
}
@override
@@ -97,3 +132,11 @@ class ShimanoDi2Constants {
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
}
enum _Di2State {
shortPress,
longPress,
keep,
doublePress,
released,
}

View File

@@ -1,10 +1,8 @@
import 'dart:typed_data';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/keymap/keymap.dart';
import 'package:prop/prop.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth_device.dart';
@@ -38,7 +36,6 @@ class WahooKickrHeadwind extends BluetoothDevice {
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
// Analyze the received bytes to determine current state
actionStreamInternal.add(LogNotification('Received ${bytesToHex(bytes)} from Headwind $characteristic'));
if (bytes.length >= 4 && bytes[0] == 0xFD && bytes[1] == 0x01) {
final mode = bytes[3];
final speed = bytes[2];
@@ -83,6 +80,9 @@ class WahooKickrHeadwind extends BluetoothDevice {
withoutResponse: true,
);
_currentMode = HeadwindMode.manual;
// Small delay to ensure mode change is processed before speed command
await Future.delayed(const Duration(milliseconds: 100));
}
// Command format: [0x02, speed_value]

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
class ZwiftConstants {
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_CUSTOM_SERVICE_SHORT_UUID = "0001";
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb";
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT = "fc82";
static const ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1";

View File

@@ -60,6 +60,7 @@ class ZwiftClickV2 extends ZwiftRide {
Future<void> setupHandshake() async {
if (isUnlocked) {
super.setupHandshake();
await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00]));
}
}
@@ -127,6 +128,15 @@ class ZwiftClickV2 extends ZwiftRide {
),
],
),
if (kDebugMode)
Button(
onPressed: () {
test();
},
leading: const Icon(Icons.translate_sharp),
style: ButtonStyle.primary(size: ButtonSize.small),
child: Text('Reset'),
),
],
)
else
@@ -168,6 +178,15 @@ class ZwiftClickV2 extends ZwiftRide {
style: ButtonStyle.primary(size: ButtonSize.small),
child: Text('Handshake'),
),
if (kDebugMode)
Button(
onPressed: () {
test();
},
leading: const Icon(Icons.translate_sharp),
style: ButtonStyle.primary(size: ButtonSize.small),
child: Text('Reset'),
),
],
),
/*else

View File

@@ -298,6 +298,8 @@ class ZwiftEmulator extends TrainerConnection {
}
Future<void> stopAdvertising() async {
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;

View File

@@ -11,9 +11,7 @@ class LogNotification extends BaseNotification {
final String message;
LogNotification(this.message) {
if (kDebugMode) {
//print('LogNotification: $message');
}
Logger.debug('LogNotification: $message');
}
@override

View File

@@ -0,0 +1,418 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
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/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 'package:prop/prop.dart';
import '../utils/keymap/keymap.dart';
class RemoteKeyboardPairing extends TrainerConnection {
bool get isLoading => _isLoading;
late final _peripheralManager = PeripheralManager();
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
GATTCharacteristic? _inputReport;
static const String connectionTitle = 'Keyboard Remote Control';
RemoteKeyboardPairing()
: super(
title: connectionTitle,
supportedActions: InGameAction.values,
);
Future<void> reconnect() async {
await _peripheralManager.stopAdvertising();
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
startAdvertising().catchError((e) {
core.settings.setRemoteControlEnabled(false);
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Remote Control pairing: $e'),
);
});
}
Future<void> startAdvertising() async {
_isLoading = true;
isStarted.value = true;
_peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
} else if (state.state == ConnectionState.disconnected) {
_central = null;
isConnected.value = false;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
}
});
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
isStarted.value = false;
return;
}
}
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getRemoteControlEnabled()) {
print('Waiting for peripheral manager to be powered on...');
if (core.settings.getLastTarget() == Target.thisDevice) {
return;
}
await Future.delayed(Duration(seconds: 1));
}
final inputReport = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4D'),
permissions: [GATTCharacteristicPermission.read],
properties: [GATTCharacteristicProperty.notify, GATTCharacteristicProperty.read],
descriptors: [
GATTDescriptor.immutable(
// Report Reference: ID=1, Type=Input(1)
uuid: UUID.fromString('2908'),
value: Uint8List.fromList([0x01, 0x01]),
),
],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
final reportMapDataAbsolute = Uint8List.fromList([
// Keyboard Report (Report ID 1)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x05, 0x07, // Usage Page (Keyboard/Keypad)
0x19, 0xE0, // Usage Minimum (Left Control)
0x29, 0xE7, // Usage Maximum (Right GUI)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data,Var,Abs) - Modifier byte
0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Const) - Reserved byte
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x05, 0x07, // Usage Page (Keyboard/Keypad)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x81, 0x00, // Input (Data,Array) - Key array (6 keys)
0xC0, // End Collection
]);
// 1) Build characteristics
final hidInfo = GATTCharacteristic.immutable(
uuid: UUID.fromString('2A4A'),
value: Uint8List.fromList([0x11, 0x01, 0x00, 0x02]),
descriptors: [], // HID v1.11, country=0, flags=2
);
final reportMap = GATTCharacteristic.immutable(
uuid: UUID.fromString('2A4B'),
//properties: [GATTCharacteristicProperty.read],
//permissions: [GATTCharacteristicPermission.read],
value: reportMapDataAbsolute,
descriptors: [
GATTDescriptor.immutable(uuid: UUID.fromString('2908'), value: Uint8List.fromList([0x0, 0x0])),
],
);
final protocolMode = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4E'),
properties: [GATTCharacteristicProperty.read, GATTCharacteristicProperty.writeWithoutResponse],
permissions: [GATTCharacteristicPermission.read, GATTCharacteristicPermission.write],
descriptors: [],
);
final hidControlPoint = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4C'),
properties: [GATTCharacteristicProperty.writeWithoutResponse],
permissions: [GATTCharacteristicPermission.write],
descriptors: [],
);
// 2) HID service
final hidService = GATTService(
uuid: UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB'),
isPrimary: true,
characteristics: [
hidInfo,
reportMap,
protocolMode,
hidControlPoint,
inputReport,
],
includedServices: [],
);
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
_peripheralManager.characteristicReadRequested.forEach((char) {
print('Read request for characteristic: ${char}');
// You can respond to read requests here if needed
});
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
// Check if this is the input report characteristic (2A4D)
if (char.characteristic.uuid == inputReport.uuid) {
if (char.state) {
_central = char.central;
_inputReport = char.characteristic;
isConnected.value = true;
print('Input report subscribed');
} else {
_inputReport = null;
_central = null;
isConnected.value = false;
print('Input report unsubscribed');
}
}
print('Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}');
});
}
await _peripheralManager.addService(hidService);
// 3) Optional Battery service
await _peripheralManager.addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
characteristics: [
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A19'),
value: Uint8List.fromList([100]),
descriptors: [],
),
],
includedServices: [],
),
);
_isServiceAdded = true;
}
final advertisement = Advertisement(
name:
'BikeControl ${Platform.isIOS
? 'iOS'
: Platform.isAndroid
? 'Android'
: ''}',
serviceUUIDs: [UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB')],
);
print('Starting advertising with Remote service...');
try {
await _peripheralManager.startAdvertising(advertisement);
} catch (e) {
if (e.toString().contains("Advertising has already started")) {
print('Advertising already started, ignoring error');
return;
} else {
rethrow;
}
}
_isLoading = false;
}
Future<void> stopAdvertising() async {
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
}
Future<void> notifyCharacteristic(Uint8List value) async {
if (_inputReport != null && _central != null) {
await _peripheralManager.notifyCharacteristic(_central!, _inputReport!, value: value);
}
}
@override
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
if (isKeyDown && isKeyUp) {
await sendKeyPress(keyPair);
return Success('Key ${keyPair.toString()} press sent');
} else if (isKeyDown) {
await sendKeyDown(keyPair);
return Success('Key ${keyPair.toString()} down sent');
} else if (isKeyUp) {
await sendKeyUp();
return Success('Key ${keyPair.toString()} up sent');
}
return NotHandled('Illegal combination');
}
/// USB HID Keyboard scan codes for common keys
static const Map<String, int> hidKeyCodes = {
'a': 0x04,
'b': 0x05,
'c': 0x06,
'd': 0x07,
'e': 0x08,
'f': 0x09,
'g': 0x0A,
'h': 0x0B,
'i': 0x0C,
'j': 0x0D,
'k': 0x0E,
'l': 0x0F,
'm': 0x10,
'n': 0x11,
'o': 0x12,
'p': 0x13,
'q': 0x14,
'r': 0x15,
's': 0x16,
't': 0x17,
'u': 0x18,
'v': 0x19,
'w': 0x1A,
'x': 0x1B,
'y': 0x1C,
'z': 0x1D,
'1': 0x1E,
'2': 0x1F,
'3': 0x20,
'4': 0x21,
'5': 0x22,
'6': 0x23,
'7': 0x24,
'8': 0x25,
'9': 0x26,
'0': 0x27,
'enter': 0x28,
'escape': 0x29,
'backspace': 0x2A,
'tab': 0x2B,
'space': 0x2C,
'minus': 0x2D,
'equals': 0x2E,
'leftbracket': 0x2F,
'rightbracket': 0x30,
'backslash': 0x31,
'semicolon': 0x33,
'quote': 0x34,
'grave': 0x35,
'comma': 0x36,
'period': 0x37,
'slash': 0x38,
'capslock': 0x39,
'f1': 0x3A,
'f2': 0x3B,
'f3': 0x3C,
'f4': 0x3D,
'f5': 0x3E,
'f6': 0x3F,
'f7': 0x40,
'f8': 0x41,
'f9': 0x42,
'f10': 0x43,
'f11': 0x44,
'f12': 0x45,
'printscreen': 0x46,
'scrolllock': 0x47,
'pause': 0x48,
'insert': 0x49,
'home': 0x4A,
'pageup': 0x4B,
'delete': 0x4C,
'end': 0x4D,
'pagedown': 0x4E,
'right': 0x4F,
'left': 0x50,
'down': 0x51,
'up': 0x52,
};
/// Modifier key bit masks
static const int modLeftCtrl = 0x01;
static const int modLeftShift = 0x02;
static const int modLeftAlt = 0x04;
static const int modLeftGui = 0x08;
static const int modRightCtrl = 0x10;
static const int modRightShift = 0x20;
static const int modRightAlt = 0x40;
static const int modRightGui = 0x80;
/// Create a keyboard HID report
/// [modifiers] - bit mask for modifier keys (Ctrl, Shift, Alt, GUI)
/// [keyCodes] - list of up to 6 key codes to send
Uint8List keyboardReport(int modifiers, List<int> keyCodes) {
final keys = List<int>.filled(6, 0);
for (var i = 0; i < keyCodes.length && i < 6; i++) {
keys[i] = keyCodes[i];
}
// Report format: [modifiers, reserved, key1, key2, key3, key4, key5, key6]
return Uint8List.fromList([modifiers, 0x00, ...keys]);
}
/// Send a keyboard key press and release
/// [key] - the key name (e.g., 'a', 'enter', 'space', 'f1', 'up', 'down')
/// [modifiers] - optional modifier keys (use modLeftCtrl, modLeftShift, etc.)
Future<void> sendKeyPress(KeyPair keyPair, {int modifiers = 0}) async {
final usbHidUsage = keyPair.physicalKey!.usbHidUsage;
final keyCode = usbHidUsage & 0xFF;
// Send key down
final downReport = keyboardReport(modifiers, [keyCode]);
if (kDebugMode) {
print(
'Sending keyboard key down: $keyPair (0x${keyCode.toRadixString(16)}) with modifiers: 0x${modifiers.toRadixString(16)}',
);
}
await notifyCharacteristic(downReport);
await Future.delayed(Duration(milliseconds: 20));
// Send key up (empty report)
final upReport = keyboardReport(0, []);
if (kDebugMode) {
print('Sending keyboard key up');
}
await notifyCharacteristic(upReport);
await Future.delayed(Duration(milliseconds: 10));
}
/// Send a key down event only (for holding keys)
Future<void> sendKeyDown(KeyPair keyPair, {int modifiers = 0}) async {
final usbHidUsage = keyPair.physicalKey!.usbHidUsage;
final keyCode = usbHidUsage & 0xFF;
final report = keyboardReport(modifiers, [keyCode]);
await notifyCharacteristic(report);
}
/// Send a key up event (release all keys)
Future<void> sendKeyUp() async {
final report = keyboardReport(0, []);
await notifyCharacteristic(report);
}
}

View File

@@ -159,37 +159,6 @@ class RemotePairing extends TrainerConnection {
descriptors: [],
);
// Input report characteristic (notify)
final keyboardInputReport = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4D'),
permissions: [GATTCharacteristicPermission.read],
properties: [GATTCharacteristicProperty.notify, GATTCharacteristicProperty.read],
descriptors: [
GATTDescriptor.immutable(
// Report Reference: ID=1, Type=Input(1)
uuid: UUID.fromString('2908'),
value: Uint8List.fromList([0x02, 0x01]),
),
],
);
final outputReport = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4D'),
permissions: [GATTCharacteristicPermission.read, GATTCharacteristicPermission.write],
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.write,
GATTCharacteristicProperty.writeWithoutResponse,
],
descriptors: [
GATTDescriptor.immutable(
// Report Reference: ID=1, Type=Input(1)
uuid: UUID.fromString('2908'),
value: Uint8List.fromList([0x02, 0x02]),
),
],
);
// 2) HID service
final hidService = GATTService(
uuid: UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB'),
@@ -198,9 +167,7 @@ class RemotePairing extends TrainerConnection {
hidInfo,
reportMap,
protocolMode,
outputReport,
hidControlPoint,
keyboardInputReport,
inputReport,
],
includedServices: [],
@@ -214,18 +181,21 @@ class RemotePairing extends TrainerConnection {
});
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
// Check if this is the input report characteristic (2A4D)
if (char.characteristic.uuid == inputReport.uuid) {
if (char.state) {
_inputReport = char.characteristic;
_central = char.central;
_inputReport = char.characteristic;
isConnected.value = true;
print('Input report subscribed');
} else {
_inputReport = null;
_central = null;
isConnected.value = false;
print('Input report unsubscribed');
}
}
print(
'Notify state changed for characteristic: ${char.characteristic.uuid} vs ${char.characteristic.uuid == inputReport.uuid}: ${char.state}',
);
print('Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}');
});
}
await _peripheralManager.addService(hidService);
@@ -259,11 +229,22 @@ class RemotePairing extends TrainerConnection {
);
print('Starting advertising with Remote service...');
await _peripheralManager.startAdvertising(advertisement);
try {
await _peripheralManager.startAdvertising(advertisement);
} catch (e) {
if (e.toString().contains("Advertising has already started")) {
print('Advertising already started, ignoring error');
return;
} else {
rethrow;
}
}
_isLoading = false;
}
Future<void> stopAdvertising() async {
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;

View File

@@ -11,6 +11,7 @@
"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",
"actAsBluetoothKeyboard": "Als Bluetooth-Tastatur fungieren",
"action": "Aktion",
"adjustControllerButtons": "Controller-Tasten anpassen",
"afterDate": "Nach dem {date}",
@@ -36,6 +37,7 @@
"battery": "Batterie",
"beforeDate": "Vor dem {date}",
"bluetoothAdvertiseAccess": "Bluetooth-Zugriff",
"bluetoothKeyboardExplanation": "So können Sie Ihr Smartphone als Bluetooth-Tastatur zur Steuerung kompatibler Apps verwenden. Nach der Kopplung können Sie die Tasten Ihres Fahrradcontrollers nutzen, um Tastatureingaben an die App zu senden.",
"bluetoothTurnedOn": "Bluetooth ist eingeschaltet",
"browserNotSupported": "Dieser Browser unterstützt kein Web-Bluetooth und die Plattform wird nicht unterstützt :(",
"button": "Taste.",
@@ -153,7 +155,7 @@
"enableLocalConnectionMethodFirst": "Aktivieren Sie zuerst die Methode „Lokale Verbindung“.",
"enableMediaKeyDetection": "Medientastenerkennung aktivieren",
"enableMywhooshLinkInTheConnectionSettingsFirst": "Aktiviere zuerst MyWhoosh Link in den Verbindungseinstellungen.",
"enablePairingProcess": "Kopplungsprozess aktivieren",
"enablePairingProcess": "Als Bluetooth-Maus fungieren",
"enablePermissions": "Berechtigungen aktivieren",
"enableSteeringWithPhone": "Lenkung über Handy-Sensoren aktivieren",
"enableVibrationFeedback": "Vibrationsfeedback beim Gangwechsel aktivieren",
@@ -279,7 +281,7 @@
"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.",
"pairingDescription": "So können Sie Ihr Smartphone als Bluetooth-Maus zur Steuerung von Apps verwenden. Nach der Kopplung können Sie die Tasten Ihres Fahrradcontrollers nutzen, um Mausbewegungen an die App zu senden.",
"pairingInstructions": "Gehe auf Deinem {targetName} in die Bluetooth-Einstellungen und suche nach BikeControl oder dem Namen Ihres Geräts. Wenn Du die Fernbedienungsfunktion nutzen möchtest, ist eine Kopplung erforderlich.",
"@pairingInstructions": {
"placeholders": {

View File

@@ -11,6 +11,7 @@
"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",
"actAsBluetoothKeyboard": "Act as Bluetooth Keyboard",
"action": "Action",
"adjustControllerButtons": "Adjust Controller Buttons",
"afterDate": "After {date}",
@@ -36,6 +37,7 @@
"battery": "Battery",
"beforeDate": "Before {date}",
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
"bluetoothKeyboardExplanation": "This will allow you to use your phone as a Bluetooth keyboard to control compatible apps. Once paired, you can use the buttons on your bike controller to send keyboard inputs to the app.",
"bluetoothTurnedOn": "Bluetooth turned on",
"browserNotSupported": "This Browser does not support Web Bluetooth and platform is not supported :(",
"button": "button.",
@@ -153,7 +155,7 @@
"enableLocalConnectionMethodFirst": "Enable Local Connection method, first.",
"enableMediaKeyDetection": "Enable Media Key Detection",
"enableMywhooshLinkInTheConnectionSettingsFirst": "Enable MyWhoosh Link in the connection settings first.",
"enablePairingProcess": "Enable Pairing Process",
"enablePairingProcess": "Act as Bluetooth Mouse",
"enablePermissions": "Enable Permissions",
"enableSteeringWithPhone": "Enable Steering with your phone's sensors",
"enableVibrationFeedback": "Enable vibration feedback when shifting gears",
@@ -279,7 +281,7 @@
"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.",
"pairingDescription": "This will allow you to use your phone as a Bluetooth mouse to control apps. Once paired, you can use the buttons on your bike controller to send mouse inputs to the app.",
"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": {

View File

@@ -11,6 +11,7 @@
"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",
"actAsBluetoothKeyboard": "Fonctionne comme un clavier Bluetooth",
"action": "Action",
"adjustControllerButtons": "Ajuster les boutons de la manette",
"afterDate": "Après {date}",
@@ -36,6 +37,7 @@
"battery": "Batterie",
"beforeDate": "Avant {date}",
"bluetoothAdvertiseAccess": "Accès à la publicité Bluetooth",
"bluetoothKeyboardExplanation": "Vous pourrez ainsi utiliser votre téléphone comme clavier Bluetooth pour contrôler les applications compatibles. Une fois l'appairage effectué, vous pourrez utiliser les boutons de votre contrôleur de vélo pour envoyer des commandes à l'application.",
"bluetoothTurnedOn": "Bluetooth activé",
"browserNotSupported": "Ce navigateur ne prend pas en charge Web Bluetooth et la plateforme n'est pas prise en charge :(",
"button": "bouton.",
@@ -153,7 +155,7 @@
"enableLocalConnectionMethodFirst": "Activez d'abord le mode de connexion locale.",
"enableMediaKeyDetection": "Activer la détection des touches multimédias",
"enableMywhooshLinkInTheConnectionSettingsFirst": "Activez d'abord MyWhoosh Link dans les paramètres de connexion.",
"enablePairingProcess": "Activer le processus d'appairage",
"enablePairingProcess": "Fonctionne comme une souris Bluetooth",
"enablePermissions": "Activer les autorisations",
"enableSteeringWithPhone": "Activez la direction avec les capteurs de votre téléphone",
"enableVibrationFeedback": "Activer le retour haptique par vibration lors du changement de vitesse",
@@ -279,7 +281,7 @@
"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.",
"pairingDescription": "Cela vous permettra d'utiliser votre téléphone comme une souris Bluetooth pour contrôler des applications. Une fois l'appairage effectué, vous pourrez utiliser les boutons de votre contrôleur de vélo pour envoyer des commandes de souris à l'application.",
"pairingInstructions": "Sur votre {targetName}, accédez aux paramètres Bluetooth et recherchez BikeControl ou le nom de votre appareil. L'appairage est nécessaire si vous souhaitez utiliser la fonction de commande à distance.",
"@pairingInstructions": {
"placeholders": {

View File

@@ -11,6 +11,7 @@
"accessibilityUsageMonitor": "• L'applicazione monitora quale finestra dell'app di allenamento è attiva per garantire che i gesti vengano inviati all'app corretta",
"accessibilityUsageNoData": "• Nessun dato personale viene raccolto o consultato tramite questo servizio",
"accessories": "Accessori",
"actAsBluetoothKeyboard": "Funziona come tastiera Bluetooth",
"action": "Azione",
"adjustControllerButtons": "Regola i pulsanti del controller",
"afterDate": "Dopo il {date}",
@@ -36,6 +37,7 @@
"battery": "Batteria",
"beforeDate": "Prima del {date}",
"bluetoothAdvertiseAccess": "Accesso pubblicitario Bluetooth",
"bluetoothKeyboardExplanation": "Questo ti permetterà di usare il tuo telefono come tastiera Bluetooth per controllare le app compatibili. Una volta associato, potrai usare i pulsanti del controller della tua bici per inviare input dalla tastiera all'app.",
"bluetoothTurnedOn": "Bluetooth attivato",
"browserNotSupported": "Questo browser non supporta il Web Bluetooth e la piattaforma non è supportata :(",
"button": "pulsante.",
@@ -153,7 +155,7 @@
"enableLocalConnectionMethodFirst": "Abilitare prima il metodo di connessione locale.",
"enableMediaKeyDetection": "Abilita rilevamento tasti multimediali",
"enableMywhooshLinkInTheConnectionSettingsFirst": "Per prima cosa, abilita MyWhoosh Link nelle impostazioni di connessione.",
"enablePairingProcess": "Abilita processo di associazione",
"enablePairingProcess": "Funziona come un mouse Bluetooth",
"enablePermissions": "Abilita autorizzazioni",
"enableSteeringWithPhone": "Abilita lo sterzo con i sensori del tuo telefono",
"enableVibrationFeedback": "Abilita il feedback delle vibrazioni durante il cambio marcia",
@@ -279,7 +281,7 @@
"openBikeControlAnnouncement": "Ottime notizie -{trainerApp} supporta il protocollo OpenBikeControl, così avrai la migliore esperienza possibile!",
"openBikeControlConnection": " ad esempio utilizzando la connessione OpenBikeControl",
"otherConnectionMethods": "Altri metodi di connessione",
"pairingDescription": "L'associazione consente la personalizzazione completa, ma potrebbe non funzionare su tutti i dispositivi.",
"pairingDescription": "Questo ti permetterà di usare il tuo telefono come mouse Bluetooth per controllare le app. Una volta associato, potrai usare i pulsanti del controller della tua bici per inviare input del mouse all'app.",
"pairingInstructions": "Sul tuo{targetName} Accedi alle impostazioni Bluetooth e cerca BikeControl o il nome del tuo dispositivo. L'associazione è necessaria se vuoi utilizzare la funzione di controllo remoto.",
"@pairingInstructions": {
"placeholders": {

View File

@@ -11,6 +11,7 @@
"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",
"actAsBluetoothKeyboard": "Działa jako klawiatura Bluetooth",
"action": "Działanie",
"adjustControllerButtons": "Dostosuj przyciski kontrolera",
"afterDate": "Po {date}",
@@ -36,6 +37,7 @@
"battery": "Bateria",
"beforeDate": "Zanim {date}",
"bluetoothAdvertiseAccess": "Dostęp do reklamy Bluetooth",
"bluetoothKeyboardExplanation": "Umożliwi to używanie telefonu jako klawiatury Bluetooth do sterowania kompatybilnymi aplikacjami. Po sparowaniu możesz używać przycisków na kontrolerze rowerowym do wysyłania poleceń z klawiatury do aplikacji.",
"bluetoothTurnedOn": "Włączono Bluetooth",
"browserNotSupported": "Ta przeglądarka nie obsługuje technologii Web Bluetooth i platforma nie jest obsługiwana :(",
"button": "przycisk.",
@@ -153,7 +155,7 @@
"enableLocalConnectionMethodFirst": "Najpierw włącz metodę połączenia lokalnego.",
"enableMediaKeyDetection": "Włącz rozpoznawanie klawiszy multimedialnych",
"enableMywhooshLinkInTheConnectionSettingsFirst": "Najpierw włącz MyWhoosh Link w ustawieniach połączenia.",
"enablePairingProcess": "Włącz proces parowania",
"enablePairingProcess": "Działa jako mysz Bluetooth",
"enablePermissions": "Nadaj uprawnienia",
"enableSteeringWithPhone": "Włącz sterowanie za pomocą czujników telefonu",
"enableVibrationFeedback": "Włącz wibracje podczas zmiany biegów",
@@ -279,7 +281,7 @@
"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.",
"pairingDescription": "Umożliwi to używanie telefonu jako myszy Bluetooth do sterowania aplikacjami. Po sparowaniu możesz używać przycisków na kontrolerze rowerowym do wysyłania poleceń myszy do aplikacji.",
"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": {

View File

@@ -2,7 +2,6 @@ import 'dart:async';
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/touch_area.dart';
import 'package:bike_control/utils/actions/android.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
@@ -18,6 +17,7 @@ import 'package:bike_control/widgets/ui/colors.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:bike_control/widgets/ui/warning.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -180,14 +180,17 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
SizedBox(height: 8),
ColoredTitle(text: 'Local / Remote Setting'),
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard))
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard) &&
(core.settings.getLocalEnabled() || core.settings.getRemoteKeyboardControlEnabled()))
Builder(
builder: (context) {
return SelectableCard(
icon: RadixIcons.keyboard,
title: Text(context.i18n.simulateKeyboardShortcut),
isActive:
_keyPair.physicalKey != null && !_keyPair.isSpecialKey && core.settings.getLocalEnabled(),
_keyPair.physicalKey != null &&
!_keyPair.isSpecialKey &&
(core.settings.getLocalEnabled() || core.settings.getRemoteKeyboardControlEnabled()),
value: _keyPair.toString(),
onPressed: () async {
await _showModeDropdown(context, SupportedMode.keyboard);
@@ -195,7 +198,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
);
},
),
if (core.actionHandler.supportedModes.contains(SupportedMode.touch))
if (core.actionHandler.supportedModes.contains(SupportedMode.touch) &&
(core.settings.getLocalEnabled() || core.settings.getRemoteControlEnabled()))
Builder(
builder: (context) {
return SelectableCard(
@@ -204,7 +208,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
isActive:
((core.actionHandler is AndroidActions || _keyPair.physicalKey == null) &&
_keyPair.touchPosition != Offset.zero) &&
core.settings.getLocalEnabled(),
(core.settings.getLocalEnabled() || core.settings.getRemoteControlEnabled()),
value: _keyPair.toString(),
trailing: IconButton.secondary(
icon: Icon(Icons.ondemand_video),
@@ -235,7 +239,6 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
onPressed: () {
if (!core.settings.getLocalEnabled()) {
buildToast(
navigatorKey.currentContext!,
title: AppLocalizations.of(context).enableLocalConnectionMethodFirst,
);
} else {
@@ -343,7 +346,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
),
onPressed: () {
if (!core.settings.getLocalEnabled()) {
buildToast(navigatorKey.currentContext!, title: 'Enable Local Connection method, first.');
buildToast(title: 'Enable Local Connection method, first.');
} else {
showDropdown(
context: context,
@@ -495,6 +498,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
_keyPair.androidAction = null;
_keyPair.inGameAction = action;
_keyPair.inGameActionValue = ingame;
_keyPair.isLongPress = _keyPair.isLongPress ? true : action.isLongPress;
widget.onUpdate();
setState(() {});
},
@@ -510,6 +514,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
_keyPair.androidAction = null;
_keyPair.inGameAction = action;
_keyPair.inGameActionValue = null;
_keyPair.isLongPress = _keyPair.isLongPress ? true : action.isLongPress;
widget.onUpdate();
setState(() {});
}
@@ -536,9 +541,15 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
.distinctBy((kp) => kp.inGameAction)
.toList();
if (!core.settings.getLocalEnabled()) {
final isEnabled =
supportedMode == SupportedMode.keyboard &&
(core.settings.getLocalEnabled() || core.settings.getRemoteKeyboardControlEnabled()) ||
supportedMode == SupportedMode.touch &&
(core.settings.getLocalEnabled() || core.settings.getRemoteControlEnabled()) ||
supportedMode == SupportedMode.media && core.settings.getLocalEnabled();
if (!isEnabled) {
return buildToast(
navigatorKey.currentContext!,
title: AppLocalizations.of(context).enableLocalConnectionMethodFirst,
);
} else if (actionsWithInGameAction != null && actionsWithInGameAction.isNotEmpty) {

View File

@@ -6,6 +6,7 @@ import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator
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_keyboard_pairing.dart';
import 'package:bike_control/bluetooth/remote_pairing.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/pages/touch_area.dart';
@@ -22,7 +23,8 @@ 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/keyboard_pair_widget.dart';
import 'package:bike_control/widgets/mouse_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';
@@ -172,7 +174,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
} else {
_pressedAction = null;
setState(() {});
buildToast(context, title: 'No connected trainer.');
buildToast(title: 'No connected trainer.');
}
return KeyEventResult.ignored;
@@ -232,12 +234,21 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
),
OpenBikeControlMdnsEmulator.connectionTitle => OpenBikeControlMdnsTile(),
OpenBikeControlBluetoothEmulator.connectionTitle => OpenBikeControlBluetoothTile(),
RemotePairing.connectionTitle => RemotePairingWidget(),
RemotePairing.connectionTitle => RemoteMousePairingWidget(),
RemoteKeyboardPairing.connectionTitle => RemoteKeyboardPairingWidget(),
_ => SizedBox.shrink(),
},
...connectedTrainers.map(
(connection) {
final supportedActions = connection.supportedActions;
final supportedActions = connection.supportedActions == InGameAction.values
? core.settings
.getTrainerApp()!
.keymap
.keyPairs
.mapNotNull((k) => k.inGameAction)
.distinct()
.toList()
: connection.supportedActions;
final actionGroups = {
if (supportedActions.contains(InGameAction.shiftUp) &&
@@ -443,7 +454,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
}) async {
if (!connection.isConnected.value) {
if (down) {
buildToast(context, title: 'No connected trainer.');
buildToast(title: 'No connected trainer.');
}
return;
@@ -499,7 +510,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
);
await IAPManager.instance.incrementCommandCount();
if (result is! Success) {
buildToast(context, title: result.message);
buildToast(title: result.message);
}
}
}

View File

@@ -110,62 +110,6 @@ class _DevicePageState extends State<DevicePage> {
),
],
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows))
ValueListenableBuilder(
valueListenable: core.mediaKeyHandler.isMediaKeyDetectionEnabled,
builder: (context, value, child) {
return SelectableCard(
isActive: value,
icon: value ? Icons.check_box : Icons.check_box_outline_blank,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(context.i18n.enableMediaKeyDetection),
Text(
context.i18n.mediaKeyDetectionTooltip,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
onPressed: () {
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value =
!core.mediaKeyHandler.isMediaKeyDetectionEnabled.value;
},
);
},
),
SizedBox(),
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && !core.settings.getShowOnboarding())
SelectableCard(
isActive: core.settings.getPhoneSteeringEnabled(),
icon: core.settings.getPhoneSteeringEnabled() ? Icons.check_box : Icons.check_box_outline_blank,
title: Row(
spacing: 4,
children: [
Icon(InGameAction.navigateRight.icon!, size: 16),
Icon(InGameAction.navigateLeft.icon!, size: 16),
SizedBox(),
Expanded(child: Text(AppLocalizations.of(context).enableSteeringWithPhone)),
IconButton.secondary(
icon: Icon(Icons.ondemand_video),
onPressed: () {
launchUrlString('https://youtube.com/shorts/zqD5ARGIVmE?feature=share');
},
),
],
),
onPressed: () {
final enable = !core.settings.getPhoneSteeringEnabled();
core.settings.setPhoneSteeringEnabled(enable);
core.connection.toggleGyroscopeSteering(enable);
setState(() {});
},
),
Gap(12),
if (!screenshotMode)
Column(
@@ -234,8 +178,66 @@ class _DevicePageState extends State<DevicePage> {
),
],
),
Gap(12),
SizedBox(),
Gap(24),
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isIOS))
ValueListenableBuilder(
valueListenable: core.mediaKeyHandler.isMediaKeyDetectionEnabled,
builder: (context, value, child) {
return SelectableCard(
isActive: value,
icon: value ? Icons.check_box : Icons.check_box_outline_blank,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(context.i18n.enableMediaKeyDetection),
Text(
context.i18n.mediaKeyDetectionTooltip,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
onPressed: () {
final newValue = !core.mediaKeyHandler.isMediaKeyDetectionEnabled.value;
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = newValue;
core.settings.setMediaKeyDetectionEnabled(newValue);
},
);
},
),
Gap(8),
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && !core.settings.getShowOnboarding())
SelectableCard(
isActive: core.settings.getPhoneSteeringEnabled(),
icon: core.settings.getPhoneSteeringEnabled() ? Icons.check_box : Icons.check_box_outline_blank,
title: Row(
spacing: 4,
children: [
Icon(InGameAction.navigateRight.icon!, size: 16),
Icon(InGameAction.navigateLeft.icon!, size: 16),
SizedBox(),
Expanded(child: Text(AppLocalizations.of(context).enableSteeringWithPhone)),
IconButton.secondary(
icon: Icon(Icons.ondemand_video),
onPressed: () {
launchUrlString('https://youtube.com/shorts/zqD5ARGIVmE?feature=share');
},
),
],
),
onPressed: () {
final enable = !core.settings.getPhoneSteeringEnabled();
core.settings.setPhoneSteeringEnabled(enable);
core.connection.toggleGyroscopeSteering(enable);
setState(() {});
},
),
SizedBox(height: 16),
if (core.connection.controllerDevices.isNotEmpty)
Row(
spacing: 8,

131
lib/pages/login.dart Normal file
View File

@@ -0,0 +1,131 @@
import 'dart:convert';
import 'dart:io';
import 'package:bike_control/utils/requirements/windows.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:sign_in_button/sign_in_button.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../utils/core.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Please sign in to continue'),
const SizedBox(height: 16),
SignInButton(
Buttons.google,
onPressed: _nativeGoogleSignIn,
),
SignInButton(
Buttons.apple,
onPressed: _signInWithApple,
),
Button.secondary(
child: Text('Logout'),
onPressed: () {
core.supabase.auth.signOut();
},
),
if (kDebugMode && Platform.isWindows)
Button.secondary(
child: Text("Register"),
onPressed: () {
WindowsProtocolHandler().register("bikecontrol");
},
),
],
),
);
}
Future<AuthResponse?> _nativeGoogleSignIn() async {
if (Platform.isAndroid || Platform.isIOS) {
/// Web Client ID that you registered with Google Cloud.
const webClientId = '709945926587-bgk7j9qc86t7nuemu100ngvl9c7irv9k.apps.googleusercontent.com';
/// iOS Client ID that you registered with Google Cloud.
const iosClientId = '709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2.apps.googleusercontent.com';
final scopes = ['email'];
final googleSignIn = GoogleSignIn.instance;
await googleSignIn.initialize(
serverClientId: webClientId,
clientId: iosClientId,
);
GoogleSignInAccount? googleUser = await googleSignIn.attemptLightweightAuthentication(reportAllExceptions: true);
googleUser ??= await googleSignIn.authenticate();
/// Authorization is required to obtain the access token with the appropriate scopes for Supabase authentication,
/// while also granting permission to access user information.
final authorization =
await googleUser.authorizationClient.authorizationForScopes(scopes) ??
await googleUser.authorizationClient.authorizeScopes(scopes);
final idToken = googleUser.authentication.idToken;
if (idToken == null) {
throw AuthException('No ID Token found.');
}
final response = await core.supabase.auth.signInWithIdToken(
provider: OAuthProvider.google,
idToken: idToken,
accessToken: authorization.accessToken,
);
return response;
} else {
await core.supabase.auth.signInWithOAuth(
OAuthProvider.google,
redirectTo: kIsWeb ? null : 'bikecontrol://login/',
authScreenLaunchMode: kIsWeb
? LaunchMode.platformDefault
: LaunchMode.externalApplication, // Launch the auth screen in a new webview on mobile.
);
return null;
}
}
/// Performs Apple sign in on iOS or macOS
Future<AuthResponse?> _signInWithApple() async {
if (Platform.isIOS || Platform.isMacOS) {
final rawNonce = core.supabase.auth.generateRawNonce();
final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
nonce: hashedNonce,
);
final idToken = credential.identityToken;
if (idToken == null) {
throw const AuthException('Could not find ID Token from generated credential.');
}
final authResponse = await core.supabase.auth.signInWithIdToken(
provider: OAuthProvider.apple,
idToken: idToken,
nonce: rawNonce,
);
return authResponse;
} else {
await core.supabase.auth.signInWithOAuth(
OAuthProvider.apple,
redirectTo: kIsWeb ? null : 'bikecontrol://login/',
authScreenLaunchMode: kIsWeb ? LaunchMode.platformDefault : LaunchMode.externalApplication,
);
return null;
}
}
}

View File

@@ -14,6 +14,7 @@ import 'package:bike_control/widgets/scan.dart';
import 'package:bike_control/widgets/title.dart';
import 'package:bike_control/widgets/ui/help_button.dart';
import 'package:bike_control/widgets/ui/permissions_list.dart';
import 'package:dartx/dartx.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -108,9 +109,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
_OnboardingStep.trainer => _TrainerOnboardingStep(
onComplete: () {
setState(() {
if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(
if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol.containsAny([
OpenBikeProtocolSupport.network,
) ??
OpenBikeProtocolSupport.dircon,
]) ??
false) {
_currentStep = _OnboardingStep.openbikecontrol;
} else {

View File

@@ -14,7 +14,8 @@ 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/keyboard_pair_widget.dart';
import 'package:bike_control/widgets/mouse_pair_widget.dart';
import 'package:bike_control/widgets/ui/colored_title.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
@@ -25,6 +26,7 @@ import 'package:universal_ble/universal_ble.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../utils/keymap/apps/supported_app.dart';
import '../utils/keymap/apps/zwift.dart';
class TrainerPage extends StatefulWidget {
final bool isMobile;
@@ -53,7 +55,7 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
if (core.logic.showForegroundMessage) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// show snackbar to inform user that the app needs to stay in foreground
buildToast(context, title: AppLocalizations.current.touchSimulationForegroundMessage);
buildToast(title: AppLocalizations.current.touchSimulationForegroundMessage);
});
}
@@ -81,7 +83,7 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
UniversalBle.getBluetoothAvailabilityState().then((state) {
if (state == AvailabilityState.poweredOn && mounted) {
core.remotePairing.reconnect();
buildToast(context, title: AppLocalizations.current.touchSimulationForegroundMessage);
buildToast(title: AppLocalizations.current.touchSimulationForegroundMessage);
}
});
}
@@ -91,9 +93,8 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
final showLocalAsOther =
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) &&
core.logic.showLocalControl &&
!core.settings.getLocalEnabled();
//(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) &&
false && core.logic.showLocalControl && !core.settings.getLocalEnabled();
final showWhooshLinkAsOther =
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showMyWhooshLink;
@@ -122,11 +123,12 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
),
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
if (core.logic.showRemote && core.settings.getTrainerApp() is! Zwift) RemoteKeyboardPairingWidget(),
];
final otherTiles = [
if (core.logic.showRemote) RemotePairingWidget(),
if (showLocalAsOther) LocalTile(),
if (core.logic.showRemote) RemoteMousePairingWidget(),
if (core.logic.showLocalControl && showLocalAsOther) LocalTile(),
if (showWhooshLinkAsOther) MyWhooshLinkTile(),
];
@@ -202,8 +204,8 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
),
),
if (core.settings.getTrainerApp() != null) ...[
Gap(22),
if (recommendedTiles.isNotEmpty) ...[
Gap(22),
ColoredTitle(text: context.i18n.recommendedConnectionMethods),
Gap(12),
],
@@ -228,6 +230,8 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
child: Accordion(
items: [
AccordionItem(
expanded:
recommendedTiles.isEmpty || (core.logic.showRemote && core.remotePairing.isStarted.value),
trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)),
content: Column(
children: [
@@ -269,7 +273,6 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
onPressed: () {
if (core.settings.getTrainerApp() == null) {
buildToast(
context,
level: LogLevel.LOGLEVEL_WARNING,
title: context.i18n.selectTrainerApp,
);

View File

@@ -178,14 +178,41 @@ abstract class BaseActions {
class StubActions extends BaseActions {
StubActions({super.supportedModes = const []});
final List<(ControllerButton button, bool isDown, bool isUp)> performedActions = [];
final List<PerformedAction> performedActions = [];
@override
Future<ActionResult> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
performedActions.add((button, isKeyDown, isKeyUp));
performedActions.add(PerformedAction(button, isDown: isKeyDown, isUp: isKeyUp));
return Future.value(Ignored('${button.name.splitByUpperCase()} clicked'));
}
@override
void cleanup() {}
void cleanup() {
performedActions.clear();
}
}
class PerformedAction {
final ControllerButton button;
final bool isDown;
final bool isUp;
PerformedAction(this.button, {required this.isDown, required this.isUp});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PerformedAction &&
runtimeType == other.runtimeType &&
button.copyWith(sourceDeviceId: null) == other.button.copyWith(sourceDeviceId: null) &&
isDown == other.isDown &&
isUp == other.isUp;
@override
int get hashCode => Object.hash(button, isDown, isUp);
@override
String toString() {
return '{button: $button, isDown: $isDown, isUp: $isUp}';
}
}

View File

@@ -40,8 +40,6 @@ class DesktopActions extends BaseActions {
final label = keyPair.logicalKey!.keyLabel;
final keyName = label.isNotEmpty ? label : keyPair.logicalKey!.debugName ?? 'Key';
buildToast(
navigatorKey.currentContext!,
location: ToastLocation.bottomLeft,
title:
'${isKeyDown

View File

@@ -7,7 +7,7 @@ import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/keymap/keymap.dart';
class RemoteActions extends BaseActions {
RemoteActions({super.supportedModes = const [SupportedMode.touch]});
RemoteActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.keyboard]});
@override
Future<ActionResult> performAction(ControllerButton button, {required bool isKeyDown, required bool isKeyUp}) async {
@@ -16,14 +16,22 @@ class RemoteActions extends BaseActions {
return superResult;
}
final keyPair = supportedApp!.keymap.getKeyPair(button)!;
if (!core.remotePairing.isConnected.value) {
if (!core.remotePairing.isConnected.value && !core.remoteKeyboardPairing.isConnected.value) {
return Error('Not connected to a ${core.settings.getLastTarget()?.name ?? 'remote'} device');
}
if (keyPair.physicalKey != null && keyPair.touchPosition == Offset.zero) {
return Error('Physical key actions are not supported, yet');
} else {
if (core.remotePairing.isConnected.value) {
if (keyPair.touchPosition == Offset.zero) {
return Error('Key $keyPair does not have a valid touch position');
}
return core.remotePairing.sendAction(keyPair, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
} else if (core.remoteKeyboardPairing.isConnected.value) {
if (keyPair.physicalKey == null) {
return Error('Key $keyPair does not have a valid physical key for keyboard actions');
}
return core.remoteKeyboardPairing.sendAction(keyPair, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
} else {
return Error('Not connected to a ${core.settings.getLastTarget()?.name ?? 'remote'} device');
}
}

View File

@@ -5,9 +5,9 @@ import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
import 'package:prop/prop.dart';
import 'package:bike_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/bluetooth/remote_keyboard_pairing.dart';
import 'package:bike_control/bluetooth/remote_pairing.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/actions/android.dart';
@@ -21,6 +21,8 @@ import 'package:dartx/dartx.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:prop/prop.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth/connection.dart';
@@ -38,12 +40,14 @@ class Core {
final settings = Settings();
final connection = Connection();
late final supabase = Supabase.instance.client;
late final whooshLink = WhooshLink();
late final zwiftEmulator = ZwiftEmulator();
late final zwiftMdnsEmulator = FtmsMdnsEmulator();
late final obpMdnsEmulator = OpenBikeControlMdnsEmulator();
late final obpBluetoothEmulator = OpenBikeControlBluetoothEmulator();
late final remotePairing = RemotePairing();
late final remoteKeyboardPairing = RemoteKeyboardPairing();
late final mediaKeyHandler = MediaKeyHandler();
late final logic = CoreLogic();
@@ -168,7 +172,11 @@ class CoreLogic {
}
bool get showObpMdnsEmulator {
return core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.network) == true;
return core.settings.getTrainerApp()?.supportsOpenBikeProtocol.containsAny([
OpenBikeProtocolSupport.network,
OpenBikeProtocolSupport.dircon,
]) ==
true;
}
bool get showObpBluetoothEmulator {
@@ -180,6 +188,10 @@ class CoreLogic {
return core.settings.getRemoteControlEnabled() && showRemote;
}
bool get isRemoteKeyboardControlEnabled {
return core.settings.getRemoteKeyboardControlEnabled() && showRemote;
}
bool get showMyWhooshLink =>
core.settings.getTrainerApp() is MyWhoosh &&
core.settings.getLastTarget() != null &&
@@ -210,7 +222,8 @@ class CoreLogic {
core.settings.getTrainerApp()?.supportsOpenBikeProtocol.isNotEmpty == true;
bool get showLocalRemoteOptions =>
core.actionHandler.supportedModes.isNotEmpty && ((showLocalControl) || (isRemoteControlEnabled));
core.actionHandler.supportedModes.isNotEmpty &&
(showLocalControl || isRemoteControlEnabled || isRemoteKeyboardControlEnabled);
bool get hasNoConnectionMethod =>
!screenshotMode &&
@@ -235,6 +248,7 @@ class CoreLogic {
if (isZwiftBleEnabled) core.zwiftEmulator,
if (isZwiftMdnsEnabled) core.zwiftMdnsEmulator,
if (isRemoteControlEnabled) core.remotePairing,
if (isRemoteKeyboardControlEnabled) core.remoteKeyboardPairing,
].filter((e) => e.isConnected.value).toList();
List<TrainerConnection> get enabledTrainerConnections => [
@@ -244,6 +258,7 @@ class CoreLogic {
if (isZwiftBleEnabled) core.zwiftEmulator,
if (isZwiftMdnsEnabled) core.zwiftMdnsEmulator,
if (isRemoteControlEnabled) core.remotePairing,
if (isRemoteKeyboardControlEnabled) core.remoteKeyboardPairing,
];
List<TrainerConnection> get trainerConnections => [
@@ -253,6 +268,7 @@ class CoreLogic {
if (showZwiftBleEmulator) core.zwiftEmulator,
if (showZwiftMsdnEmulator) core.zwiftMdnsEmulator,
if (showRemote) core.remotePairing,
if (showRemote) core.remoteKeyboardPairing,
];
Future<bool> isTrainerConnected() async {
@@ -329,5 +345,15 @@ class CoreLogic {
);
});
}
if (isRemoteKeyboardControlEnabled && !core.remoteKeyboardPairing.isStarted.value) {
core.remoteKeyboardPairing.startAdvertising().catchError((e, s) {
recordError(e, s, context: 'Remote Keyboard Pairing');
core.settings.setRemoteKeyboardControlEnabled(false);
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Remote Keyboard Control pairing: $e'),
);
});
}
}
}

View File

@@ -217,6 +217,8 @@ class IAPManager {
}
void setAttributes() {
_revenueCatService?.setAttributes();
if (!screenshotMode) {
_revenueCatService?.setAttributes();
}
}
}

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
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/utils/core.dart';
import 'package:bike_control/widgets/ui/toast.dart';
@@ -11,9 +10,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:prop/prop.dart' as zp;
import 'package:prop/prop.dart' hide LogLevel;
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:version/version.dart';
/// RevenueCat-based IAP service for iOS, macOS, and Android
@@ -34,13 +35,14 @@ class RevenueCatService {
final int Function() getDailyCommandLimit;
final void Function(int limit) setDailyCommandLimit;
static const _isAndroidWorking = false;
bool _isInitialized = false;
String? _trialStartDate;
String? _lastCommandDate;
int? _dailyCommandCount;
StreamSubscription<CustomerInfo>? _customerInfoSubscription;
late final StreamSubscription<AuthState> _authSubscription;
RevenueCatService(
this._prefs, {
required this.isPurchasedNotifier,
@@ -119,11 +121,37 @@ class RevenueCatService {
_isInitialized = true;
if (Platform.isAndroid && !isPurchasedNotifier.value && !_isAndroidWorking) {
setDailyCommandLimit(10000);
} else if (!isTrialExpired && Platform.isAndroid) {
if (!isTrialExpired && Platform.isAndroid) {
setDailyCommandLimit(80);
}
_authSubscription = core.supabase.auth.onAuthStateChange.listen((data) {
final AuthChangeEvent event = data.event;
final Session? session = data.session;
Logger.info('event: $event, session: ${session?.user.id} via ${session?.user.email}');
switch (event) {
case AuthChangeEvent.initialSession:
setAttributes();
case AuthChangeEvent.signedIn:
setAttributes();
// handle signed in
case AuthChangeEvent.signedOut:
setAttributes();
// handle signed out
case AuthChangeEvent.passwordRecovery:
// handle password recovery
case AuthChangeEvent.tokenRefreshed:
// handle token refreshed
case AuthChangeEvent.userUpdated:
// handle user updated
case AuthChangeEvent.userDeleted:
// handle user deleted
case AuthChangeEvent.mfaChallengeVerified:
// handle mfa challenge verified
}
});
} catch (e, s) {
recordError(e, s, context: 'Initializing RevenueCat Service');
core.connection.signalNotification(
@@ -240,16 +268,7 @@ class RevenueCatService {
/// Purchase the full version (use paywall instead)
Future<void> purchaseFullVersion(BuildContext context) async {
// Direct the user to the paywall for a better experience
if (Platform.isAndroid && !_isAndroidWorking) {
_trialStartDate = null;
await startTrial();
buildToast(
navigatorKey.currentContext!,
title: AppLocalizations.of(context).unlockingNotPossible,
duration: Duration(seconds: 5),
);
setDailyCommandLimit(10000);
} else if (Platform.isMacOS) {
if (Platform.isMacOS) {
try {
final offerings = await Purchases.getOfferings();
final purchaseParams = PurchaseParams.package(offerings.current!.availablePackages.first);
@@ -260,7 +279,7 @@ class RevenueCatService {
} on PlatformException catch (e) {
var errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode != PurchasesErrorCode.purchaseCancelledError) {
buildToast(context, title: e.message);
buildToast(title: e.message);
}
}
} else {
@@ -316,6 +335,9 @@ class RevenueCatService {
/// Increment the daily command count
Future<void> incrementCommandCount() async {
if (isPurchasedNotifier.value) {
return; // No need to track for purchased users
}
try {
final today = DateTime.now().toIso8601String().split('T')[0];
final lastDate = await _prefs.read(key: _lastCommandDateKey);
@@ -380,8 +402,11 @@ class RevenueCatService {
}
Future<void> setAttributes() async {
final Session? session = core.supabase.auth.currentSession;
// attributes are fully anonymous
await Purchases.setAttributes({
if (session?.user.id != null) "bikecontrol_user": session!.user.id,
"bikecontrol_trainer": core.settings.getTrainerApp()?.name ?? '-',
"bikecontrol_target": core.settings.getLastTarget()?.name ?? '-',
if (core.connection.controllerDevices.isNotEmpty)

View File

@@ -8,6 +8,9 @@ import 'package:bike_control/utils/windows_store_environment.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:gotrue/src/types/auth_state.dart';
import 'package:prop/prop.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:windows_iap/windows_iap.dart';
/// Windows-specific IAP service
@@ -44,6 +47,32 @@ class WindowsIAPService {
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
_dailyCommandCount = int.tryParse(await _prefs.read(key: _dailyCommandCountKey) ?? '0');
_isInitialized = true;
_authSubscription = core.supabase.auth.onAuthStateChange.listen((data) {
final AuthChangeEvent event = data.event;
final Session? session = data.session;
Logger.info('event: $event, session: ${session?.user.id} via ${session?.user.email}');
switch (event) {
case AuthChangeEvent.initialSession:
case AuthChangeEvent.signedIn:
// handle signed in
case AuthChangeEvent.signedOut:
// handle signed out
case AuthChangeEvent.passwordRecovery:
// handle password recovery
case AuthChangeEvent.tokenRefreshed:
// handle token refreshed
case AuthChangeEvent.userUpdated:
// handle user updated
case AuthChangeEvent.userDeleted:
// handle user deleted
case AuthChangeEvent.mfaChallengeVerified:
// handle mfa challenge verified
}
});
} catch (e, s) {
recordError(e, s, context: 'Initializing');
debugPrint('Failed to initialize Windows IAP: $e');
@@ -92,7 +121,6 @@ class WindowsIAPService {
if (status == StorePurchaseStatus.succeeded || status == StorePurchaseStatus.alreadyPurchased) {
IAPManager.instance.isPurchased.value = true;
buildToast(
navigatorKey.currentContext!,
title: 'Purchase Successful',
subtitle: 'Thank you for your purchase! You now have unlimited access.',
);
@@ -109,6 +137,8 @@ class WindowsIAPService {
/// Get the number of days remaining in the trial
int trialDaysRemaining = 0;
late final StreamSubscription<AuthState> _authSubscription;
/// Check if the trial has expired
bool get isTrialExpired {
return !IAPManager.instance.isPurchased.value && hasTrialStarted && trialDaysRemaining <= 0;

View File

@@ -10,7 +10,7 @@ class OpenBikeControl extends SupportedApp {
packageName: "org.openbikecontrol",
compatibleTargets: Target.values,
supportsZwiftEmulation: false,
supportsOpenBikeProtocol: OpenBikeProtocolSupport.values,
supportsOpenBikeProtocol: [OpenBikeProtocolSupport.network, OpenBikeProtocolSupport.ble],
keymap: Keymap(
keyPairs: [],
),

View File

@@ -50,6 +50,18 @@ class Rouvy extends SupportedApp {
logicalKey: LogicalKeyboardKey.keyB,
inGameAction: InGameAction.back,
),
KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.keyY,
logicalKey: LogicalKeyboardKey.keyY,
inGameAction: InGameAction.kudos,
),
KeyPair(
buttons: [ZwiftButtons.y],
physicalKey: PhysicalKeyboardKey.keyZ,
logicalKey: LogicalKeyboardKey.keyZ,
inGameAction: InGameAction.pause,
),
],
),
);

View File

@@ -12,6 +12,7 @@ import 'my_whoosh.dart';
enum OpenBikeProtocolSupport {
ble,
network,
dircon,
}
abstract class SupportedApp {

View File

@@ -18,6 +18,7 @@ class TrainingPeaks extends SupportedApp {
packageName: "com.indieVelo.client",
compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values,
supportsZwiftEmulation: false,
supportsOpenBikeProtocol: [OpenBikeProtocolSupport.ble, OpenBikeProtocolSupport.dircon],
star: true,
keymap: Keymap(
keyPairs: [

View File

@@ -12,8 +12,8 @@ enum InGameAction {
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),
steerLeft('Steer Left', alternativeTitle: 'Left', icon: RadixIcons.doubleArrowLeft, isLongPress: true),
steerRight('Steer Right', alternativeTitle: 'Right', icon: RadixIcons.doubleArrowRight, isLongPress: true),
// mywhoosh
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], icon: BootstrapIcons.cameraReels),
@@ -31,9 +31,13 @@ enum InGameAction {
back('Back', icon: BootstrapIcons.arrowLeft),
rideOnBomb('Ride On Bomb', icon: LucideIcons.bomb, isLongPress: true),
// rouvy
kudos('Kudos', icon: BootstrapIcons.handThumbsUp),
pause('Pause/Resume', icon: BootstrapIcons.pause, isLongPress: true),
// headwind
headwindSpeed('Headwind Speed', possibleValues: [0, 25, 50, 75, 100]),
headwindHeartRateMode('Headwind HR Mode'),
headwindSpeed('Headwind Speed', possibleValues: [0, 25, 50, 75, 100], icon: Icons.air),
headwindHeartRateMode('Headwind HR Mode', icon: Icons.favorite),
// openbikecontrol
up('Up', icon: RadixIcons.arrowUp),
@@ -95,7 +99,7 @@ class ControllerButton {
}
String get displayName {
if (sourceDeviceId == null || true) {
if (sourceDeviceId == null) {
return name;
}

View File

@@ -8,6 +8,7 @@ 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/foundation.dart';
import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@@ -168,7 +169,12 @@ class KeyPair {
_ => Icons.keyboard,
},
//_ when inGameAction != null && core.logic.emulatorEnabled => Icons.link,
_ when inGameAction != null && inGameAction!.icon != null && core.logic.emulatorEnabled => inGameAction!.icon,
_
when inGameAction != null &&
inGameAction!.icon != null &&
(core.logic.emulatorEnabled ||
[InGameAction.headwindHeartRateMode, InGameAction.headwindSpeed].contains(inGameAction!)) =>
inGameAction!.icon,
_
when androidAction != null &&
@@ -197,10 +203,9 @@ class KeyPair {
bool get hasActiveAction =>
screenshotMode ||
(physicalKey != null &&
core.logic.showLocalControl &&
core.settings.getLocalEnabled() &&
core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) ||
(physicalKey != null && (core.logic.showLocalControl && core.settings.getLocalEnabled()) ||
(core.logic.showRemote && core.settings.getRemoteKeyboardControlEnabled()) &&
core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) ||
(isSpecialKey &&
core.logic.showLocalControl &&
core.settings.getLocalEnabled() &&
@@ -226,11 +231,17 @@ class KeyPair {
(inGameAction != null &&
core.logic.showZwiftMsdnEmulator &&
core.settings.getZwiftMdnsEmulatorEnabled() &&
core.zwiftMdnsEmulator.supportedActions.contains(inGameAction));
core.zwiftMdnsEmulator.supportedActions.contains(inGameAction)) ||
(inGameAction != null &&
[InGameAction.headwindHeartRateMode, InGameAction.headwindSpeed].contains(inGameAction) &&
(core.connection.accessories.isNotEmpty || kDebugMode));
@override
String toString() {
final text = (inGameAction != null && core.logic.emulatorEnabled)
final text =
(inGameAction != null &&
(core.logic.emulatorEnabled ||
[InGameAction.headwindHeartRateMode, InGameAction.headwindSpeed].contains(inGameAction!)))
? [
inGameAction!.title,
if (inGameActionValue != null) '$inGameActionValue',

View File

@@ -91,9 +91,9 @@ class KeymapManager {
if (jsonData != null && jsonData.isNotEmpty) {
final success = await core.settings.importCustomAppProfile(jsonData);
if (success) {
buildToast(context, title: context.i18n.profileImportedSuccessfully);
buildToast(title: context.i18n.profileImportedSuccessfully);
} else {
buildToast(context, title: context.i18n.failedToImportProfile);
buildToast(title: context.i18n.failedToImportProfile);
}
}
},
@@ -107,7 +107,7 @@ class KeymapManager {
if (jsonData != null) {
Clipboard.setData(ClipboardData(text: jsonData));
buildToast(context, title: context.i18n.profileExportedToClipboard(currentProfile));
buildToast(title: context.i18n.profileExportedToClipboard(currentProfile));
}
},
),

View File

@@ -25,7 +25,7 @@ class MediaKeyHandler {
_smtc?.disableSmtc();
} else {
mediaKeyDetector.setIsPlaying(isPlaying: false);
mediaKeyDetector.removeListenerWithDevice(_onMediaKeyDetectedListenerWithDevice);
mediaKeyDetector.removeListener(_onMediaKeyDetectedListener);
}
} else {
FlutterVolumeController.addListener(
@@ -80,7 +80,7 @@ class MediaKeyHandler {
);
_smtc!.buttonPressStream.listen(_onMediaKeyPressedListener);
} else {
mediaKeyDetector.addListenerWithDevice(_onMediaKeyDetectedListenerWithDevice);
mediaKeyDetector.addListener(_onMediaKeyDetectedListener);
mediaKeyDetector.setIsPlaying(isPlaying: true);
}
}
@@ -88,11 +88,7 @@ class MediaKeyHandler {
}
bool _onMediaKeyDetectedListener(MediaKey mediaKey) {
return _onMediaKeyDetectedListenerWithDevice(mediaKey, 'HID Device');
}
bool _onMediaKeyDetectedListenerWithDevice(MediaKey mediaKey, String deviceId) {
final hidDevice = HidDevice(deviceId);
final hidDevice = HidDevice('HID Device');
var availableDevice = core.connection.controllerDevices.firstOrNullWhere(
(e) => e.toString() == hidDevice.toString(),

View File

@@ -4,7 +4,6 @@ import 'dart:isolate';
import 'dart:ui';
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/requirements/platform.dart';
@@ -133,7 +132,6 @@ class NotificationRequirement extends PlatformRequirement {
?.requestNotificationsPermission();
if (result == false) {
buildToast(
navigatorKey.currentContext!,
title: 'Enable notifications for BikeControl in Android Settings',
);
}
@@ -148,7 +146,6 @@ class NotificationRequirement extends PlatformRequirement {
core.settings.setHasAskedPermissions(true);
if (result == false) {
buildToast(
navigatorKey.currentContext!,
title: 'Enable notifications for BikeControl in System Preferences → Notifications → Bike Control',
);
launchUrlString('x-apple.systempreferences:com.apple.preference.notifications');
@@ -164,7 +161,6 @@ class NotificationRequirement extends PlatformRequirement {
core.settings.setHasAskedPermissions(true);
if (result == false) {
buildToast(
navigatorKey.currentContext!,
title: 'Enable notifications for BikeControl in System Preferences → Notifications → Bike Control',
);
launchUrlString('x-apple.systempreferences:com.apple.preference.notifications');

View File

@@ -22,7 +22,6 @@ class KeyboardRequirement extends PlatformRequirement {
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
buildToast(
context,
title: AppLocalizations.current.enableKeyboardAccessMessage,
);
await keyPressSimulator.requestAccess(onlyOpenPrefPane: Platform.isMacOS);
@@ -62,7 +61,7 @@ class BluetoothTurnedOn extends PlatformRequirement {
await PeripheralManager().showAppSettings();
} else if (currentState == AvailabilityState.poweredOff) {
if (Platform.isMacOS) {
buildToast(context, title: name);
buildToast(title: name);
} else {
await UniversalBle.enableBluetooth();
}

View File

@@ -0,0 +1,70 @@
import 'dart:io';
import 'package:ffi/ffi.dart';
import 'package:flutter/foundation.dart';
import 'package:win32/win32.dart';
const _hive = HKEY_CURRENT_USER;
class WindowsProtocolHandler {
List<String> getArguments(List<String>? arguments) {
if (arguments == null) return ['%s'];
if (arguments.isEmpty && !arguments.any((e) => e.contains('%s'))) {
throw ArgumentError('arguments must contain at least 1 instance of "%s"');
}
return arguments;
}
void register(String scheme, {String? executable, List<String>? arguments}) {
if (defaultTargetPlatform != TargetPlatform.windows) return;
final prefix = _regPrefix(scheme);
final capitalized = scheme[0].toUpperCase() + scheme.substring(1);
final args = getArguments(arguments).map((a) => _sanitize(a));
final cmd = '${executable ?? Platform.resolvedExecutable} ${args.join(' ')}';
_regCreateStringKey(_hive, prefix, '', 'URL:$capitalized');
_regCreateStringKey(_hive, prefix, 'URL Protocol', '');
_regCreateStringKey(_hive, '$prefix\\shell\\open\\command', '', cmd);
}
void unregister(String scheme) {
if (defaultTargetPlatform != TargetPlatform.windows) return;
final txtKey = TEXT(_regPrefix(scheme));
try {
RegDeleteTree(HKEY_CURRENT_USER, txtKey);
} finally {
free(txtKey);
}
}
String _regPrefix(String scheme) => 'SOFTWARE\\Classes\\$scheme';
int _regCreateStringKey(int hKey, String key, String valueName, String data) {
final txtKey = TEXT(key);
final txtValue = TEXT(valueName);
final txtData = TEXT(data);
try {
return RegSetKeyValue(
hKey,
txtKey,
txtValue,
REG_SZ,
txtData,
txtData.length * 2 + 2,
);
} finally {
free(txtKey);
free(txtValue);
free(txtData);
}
}
String _sanitize(String value) {
value = value.replaceAll(r'%s', '%1').replaceAll(r'"', '\\"');
return '"$value"';
}
}

View File

@@ -14,6 +14,7 @@ import 'package:path_provider_windows/path_provider_windows.dart';
import 'package:prop/emulators/prefs.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_windows/shared_preferences_windows.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../../main.dart';
@@ -49,6 +50,15 @@ class Settings {
final app = getKeyMap();
core.actionHandler.init(app);
try {
await Supabase.initialize(
url: 'https://pikrcyynovdvogrldfnw.supabase.co',
anonKey: const String.fromEnvironment('SUPABASE_ANON_KEY'),
);
} catch (e, s) {
recordError(e, s, context: 'Supabase initialization');
}
// Initialize IAP manager
await IAPManager.instance.initialize();
@@ -319,6 +329,14 @@ class Settings {
return prefs.getBool('remote_control_enabled') ?? false;
}
void setRemoteKeyboardControlEnabled(bool value) {
prefs.setBool('remote_keyboard_control_enabled', value);
}
bool getRemoteKeyboardControlEnabled() {
return prefs.getBool('remote_keyboard_control_enabled') ?? false;
}
bool getLocalEnabled() {
return prefs.getBool('local_control_enabled') ?? false;
}
@@ -406,4 +424,12 @@ class Settings {
Future<void> setHasAskedPermissions(bool asked) async {
await prefs.setBool('asked_permissions', asked);
}
bool getMediaKeyDetectionEnabled() {
return prefs.getBool('media_key_detection_enabled') ?? false;
}
Future<void> setMediaKeyDetectionEnabled(bool enabled) async {
await prefs.setBool('media_key_detection_enabled', enabled);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:prop/prop.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/pages/markdown.dart';
@@ -6,6 +5,7 @@ import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/widgets/ui/connection_method.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:prop/prop.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class MyWhooshLinkTile extends StatefulWidget {
@@ -43,7 +43,6 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
core.whooshLink.stopServer();
} else if (value) {
buildToast(
navigatorKey.currentContext!,
title: AppLocalizations.of(context).myWhooshLinkInfo,
level: LogLevel.LOGLEVEL_INFO,
duration: Duration(seconds: 6),
@@ -60,7 +59,6 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
recordError(e, s, context: 'MyWhoosh Link Server');
core.settings.setMyWhooshLinkEnabled(false);
buildToast(
context,
title: context.i18n.errorStartingMyWhooshLink,
);
});

View File

@@ -1,10 +1,10 @@
import 'package:prop/prop.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/widgets/ui/connection_method.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
import 'package:prop/prop.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class OpenBikeControlBluetoothTile extends StatefulWidget {
@@ -45,7 +45,6 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlBluetoothTile> {
recordError(e, s, context: 'OBP BLE Emulator');
core.settings.setObpBleEnabled(false);
buildToast(
context,
level: LogLevel.LOGLEVEL_WARNING,
title: context.i18n.errorStartingOpenBikeControlBluetoothServer,
);

View File

@@ -1,13 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.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:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/keymap/apps/custom_app.dart';
@@ -152,8 +154,48 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: [
Text(AppLocalizations.current.pressKeyToAssign(_pressedButton.toString())),
Text(
AppLocalizations.current.pressKeyToAssign(_pressedButton?.displayName ?? _pressedButton.toString()),
),
Text(_formatKey(_pressedKey)),
if (kDebugMode && (Platform.isAndroid || Platform.isIOS))
SizedBox(
height: 300,
width: 300,
child: ListView(
shrinkWrap: true,
children: LogicalKeyboardKey.knownLogicalKeys
.map(
(key) => ListTile(
contentPadding: EdgeInsets.zero,
minVerticalPadding: 0,
title: Row(
children: [
Chip(label: Text(key.keyLabel)),
],
),
onTap: () {
setState(() {
_pressedKey = KeyDownEvent(
physicalKey: PhysicalKeyboardKey(0x80),
logicalKey: key,
character: null,
timeStamp: Duration.zero,
);
widget.customApp.setKey(
_pressedButton!,
physicalKey: _pressedKey!.physicalKey,
logicalKey: key,
modifiers: _activeModifiers.toList(),
touchPosition: widget.keyPair?.touchPosition,
);
});
},
),
)
.toList(),
),
),
],
),
),

View File

@@ -3,7 +3,6 @@ import 'dart:io';
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/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
@@ -280,7 +279,6 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
if (redeemed) {
await IAPManager.instance.redeem(purchaseId);
buildToast(
context,
title: 'Success',
subtitle: 'Purchase redeemed successfully!',
);
@@ -469,7 +467,6 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
} catch (e) {
if (mounted) {
buildToast(
navigatorKey.currentContext!,
title: 'Error',
subtitle: 'An error occurred: $e',
);

View File

@@ -0,0 +1,53 @@
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/widgets/ui/connection_method.dart';
import 'package:prop/prop.dart' show LogLevel;
import 'package:shadcn_flutter/shadcn_flutter.dart';
class RemoteKeyboardPairingWidget extends StatefulWidget {
const RemoteKeyboardPairingWidget({super.key});
@override
State<RemoteKeyboardPairingWidget> createState() => _PairWidgetState();
}
class _PairWidgetState extends State<RemoteKeyboardPairingWidget> {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: core.remoteKeyboardPairing.isStarted,
builder: (context, isStarted, child) {
return ValueListenableBuilder(
valueListenable: core.remoteKeyboardPairing.isConnected,
builder: (context, isConnected, child) {
return ConnectionMethod(
supportedActions: null,
isEnabled: core.logic.isRemoteKeyboardControlEnabled,
isStarted: isStarted,
showTroubleshooting: true,
type: ConnectionMethodType.bluetooth,
instructionLink: 'https://youtube.com/shorts/qalBSiAz7wg',
title: AppLocalizations.of(context).actAsBluetoothKeyboard,
description: AppLocalizations.of(context).bluetoothKeyboardExplanation,
isConnected: isConnected,
requirements: core.permissions.getRemoteControlRequirements(),
onChange: (value) async {
core.settings.setRemoteKeyboardControlEnabled(value);
if (!value) {
core.remoteKeyboardPairing.stopAdvertising();
} else {
core.remoteKeyboardPairing.startAdvertising().catchError((e) {
core.settings.setRemoteControlEnabled(false);
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString()));
});
}
setState(() {});
},
);
},
);
},
);
}
}

View File

@@ -75,7 +75,7 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
@override
Widget build(BuildContext context) {
final keyButtonMap = core.connection.devices.associateWith((d) {
final keyButtonMap = core.connection.controllerDevices.associateWith((d) {
final allButtons = d.availableButtons;
return widget.keymap.keyPairs
.whereNot(
@@ -175,7 +175,7 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
skipName: '$currentProfile (Copy)',
);
if (newName != null && context.mounted) {
buildToast(context, title: context.i18n.createdNewCustomProfile(newName));
buildToast(title: context.i18n.createdNewCustomProfile(newName));
selectedKeyPair = core.actionHandler.supportedApp!.keymap.keyPairs.firstWhere(
(e) => e == keyPair,
);

View File

@@ -69,7 +69,7 @@ class _LogviewerState extends State<LogViewer> {
.join('\n');
Clipboard.setData(ClipboardData(text: logText));
buildToast(context, title: context.i18n.logsHaveBeenCopiedToClipboard);
buildToast(title: context.i18n.logsHaveBeenCopiedToClipboard);
},
),
],

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
import 'package:bike_control/pages/login.dart';
import 'package:bike_control/pages/markdown.dart';
import 'package:bike_control/pages/navigation.dart';
import 'package:bike_control/utils/core.dart';
@@ -149,6 +150,16 @@ class BKMenuButton extends StatelessWidget {
core.connection.disconnectAll();
},
),
MenuButton(
child: Text('Login'),
onPressed: (c) async {
openDrawer(
context: context,
builder: (c) => LoginPage(),
position: OverlayPosition.bottom,
);
},
),
MenuDivider(),
],
if (currentPage == BCPage.logs) ...[

View File

@@ -9,14 +9,14 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../utils/requirements/multi.dart';
class RemotePairingWidget extends StatefulWidget {
const RemotePairingWidget({super.key});
class RemoteMousePairingWidget extends StatefulWidget {
const RemoteMousePairingWidget({super.key});
@override
State<RemotePairingWidget> createState() => _PairWidgetState();
State<RemoteMousePairingWidget> createState() => _PairWidgetState();
}
class _PairWidgetState extends State<RemotePairingWidget> {
class _PairWidgetState extends State<RemoteMousePairingWidget> {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';
import 'package:prop/prop.dart';
import 'package:bike_control/utils/actions/base_actions.dart' as actions;
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
@@ -12,6 +11,7 @@ import 'package:bike_control/widgets/ui/button_widget.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:prop/prop.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../bluetooth/messages/notification.dart';
@@ -104,13 +104,12 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
_keys.removeLast();
}
} else if (core.actionHandler.supportedApp == null) {
buildToast(context, level: LogLevel.LOGLEVEL_WARNING, title: context.i18n.selectTrainerAppAndTarget);
buildToast(level: LogLevel.LOGLEVEL_WARNING, title: context.i18n.selectTrainerAppAndTarget);
} else {
final button = data.buttonsClicked.first;
if (core.actionHandler.supportedApp is! CustomApp &&
core.actionHandler.supportedApp?.keymap.getKeyPair(button) == null) {
buildToast(
context,
level: LogLevel.LOGLEVEL_WARNING,
titleWidget: Text.rich(
TextSpan(
@@ -146,7 +145,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
}
} else if (data is ActionNotification && data.result is! actions.Ignored) {
buildToast(
context,
location: ToastLocation.bottomLeft,
level: data.result is actions.Error ? LogLevel.LOGLEVEL_WARNING : LogLevel.LOGLEVEL_INFO,
title: data.result.message,
@@ -154,7 +152,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
);
} else if (data is AlertNotification) {
buildToast(
context,
location: ToastLocation.bottomRight,
level: data.level,
title: data.alertMessage,

View File

@@ -91,23 +91,29 @@ class _AppTitleState extends State<AppTitle> with WidgetsBindingObserver {
return;
} else if (updater.isAvailable) {
final updateStatus = await updater.checkForUpdate();
core.connection.signalNotification(LogNotification('Shorebird update status: $updateStatus'));
if (updateStatus == UpdateStatus.outdated) {
updater
.update()
.then((value) {
.then((value) async {
setState(() {
_updateType = UpdateType.shorebird;
});
final nextPatch = await updater.readNextPatch();
final currentVersion = Version.parse(packageInfoValue!.version);
setState(() {
_newVersion = Version(
currentVersion.major,
currentVersion.minor,
currentVersion.patch,
build: nextPatch?.number.toString() ?? '',
);
});
})
.catchError((e) {
buildToast(context, title: AppLocalizations.current.failedToUpdate(e.toString()));
buildToast(title: AppLocalizations.current.failedToUpdate(e.toString()));
});
} else if (updateStatus == UpdateStatus.restartRequired) {
if (Platform.isIOS) {
// TODO other platforms can't be trusted https://github.com/shorebirdtech/shorebird/issues/3498
_updateType = UpdateType.shorebird;
}
_updateType = UpdateType.shorebird;
}
if (_updateType == UpdateType.shorebird) {
final nextPatch = await updater.readNextPatch();
@@ -212,7 +218,7 @@ class _AppTitleState extends State<AppTitle> with WidgetsBindingObserver {
await _shorebirdRestart();
} else if (_updateType == UpdateType.playStore) {
await launchUrlString(
'https://play.google.com/store/apps/details?id=org.jonasbark.swiftcontrol',
'https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol',
mode: LaunchMode.externalApplication,
);
} else if (_updateType == UpdateType.appStore) {

View File

@@ -12,6 +12,7 @@ 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';
import 'package:url_launcher/url_launcher_string.dart';
enum ConnectionMethodType {
bluetooth,
@@ -91,7 +92,7 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
return SelectableCard(
onPressed: () {
if (kIsWeb) {
buildToast(context, title: 'Not Supported on Web :)');
buildToast(title: 'Not Supported on Web :)');
} else if (widget.requirements.isEmpty) {
widget.onChange(!widget.isEnabled);
} else {
@@ -163,13 +164,19 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
style: widget.isEnabled && Theme.of(context).brightness == Brightness.light
? ButtonStyle.outline().withBorder(border: Border.all(color: Colors.gray.shade500))
: ButtonStyle.outline(),
leading: Icon(Icons.help_outline),
leading: Icon(
widget.instructionLink!.contains("youtube") ? Icons.ondemand_video : Icons.help_outline,
),
onPressed: () {
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (c) => MarkdownPage(assetPath: widget.instructionLink!),
);
if (widget.instructionLink!.contains("youtube")) {
launchUrlString(widget.instructionLink!);
} else {
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (c) => MarkdownPage(assetPath: widget.instructionLink!),
);
}
},
child: Text(AppLocalizations.of(context).instructions),
),

View File

@@ -1,9 +1,9 @@
import 'package:prop/prop.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/widgets/ui/button_widget.dart';
import 'package:prop/prop.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
void buildToast(
BuildContext context, {
void buildToast({
LogLevel level = LogLevel.LOGLEVEL_INFO,
String? title,
Widget? titleWidget,
@@ -13,45 +13,47 @@ void buildToast(
String? subtitle,
Duration? duration,
}) {
showToast(
context: context,
location: location,
showDuration: switch (level) {
LogLevel.LOGLEVEL_DEBUG => const Duration(seconds: 2),
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(
filled: switch (level) {
LogLevel.LOGLEVEL_WARNING => true,
LogLevel.LOGLEVEL_ERROR => true,
_ => false,
if (navigatorKey.currentContext?.mounted ?? false) {
showToast(
context: navigatorKey.currentContext!,
location: location,
showDuration: switch (level) {
LogLevel.LOGLEVEL_DEBUG => const Duration(seconds: 2),
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),
},
fillColor: switch (level) {
LogLevel.LOGLEVEL_DEBUG => null,
LogLevel.LOGLEVEL_INFO => null,
LogLevel.LOGLEVEL_WARNING => Theme.of(context).colorScheme.chart1,
LogLevel.LOGLEVEL_ERROR => Theme.of(context).colorScheme.destructive,
_ => null,
},
child: Basic(
title: titleWidget ?? Text(title ?? ''),
subtitle: subtitle != null ? Text(subtitle) : null,
trailing: titleWidget is ButtonWidget
? null
: PrimaryButton(
size: ButtonSize.small,
onPressed: () {
// Close the toast programmatically when clicking Undo.
overlay.close();
onClose?.call();
},
child: Text(closeTitle),
),
trailingAlignment: Alignment.center,
builder: (context, overlay) => SurfaceCard(
filled: switch (level) {
LogLevel.LOGLEVEL_WARNING => true,
LogLevel.LOGLEVEL_ERROR => true,
_ => false,
},
fillColor: switch (level) {
LogLevel.LOGLEVEL_DEBUG => null,
LogLevel.LOGLEVEL_INFO => null,
LogLevel.LOGLEVEL_WARNING => Theme.of(context).colorScheme.chart1,
LogLevel.LOGLEVEL_ERROR => Theme.of(context).colorScheme.destructive,
_ => null,
},
child: Basic(
title: titleWidget ?? Text(title ?? ''),
subtitle: subtitle != null ? Text(subtitle) : null,
trailing: titleWidget is ButtonWidget
? null
: PrimaryButton(
size: ButtonSize.small,
onPressed: () {
// Close the toast programmatically when clicking Undo.
overlay.close();
onClose?.call();
},
child: Text(closeTitle),
),
trailingAlignment: Alignment.center,
),
),
),
);
);
}
}

View File

@@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import app_links
import audio_session
import bluetooth_low_energy_darwin
import device_info_plus
import file_selector_macos
@@ -12,9 +14,11 @@ import flutter_local_notifications
import flutter_secure_storage_darwin
import flutter_volume_controller
import gamepads_darwin
import google_sign_in_ios
import in_app_purchase_storekit
import in_app_review
import ios_receipt
import just_audio
import keypress_simulator_macos
import media_key_detector_macos
import nsd_macos
@@ -22,12 +26,15 @@ import package_info_plus
import purchases_flutter
import screen_retriever_macos
import shared_preferences_foundation
import sign_in_with_apple
import universal_ble
import url_launcher_macos
import wakelock_plus
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
BluetoothLowEnergyDarwinPlugin.register(with: registry.registrar(forPlugin: "BluetoothLowEnergyDarwinPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
@@ -35,9 +42,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FlutterVolumeControllerPlugin.register(with: registry.registrar(forPlugin: "FlutterVolumeControllerPlugin"))
GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
IosReceiptPlugin.register(with: registry.registrar(forPlugin: "IosReceiptPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin"))
MediaKeyDetectorPlugin.register(with: registry.registrar(forPlugin: "MediaKeyDetectorPlugin"))
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
@@ -45,6 +54,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))

View File

@@ -1,4 +1,18 @@
PODS:
- app_links (6.4.1):
- FlutterMacOS
- AppAuth (2.0.0):
- AppAuth/Core (= 2.0.0)
- AppAuth/ExternalUserAgent (= 2.0.0)
- AppAuth/Core (2.0.0)
- AppAuth/ExternalUserAgent (2.0.0):
- AppAuth/Core
- AppCheckCore (11.2.0):
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- audio_session (0.0.1):
- FlutterMacOS
- bluetooth_low_energy_darwin (0.0.1):
- Flutter
- FlutterMacOS
@@ -16,6 +30,33 @@ PODS:
- FlutterMacOS (1.0.0)
- gamepads_darwin (0.1.1):
- FlutterMacOS
- google_sign_in_ios (0.0.1):
- Flutter
- FlutterMacOS
- GoogleSignIn (~> 9.0)
- GTMSessionFetcher (>= 3.4.0)
- GoogleSignIn (9.1.0):
- AppAuth (~> 2.0)
- AppCheckCore (~> 11.0)
- GTMAppAuth (~> 5.0)
- GTMSessionFetcher/Core (~> 3.3)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (5.0.0):
- AppAuth/Core (~> 2.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher (3.5.0):
- GTMSessionFetcher/Full (= 3.5.0)
- GTMSessionFetcher/Core (3.5.0)
- GTMSessionFetcher/Full (3.5.0):
- GTMSessionFetcher/Core
- in_app_purchase_storekit (0.0.1):
- Flutter
- FlutterMacOS
@@ -23,6 +64,9 @@ PODS:
- FlutterMacOS
- ios_receipt (0.0.1):
- FlutterMacOS
- just_audio (0.0.1):
- Flutter
- FlutterMacOS
- keypress_simulator_macos (0.0.1):
- FlutterMacOS
- media_key_detector_macos (0.0.1):
@@ -31,6 +75,7 @@ PODS:
- FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- PromisesObjC (2.4.0)
- purchases_flutter (9.10.6):
- FlutterMacOS
- PurchasesHybridCommon (= 17.27.1)
@@ -42,6 +87,8 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sign_in_with_apple (0.0.1):
- FlutterMacOS
- universal_ble (0.0.1):
- Flutter
- FlutterMacOS
@@ -53,6 +100,8 @@ PODS:
- FlutterMacOS
DEPENDENCIES:
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
- bluetooth_low_energy_darwin (from `Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
@@ -61,9 +110,11 @@ DEPENDENCIES:
- flutter_volume_controller (from `Flutter/ephemeral/.symlinks/plugins/flutter_volume_controller/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`)
- google_sign_in_ios (from `Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin`)
- 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`)
- just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`)
- 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`)
@@ -71,6 +122,7 @@ DEPENDENCIES:
- purchases_flutter (from `Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`)
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
@@ -78,10 +130,21 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- AppAuth
- AppCheckCore
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- PromisesObjC
- PurchasesHybridCommon
- RevenueCat
EXTERNAL SOURCES:
app_links:
:path: Flutter/ephemeral/.symlinks/plugins/app_links/macos
audio_session:
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
bluetooth_low_energy_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin
device_info_plus:
@@ -98,12 +161,16 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
gamepads_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos
google_sign_in_ios:
:path: Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin
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
just_audio:
:path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin
keypress_simulator_macos:
:path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos
media_key_detector_macos:
@@ -118,6 +185,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
sign_in_with_apple:
:path: Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos
universal_ble:
:path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin
url_launcher_macos:
@@ -128,6 +197,10 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
audio_session: 728ae3823d914f809c485d390274861a24b0904e
bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150
@@ -136,18 +209,26 @@ SPEC CHECKSUMS:
flutter_volume_controller: 25d09126b0d695560f11c80b1311d5063fed882f
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
gamepads_darwin: 07af6c60c282902b66574c800e20b2b26e68fda8
google_sign_in_ios: 7336a3372ea93ea56a21e126a0055ffca3723601
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
in_app_review: 866c9b17c87a7b46a395bda43f5d3ca02deb585a
ios_receipt: 8741a75f39e6ca0866313b73c69a5b674cf5c98c
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
media_key_detector_macos: a93757a483b4b47283ade432b1af9e427c47329f
nsd_macos: 1a38a38a33adbb396b4c6f303bc076073514cadc
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
purchases_flutter: 55ed35144683d8673f0a4b5077e8f4d8291f291e
PurchasesHybridCommon: 027f03312519c51056457eb2e4f7ee1c91b61b8f
RevenueCat: ecbba580fa453b0d4a0475449b904196d74ef678
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sign_in_with_apple: a9e97e744e8edc36aefc2723111f652102a7a727
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce
wakelock_plus: 9d63063ffb7af1c215209769067c57103bde719d

View File

@@ -240,6 +240,7 @@
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
416B733CA2212067A6157BC2 /* [CP] Embed Pods Frameworks */,
64E1C1C2337BD399A29F09AF /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -398,6 +399,23 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
64E1C1C2337BD399A29F09AF /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
DDE99C1FE0195AFA9D281938 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;

View File

@@ -1,8 +1,23 @@
import Cocoa
import FlutterMacOS
import app_links
@main
class AppDelegate: FlutterAppDelegate {
public override func application(_ application: NSApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([any NSUserActivityRestoring]) -> Void) -> Bool {
guard let url = AppLinks.shared.getUniversalLink(userActivity) else {
return false
}
AppLinks.shared.handleLink(link: url.absoluteString)
return false // Returning true will stop the propagation to other packages
}
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}

View File

@@ -2,19 +2,25 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.google.GIDSignIn</string>
</array>
</dict>
</plist>

View File

@@ -48,5 +48,19 @@
<string>NSApplication</string>
<key>NSAccessibilityUsageDescription</key>
<string>BikeControl needs to send keys to your trainer app.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2</string>
<string>bikecontrol</string>
</array>
</dict>
</array>
<key>GIDClientID</key>
<string>709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2.apps.googleusercontent.com</string>
</dict>
</plist>

View File

@@ -2,19 +2,25 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>keychain-access-groups</key>
<array/>
<key>com.apple.security.network.server</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.google.GIDSignIn</string>
</array>
</dict>
</plist>

View File

@@ -21,24 +21,12 @@ class MediaKeyDetector {
_platform.addListener(listener);
}
/// Listen for the media key event with device information
void addListenerWithDevice(void Function(MediaKey mediaKey, String deviceId) listener) {
_lazilyInitialize();
_platform.addListenerWithDevice(listener);
}
/// Remove the previously registered listener
void removeListener(void Function(MediaKey mediaKey) listener) {
_lazilyInitialize();
_platform.removeListener(listener);
}
/// Remove the previously registered listener with device information
void removeListenerWithDevice(void Function(MediaKey mediaKey, String deviceId) listener) {
_lazilyInitialize();
_platform.removeListenerWithDevice(listener);
}
void _lazilyInitialize() {
if (!_initialized) {
_platform.initialize();

View File

@@ -50,7 +50,6 @@ abstract class MediaKeyDetectorPlatform extends PlatformInterface {
void initialize();
final List<void Function(MediaKey mediaKey)> _listeners = [];
final List<void Function(MediaKey mediaKey, String deviceId)> _listenersWithDevice = [];
/// Listen for the media key event
void addListener(void Function(MediaKey mediaKey) listener) {
@@ -59,33 +58,16 @@ abstract class MediaKeyDetectorPlatform extends PlatformInterface {
}
}
/// Listen for the media key event with device information
void addListenerWithDevice(void Function(MediaKey mediaKey, String deviceId) listener) {
if (!_listenersWithDevice.contains(listener)) {
_listenersWithDevice.add(listener);
}
}
/// Remove the previously registered listener
void removeListener(void Function(MediaKey mediaKey) listener) {
_listeners.remove(listener);
}
/// Remove the previously registered listener with device information
void removeListenerWithDevice(void Function(MediaKey mediaKey, String deviceId) listener) {
_listenersWithDevice.remove(listener);
}
/// Trigger all listeners to indicate that the specified media key was pressed
void triggerListeners(MediaKey mediaKey, [String? deviceId]) {
void triggerListeners(MediaKey mediaKey) {
for (final l in _listeners) {
l(mediaKey);
}
if (deviceId != null) {
for (final l in _listenersWithDevice) {
l(mediaKey, deviceId);
}
}
}
final Map<LogicalKeyboardKey, MediaKey> _keyMap = {

View File

@@ -1,4 +1,3 @@
export './media_key.dart' show MediaKey;
export './media_key_event.dart' show MediaKeyEvent;
export './method_channel_media_key_detector.dart'
show MethodChannelMediaKeyDetector;

View File

@@ -1,28 +0,0 @@
/// Represents a media key event with device information
class MediaKeyEvent {
/// Creates a media key event
const MediaKeyEvent({
required this.key,
required this.deviceId,
});
/// The media key that was pressed
final String key;
/// The unique identifier of the device that sent the event
final String deviceId;
@override
String toString() => 'MediaKeyEvent(key: $key, deviceId: $deviceId)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MediaKeyEvent &&
runtimeType == other.runtimeType &&
key == other.key &&
deviceId == other.deviceId;
@override
int get hashCode => key.hashCode ^ deviceId.hashCode;
}

View File

@@ -1,12 +1,3 @@
# 0.0.3
- **NEW**: Add device source detection using Windows Raw Input API
- Media key events now include unique device identifier
- Enables distinguishing between multiple bluetooth media controllers
- Adds `addListenerWithDevice` API for device-aware event handling
- Maintains backward compatibility with existing `addListener` API
- Falls back to RegisterHotKey API for compatibility
# 0.0.2
- Implement global media key detection using Windows RegisterHotKey API

View File

@@ -6,7 +6,7 @@ The windows implementation of `media_key_detector`.
## Features
This plugin provides global media key detection on Windows with device source identification. This allows your application to respond to media keys (play/pause, next track, previous track, volume up, volume down) from multiple devices and distinguish which device sent each event.
This plugin provides global media key detection on Windows using the Windows `RegisterHotKey` API. This allows your application to respond to media keys (play/pause, next track, previous track, volume up, volume down) even when it's not the focused application.
### Supported Media Keys
@@ -19,33 +19,17 @@ This plugin provides global media key detection on Windows with device source id
### Implementation Details
The plugin uses:
- **Raw Input API** for device-specific media key detection (primary method)
- `RegisterHotKey` Windows API for global hotkey registration (fallback)
- Event channels for communicating media key events with device information to Dart
- Window message handlers to process WM_INPUT and WM_HOTKEY messages
- `RegisterHotKey` Windows API for global hotkey registration
- Event channels for communicating media key events to Dart
- Window message handlers to process WM_HOTKEY messages
The Raw Input API allows the plugin to identify which physical device (e.g., keyboard, bluetooth remote) sent the media key event. This enables users with multiple media controllers to configure different actions for each device.
Hotkeys and raw input are registered when `setIsPlaying(true)` is called and automatically unregistered when `setIsPlaying(false)` is called or when the plugin is destroyed.
### Device Source Detection
When a media key is pressed, the plugin provides:
- The media key that was pressed (e.g., playPause, fastForward)
- The unique device identifier of the source device
This enables scenarios where:
- A user has two bluetooth media remotes
- Both remotes have a "play" button
- Each remote can be configured to trigger different actions
Hotkeys are registered when `setIsPlaying(true)` is called and automatically unregistered when `setIsPlaying(false)` is called or when the plugin is destroyed.
## Usage
This package is [endorsed][endorsed_link], which means you can simply use `media_key_detector`
normally. This package will be automatically included in your app when you do.
### Basic Usage (without device information)
```dart
import 'package:media_key_detector/media_key_detector.dart';
@@ -74,35 +58,6 @@ mediaKeyDetector.addListener((MediaKey key) {
});
```
### Advanced Usage (with device identification)
```dart
import 'package:media_key_detector/media_key_detector.dart';
// Enable media key detection
mediaKeyDetector.setIsPlaying(isPlaying: true);
// Listen for media key events with device information
mediaKeyDetector.addListenerWithDevice((MediaKey key, String deviceId) {
// deviceId contains the unique identifier of the device that sent the event
// For example: "\\?\HID#VID_046D&PID_C52B&MI_00#..."
print('Media key $key pressed by device: $deviceId');
// Configure different actions based on device
if (deviceId.contains('VID_046D')) {
// Handle keys from Logitech device
handleLogitechRemote(key);
} else if (deviceId.contains('VID_05AC')) {
// Handle keys from Apple device
handleAppleKeyboard(key);
} else {
// Handle keys from other devices
handleGenericDevice(key);
}
});
```
[endorsed_link]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis

View File

@@ -19,26 +19,13 @@ class MediaKeyDetectorWindows extends MediaKeyDetectorPlatform {
@override
void initialize() {
_eventChannel.receiveBroadcastStream().listen((event) {
final keyIdx = event as int;
MediaKey? key;
String? deviceId;
// Check if event is a map (new format with device info)
if (event is Map) {
final keyIdx = event['key'] as int?;
deviceId = event['device'] as String?;
if (keyIdx != null && keyIdx > -1 && keyIdx < MediaKey.values.length) {
key = MediaKey.values[keyIdx];
}
} else if (event is int) {
// Backward compatibility: old format with just key index
if (event > -1 && event < MediaKey.values.length) {
key = MediaKey.values[event];
}
if (keyIdx > -1 && keyIdx < MediaKey.values.length) {
key = MediaKey.values[keyIdx];
}
if (key != null) {
triggerListeners(key, deviceId);
triggerListeners(key);
}
});
}

View File

@@ -12,10 +12,6 @@
#include <map>
#include <memory>
#include <atomic>
#include <string>
#include <vector>
#include <sstream>
#include <iomanip>
namespace {
@@ -48,15 +44,6 @@ class MediaKeyDetectorWindows : public flutter::Plugin {
// Unregister global hotkeys
void UnregisterHotkeys();
// Register for raw input from keyboard devices
void RegisterRawInput(HWND hwnd);
// Unregister raw input
void UnregisterRawInput(HWND hwnd);
// Get device identifier from device handle
std::string GetDeviceIdentifier(HANDLE hDevice);
// Handle Windows messages
std::optional<LRESULT> HandleWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam);
@@ -65,10 +52,6 @@ class MediaKeyDetectorWindows : public flutter::Plugin {
std::atomic<bool> is_playing_{false};
int window_proc_id_ = -1;
bool hotkeys_registered_ = false;
bool raw_input_registered_ = false;
// Cache for device identifiers
std::map<HANDLE, std::string> device_cache_;
};
// static
@@ -121,8 +104,6 @@ MediaKeyDetectorWindows::MediaKeyDetectorWindows(flutter::PluginRegistrarWindows
}
MediaKeyDetectorWindows::~MediaKeyDetectorWindows() {
HWND hwnd = registrar_->GetView()->GetNativeWindow();
UnregisterRawInput(hwnd);
UnregisterHotkeys();
if (window_proc_id_ != -1) {
registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_id_);
@@ -143,13 +124,10 @@ void MediaKeyDetectorWindows::HandleMethodCall(
if (is_playing_it != arguments->end()) {
if (auto* is_playing = std::get_if<bool>(&is_playing_it->second)) {
is_playing_.store(*is_playing);
HWND hwnd = registrar_->GetView()->GetNativeWindow();
if (*is_playing) {
RegisterHotkeys();
RegisterRawInput(hwnd);
} else {
UnregisterHotkeys();
UnregisterRawInput(hwnd);
}
result->Success();
return;
@@ -207,131 +185,8 @@ void MediaKeyDetectorWindows::UnregisterHotkeys() {
hotkeys_registered_ = false;
}
void MediaKeyDetectorWindows::RegisterRawInput(HWND hwnd) {
if (raw_input_registered_) {
return;
}
// Register for raw input from keyboard devices
RAWINPUTDEVICE rid[1];
// Keyboard devices
rid[0].usUsagePage = 0x01; // Generic Desktop Controls
rid[0].usUsage = 0x06; // Keyboard
rid[0].dwFlags = RIDEV_INPUTSINK; // Receive input even when not in foreground
rid[0].hwndTarget = hwnd;
if (RegisterRawInputDevices(rid, 1, sizeof(rid[0]))) {
raw_input_registered_ = true;
}
}
void MediaKeyDetectorWindows::UnregisterRawInput(HWND hwnd) {
if (!raw_input_registered_) {
return;
}
// Unregister raw input
RAWINPUTDEVICE rid[1];
rid[0].usUsagePage = 0x01;
rid[0].usUsage = 0x06;
rid[0].dwFlags = RIDEV_REMOVE;
rid[0].hwndTarget = nullptr;
RegisterRawInputDevices(rid, 1, sizeof(rid[0]));
raw_input_registered_ = false;
device_cache_.clear();
}
std::string MediaKeyDetectorWindows::GetDeviceIdentifier(HANDLE hDevice) {
// Check cache first
auto it = device_cache_.find(hDevice);
if (it != device_cache_.end()) {
return it->second;
}
// Get device name
UINT size = 0;
GetRawInputDeviceInfoA(hDevice, RIDI_DEVICENAME, nullptr, &size);
if (size == 0) {
return "Unknown Device";
}
std::vector<char> name(size);
if (GetRawInputDeviceInfoA(hDevice, RIDI_DEVICENAME, name.data(), &size) == static_cast<UINT>(-1)) {
return "Unknown Device";
}
std::string deviceName(name.data());
// Cache the result
device_cache_[hDevice] = deviceName;
return deviceName;
}
std::optional<LRESULT> MediaKeyDetectorWindows::HandleWindowProc(
HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
// Handle raw input messages for device-specific detection
if (message == WM_INPUT && event_sink_) {
UINT dwSize;
GetRawInputData((HRAWINPUT)lparam, RID_INPUT, nullptr, &dwSize, sizeof(RAWINPUTHEADER));
std::vector<BYTE> buffer(dwSize);
if (GetRawInputData((HRAWINPUT)lparam, RID_INPUT, buffer.data(), &dwSize, sizeof(RAWINPUTHEADER)) != dwSize) {
return std::nullopt;
}
RAWINPUT* raw = (RAWINPUT*)buffer.data();
if (raw->header.dwType == RIM_TYPEKEYBOARD) {
RAWKEYBOARD& keyboard = raw->data.keyboard;
// Check for media keys
int key_index = -1;
// Media keys have VKey codes
// Check for key down event (RI_KEY_MAKE is defined as 0, so we check both explicitly)
if (keyboard.Flags == RI_KEY_MAKE) { // Key down event
switch (keyboard.VKey) {
case VK_MEDIA_PLAY_PAUSE:
key_index = 0; // MediaKey.playPause
break;
case VK_MEDIA_PREV_TRACK:
key_index = 1; // MediaKey.rewind
break;
case VK_MEDIA_NEXT_TRACK:
key_index = 2; // MediaKey.fastForward
break;
case VK_VOLUME_UP:
key_index = 3; // MediaKey.volumeUp
break;
case VK_VOLUME_DOWN:
key_index = 4; // MediaKey.volumeDown
break;
}
if (key_index >= 0) {
// Get device identifier
std::string deviceId = GetDeviceIdentifier(raw->header.hDevice);
// Send event with both key index and device identifier
flutter::EncodableMap event_data;
event_data[EncodableValue("key")] = EncodableValue(key_index);
event_data[EncodableValue("device")] = EncodableValue(deviceId);
event_sink_->Success(EncodableValue(event_data));
return 0;
}
}
}
}
// Fallback to hotkey messages (for compatibility)
if (message == WM_HOTKEY && event_sink_) {
int key_index = -1;
@@ -355,12 +210,7 @@ std::optional<LRESULT> MediaKeyDetectorWindows::HandleWindowProc(
}
if (key_index >= 0) {
// Send event with key index only (no device info for hotkey)
flutter::EncodableMap event_data;
event_data[EncodableValue("key")] = EncodableValue(key_index);
event_data[EncodableValue("device")] = EncodableValue("HID Device");
event_sink_->Success(EncodableValue(event_data));
event_sink_->Success(EncodableValue(key_index));
}
return 0;

2
prop

Submodule prop updated: 7517d9727b...9f4cefba4a

View File

@@ -0,0 +1,19 @@
import 'dart:io';
import 'package:universal_ble/universal_ble.dart';
class DirCon {
final Socket socket;
DirCon({required this.socket});
List<String> get serviceUUIDs => [];
List<BleCharacteristic> getCharacteristics(String serviceUUID) => [];
void processWriteCallback(String characteristicUUID, List<int> characteristicData) {}
void handleIncomingData(List<int> data) {}
void sendCharacteristicNotification(String uuid, List<int> responseData) {}
}

View File

@@ -6,4 +6,34 @@ class SharedLogic {
static Uint8List? handleWriteRequest(String characteristic, Uint8List value) {
return null;
}
static Future<void> keepAlive() async {}
static void stopKeepAlive() {}
}
class Logger {
static void info(String text) {
if (kDebugMode) {
print('${DateTime.now()} \x1B[32m$text\x1B[0m');
}
}
static void warn(String text) {
if (kDebugMode) {
print('\x1B[33m$text\x1B[0m');
}
}
static void error(String text) {
if (kDebugMode) {
print('\x1B[31m$text\x1B[0m');
}
}
static void debug(String s) {
if (kDebugMode) {
print('\x1B[34m$s\x1B[0m');
}
}
}

View File

@@ -16,6 +16,14 @@ packages:
relative: true
source: path
version: "0.0.1"
adaptive_number:
dependency: transitive
description:
name: adaptive_number
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
analyzer:
dependency: transitive
description:
@@ -48,6 +56,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.2"
app_links:
dependency: transitive
description:
name: app_links
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
@@ -80,6 +120,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audio_session:
dependency: transitive
description:
name: audio_session
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
bluetooth_low_energy:
dependency: "direct main"
description:
@@ -156,10 +204,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
cli_util:
dependency: transitive
description:
@@ -232,6 +280,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dart_jsonwebtoken:
dependency: transitive
description:
name: dart_jsonwebtoken
sha256: "0de65691c1d736e9459f22f654ddd6fd8368a271d4e41aa07e53e6301eff5075"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
dart_style:
dependency: transitive
description:
@@ -289,6 +345,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.3"
ed25519_edwards:
dependency: transitive
description:
name: ed25519_edwards
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
email_validator:
dependency: transitive
description:
@@ -530,11 +594,27 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
font_awesome_flutter:
dependency: transitive
description:
name: font_awesome_flutter
sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0
url: "https://pub.dev"
source: hosted
version: "10.12.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
functions_client:
dependency: transitive
description:
name: functions_client
sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
gamepads:
dependency: "direct main"
description:
@@ -632,6 +712,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.2.0"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
dependency: "direct main"
description:
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: "5ec98ab35387c68c0050495bb211bd88375873723a80fae7c2e9266ea0bdd8bb"
url: "https://pub.dev"
source: hosted
version: "7.2.7"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
gotrue:
dependency: transitive
description:
name: gotrue
sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2
url: "https://pub.dev"
source: hosted
version: "2.18.0"
gsettings:
dependency: transitive
description:
@@ -868,6 +1004,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.9.0"
just_audio:
dependency: transitive
description:
name: just_audio
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
url: "https://pub.dev"
source: hosted
version: "0.10.5"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev"
source: hosted
version: "0.4.16"
jwt_decode:
dependency: transitive
description:
name: jwt_decode
sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb
url: "https://pub.dev"
source: hosted
version: "0.3.1"
keypress_simulator:
dependency: "direct main"
description:
@@ -940,18 +1108,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
matrix4_transform:
dependency: transitive
description:
@@ -1274,6 +1442,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix:
dependency: transitive
description:
@@ -1282,6 +1458,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.3"
postgrest:
dependency: transitive
description:
name: postgrest
sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460
url: "https://pub.dev"
source: hosted
version: "2.6.0"
process:
dependency: transitive
description:
@@ -1353,6 +1537,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
realtime_client:
dependency: transitive
description:
name: realtime_client
sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
restart_app:
dependency: "direct main"
description:
@@ -1362,6 +1554,14 @@ packages:
url: "https://github.com/maple135790/restart_app.git"
source: git
version: "1.4.0"
retry:
dependency: transitive
description:
name: retry
sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
rxdart:
dependency: transitive
description:
@@ -1499,6 +1699,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.5"
sign_in_button:
dependency: "direct main"
description:
name: sign_in_button
sha256: "7bcd5e3ca5f80578da6a92b8749badf4003cf4dc578b5cb737b9082871354ff8"
url: "https://pub.dev"
source: hosted
version: "4.0.1"
sign_in_with_apple:
dependency: "direct main"
description:
name: sign_in_with_apple
sha256: "8bd875c8e8748272749eb6d25b896f768e7e9d60988446d543fe85a37a2392b8"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
sign_in_with_apple_platform_interface:
dependency: transitive
description:
name: sign_in_with_apple_platform_interface
sha256: "981bca52cf3bb9c3ad7ef44aace2d543e5c468bb713fd8dda4275ff76dfa6659"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
sign_in_with_apple_web:
dependency: transitive
description:
name: sign_in_with_apple_web
sha256: f316400827f52cafcf50d00e1a2e8a0abc534ca1264e856a81c5f06bd5b10fed
url: "https://pub.dev"
source: hosted
version: "3.0.0"
skeletonizer:
dependency: transitive
description:
@@ -1536,6 +1768,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.12.1"
storage_client:
dependency: transitive
description:
name: storage_client
sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
stream_channel:
dependency: transitive
description:
@@ -1552,6 +1792,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
supabase:
dependency: transitive
description:
name: supabase
sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911
url: "https://pub.dev"
source: hosted
version: "2.10.2"
supabase_flutter:
dependency: "direct main"
description:
name: supabase_flutter
sha256: "92b2416ecb6a5c3ed34cf6e382b35ce6cc8921b64f2a9299d5d28968d42b09bb"
url: "https://pub.dev"
source: hosted
version: "2.12.0"
sync_http:
dependency: transitive
description:
@@ -1560,6 +1816,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
@@ -1572,10 +1836,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.8"
time:
dependency: transitive
description:
@@ -1745,6 +2009,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webdriver:
dependency: transitive
description:
@@ -1856,6 +2136,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.0.4"
yet_another_json_isolate:
dependency: transitive
description:
name: yet_another_json_isolate
sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e
url: "https://pub.dev"
source: hosted
version: "2.1.0"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"

View File

@@ -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.6.2+93
version: 4.7.4+99
environment:
sdk: ^3.9.0
@@ -30,6 +30,12 @@ dependencies:
flutter_md: ^0.0.7
permission_handler: ^12.0.1
dartx: any
supabase_flutter: ^2.12.0
sign_in_button: ^4.0.1
google_sign_in: ^7.2.0
sign_in_with_apple: ^7.0.1
nsd: ^4.0.3
image_picker: ^1.1.2
in_app_review: ^2.0.11
@@ -104,6 +110,7 @@ flutter:
- INSTRUCTIONS_ZWIFT.md
- INSTRUCTIONS_LOCAL.md
- shorebird.yaml
- assets/silence.mp3
- icon.png
flutter_intl:
@@ -120,4 +127,5 @@ msix_config:
store: true
logo_path: web/icons/Icon-512.png
build_windows: false
protocol_activation: https, bikecontrol
capabilities: internetClient,bluetooth,inputInjectionBrokered

View File

@@ -0,0 +1,9 @@
dependency_overrides:
prop:
path: prop
gamepads_windows:
git:
url: https://github.com/lea108/gamepads.git
ref: windows-api-rework
path: packages/gamepads_windows

View File

@@ -27,6 +27,7 @@ void main() {
manufacturerData: [
ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RC1_RIGHT_SIDE])),
],
services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase()],
);
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ZwiftPlay>());
});
@@ -52,6 +53,17 @@ void main() {
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ZwiftRide>());
});
test('Detect Zwift Ride oldest firmware', () {
final device = _createBleDevice(
name: 'Zwift Ride',
manufacturerData: [
ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RIDE_LEFT_SIDE])),
],
services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_SHORT_UUID.toLowerCase()],
);
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ZwiftRide>());
});
test('Detect Zwift Click V1', () {
final device = _createBleDevice(
name: 'Zwift Click',

View File

@@ -1,14 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
import 'package:bike_control/utils/settings/settings.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
group('Custom Profile Tests', () {
setUp(() async {
// Initialize SharedPreferences with in-memory storage for testing
SharedPreferences.setMockInitialValues({});
FlutterSecureStorage.setMockInitialValues({});
await core.settings.init();
});

View File

@@ -1,9 +1,9 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:bike_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_ble/universal_ble.dart';
void main() {
@@ -28,7 +28,7 @@ void main() {
_hexToUint8List('FEEFFFEE0206030398565E000158'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
expect(stubActions.performedActions.first, PerformedAction(CycplusBc2Buttons.shiftUp, isDown: true, isUp: true));
stubActions.performedActions.clear();
// Packet 2: [6]=03 [7]=01 -> Trigger: shiftDown
@@ -37,7 +37,10 @@ void main() {
_hexToUint8List('FEEFFFEE0206030198575E000157'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftDown);
expect(
stubActions.performedActions.first,
PerformedAction(CycplusBc2Buttons.shiftDown, isDown: true, isUp: true),
);
stubActions.performedActions.clear();
// Packet 3: [6]=03 [7]=03 -> No trigger (lock state)
@@ -53,7 +56,7 @@ void main() {
_hexToUint8List('FEEFFFEE0206010399585E000159'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
expect(stubActions.performedActions.first, PerformedAction(CycplusBc2Buttons.shiftUp, isDown: true, isUp: true));
stubActions.performedActions.clear();
});
@@ -89,7 +92,7 @@ void main() {
_hexToUint8List('FEEFFFEE0206010300005E000100'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
expect(stubActions.performedActions.first, PerformedAction(CycplusBc2Buttons.shiftUp, isDown: true, isUp: true));
});
test('Test both buttons can trigger simultaneously', () {
@@ -110,7 +113,10 @@ void main() {
_hexToUint8List('FEEFFFEE0206020200005E000100'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.contains(CycplusBc2Buttons.shiftUp), true);
expect(
stubActions.performedActions.contains(PerformedAction(CycplusBc2Buttons.shiftUp, isDown: true, isUp: true)),
true,
);
});
});
}

View File

@@ -1,4 +1,5 @@
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/apps/my_whoosh.dart';
import 'package:bike_control/utils/keymap/keymap.dart';
@@ -11,6 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart';
Future<void> main() async {
FlutterSecureStorage.setMockInitialValues({});
SharedPreferences.setMockInitialValues({});
screenshotMode = true;
await core.settings.init();
await core.settings.reset();

View File

@@ -19,6 +19,7 @@ Future<void> main() async {
group('Shimano DI2 Tests', () {
test('Should parse Di2 values correctly', () async {
stubActions.cleanup();
final instance = ShimanoDi2(BleDevice(name: 'Di2', deviceId: ''));
await instance.processCharacteristic(
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
@@ -41,6 +42,7 @@ Future<void> main() async {
});
test('should transmit all events', () async {
stubActions.cleanup();
final instance = ShimanoDi2(BleDevice(name: 'Di2', deviceId: ''));
await instance.processCharacteristic(
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
@@ -60,7 +62,12 @@ Future<void> main() async {
Uint8List.fromList([0x21, 0x14, 0xF0, 0xF0]),
);
final button = ControllerButton('D-Fly Channel 1');
expect(stubActions.performedActions, equals([(button, true, false), (button, false, true)]));
expect(
stubActions.performedActions,
equals([
PerformedAction(button, isDown: true, isUp: true),
]),
);
await instance.processCharacteristic(
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
@@ -68,7 +75,10 @@ Future<void> main() async {
);
expect(
stubActions.performedActions,
equals([(button, true, false), (button, false, true), (button, true, false), (button, false, true)]),
equals([
PerformedAction(button, isDown: true, isUp: true),
PerformedAction(button, isDown: true, isUp: true),
]),
);
});
});

View File

@@ -1,9 +1,9 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:bike_control/bluetooth/devices/thinkrider/thinkrider_vs200.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_ble/universal_ble.dart';
void main() {
@@ -21,7 +21,10 @@ void main() {
_hexToUint8List('F3050301FC'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, ThinkRiderVs200Buttons.shiftUp);
expect(
stubActions.performedActions.first,
PerformedAction(ThinkRiderVs200Buttons.shiftUp, isDown: true, isUp: true),
);
});
test('Test shift down button press with correct pattern', () {
@@ -35,7 +38,10 @@ void main() {
_hexToUint8List('F3050300FB'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, ThinkRiderVs200Buttons.shiftDown);
expect(
stubActions.performedActions.first,
PerformedAction(ThinkRiderVs200Buttons.shiftDown, isDown: true, isUp: true),
);
});
test('Test multiple button presses', () {
@@ -49,7 +55,10 @@ void main() {
_hexToUint8List('F3050301FC'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, ThinkRiderVs200Buttons.shiftUp);
expect(
stubActions.performedActions.first,
PerformedAction(ThinkRiderVs200Buttons.shiftUp, isDown: true, isUp: true),
);
stubActions.performedActions.clear();
// Shift down
@@ -58,7 +67,10 @@ void main() {
_hexToUint8List('F3050300FB'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, ThinkRiderVs200Buttons.shiftDown);
expect(
stubActions.performedActions.first,
PerformedAction(ThinkRiderVs200Buttons.shiftDown, isDown: true, isUp: true),
);
});
test('Test incorrect pattern does not trigger action', () {
@@ -73,6 +85,46 @@ void main() {
);
expect(stubActions.performedActions.isEmpty, true);
});
test('Test shift up performs single click action (not double)', () {
core.actionHandler = StubActions();
final stubActions = core.actionHandler as StubActions;
final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285'));
// Send shift up pattern: F3-05-03-01-FC
device.processCharacteristic(
ThinkRiderVs200Constants.CHARACTERISTIC_UUID,
_hexToUint8List('F3050301FC'),
);
// Should have exactly 1 action (single click with isKeyDown: true, isKeyUp: true)
// NOT 2 actions (down then up)
expect(stubActions.performedActions.length, 1);
expect(
stubActions.performedActions.first,
equals(PerformedAction(ThinkRiderVs200Buttons.shiftUp, isDown: true, isUp: true)),
);
});
test('Test shift down performs single click action (not double)', () {
core.actionHandler = StubActions();
final stubActions = core.actionHandler as StubActions;
final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285'));
// Send shift down pattern: F3-05-03-00-FB
device.processCharacteristic(
ThinkRiderVs200Constants.CHARACTERISTIC_UUID,
_hexToUint8List('F3050300FB'),
);
// Should have exactly 1 action (single click with isKeyDown: true, isKeyUp: true)
// NOT 2 actions (down then up)
expect(stubActions.performedActions.length, 1);
expect(
stubActions.performedActions.first,
equals(PerformedAction(ThinkRiderVs200Buttons.shiftDown, isDown: true, isUp: true)),
);
});
});
}

View File

@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <bluetooth_low_energy_windows/bluetooth_low_energy_windows_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
@@ -22,6 +23,8 @@
#include <windows_iap/windows_iap_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
BluetoothLowEnergyWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("BluetoothLowEnergyWindowsPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
app_links
bluetooth_low_energy_windows
file_selector_windows
flutter_secure_storage_windows

Some files were not shown because too many files have changed in this diff Show More