Compare commits

..

60 Commits

Author SHA1 Message Date
Jonas Bark
02c038daaa additional fixes 2026-02-16 18:32:31 +01:00
Jonas Bark
05352d7118 additional fixes 2026-02-16 18:22:16 +01:00
Jonas Bark
5c7e8b923b fix shimano di2 implementation 2026-02-16 17:58:10 +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
88 changed files with 1489 additions and 687 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",

BIN
assets/silence.mp3 Normal file

Binary file not shown.

View File

@@ -1,4 +1,6 @@
PODS:
- audio_session (0.0.1):
- Flutter
- bluetooth_low_energy_darwin (0.0.1):
- Flutter
- FlutterMacOS
@@ -25,6 +27,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):
@@ -63,6 +68,7 @@ PODS:
- Flutter
DEPENDENCIES:
- 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`)
@@ -75,6 +81,7 @@ DEPENDENCIES:
- 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`)
@@ -96,6 +103,8 @@ SPEC REPOS:
- RevenueCatUI
EXTERNAL SOURCES:
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
bluetooth_low_energy_darwin:
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
device_info_plus:
@@ -120,6 +129,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:
@@ -146,6 +157,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe
bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
@@ -158,6 +170,7 @@ SPEC CHECKSUMS:
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

View File

@@ -280,10 +280,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -372,10 +376,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";

View File

@@ -30,21 +30,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

@@ -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

