mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Merge pull request #221 from jonasbark/copilot/configure-hotkeys-for-buttons
Add keyboard hotkey configuration for button simulator
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ class WahooKickrHeadwind extends BluetoothDevice {
|
||||
WahooKickrHeadwind(super.scanResult)
|
||||
: super(
|
||||
availableButtons: const [],
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
@override
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
|
||||
88
test/button_simulator_hotkeys_test.dart
Normal file
88
test/button_simulator_hotkeys_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user