Merge pull request #221 from jonasbark/copilot/configure-hotkeys-for-buttons

Add keyboard hotkey configuration for button simulator
This commit is contained in:
jonasbark
2025-12-11 08:26:35 +00:00
committed by GitHub
10 changed files with 597 additions and 117 deletions

View File

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

View File

@@ -17,6 +17,7 @@ class WahooKickrHeadwind extends BluetoothDevice {
WahooKickrHeadwind(super.scanResult)
: super(
availableButtons: const [],
isBeta: true,
);
@override

View File

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

View File

@@ -314,7 +314,9 @@ class FtmsMdnsEmulator extends TrainerConnection {
}
void _write(Socket socket, List<int> 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<int> _buildNotify(String uuid, final List<int> data) {

View File

@@ -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<ButtonSimulator> createState() => _ButtonSimulatorState();
}
class _ButtonSimulatorState extends State<ButtonSimulator> {
late final FocusNode _focusNode;
Map<InGameAction, String> _hotkeys = {};
// Default hotkeys for actions
static const List<String> _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<void> _loadHotkeys() async {
final savedHotkeys = core.settings.getButtonSimulatorHotkeys();
// If no saved hotkeys, initialize with defaults
if (savedHotkeys.isEmpty) {
final connectedTrainers = core.logic.connectedTrainerConnections;
final allActions = <InGameAction>[];
for (final connection in connectedTrainers) {
allActions.addAll(connection.supportedActions);
}
// Assign default hotkeys to actions
final Map<InGameAction, String> 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<TrainerConnection> connections) {
showDialog(
context: context,
builder: (context) => _HotkeySettingsDialog(
connections: connections,
currentHotkeys: _hotkeys,
onSave: (newHotkeys) {
setState(() {
_hotkeys = newHotkeys;
});
},
),
);
}
}
class _HotkeySettingsDialog extends StatefulWidget {
final List<TrainerConnection> connections;
final Map<InGameAction, String> currentHotkeys;
final Function(Map<InGameAction, String>) onSave;
const _HotkeySettingsDialog({
required this.connections,
required this.currentHotkeys,
required this.onSave,
});
@override
State<_HotkeySettingsDialog> createState() => _HotkeySettingsDialogState();
}
class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> {
late Map<InGameAction, String> _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 = <InGameAction>[];
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();
}
},
),
],
),
);
}
}

View File

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

View File

@@ -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<int>? possibleValues;
const InGameAction(this.title, {this.possibleValues});
const InGameAction(this.title, {this.possibleValues, this.alternativeTitle});
@override
String toString() {

View File

@@ -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<InGameAction, String> getButtonSimulatorHotkeys() {
final json = prefs.getString('button_simulator_hotkeys');
if (json == null) return {};
try {
final decoded = jsonDecode(json) as Map<String, dynamic>;
return decoded.map(
(key, value) => MapEntry(InGameAction.values.firstWhere((e) => e.name == key), value.toString()),
);
} catch (e) {
return {};
}
}
Future<void> setButtonSimulatorHotkeys(Map<InGameAction, String> hotkeys) async {
await prefs.setString(
'button_simulator_hotkeys',
jsonEncode(hotkeys.map((key, value) => MapEntry(key.name, value))),
);
}
Future<void> setButtonSimulatorHotkey(InGameAction action, String hotkey) async {
final hotkeys = getButtonSimulatorHotkeys();
hotkeys[action] = hotkey;
await setButtonSimulatorHotkeys(hotkeys);
}
Future<void> removeButtonSimulatorHotkey(InGameAction action) async {
final hotkeys = getButtonSimulatorHotkeys();
hotkeys.remove(action);
await setButtonSimulatorHotkeys(hotkeys);
}
}

View File

@@ -37,6 +37,7 @@ class _ZwiftTileState extends State<ZwiftTile> {
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()));
});

View File

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