@@ -104,18 +104,26 @@ 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}',
);
if (kDebugMode) {
print('Write request for characteristic: ${characteristic.uuid}: ${bytesToReadableHex(value)}');
}
switch (eventArgs.characteristic.uuid.toString().toLowerCase()) {
case OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID:
try {
final appInfo = OpenBikeProtocolParser.parseAppInfo(value);
// use this fallback if first message is incomplete (e.g. TrainingPeaks on macOS)
AppInfo appInfo = OpenBikeProtocolParser.parseAppInfo(
Uint8List.fromList([...?firstAppInfoMessage, ...value]),
);
firstAppInfoMessage = null;
isConnected.value = true;
connectedApp.value = appInfo;
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
@@ -125,6 +133,10 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo'));
} catch (e) {
core.connection.signalNotification(LogNotification('Error parsing App Info ${bytesToHex(value)}: $e'));
if (firstAppInfoMessage == null) {
firstAppInfoMessage = value;
return;
}
}
break;
default:
@@ -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,89 @@ 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 Map<_Di2State, List<ControllerButton>> mapped = _lastButtons.entries.groupBy((e) => e.value.type).map((
key,
value,
) {
final buttons = value
.map((entry) => availableButtons.firstWhere((button) => button.name == 'D-Fly Channel ${entry.key + 1}'))
.toList();
return MapEntry(key, buttons);
});
final shortPress = [...?mapped[_Di2State.shortPress], ...?mapped[_Di2State.doublePress]];
if (shortPress.isNotEmpty) {
Logger.debug('Short Press Buttons to trigger: ${shortPress.map((b) => b.name).join(', ')}');
handleButtonsClicked(shortPress);
handleButtonsClicked([]);
_resetButtonsForState([_Di2State.shortPress]);
}
final longPress = mapped[_Di2State.longPress] ?? [];
if (longPress.isNotEmpty) {
Logger.debug('Long Press Buttons to trigger: ${longPress.map((b) => b.name).join(', ')}');
handleButtonsClicked(longPress);
}
final released = mapped[_Di2State.released] ?? [];
final keepPress = mapped[_Di2State.longPress] ?? [];
if (released.isNotEmpty && keepPress.isEmpty) {
Logger.debug('Releasing all Buttons');
handleButtonsClicked([]);
}
final doublePress = mapped[_Di2State.doublePress] ?? [];
if (doublePress.isNotEmpty) {
Logger.debug('Buttons to still trigger: ${doublePress.map((b) => b.name).join(', ')}');
handleButtonsClicked(doublePress);
handleButtonsClicked([]);
_resetButtonsForState([_Di2State.doublePress]);
}
}
}
return Future.value();
}
void _resetButtonsForState(List<_Di2State> list) {
_lastButtons.forEach((key, value) {
if (list.contains(value.type)) {
_lastButtons[key] = (value: value.value, type: _Di2State.released);
}
});
}
@override
@@ -97,3 +160,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,

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,7 @@ 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:universal_ble/universal_ble.dart';
import '../bluetooth/connection.dart';
@@ -44,6 +45,7 @@ class Core {
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 +170,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 +186,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 +220,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 +246,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 +256,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 +266,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 +343,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';
@@ -34,7 +33,6 @@ class RevenueCatService {
final int Function() getDailyCommandLimit;
final void Function(int limit) setDailyCommandLimit;
static const _isAndroidWorking = false;
bool _isInitialized = false;
String? _trialStartDate;
String? _lastCommandDate;
@@ -119,9 +117,7 @@ class RevenueCatService {
_isInitialized = true;
if (Platform.isAndroid && !isPurchasedNotifier.value && !_isAndroidWorking) {
setDailyCommandLimit(10000);
} else if (!isTrialExpired && Platform.isAndroid) {
if (!isTrialExpired && Platform.isAndroid) {
setDailyCommandLimit(80);
}
} catch (e, s) {
@@ -240,16 +236,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 +247,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 +303,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);

View File

@@ -92,7 +92,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.',
);

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

@@ -319,6 +319,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 +414,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

@@ -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),
),
@@ -230,7 +237,7 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
Future.wait(widget.requirements.map((e) => e.getStatus())).then((result) {
final allDone = result.every((e) => e);
if (context.mounted) {
if (context.mounted && widget.isEnabled != allDone) {
widget.onChange(allDone);
}
});

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,51 @@ 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) {
final isMobile = MediaQuery.sizeOf(navigatorKey.currentContext!).width < 600;
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) => Container(
margin: EdgeInsets.only(bottom: isMobile ? 50 : 0),
child: 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,7 @@
import FlutterMacOS
import Foundation
import audio_session
import bluetooth_low_energy_darwin
import device_info_plus
import file_selector_macos
@@ -15,6 +16,7 @@ import gamepads_darwin
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
@@ -28,6 +30,7 @@ import wakelock_plus
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
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"))
@@ -38,6 +41,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
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"))

View File

@@ -1,4 +1,6 @@
PODS:
- audio_session (0.0.1):
- FlutterMacOS
- bluetooth_low_energy_darwin (0.0.1):
- Flutter
- FlutterMacOS
@@ -23,6 +25,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):
@@ -53,6 +58,7 @@ PODS:
- FlutterMacOS
DEPENDENCIES:
- 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`)
@@ -64,6 +70,7 @@ DEPENDENCIES:
- 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`)
@@ -82,6 +89,8 @@ SPEC REPOS:
- RevenueCat
EXTERNAL SOURCES:
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:
@@ -104,6 +113,8 @@ EXTERNAL SOURCES:
: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:
@@ -128,6 +139,7 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
audio_session: 728ae3823d914f809c485d390274861a24b0904e
bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150
@@ -139,6 +151,7 @@ SPEC CHECKSUMS:
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

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...a154bf1b7c

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

@@ -80,6 +80,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 +164,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:
@@ -868,6 +876,30 @@ 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"
keypress_simulator:
dependency: "direct main"
description:
@@ -940,18 +972,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:
@@ -1560,6 +1592,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 +1612,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:

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
@@ -104,6 +104,7 @@ flutter:
- INSTRUCTIONS_ZWIFT.md
- INSTRUCTIONS_LOCAL.md
- shorebird.yaml
- assets/silence.mp3
- icon.png
flutter_intl:

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)),
);
});
});
}