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 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 b2fe8c4..55b1cc9 100644 --- a/lib/pages/button_simulator.dart +++ b/lib/pages/button_simulator.dart @@ -1,7 +1,9 @@ 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/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'; @@ -11,123 +13,280 @@ 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', + ]; + + static const Duration _keyPressDuration = Duration(milliseconds: 100); + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'ButtonSimulatorFocus', canRequestFocus: true); + _loadHotkeys(); + _focusNode.requestFocus(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + 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 = {}; + int hotkeyIndex = 0; + for (final action in allActions.distinct()) { + if (hotkeyIndex < _defaultHotkeyOrder.length) { + defaultHotkeys[action] = _defaultHotkeyOrder[hotkeyIndex]; + hotkeyIndex++; + } + } + + await core.settings.setButtonSimulatorHotkeys(defaultHotkeys); + if (mounted) { + 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 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)); + + if (connection != null) { + _sendKey(context, down: true, action: action, connection: connection); + // Schedule key up event + Future.delayed( + _keyPressDuration, + () { + if (mounted) { + _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; + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKey, + child: Scaffold( + headers: [ + AppBar( + leading: [BackButton()], + title: Text(context.i18n.simulateButtons), + trailing: [ + PrimaryButton( + child: 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(), - }; + 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( + return [ + GradientText(connection.title).bold.large, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + for (final group in actionGroups.entries) ...[ + Text(group.key).bold, + Wrap( + spacing: 12, + runSpacing: 12, + children: group.value.map( + (action) { + final hotkey = _hotkeys[action]; + return PrimaryButton( size: ButtonSize(1.6), - child: Text(action.title), + 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); }, onTapUp: (c) async { _sendKey(context, down: false, action: action, connection: connection); }, - ), - ) - .toList(), - ), - SizedBox(height: 12), + ); + }, + ).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(), - ), + ), + ]; + }, + ).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 +340,192 @@ 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; + InGameAction? _editingAction; + late FocusNode _focusNode; + + static final _validHotkeyPattern = RegExp(r'[0-9a-z]'); + + @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 single character 1-9 and a-z + if (key.length == 1 && _validHotkeyPattern.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: AlertDialog( + 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]; + final isEditing = _editingAction == action; + + 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.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.gray)), + SizedBox(width: 8), + OutlineButton( + size: ButtonSize.small, + child: Text(isEditing ? 'Cancel' : 'Set'), + onPressed: () { + setState(() { + _editingAction = isEditing ? null : action; + }); + }, + ), + if (hotkey != null && !isEditing) ...[ + SizedBox(width: 4), + OutlineButton( + size: ButtonSize.small, + child: Text('Clear'), + onPressed: () { + setState(() { + _editableHotkeys.remove(action); + }); + }, + ), + ], + ], + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ), + actions: [ + SecondaryButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + PrimaryButton( + child: Text('Save'), + onPressed: () async { + await core.settings.setButtonSimulatorHotkeys(_editableHotkeys); + widget.onSave(_editableHotkeys); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ], + ), + ); + } } 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 e5b8e66..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; @@ -303,4 +304,37 @@ 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(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.map((key, value) => MapEntry(key.name, value))), + ); + } + + Future setButtonSimulatorHotkey(InGameAction action, String hotkey) async { + final hotkeys = getButtonSimulatorHotkeys(); + hotkeys[action] = hotkey; + await setButtonSimulatorHotkeys(hotkeys); + } + + Future removeButtonSimulatorHotkey(InGameAction action) async { + final hotkeys = getButtonSimulatorHotkeys(); + 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 new file mode 100644 index 0000000..a58c2f3 --- /dev/null +++ b/test/button_simulator_hotkeys_test.dart @@ -0,0 +1,88 @@ +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/keymap/buttons.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 = { + InGameAction.shiftUp: '1', + InGameAction.shiftDown: '2', + InGameAction.uturn: '3', + }; + + await core.settings.setButtonSimulatorHotkeys(testHotkeys); + + final retrievedHotkeys = core.settings.getButtonSimulatorHotkeys(); + 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(InGameAction.shiftUp, 'q'); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys[InGameAction.shiftUp], 'q'); + }); + + test('Should update existing hotkey', () async { + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, '1'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, 'q'); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys[InGameAction.shiftUp], 'q'); + }); + + test('Should remove hotkey', () async { + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, '1'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftDown, '2'); + + await core.settings.removeButtonSimulatorHotkey(InGameAction.shiftUp); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys.containsKey(InGameAction.shiftUp), false); + expect(hotkeys[InGameAction.shiftDown], '2'); + }); + + test('Should handle multiple actions with different hotkeys', () async { + final testHotkeys = { + 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[InGameAction.steerLeft], 'q'); + expect(retrievedHotkeys[InGameAction.usePowerUp], 's'); + }); + + test('Should clear all hotkeys', () async { + await core.settings.setButtonSimulatorHotkeys({InGameAction.shiftUp: '1', InGameAction.shiftDown: '2'}); + await core.settings.setButtonSimulatorHotkeys({}); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys.isEmpty, true); + }); + }); +}