From 5d5d8ffb1880610edab2f43e4fb3da49882ee620 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 07:17:31 +0000 Subject: [PATCH 2/8] Add keyboard hotkey configuration for button simulator Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com> --- lib/pages/button_simulator.dart | 530 ++++++++++++++++++++++++------- lib/utils/settings/settings.dart | 28 ++ 2 files changed, 448 insertions(+), 110 deletions(-) diff --git a/lib/pages/button_simulator.dart b/lib/pages/button_simulator.dart index b2fe8c4..c6308f0 100644 --- a/lib/pages/button_simulator.dart +++ b/lib/pages/button_simulator.dart @@ -1,5 +1,6 @@ import 'package:dartx/dartx.dart'; import 'package:flutter/material.dart' show BackButton; +import 'package:flutter/services.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:swift_control/bluetooth/devices/trainer_connection.dart'; import 'package:swift_control/utils/actions/android.dart'; @@ -11,124 +12,249 @@ import 'package:swift_control/utils/keymap/keymap.dart'; import 'package:swift_control/widgets/ui/gradient_text.dart'; import 'package:swift_control/widgets/ui/warning.dart'; -class ButtonSimulator extends StatelessWidget { +class ButtonSimulator extends StatefulWidget { const ButtonSimulator({super.key}); + @override + State createState() => _ButtonSimulatorState(); +} + +class _ButtonSimulatorState extends State { + late final FocusNode _focusNode; + Map _hotkeys = {}; + + // Default hotkeys for actions + static const List _defaultHotkeyOrder = [ + '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', + 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', + 'z', 'x', 'c', 'v', 'b', 'n', 'm', + ]; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'ButtonSimulatorFocus', canRequestFocus: true); + _loadHotkeys(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + void _loadHotkeys() { + final savedHotkeys = core.settings.getButtonSimulatorHotkeys(); + + // If no saved hotkeys, initialize with defaults + if (savedHotkeys.isEmpty) { + final connectedTrainers = core.logic.connectedTrainerConnections; + final allActions = []; + + for (final connection in connectedTrainers) { + allActions.addAll(connection.supportedActions); + } + + // Assign default hotkeys to actions + final Map defaultHotkeys = {}; + int hotkeyIndex = 0; + for (final action in allActions.distinct()) { + if (hotkeyIndex < _defaultHotkeyOrder.length) { + defaultHotkeys[action.name] = _defaultHotkeyOrder[hotkeyIndex]; + hotkeyIndex++; + } + } + + core.settings.setButtonSimulatorHotkeys(defaultHotkeys); + setState(() { + _hotkeys = defaultHotkeys; + }); + } else { + setState(() { + _hotkeys = savedHotkeys; + }); + } + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final key = event.logicalKey.keyLabel.toLowerCase(); + + // Find the action associated with this key + final actionName = _hotkeys.entries + .firstOrNullWhere((entry) => entry.value == key) + ?.key; + + if (actionName == null) return KeyEventResult.ignored; + + final action = InGameAction.values.firstOrNullWhere((a) => a.name == actionName); + if (action == null) return KeyEventResult.ignored; + + // Find the connection that supports this action + final connectedTrainers = core.logic.connectedTrainerConnections; + final connection = connectedTrainers.firstOrNullWhere( + (c) => c.supportedActions.contains(action) + ); + + if (connection != null) { + _sendKey(context, down: true, action: action, connection: connection); + // Schedule key up event + Future.delayed(Duration(milliseconds: 100), () { + _sendKey(context, down: false, action: action, connection: connection); + }); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { final connectedTrainers = core.logic.connectedTrainerConnections; - return Scaffold( - headers: [ - AppBar( - leading: [BackButton()], - title: Text(context.i18n.simulateButtons), - ), - ], - child: Scrollbar( - child: SingleChildScrollView( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: [ - if (connectedTrainers.isEmpty) - Warning( - children: [ - Text('No connected trainers found. Connect a trainer to simulate button presses.'), - ], - ), - ...connectedTrainers.map( - (connection) { - final supportedActions = connection.supportedActions; - - final actionGroups = { - if (supportedActions.contains(InGameAction.shiftUp) && - supportedActions.contains(InGameAction.shiftDown)) - 'Shifting': [InGameAction.shiftUp, InGameAction.shiftDown], - 'Other': supportedActions - .where((action) => action != InGameAction.shiftUp && action != InGameAction.shiftDown) - .toList(), - }; - - return [ - GradientText(connection.title).bold.large, - Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 12, - children: [ - for (final group in actionGroups.entries) ...[ - Text(group.key).bold, - Wrap( - spacing: 12, - runSpacing: 12, - children: group.value - .map( - (action) => PrimaryButton( - size: ButtonSize(1.6), - child: Text(action.title), - onTapDown: (c) async { - _sendKey(context, down: true, action: action, connection: connection); - }, - onTapUp: (c) async { - _sendKey(context, down: false, action: action, connection: connection); - }, - ), - ) - .toList(), - ), - SizedBox(height: 12), - ], - ], - ), - ]; - }, - ).flatten(), - // local control doesn't make much sense - it would send the key events to BikeControl itself - if (false && - core.logic.showLocalControl && - core.settings.getLocalEnabled() && - core.actionHandler.supportedApp != null) ...[ - GradientText('Local Control'), - Wrap( - spacing: 12, - runSpacing: 12, - children: core.actionHandler.supportedApp!.keymap.keyPairs - .map( - (keyPair) => PrimaryButton( - child: Text(keyPair.toString()), - onPressed: () async { - if (core.actionHandler is AndroidActions) { - await (core.actionHandler as AndroidActions).performAction( - keyPair.buttons.first, - isKeyDown: true, - isKeyUp: false, - ); - await (core.actionHandler as AndroidActions).performAction( - keyPair.buttons.first, - isKeyDown: false, - isKeyUp: true, - ); - } else { - await (core.actionHandler as DesktopActions).performAction( - keyPair.buttons.first, - isKeyDown: true, - isKeyUp: false, - ); - await (core.actionHandler as DesktopActions).performAction( - keyPair.buttons.first, - isKeyDown: false, - isKeyUp: true, - ); - } - }, - ), - ) - .toList(), - ), - ], + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKey, + child: Scaffold( + headers: [ + AppBar( + leading: [BackButton()], + title: Text(context.i18n.simulateButtons), + trailing: [ + IconButton( + icon: Icon(Icons.settings), + onPressed: () => _showHotkeySettings(context, connectedTrainers), + ), ], ), + ], + child: Scrollbar( + child: SingleChildScrollView( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + if (connectedTrainers.isEmpty) + Warning( + children: [ + Text('No connected trainers found. Connect a trainer to simulate button presses.'), + ], + ), + ...connectedTrainers.map( + (connection) { + final supportedActions = connection.supportedActions; + + final actionGroups = { + if (supportedActions.contains(InGameAction.shiftUp) && + supportedActions.contains(InGameAction.shiftDown)) + 'Shifting': [InGameAction.shiftUp, InGameAction.shiftDown], + 'Other': supportedActions + .where((action) => action != InGameAction.shiftUp && action != InGameAction.shiftDown) + .toList(), + }; + + return [ + GradientText(connection.title).bold.large, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + for (final group in actionGroups.entries) ...[ + Text(group.key).bold, + Wrap( + spacing: 12, + runSpacing: 12, + children: group.value + .map( + (action) { + final hotkey = _hotkeys[action.name]; + return PrimaryButton( + size: ButtonSize(1.6), + leading: hotkey != null + ? Container( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.3), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + hotkey.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ) + : null, + child: Text(action.title), + onTapDown: (c) async { + _sendKey(context, down: true, action: action, connection: connection); + }, + onTapUp: (c) async { + _sendKey(context, down: false, action: action, connection: connection); + }, + ); + }, + ) + .toList(), + ), + SizedBox(height: 12), + ], + ], + ), + ]; + }, + ).flatten(), + // local control doesn't make much sense - it would send the key events to BikeControl itself + if (false && + core.logic.showLocalControl && + core.settings.getLocalEnabled() && + core.actionHandler.supportedApp != null) ...[ + GradientText('Local Control'), + Wrap( + spacing: 12, + runSpacing: 12, + children: core.actionHandler.supportedApp!.keymap.keyPairs + .map( + (keyPair) => PrimaryButton( + child: Text(keyPair.toString()), + onPressed: () async { + if (core.actionHandler is AndroidActions) { + await (core.actionHandler as AndroidActions).performAction( + keyPair.buttons.first, + isKeyDown: true, + isKeyUp: false, + ); + await (core.actionHandler as AndroidActions).performAction( + keyPair.buttons.first, + isKeyDown: false, + isKeyUp: true, + ); + } else { + await (core.actionHandler as DesktopActions).performAction( + keyPair.buttons.first, + isKeyDown: true, + isKeyUp: false, + ); + await (core.actionHandler as DesktopActions).performAction( + keyPair.buttons.first, + isKeyDown: false, + isKeyUp: true, + ); + } + }, + ), + ) + .toList(), + ), + ], + ], + ), + ), ), ), ); @@ -181,4 +307,188 @@ class ButtonSimulator extends StatelessWidget { ); } } + + void _showHotkeySettings(BuildContext context, List connections) { + showDialog( + context: context, + builder: (context) => _HotkeySettingsDialog( + connections: connections, + currentHotkeys: _hotkeys, + onSave: (newHotkeys) { + setState(() { + _hotkeys = newHotkeys; + }); + }, + ), + ); + } +} + +class _HotkeySettingsDialog extends StatefulWidget { + final List connections; + final Map currentHotkeys; + final Function(Map) onSave; + + const _HotkeySettingsDialog({ + required this.connections, + required this.currentHotkeys, + required this.onSave, + }); + + @override + State<_HotkeySettingsDialog> createState() => _HotkeySettingsDialogState(); +} + +class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { + late Map _editableHotkeys; + String? _editingAction; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _editableHotkeys = Map.from(widget.currentHotkeys); + _focusNode = FocusNode(debugLabel: 'HotkeySettingsFocus', canRequestFocus: true); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (_editingAction == null || event is! KeyDownEvent) return KeyEventResult.ignored; + + final key = event.logicalKey.keyLabel.toLowerCase(); + + // Only allow 1-9 and a-z + if (key.length == 1 && (RegExp(r'[0-9a-z]').hasMatch(key))) { + setState(() { + _editableHotkeys[_editingAction!] = key; + _editingAction = null; + }); + return KeyEventResult.handled; + } + + // Escape to cancel + if (event.logicalKey == LogicalKeyboardKey.escape) { + setState(() { + _editingAction = null; + }); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final allActions = []; + for (final connection in widget.connections) { + allActions.addAll(connection.supportedActions); + } + final uniqueActions = allActions.distinct().toList(); + + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKey, + child: Dialog( + title: Text('Configure Keyboard Hotkeys'), + content: SizedBox( + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text('Assign keyboard shortcuts to simulator buttons').muted, + SizedBox(height: 8), + Flexible( + child: SingleChildScrollView( + child: Column( + spacing: 8, + children: uniqueActions.map((action) { + final hotkey = _editableHotkeys[action.name]; + final isEditing = _editingAction == action.name; + + return Card( + child: Container( + padding: EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text(action.title), + ), + if (isEditing) + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(4), + ), + child: Text('Press a key...', style: TextStyle(color: Colors.blue)), + ) + else if (hotkey != null) + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.3), + borderRadius: BorderRadius.circular(4), + ), + child: Text(hotkey.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold)), + ) + else + Text('No hotkey', style: TextStyle(color: Colors.grey)), + SizedBox(width: 8), + OutlineButton( + size: ButtonSize.small, + child: Text(isEditing ? 'Cancel' : 'Set'), + onPressed: () { + setState(() { + _editingAction = isEditing ? null : action.name; + }); + }, + ), + if (hotkey != null && !isEditing) ...[ + SizedBox(width: 4), + OutlineButton( + size: ButtonSize.small, + child: Text('Clear'), + onPressed: () { + setState(() { + _editableHotkeys.remove(action.name); + }); + }, + ), + ], + ], + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ), + actions: [ + SecondaryButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + PrimaryButton( + child: Text('Save'), + onPressed: () { + core.settings.setButtonSimulatorHotkeys(_editableHotkeys); + widget.onSave(_editableHotkeys); + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } } diff --git a/lib/utils/settings/settings.dart b/lib/utils/settings/settings.dart index e5b8e66..f036f59 100644 --- a/lib/utils/settings/settings.dart +++ b/lib/utils/settings/settings.dart @@ -303,4 +303,32 @@ class Settings { void setLocalEnabled(bool value) { prefs.setBool('local_control_enabled', value); } + + // Button Simulator Hotkey Settings + Map getButtonSimulatorHotkeys() { + final json = prefs.getString('button_simulator_hotkeys'); + if (json == null) return {}; + try { + final decoded = jsonDecode(json) as Map; + return decoded.map((key, value) => MapEntry(key, value.toString())); + } catch (e) { + return {}; + } + } + + Future setButtonSimulatorHotkeys(Map hotkeys) async { + await prefs.setString('button_simulator_hotkeys', jsonEncode(hotkeys)); + } + + Future setButtonSimulatorHotkey(String actionName, String hotkey) async { + final hotkeys = getButtonSimulatorHotkeys(); + hotkeys[actionName] = hotkey; + await setButtonSimulatorHotkeys(hotkeys); + } + + Future removeButtonSimulatorHotkey(String actionName) async { + final hotkeys = getButtonSimulatorHotkeys(); + hotkeys.remove(actionName); + await setButtonSimulatorHotkeys(hotkeys); + } } From 4881fe4778053d96bd76d1ac3e77eff74a3e3b26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 07:19:28 +0000 Subject: [PATCH 3/8] Add test for button simulator hotkeys and fix trailing comma Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com> --- lib/pages/button_simulator.dart | 9 +- test/button_simulator_hotkeys_test.dart | 105 ++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 test/button_simulator_hotkeys_test.dart diff --git a/lib/pages/button_simulator.dart b/lib/pages/button_simulator.dart index c6308f0..b49350c 100644 --- a/lib/pages/button_simulator.dart +++ b/lib/pages/button_simulator.dart @@ -101,9 +101,12 @@ class _ButtonSimulatorState extends State { if (connection != null) { _sendKey(context, down: true, action: action, connection: connection); // Schedule key up event - Future.delayed(Duration(milliseconds: 100), () { - _sendKey(context, down: false, action: action, connection: connection); - }); + Future.delayed( + Duration(milliseconds: 100), + () { + _sendKey(context, down: false, action: action, connection: connection); + }, + ); return KeyEventResult.handled; } diff --git a/test/button_simulator_hotkeys_test.dart b/test/button_simulator_hotkeys_test.dart new file mode 100644 index 0000000..6e52bf7 --- /dev/null +++ b/test/button_simulator_hotkeys_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:swift_control/utils/core.dart'; +import 'package:swift_control/utils/settings/settings.dart'; + +void main() { + group('Button Simulator Hotkey Tests', () { + setUp(() async { + // Initialize SharedPreferences with in-memory storage for testing + SharedPreferences.setMockInitialValues({}); + await core.settings.init(); + }); + + test('Should initialize with empty hotkeys', () { + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys.isEmpty, true); + }); + + test('Should save and retrieve hotkeys', () async { + final testHotkeys = { + 'shiftUp': '1', + 'shiftDown': '2', + 'uturn': '3', + }; + + await core.settings.setButtonSimulatorHotkeys(testHotkeys); + + final retrievedHotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(retrievedHotkeys['shiftUp'], '1'); + expect(retrievedHotkeys['shiftDown'], '2'); + expect(retrievedHotkeys['uturn'], '3'); + expect(retrievedHotkeys.length, 3); + }); + + test('Should set individual hotkey', () async { + await core.settings.setButtonSimulatorHotkey('shiftUp', 'q'); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys['shiftUp'], 'q'); + }); + + test('Should update existing hotkey', () async { + await core.settings.setButtonSimulatorHotkey('shiftUp', '1'); + await core.settings.setButtonSimulatorHotkey('shiftUp', 'q'); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys['shiftUp'], 'q'); + }); + + test('Should remove hotkey', () async { + await core.settings.setButtonSimulatorHotkey('shiftUp', '1'); + await core.settings.setButtonSimulatorHotkey('shiftDown', '2'); + + await core.settings.removeButtonSimulatorHotkey('shiftUp'); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys.containsKey('shiftUp'), false); + expect(hotkeys['shiftDown'], '2'); + }); + + test('Should persist hotkeys across settings instances', () async { + final testHotkeys = { + 'shiftUp': 'a', + 'shiftDown': 'b', + }; + + await core.settings.setButtonSimulatorHotkeys(testHotkeys); + + // Create new settings instance + final newSettings = Settings(); + await newSettings.init(); + + final retrievedHotkeys = newSettings.getButtonSimulatorHotkeys(); + expect(retrievedHotkeys['shiftUp'], 'a'); + expect(retrievedHotkeys['shiftDown'], 'b'); + }); + + test('Should handle multiple actions with different hotkeys', () async { + final testHotkeys = { + 'shiftUp': '1', + 'shiftDown': '2', + 'uturn': '3', + 'steerLeft': 'q', + 'steerRight': 'w', + 'openActionBar': 'a', + 'usePowerUp': 's', + }; + + await core.settings.setButtonSimulatorHotkeys(testHotkeys); + + final retrievedHotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(retrievedHotkeys.length, 7); + expect(retrievedHotkeys['steerLeft'], 'q'); + expect(retrievedHotkeys['usePowerUp'], 's'); + }); + + test('Should clear all hotkeys', () async { + await core.settings.setButtonSimulatorHotkeys({'shiftUp': '1', 'shiftDown': '2'}); + await core.settings.setButtonSimulatorHotkeys({}); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys.isEmpty, true); + }); + }); +} From cb497daee45e2a35702bf2cbaadde681115207c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 07:21:57 +0000 Subject: [PATCH 4/8] Address code review feedback: extract constants and fix async handling Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com> --- lib/pages/button_simulator.dart | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/pages/button_simulator.dart b/lib/pages/button_simulator.dart index b49350c..99785b6 100644 --- a/lib/pages/button_simulator.dart +++ b/lib/pages/button_simulator.dart @@ -30,6 +30,8 @@ class _ButtonSimulatorState extends State { 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ]; + + static const Duration _keyPressDuration = Duration(milliseconds: 100); @override void initState() { @@ -44,7 +46,7 @@ class _ButtonSimulatorState extends State { super.dispose(); } - void _loadHotkeys() { + Future _loadHotkeys() async { final savedHotkeys = core.settings.getButtonSimulatorHotkeys(); // If no saved hotkeys, initialize with defaults @@ -66,10 +68,12 @@ class _ButtonSimulatorState extends State { } } - core.settings.setButtonSimulatorHotkeys(defaultHotkeys); - setState(() { - _hotkeys = defaultHotkeys; - }); + await core.settings.setButtonSimulatorHotkeys(defaultHotkeys); + if (mounted) { + setState(() { + _hotkeys = defaultHotkeys; + }); + } } else { setState(() { _hotkeys = savedHotkeys; @@ -102,7 +106,7 @@ class _ButtonSimulatorState extends State { _sendKey(context, down: true, action: action, connection: connection); // Schedule key up event Future.delayed( - Duration(milliseconds: 100), + _keyPressDuration, () { _sendKey(context, down: false, action: action, connection: connection); }, @@ -346,6 +350,8 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { late Map _editableHotkeys; String? _editingAction; late FocusNode _focusNode; + + static final _validHotkeyPattern = RegExp(r'[0-9a-z]'); @override void initState() { @@ -366,7 +372,7 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { final key = event.logicalKey.keyLabel.toLowerCase(); // Only allow 1-9 and a-z - if (key.length == 1 && (RegExp(r'[0-9a-z]').hasMatch(key))) { + if (key.length == 1 && _validHotkeyPattern.hasMatch(key)) { setState(() { _editableHotkeys[_editingAction!] = key; _editingAction = null; From e9aaa961856189bfd513045f05c638066cbd38b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 07:23:15 +0000 Subject: [PATCH 5/8] Fix remaining code review issues: remove redundant check and await settings save Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com> --- lib/pages/button_simulator.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/pages/button_simulator.dart b/lib/pages/button_simulator.dart index 99785b6..4fdab2e 100644 --- a/lib/pages/button_simulator.dart +++ b/lib/pages/button_simulator.dart @@ -372,7 +372,7 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { final key = event.logicalKey.keyLabel.toLowerCase(); // Only allow 1-9 and a-z - if (key.length == 1 && _validHotkeyPattern.hasMatch(key)) { + if (_validHotkeyPattern.hasMatch(key)) { setState(() { _editableHotkeys[_editingAction!] = key; _editingAction = null; @@ -490,10 +490,12 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { ), PrimaryButton( child: Text('Save'), - onPressed: () { - core.settings.setButtonSimulatorHotkeys(_editableHotkeys); + onPressed: () async { + await core.settings.setButtonSimulatorHotkeys(_editableHotkeys); widget.onSave(_editableHotkeys); - Navigator.of(context).pop(); + if (context.mounted) { + Navigator.of(context).pop(); + } }, ), ], From c60a990938e6bbf5feff4e4ac1a2bb24cfb1c17e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 07:24:50 +0000 Subject: [PATCH 6/8] Add mounted check in delayed callback and length validation for keys Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com> --- lib/pages/button_simulator.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pages/button_simulator.dart b/lib/pages/button_simulator.dart index 4fdab2e..b3ef274 100644 --- a/lib/pages/button_simulator.dart +++ b/lib/pages/button_simulator.dart @@ -108,7 +108,9 @@ class _ButtonSimulatorState extends State { Future.delayed( _keyPressDuration, () { - _sendKey(context, down: false, action: action, connection: connection); + if (mounted) { + _sendKey(context, down: false, action: action, connection: connection); + } }, ); return KeyEventResult.handled; @@ -371,8 +373,8 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { final key = event.logicalKey.keyLabel.toLowerCase(); - // Only allow 1-9 and a-z - if (_validHotkeyPattern.hasMatch(key)) { + // Only allow single character 1-9 and a-z + if (key.length == 1 && _validHotkeyPattern.hasMatch(key)) { setState(() { _editableHotkeys[_editingAction!] = key; _editingAction = null; From 9316881048621980f52254b034fb64925d2eac4b Mon Sep 17 00:00:00 2001 From: Jonas Bark Date: Thu, 11 Dec 2025 09:20:02 +0100 Subject: [PATCH 7/8] button simulator adjustments --- lib/bluetooth/devices/zwift/constants.dart | 4 +- .../devices/zwift/ftms_mdns_emulator.dart | 10 +- lib/pages/button_simulator.dart | 182 ++++++++++-------- lib/pages/touch_area.dart | 12 +- lib/utils/keymap/buttons.dart | 11 +- lib/utils/settings/settings.dart | 22 ++- lib/widgets/apps/zwift_tile.dart | 1 + test/button_simulator_hotkeys_test.dart | 71 +++---- 8 files changed, 166 insertions(+), 147 deletions(-) diff --git a/lib/bluetooth/devices/zwift/constants.dart b/lib/bluetooth/devices/zwift/constants.dart index fec014a..73a6072 100644 --- a/lib/bluetooth/devices/zwift/constants.dart +++ b/lib/bluetooth/devices/zwift/constants.dart @@ -99,8 +99,8 @@ class ZwiftButtons { static const ControllerButton powerUpLeft = ControllerButton('powerUpLeft', action: InGameAction.shiftDown); // right controller - static const ControllerButton a = ControllerButton('a', action: null, color: Colors.lightGreen); - static const ControllerButton b = ControllerButton('b', action: null, color: Colors.pinkAccent); + static const ControllerButton a = ControllerButton('a', action: InGameAction.select, color: Colors.lightGreen); + static const ControllerButton b = ControllerButton('b', action: InGameAction.back, color: Colors.pinkAccent); static const ControllerButton z = ControllerButton('z', action: null, color: Colors.deepOrangeAccent); static const ControllerButton y = ControllerButton('y', action: null, color: Colors.lightBlue); static const ControllerButton onOffRight = ControllerButton('onOffRight', action: InGameAction.toggleUi); diff --git a/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart b/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart index b338e7a..249ff69 100644 --- a/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart +++ b/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart @@ -314,7 +314,9 @@ class FtmsMdnsEmulator extends TrainerConnection { } void _write(Socket socket, List responseData) { - print('Sending response: ${bytesToHex(responseData)}'); + if (kDebugMode) { + print('Sending response: ${bytesToHex(responseData)}'); + } socket.add(responseData); } @@ -375,8 +377,10 @@ class FtmsMdnsEmulator extends TrainerConnection { _write(_socket!, zero); } - print('Sent action ${keyPair.inGameAction!.name} to Zwift Emulator'); - return Success('Sent action: ${keyPair.inGameAction!.name}'); + if (kDebugMode) { + print('Sent action ${keyPair.inGameAction!.title} to Zwift Emulator'); + } + return Success('Sent action: ${keyPair.inGameAction!.title}'); } List _buildNotify(String uuid, final List data) { diff --git a/lib/pages/button_simulator.dart b/lib/pages/button_simulator.dart index b3ef274..55b1cc9 100644 --- a/lib/pages/button_simulator.dart +++ b/lib/pages/button_simulator.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart' show BackButton; import 'package:flutter/services.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:swift_control/bluetooth/devices/trainer_connection.dart'; +import 'package:swift_control/pages/touch_area.dart'; import 'package:swift_control/utils/actions/android.dart'; import 'package:swift_control/utils/actions/desktop.dart'; import 'package:swift_control/utils/core.dart'; @@ -21,16 +22,47 @@ class ButtonSimulator extends StatefulWidget { class _ButtonSimulatorState extends State { late final FocusNode _focusNode; - Map _hotkeys = {}; - + Map _hotkeys = {}; + // Default hotkeys for actions static const List _defaultHotkeyOrder = [ - '1', '2', '3', '4', '5', '6', '7', '8', '9', - 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', - 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', - 'z', 'x', 'c', 'v', 'b', 'n', 'm', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'q', + 'w', + 'e', + 'r', + 't', + 'y', + 'u', + 'i', + 'o', + 'p', + 'a', + 's', + 'd', + 'f', + 'g', + 'h', + 'j', + 'k', + 'l', + 'z', + 'x', + 'c', + 'v', + 'b', + 'n', + 'm', ]; - + static const Duration _keyPressDuration = Duration(milliseconds: 100); @override @@ -38,6 +70,7 @@ class _ButtonSimulatorState extends State { super.initState(); _focusNode = FocusNode(debugLabel: 'ButtonSimulatorFocus', canRequestFocus: true); _loadHotkeys(); + _focusNode.requestFocus(); } @override @@ -48,26 +81,26 @@ class _ButtonSimulatorState extends State { Future _loadHotkeys() async { final savedHotkeys = core.settings.getButtonSimulatorHotkeys(); - + // If no saved hotkeys, initialize with defaults if (savedHotkeys.isEmpty) { final connectedTrainers = core.logic.connectedTrainerConnections; final allActions = []; - + for (final connection in connectedTrainers) { allActions.addAll(connection.supportedActions); } - + // Assign default hotkeys to actions - final Map defaultHotkeys = {}; + final Map defaultHotkeys = {}; int hotkeyIndex = 0; for (final action in allActions.distinct()) { if (hotkeyIndex < _defaultHotkeyOrder.length) { - defaultHotkeys[action.name] = _defaultHotkeyOrder[hotkeyIndex]; + defaultHotkeys[action] = _defaultHotkeyOrder[hotkeyIndex]; hotkeyIndex++; } } - + await core.settings.setButtonSimulatorHotkeys(defaultHotkeys); if (mounted) { setState(() { @@ -83,25 +116,18 @@ class _ButtonSimulatorState extends State { KeyEventResult _onKey(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; - + final key = event.logicalKey.keyLabel.toLowerCase(); - + // Find the action associated with this key - final actionName = _hotkeys.entries - .firstOrNullWhere((entry) => entry.value == key) - ?.key; - - if (actionName == null) return KeyEventResult.ignored; - - final action = InGameAction.values.firstOrNullWhere((a) => a.name == actionName); + final action = _hotkeys.entries.firstOrNullWhere((entry) => entry.value == key)?.key; + if (action == null) return KeyEventResult.ignored; - + // Find the connection that supports this action final connectedTrainers = core.logic.connectedTrainerConnections; - final connection = connectedTrainers.firstOrNullWhere( - (c) => c.supportedActions.contains(action) - ); - + final connection = connectedTrainers.firstOrNullWhere((c) => c.supportedActions.contains(action)); + if (connection != null) { _sendKey(context, down: true, action: action, connection: connection); // Schedule key up event @@ -115,7 +141,7 @@ class _ButtonSimulatorState extends State { ); return KeyEventResult.handled; } - + return KeyEventResult.ignored; } @@ -133,8 +159,8 @@ class _ButtonSimulatorState extends State { leading: [BackButton()], title: Text(context.i18n.simulateButtons), trailing: [ - IconButton( - icon: Icon(Icons.settings), + PrimaryButton( + child: Icon(Icons.settings), onPressed: () => _showHotkeySettings(context, connectedTrainers), ), ], @@ -177,39 +203,37 @@ class _ButtonSimulatorState extends State { Wrap( spacing: 12, runSpacing: 12, - children: group.value - .map( - (action) { - final hotkey = _hotkeys[action.name]; - return PrimaryButton( - size: ButtonSize(1.6), - leading: hotkey != null - ? Container( - padding: EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.3), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - hotkey.toUpperCase(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ) - : null, - child: Text(action.title), - onTapDown: (c) async { - _sendKey(context, down: true, action: action, connection: connection); - }, - onTapUp: (c) async { - _sendKey(context, down: false, action: action, connection: connection); - }, - ); + children: group.value.map( + (action) { + final hotkey = _hotkeys[action]; + return PrimaryButton( + size: ButtonSize(1.6), + leading: hotkey != null + ? KeyWidget( + label: hotkey.toUpperCase(), + ) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(action.title), + if (action.alternativeTitle != null) + Text( + action.alternativeTitle!, + style: TextStyle(fontSize: 12, color: Colors.gray), + ), + ], + ), + onPressed: () {}, + onTapDown: (c) async { + _sendKey(context, down: true, action: action, connection: connection); }, - ) - .toList(), + onTapUp: (c) async { + _sendKey(context, down: false, action: action, connection: connection); + }, + ); + }, + ).toList(), ), SizedBox(height: 12), ], @@ -335,8 +359,8 @@ class _ButtonSimulatorState extends State { class _HotkeySettingsDialog extends StatefulWidget { final List connections; - final Map currentHotkeys; - final Function(Map) onSave; + final Map currentHotkeys; + final Function(Map) onSave; const _HotkeySettingsDialog({ required this.connections, @@ -349,10 +373,10 @@ class _HotkeySettingsDialog extends StatefulWidget { } class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { - late Map _editableHotkeys; - String? _editingAction; + late Map _editableHotkeys; + InGameAction? _editingAction; late FocusNode _focusNode; - + static final _validHotkeyPattern = RegExp(r'[0-9a-z]'); @override @@ -370,9 +394,9 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { KeyEventResult _onKey(FocusNode node, KeyEvent event) { if (_editingAction == null || event is! KeyDownEvent) return KeyEventResult.ignored; - + final key = event.logicalKey.keyLabel.toLowerCase(); - + // Only allow single character 1-9 and a-z if (key.length == 1 && _validHotkeyPattern.hasMatch(key)) { setState(() { @@ -381,7 +405,7 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { }); return KeyEventResult.handled; } - + // Escape to cancel if (event.logicalKey == LogicalKeyboardKey.escape) { setState(() { @@ -389,7 +413,7 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { }); return KeyEventResult.handled; } - + return KeyEventResult.ignored; } @@ -405,7 +429,7 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { focusNode: _focusNode, autofocus: true, onKeyEvent: _onKey, - child: Dialog( + child: AlertDialog( title: Text('Configure Keyboard Hotkeys'), content: SizedBox( width: 500, @@ -421,9 +445,9 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { child: Column( spacing: 8, children: uniqueActions.map((action) { - final hotkey = _editableHotkeys[action.name]; - final isEditing = _editingAction == action.name; - + final hotkey = _editableHotkeys[action]; + final isEditing = _editingAction == action; + return Card( child: Container( padding: EdgeInsets.all(12), @@ -445,20 +469,20 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { Container( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: Colors.grey.withOpacity(0.3), + color: Colors.gray.withOpacity(0.3), borderRadius: BorderRadius.circular(4), ), child: Text(hotkey.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold)), ) else - Text('No hotkey', style: TextStyle(color: Colors.grey)), + Text('No hotkey', style: TextStyle(color: Colors.gray)), SizedBox(width: 8), OutlineButton( size: ButtonSize.small, child: Text(isEditing ? 'Cancel' : 'Set'), onPressed: () { setState(() { - _editingAction = isEditing ? null : action.name; + _editingAction = isEditing ? null : action; }); }, ), @@ -469,7 +493,7 @@ class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { child: Text('Clear'), onPressed: () { setState(() { - _editableHotkeys.remove(action.name); + _editableHotkeys.remove(action); }); }, ), diff --git a/lib/pages/touch_area.dart b/lib/pages/touch_area.dart index cbda67a..64392f0 100644 --- a/lib/pages/touch_area.dart +++ b/lib/pages/touch_area.dart @@ -405,14 +405,14 @@ class KeypairExplanation extends StatelessWidget { else Icon(keyPair.icon), if (keyPair.inGameAction != null && core.logic.emulatorEnabled) - _KeyWidget( + KeyWidget( label: [ keyPair.inGameAction.toString().split('.').last, if (keyPair.inGameActionValue != null) ': ${keyPair.inGameActionValue}', ].joinToString(separator: ''), ) else if (keyPair.isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media)) - _KeyWidget( + KeyWidget( label: switch (keyPair.physicalKey) { PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause', PhysicalKeyboardKey.mediaStop => 'Stop', @@ -424,12 +424,12 @@ class KeypairExplanation extends StatelessWidget { }, ) else if (keyPair.physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[ - _KeyWidget( + KeyWidget( label: keyPair.toString(), ), ] else ...[ if (!withKey && keyPair.touchPosition != Offset.zero && core.logic.showLocalRemoteOptions) - _KeyWidget(label: 'X:${keyPair.touchPosition.dx.toInt()}, Y:${keyPair.touchPosition.dy.toInt()}'), + KeyWidget(label: 'X:${keyPair.touchPosition.dx.toInt()}, Y:${keyPair.touchPosition.dy.toInt()}'), ], if (keyPair.isLongPress) Text(context.i18n.longPress, style: TextStyle(fontSize: 10)), ], @@ -437,9 +437,9 @@ class KeypairExplanation extends StatelessWidget { } } -class _KeyWidget extends StatelessWidget { +class KeyWidget extends StatelessWidget { final String label; - const _KeyWidget({super.key, required this.label}); + const KeyWidget({super.key, required this.label}); @override Widget build(BuildContext context) { diff --git a/lib/utils/keymap/buttons.dart b/lib/utils/keymap/buttons.dart index b86d429..07f87ac 100644 --- a/lib/utils/keymap/buttons.dart +++ b/lib/utils/keymap/buttons.dart @@ -10,9 +10,9 @@ import 'package:swift_control/bluetooth/devices/zwift/constants.dart'; enum InGameAction { shiftUp('Shift Up'), shiftDown('Shift Down'), - uturn('U-Turn'), - steerLeft('Steer Left'), - steerRight('Steer Right'), + uturn('U-Turn', alternativeTitle: 'Down'), + steerLeft('Steer Left', alternativeTitle: 'Left'), + steerRight('Steer Right', alternativeTitle: 'Right'), // mywhoosh cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), @@ -24,7 +24,7 @@ enum InGameAction { decreaseResistance('Decrease Resistance'), // zwift - openActionBar('Open Action Bar'), + openActionBar('Open Action Bar', alternativeTitle: 'Up'), usePowerUp('Use Power-Up'), select('Select'), back('Back'), @@ -35,9 +35,10 @@ enum InGameAction { headwindHeartRateMode('Headwind HR Mode'); final String title; + final String? alternativeTitle; final List? possibleValues; - const InGameAction(this.title, {this.possibleValues}); + const InGameAction(this.title, {this.possibleValues, this.alternativeTitle}); @override String toString() { diff --git a/lib/utils/settings/settings.dart b/lib/utils/settings/settings.dart index f036f59..5071f75 100644 --- a/lib/utils/settings/settings.dart +++ b/lib/utils/settings/settings.dart @@ -14,6 +14,7 @@ import 'package:window_manager/window_manager.dart'; import '../../main.dart'; import '../actions/desktop.dart'; import '../keymap/apps/custom_app.dart'; +import '../keymap/buttons.dart'; class Settings { late final SharedPreferences prefs; @@ -305,30 +306,35 @@ class Settings { } // Button Simulator Hotkey Settings - Map getButtonSimulatorHotkeys() { + Map getButtonSimulatorHotkeys() { final json = prefs.getString('button_simulator_hotkeys'); if (json == null) return {}; try { final decoded = jsonDecode(json) as Map; - return decoded.map((key, value) => MapEntry(key, value.toString())); + return decoded.map( + (key, value) => MapEntry(InGameAction.values.firstWhere((e) => e.name == key), value.toString()), + ); } catch (e) { return {}; } } - Future setButtonSimulatorHotkeys(Map hotkeys) async { - await prefs.setString('button_simulator_hotkeys', jsonEncode(hotkeys)); + Future setButtonSimulatorHotkeys(Map hotkeys) async { + await prefs.setString( + 'button_simulator_hotkeys', + jsonEncode(hotkeys.map((key, value) => MapEntry(key.name, value))), + ); } - Future setButtonSimulatorHotkey(String actionName, String hotkey) async { + Future setButtonSimulatorHotkey(InGameAction action, String hotkey) async { final hotkeys = getButtonSimulatorHotkeys(); - hotkeys[actionName] = hotkey; + hotkeys[action] = hotkey; await setButtonSimulatorHotkeys(hotkeys); } - Future removeButtonSimulatorHotkey(String actionName) async { + Future removeButtonSimulatorHotkey(InGameAction action) async { final hotkeys = getButtonSimulatorHotkeys(); - hotkeys.remove(actionName); + hotkeys.remove(action); await setButtonSimulatorHotkeys(hotkeys); } } diff --git a/lib/widgets/apps/zwift_tile.dart b/lib/widgets/apps/zwift_tile.dart index 325837e..db0eba1 100644 --- a/lib/widgets/apps/zwift_tile.dart +++ b/lib/widgets/apps/zwift_tile.dart @@ -37,6 +37,7 @@ class _ZwiftTileState extends State { core.zwiftEmulator.stopAdvertising(); } else if (value) { core.zwiftEmulator.startAdvertising(widget.onUpdate).catchError((e) { + core.zwiftEmulator.isStarted.value = false; core.settings.setZwiftBleEmulatorEnabled(false); core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString())); }); diff --git a/test/button_simulator_hotkeys_test.dart b/test/button_simulator_hotkeys_test.dart index 6e52bf7..a58c2f3 100644 --- a/test/button_simulator_hotkeys_test.dart +++ b/test/button_simulator_hotkeys_test.dart @@ -1,7 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:swift_control/utils/core.dart'; -import 'package:swift_control/utils/settings/settings.dart'; +import 'package:swift_control/utils/keymap/buttons.dart'; void main() { group('Button Simulator Hotkey Tests', () { @@ -18,84 +18,67 @@ void main() { test('Should save and retrieve hotkeys', () async { final testHotkeys = { - 'shiftUp': '1', - 'shiftDown': '2', - 'uturn': '3', + InGameAction.shiftUp: '1', + InGameAction.shiftDown: '2', + InGameAction.uturn: '3', }; await core.settings.setButtonSimulatorHotkeys(testHotkeys); final retrievedHotkeys = core.settings.getButtonSimulatorHotkeys(); - expect(retrievedHotkeys['shiftUp'], '1'); - expect(retrievedHotkeys['shiftDown'], '2'); - expect(retrievedHotkeys['uturn'], '3'); + expect(retrievedHotkeys[InGameAction.shiftUp], '1'); + expect(retrievedHotkeys[InGameAction.shiftDown], '2'); + expect(retrievedHotkeys[InGameAction.uturn], '3'); expect(retrievedHotkeys.length, 3); }); test('Should set individual hotkey', () async { - await core.settings.setButtonSimulatorHotkey('shiftUp', 'q'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, 'q'); final hotkeys = core.settings.getButtonSimulatorHotkeys(); - expect(hotkeys['shiftUp'], 'q'); + expect(hotkeys[InGameAction.shiftUp], 'q'); }); test('Should update existing hotkey', () async { - await core.settings.setButtonSimulatorHotkey('shiftUp', '1'); - await core.settings.setButtonSimulatorHotkey('shiftUp', 'q'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, '1'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, 'q'); final hotkeys = core.settings.getButtonSimulatorHotkeys(); - expect(hotkeys['shiftUp'], 'q'); + expect(hotkeys[InGameAction.shiftUp], 'q'); }); test('Should remove hotkey', () async { - await core.settings.setButtonSimulatorHotkey('shiftUp', '1'); - await core.settings.setButtonSimulatorHotkey('shiftDown', '2'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, '1'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftDown, '2'); - await core.settings.removeButtonSimulatorHotkey('shiftUp'); + await core.settings.removeButtonSimulatorHotkey(InGameAction.shiftUp); final hotkeys = core.settings.getButtonSimulatorHotkeys(); - expect(hotkeys.containsKey('shiftUp'), false); - expect(hotkeys['shiftDown'], '2'); - }); - - test('Should persist hotkeys across settings instances', () async { - final testHotkeys = { - 'shiftUp': 'a', - 'shiftDown': 'b', - }; - - await core.settings.setButtonSimulatorHotkeys(testHotkeys); - - // Create new settings instance - final newSettings = Settings(); - await newSettings.init(); - - final retrievedHotkeys = newSettings.getButtonSimulatorHotkeys(); - expect(retrievedHotkeys['shiftUp'], 'a'); - expect(retrievedHotkeys['shiftDown'], 'b'); + expect(hotkeys.containsKey(InGameAction.shiftUp), false); + expect(hotkeys[InGameAction.shiftDown], '2'); }); test('Should handle multiple actions with different hotkeys', () async { final testHotkeys = { - 'shiftUp': '1', - 'shiftDown': '2', - 'uturn': '3', - 'steerLeft': 'q', - 'steerRight': 'w', - 'openActionBar': 'a', - 'usePowerUp': 's', + InGameAction.shiftUp: '1', + InGameAction.shiftDown: '2', + InGameAction.uturn: '3', + InGameAction.steerLeft: 'q', + InGameAction.steerRight: 'w', + InGameAction.openActionBar: 'a', + InGameAction.usePowerUp: 's', }; await core.settings.setButtonSimulatorHotkeys(testHotkeys); final retrievedHotkeys = core.settings.getButtonSimulatorHotkeys(); expect(retrievedHotkeys.length, 7); - expect(retrievedHotkeys['steerLeft'], 'q'); - expect(retrievedHotkeys['usePowerUp'], 's'); + expect(retrievedHotkeys[InGameAction.steerLeft], 'q'); + expect(retrievedHotkeys[InGameAction.usePowerUp], 's'); }); test('Should clear all hotkeys', () async { - await core.settings.setButtonSimulatorHotkeys({'shiftUp': '1', 'shiftDown': '2'}); + await core.settings.setButtonSimulatorHotkeys({InGameAction.shiftUp: '1', InGameAction.shiftDown: '2'}); await core.settings.setButtonSimulatorHotkeys({}); final hotkeys = core.settings.getButtonSimulatorHotkeys(); From b94fed2f2120f4fff1d68d673c60d9880a854da4 Mon Sep 17 00:00:00 2001 From: Jonas Bark Date: Thu, 11 Dec 2025 09:26:08 +0100 Subject: [PATCH 8/8] add wahoo kickr support to readme --- README.md | 6 +++++- lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5122075..d8ae298 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,11 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt - We're working on creating an affordable alternative based on an open standard, supported by all major trainer apps - register your interest [here](https://openbikecontrol.org/#HARDWARE) -Support for other devices can be added; check the issues tab here on GitHub. +Support for other devices can be added; check the issues tab here on GitHub. + +## Supported Accessories +- Wahoo KICKR HEADWIND (beta) + - control fan speed using your controller ## Supported Platforms diff --git a/lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart b/lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart index 37537fe..40143d2 100644 --- a/lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart +++ b/lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart @@ -17,6 +17,7 @@ class WahooKickrHeadwind extends BluetoothDevice { WahooKickrHeadwind(super.scanResult) : super( availableButtons: const [], + isBeta: true, ); @override