integration #1

This commit is contained in:
Jonas Bark
2025-10-28 17:58:44 +01:00
parent 7e18a169d4
commit 56d9e62610
21 changed files with 624 additions and 441 deletions

View File

@@ -59,7 +59,7 @@ class ZwiftButtons {
// left controller
static const ControllerButton navigationUp = ControllerButton(
'navigationUp',
action: null,
action: InGameAction.toggleUi,
icon: Icons.keyboard_arrow_up,
color: Colors.black,
);

View File

@@ -0,0 +1,356 @@
import 'dart:io';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:permission_handler/permission_handler.dart';
import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'protocol/zwift.pb.dart' show RideKeyPadStatus;
final zwiftEmulator = ZwiftEmulator();
class ZwiftEmulator {
static final List<InGameAction> supportedActions = InGameAction.values;
ValueNotifier<bool> isConnected = ValueNotifier<bool>(false);
bool get isAdvertising => _isAdvertising;
bool get isLoading => _isLoading;
final peripheralManager = PeripheralManager();
bool _isAdvertising = false;
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
GATTCharacteristic? _asyncCharacteristic;
Future<void> reconnect() async {
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
_isServiceAdded = false;
_isAdvertising = false;
startAdvertising(() {});
}
Future<void> startAdvertising(VoidCallback onUpdate) async {
_isLoading = true;
onUpdate();
peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
if (!kIsWeb && Platform.isAndroid) {
if (Platform.isAndroid) {
peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
/*(actionHandler as RemoteActions).setConnectedCentral(state.central, inputReport);
//peripheralManager.stopAdvertising();
onUpdate();*/
} else if (state.state == ConnectionState.disconnected) {
//(actionHandler as RemoteActions).setConnectedCentral(null, null);
onUpdate();
}
});
}
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
_isAdvertising = false;
onUpdate();
return;
}
}
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
print('Waiting for peripheral manager to be powered on...');
if (settings.getLastTarget() == Target.thisDevice) {
return;
}
await Future.delayed(Duration(seconds: 1));
}
final syncTxCharacteristic = GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.indicate,
],
permissions: [
GATTCharacteristicPermission.read,
],
);
_asyncCharacteristic = GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.notify,
],
permissions: [],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
case ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID:
print('Handling read request for SYNC TX characteristic');
break;
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
await peripheralManager.respondReadRequestWithValue(
eventArgs.request,
value: Uint8List.fromList([100]),
);
break;
default:
print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}');
}
final request = eventArgs.request;
final trimmedValue = Uint8List.fromList([]);
await peripheralManager.respondReadRequestWithValue(
request,
value: trimmedValue,
);
// You can respond to read requests here if needed
});
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
print(
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
);
});
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
_central = eventArgs.central;
isConnected.value = true;
final characteristic = eventArgs.characteristic;
final request = eventArgs.request;
final value = request.value;
print(
'Write request for characteristic: ${characteristic.uuid}',
);
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
case ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID:
print(
'Handling write request for SYNC RX characteristic, value: ${value.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}\n${String.fromCharCodes(value)}',
);
final handshake = [...ZwiftConstants.RIDE_ON, ...ZwiftConstants.RESPONSE_START_CLICK_V2];
if (value.contentEquals(handshake)) {
await peripheralManager.notifyCharacteristic(
_central!,
syncTxCharacteristic,
value: ZwiftConstants.RIDE_ON,
);
onUpdate();
}
break;
default:
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
}
await peripheralManager.respondWriteRequest(request);
});
}
// Device Information
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString('180A'),
isPrimary: true,
characteristics: [
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A29'),
value: Uint8List.fromList('Zwift Inc'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A25'),
value: Uint8List.fromList('09-B48123283828F1337'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A27'),
value: Uint8List.fromList('A.0'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A26'),
value: Uint8List.fromList('1.1.0'.codeUnits),
descriptors: [],
),
],
includedServices: [],
),
);
// Battery Service
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
characteristics: [
GATTCharacteristic.mutable(
uuid: UUID.fromString('2A19'),
descriptors: [],
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.notify,
],
permissions: [
GATTCharacteristicPermission.read,
],
),
],
includedServices: [],
),
);
// Unknown Service
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT),
isPrimary: true,
characteristics: [
_asyncCharacteristic!,
GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.writeWithoutResponse,
],
permissions: [],
),
syncTxCharacteristic,
GATTCharacteristic.mutable(
uuid: UUID.fromString('00000005-19CA-4651-86E5-FA29DCDD09D1'),
descriptors: [],
properties: [
GATTCharacteristicProperty.notify,
],
permissions: [],
),
GATTCharacteristic.mutable(
uuid: UUID.fromString('00000006-19CA-4651-86E5-FA29DCDD09D1'),
descriptors: [],
properties: [
GATTCharacteristicProperty.indicate,
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.writeWithoutResponse,
GATTCharacteristicProperty.write,
],
permissions: [
GATTCharacteristicPermission.read,
GATTCharacteristicPermission.write,
],
),
],
includedServices: [],
),
);
_isServiceAdded = true;
}
final advertisement = Advertisement(
name: 'SwiftControl',
serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT)],
serviceData: {
UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT): Uint8List.fromList([0x02]),
},
manufacturerSpecificData: [
ManufacturerSpecificData(
id: 0x094A,
data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x13, 0x37]),
),
],
);
print('Starting advertising with HID service...');
await peripheralManager.startAdvertising(advertisement);
_isAdvertising = true;
_isLoading = false;
onUpdate();
}
Future<void> stopAdvertising() async {
await peripheralManager.stopAdvertising();
_isAdvertising = false;
_isLoading = false;
}
Future<String> sendAction(InGameAction inGameAction, int? inGameActionValue) async {
final button = switch (inGameAction) {
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
InGameAction.navigateLeft => RideButtonMask.LEFT_BTN,
InGameAction.navigateRight => RideButtonMask.RIGHT_BTN,
InGameAction.increaseResistance => RideButtonMask.SHFT_UP_R_BTN,
InGameAction.decreaseResistance => RideButtonMask.SHFT_UP_L_BTN,
InGameAction.toggleUi => RideButtonMask.UP_BTN,
InGameAction.cameraAngle => RideButtonMask.Z_BTN,
InGameAction.emote => RideButtonMask.A_BTN,
InGameAction.uturn => RideButtonMask.DOWN_BTN,
InGameAction.steerLeft => RideButtonMask.LEFT_BTN,
InGameAction.steerRight => RideButtonMask.RIGHT_BTN,
};
final status = RideKeyPadStatus()
..buttonMap = (~button.mask) & 0xFFFFFFFF
..analogPaddles.clear();
// Serialize to bytes if you need to send it
final bytes = status.writeToBuffer();
//..buttonMinus = !down ? PlayButtonStatus.ON : PlayButtonStatus.OFF;
final commandProto = Uint8List.fromList([
Opcode.CONTROLLER_NOTIFICATION.value,
...bytes,
]);
print('Constructed proto : ${commandProto.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}');
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: commandProto);
final zero = Uint8List.fromList([0x23, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
return 'Sent action: ${inGameAction.name}';
}
}
class ZwiftEmulatorInformation extends StatelessWidget {
const ZwiftEmulatorInformation({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: zwiftEmulator.isConnected,
builder: (context, isConnected, _) {
return StatefulBuilder(
builder: (context, setState) {
return Text('Zwift is ${isConnected ? 'connected' : 'not connected'}');
},
);
},
);
}
}

View File

@@ -34,6 +34,7 @@ enum ConnectionType {
unknown,
local,
remote,
zwift,
}
Future<void> initializeActions(ConnectionType connectionType) async {
@@ -42,18 +43,21 @@ Future<void> initializeActions(ConnectionType connectionType) async {
} else if (Platform.isAndroid) {
actionHandler = switch (connectionType) {
ConnectionType.local => AndroidActions(),
ConnectionType.zwift => AndroidActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.unknown => StubActions(),
};
} else if (Platform.isIOS) {
actionHandler = switch (connectionType) {
ConnectionType.local => StubActions(),
ConnectionType.zwift => StubActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.unknown => StubActions(),
};
} else {
actionHandler = switch (connectionType) {
ConnectionType.local => DesktopActions(),
ConnectionType.zwift => DesktopActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.unknown => StubActions(),
};

View File

@@ -6,9 +6,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:swift_control/bluetooth/devices/link/link_device.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/keymap/apps/zwift.dart';
import 'package:swift_control/utils/keymap/manager.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
@@ -229,7 +231,8 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (connection.remoteDevices.isNotEmpty ||
actionHandler is RemoteActions ||
settings.getTrainerApp() is MyWhoosh)
settings.getTrainerApp() is MyWhoosh ||
settings.getTrainerApp() is Zwift)
Container(
margin: const EdgeInsets.only(bottom: 8.0),
width: double.infinity,
@@ -254,6 +257,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (settings.getTrainerApp() is MyWhoosh && !whooshLink.isConnected.value)
LinkDevice('').showInformation(context),
if (settings.getTrainerApp() is Zwift) ZwiftEmulatorInformation(),
if (actionHandler is RemoteActions)
Row(

View File

@@ -175,7 +175,9 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
void _reloadRequirements() {
getRequirements(settings.getLastTarget()?.connectionType ?? ConnectionType.unknown).then((req) {
getRequirements(
settings.getTrainerApp()?.connectionType ?? settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
).then((req) {
_requirements = req;
final unresolvedIndex = req.indexWhere((req) => !req.status);
if (unresolvedIndex != -1) {

View File

@@ -9,6 +9,7 @@ import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/widgets/button_widget.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
@@ -400,7 +401,7 @@ class KeypairExplanation extends StatelessWidget {
)
else
Icon(keyPair.icon),
if (keyPair.inGameAction != null && whooshLink.isConnected.value)
if (keyPair.inGameAction != null && (whooshLink.isConnected.value || zwiftEmulator.isConnected.value))
_KeyWidget(
label: [
keyPair.inGameAction.toString().split('.').last,

View File

@@ -1,5 +1,6 @@
import 'package:accessibility/accessibility.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/base_actions.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
@@ -37,6 +38,8 @@ class AndroidActions extends BaseActions {
if (keyPair.inGameAction != null && whooshLink.isConnected.value) {
return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
} else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) {
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
} else if (keyPair.isSpecialKey) {
await accessibilityHandler.controlMedia(switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaTrackNext => MediaAction.next,

View File

@@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/base_actions.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
@@ -25,6 +26,8 @@ class DesktopActions extends BaseActions {
// Handle regular key press mode (existing behavior)
if (keyPair.inGameAction != null && whooshLink.isConnected.value) {
return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
} else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) {
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
} else if (keyPair.physicalKey != null) {
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);

View File

@@ -4,6 +4,7 @@ import 'package:accessibility/accessibility.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/base_actions.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
@@ -29,6 +30,8 @@ class RemoteActions extends BaseActions {
if (keyPair.inGameAction != null && whooshLink.isConnected.value) {
return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
} else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) {
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
} else if (!(actionHandler as RemoteActions).isConnected) {
return 'Not connected to a ${settings.getLastTarget()?.name ?? 'remote'} device';
}

View File

@@ -1,6 +1,7 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import '../buttons.dart';
import '../keymap.dart';
@@ -10,6 +11,7 @@ class Biketerra extends SupportedApp {
: super(
name: 'Biketerra',
packageName: "biketerra",
compatibleTargets: Target.values,
keymap: Keymap(
keyPairs: [
KeyPair(

View File

@@ -1,6 +1,7 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import '../buttons.dart';
import '../keymap.dart';
@@ -11,6 +12,7 @@ class CustomApp extends SupportedApp {
CustomApp({this.profileName = 'Other'})
: super(
name: profileName,
compatibleTargets: Target.values,
packageName: "custom_$profileName",
keymap: Keymap(keyPairs: []),
);

View File

@@ -1,6 +1,7 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import '../buttons.dart';
import '../keymap.dart';
@@ -10,6 +11,7 @@ class MyWhoosh extends SupportedApp {
: super(
name: 'MyWhoosh',
packageName: "com.mywhoosh.whooshgame",
compatibleTargets: Target.values,
keymap: Keymap(
keyPairs: [
KeyPair(

View File

@@ -1,18 +1,29 @@
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/biketerra.dart';
import 'package:swift_control/utils/keymap/apps/training_peaks.dart';
import 'package:swift_control/utils/keymap/apps/zwift.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import '../keymap.dart';
import 'custom_app.dart';
import 'my_whoosh.dart';
abstract class SupportedApp {
final List<Target> compatibleTargets;
final String packageName;
final String name;
final Keymap keymap;
final ConnectionType? connectionType;
const SupportedApp({required this.name, required this.packageName, required this.keymap});
const SupportedApp({
required this.name,
required this.packageName,
required this.keymap,
required this.compatibleTargets,
this.connectionType,
});
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];
static final List<SupportedApp> supportedApps = [MyWhoosh(), Zwift(), TrainingPeaks(), Biketerra(), CustomApp()];
@override
String toString() {

View File

@@ -4,6 +4,7 @@ import 'package:swift_control/bluetooth/devices/elite/elite_square.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import '../keymap.dart';
@@ -12,6 +13,7 @@ class TrainingPeaks extends SupportedApp {
: super(
name: 'TrainingPeaks Virtual / IndieVelo',
packageName: "com.indieVelo.client",
compatibleTargets: Target.values,
keymap: Keymap(
keyPairs: [
// Explicit controller-button mappings with updated touch coordinates

View File

@@ -0,0 +1,23 @@
import 'package:dartx/dartx.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:universal_ble/universal_ble.dart';
import '../keymap.dart';
class Zwift extends SupportedApp {
Zwift()
: super(
name: 'Zwift',
packageName: "com.zwift.zwiftgame",
connectionType: ConnectionType.zwift,
compatibleTargets: Target.values.whereNot((e) => e == Target.thisDevice).toList(),
keymap: Keymap(
keyPairs: ZwiftClickV2(BleDevice(name: '', deviceId: '')).availableButtons
.map((b) => KeyPair(buttons: [b], physicalKey: null, logicalKey: null, inGameAction: b.action))
.toList(),
),
);
}

View File

@@ -7,6 +7,7 @@ import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/keymap/apps/zwift.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/utils/requirements/remote.dart';
import 'package:swift_control/widgets/beta_pill.dart';
@@ -86,49 +87,70 @@ typedef BoolFunction = bool Function();
enum Target {
thisDevice(
title: 'This device',
description: 'Trainer app runs on this device',
title: 'This Device',
icon: Icons.devices,
),
iPad(
title: 'iPad',
description: 'Remotely control any trainer app on an iPad by acting as a Mouse, or directly via MyWhoosh Link',
iOS(
title: 'iPhone / iPad / Apple TV',
icon: Icons.settings_remote_outlined,
),
android(
title: 'Android Device',
description: 'Remotely control any trainer app on another Android device, or directly via MyWhoosh Link',
icon: Icons.settings_remote_outlined,
isBeta: true,
),
macOS(
title: 'Mac',
description: 'Remotely control any trainer app on another Mac, or directly via MyWhoosh Link',
icon: Icons.settings_remote_outlined,
isBeta: true,
),
windows(
title: 'Windows PC',
description: 'Remotely control any trainer app on another Windows PC, or directly via MyWhoosh Link',
icon: Icons.settings_remote_outlined,
isBeta: true,
);
final String title;
final String description;
final IconData icon;
final bool isBeta;
const Target({required this.title, required this.description, required this.icon, this.isBeta = false});
const Target({required this.title, required this.icon});
bool get isCompatible {
return actionHandler.supportedApp?.compatibleTargets.contains(this) == true;
}
bool get isBeta {
final supportedApp = actionHandler.supportedApp;
if (supportedApp is Zwift) {
// everything is supported, this device is not compatible anyway
return false;
}
return switch (this) {
Target.thisDevice => !Platform.isIOS,
Target.thisDevice => false,
_ => true,
};
}
String getDescription(SupportedApp? app) {
return switch (this) {
Target.thisDevice when !isCompatible =>
'Due to platform restrictions only controlling ${app?.name ?? 'the Trainer app'} on other devices is supported.',
Target.thisDevice => 'Run ${app?.name ?? 'the Trainer app'} on this device.',
Target.iOS =>
'Run ${app?.name ?? 'the Trainer app'} on your Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
Target.android =>
'Run ${app?.name ?? 'the Trainer app'} on your Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
Target.macOS =>
'Run ${app?.name ?? 'the Trainer app'} on your Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
Target.windows =>
'Run ${app?.name ?? 'the Trainer app'} on your Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
};
}
String? get warning {
if (actionHandler.supportedApp is Zwift) {
// no warnings for zwift
return null;
}
return switch (this) {
Target.android when Platform.isAndroid =>
"Select 'This device' unless you want to control another Android device. Are you sure?",
@@ -170,86 +192,104 @@ class TargetRequirement extends PlatformRequirement {
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return Column(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)),
DropdownMenu<SupportedApp>(
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
return DropdownMenuEntry(
value: app,
label: app.name,
);
}).toList(),
hintText: 'Select Trainer app',
initialSelection: settings.getTrainerApp(),
onSelected: (selectedApp) async {
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
whooshLink.stopServer();
}
settings.setTrainerApp(selectedApp!);
if (actionHandler.supportedApp == null ||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
actionHandler.init(selectedApp);
settings.setSupportedApp(selectedApp);
}
},
),
SizedBox(height: 8),
Text(
'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
style: TextStyle(fontWeight: FontWeight.bold),
),
DropdownMenu<Target>(
dropdownMenuEntries: Target.values.map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
trailingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
if (target.isBeta || (!Platform.isIOS && target == Target.iPad)) BetaPill(),
],
),
Text(
target.isCompatible
? target.description
: 'Due to iOS restrictions only controlling trainer apps on other devices is supported.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
);
}).toList(),
hintText: name,
initialSelection: settings.getLastTarget(),
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
initializeActions(target.connectionType);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
);
return StatefulBuilder(
builder: (c, setState) => Column(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)),
DropdownMenu<SupportedApp>(
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
return DropdownMenuEntry(
value: app,
label: app.name,
);
}).toList(),
hintText: 'Select Trainer app',
initialSelection: settings.getTrainerApp(),
onSelected: (selectedApp) async {
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
whooshLink.stopServer();
}
onUpdate();
}
},
),
],
settings.setTrainerApp(selectedApp!);
if (actionHandler.supportedApp == null ||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
actionHandler.init(selectedApp);
settings.setSupportedApp(selectedApp);
}
setState(() {});
},
),
SizedBox(height: 8),
Text(
'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
style: TextStyle(fontWeight: FontWeight.bold),
),
DropdownMenu<Target>(
dropdownMenuEntries: Target.values.map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
trailingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
if (target.isBeta) BetaPill(),
],
),
Text(
target.getDescription(actionHandler.supportedApp),
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (target == Target.thisDevice)
Container(
margin: EdgeInsets.only(top: 12),
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
);
}).toList(),
hintText: 'Select Target device',
initialSelection: settings.getLastTarget(),
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
initializeActions(settings.getTrainerApp()?.connectionType ?? target.connectionType);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
);
}
setState(() {});
}
},
),
ElevatedButton(
onPressed: settings.getTrainerApp() != null && settings.getLastTarget() != null
? () {
onUpdate();
}
: null,
child: Text('Continue'),
),
],
),
);
}

View File

@@ -45,6 +45,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
BluetoothTurnedOn(),
switch (connectionType) {
ConnectionType.local => KeyboardRequirement(),
ConnectionType.zwift => ZwiftRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
@@ -55,6 +56,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
BluetoothTurnedOn(),
switch (connectionType) {
ConnectionType.local => RemoteRequirement(),
ConnectionType.zwift => ZwiftRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
@@ -65,6 +67,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
BluetoothTurnedOn(),
switch (connectionType) {
ConnectionType.local => KeyboardRequirement(),
ConnectionType.zwift => ZwiftRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
@@ -84,7 +87,8 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
],
switch (connectionType) {
ConnectionType.local => AccessibilityRequirement(),
ConnectionType.remote => ZwiftRequirement(),
ConnectionType.zwift => ZwiftRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
];

View File

@@ -35,7 +35,7 @@ class RemoteRequirement extends PlatformRequirement {
? null
: Text(
switch (settings.getLastTarget()) {
Target.iPad =>
Target.iOS =>
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
_ =>
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required to use the remote feature.',

View File

@@ -1,36 +1,14 @@
import 'dart:io';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:permission_handler/permission_handler.dart';
import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' hide RideButtonMask;
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/pages/markdown.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import '../../bluetooth/devices/zwift/zwift_ride.dart';
import '../../pages/markdown.dart';
final peripheralManager = PeripheralManager();
bool _isAdvertising = false;
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
GATTCharacteristic? _asyncCharacteristic;
class ZwiftRequirement extends PlatformRequirement {
ZwiftRequirement()
: super(
'Connect to your target device',
'Pair SwiftControl with Zwift',
);
@override
@@ -41,273 +19,10 @@ class ZwiftRequirement extends PlatformRequirement {
return settings.getLastTarget() == null
? null
: Text(
switch (settings.getLastTarget()) {
Target.iPad =>
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
_ =>
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required to use the remote feature.',
},
'In Zwift on your ${settings.getLastTarget()?.title} go into the Pairing settings and select SwiftControl from the list of available controllers.',
);
}
Future<void> reconnect() async {
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
_isServiceAdded = false;
_isAdvertising = false;
(actionHandler as RemoteActions).setConnectedCentral(null, null);
startAdvertising(() {});
}
Future<void> startAdvertising(VoidCallback onUpdate) async {
peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
if (!kIsWeb && Platform.isAndroid) {
if (Platform.isAndroid) {
peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
/*(actionHandler as RemoteActions).setConnectedCentral(state.central, inputReport);
//peripheralManager.stopAdvertising();
onUpdate();*/
} else if (state.state == ConnectionState.disconnected) {
//(actionHandler as RemoteActions).setConnectedCentral(null, null);
onUpdate();
}
});
}
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
_isAdvertising = false;
onUpdate();
return;
}
}
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
print('Waiting for peripheral manager to be powered on...');
if (settings.getLastTarget() == Target.thisDevice) {
return;
}
await Future.delayed(Duration(seconds: 1));
}
final syncTxCharacteristic = GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.indicate,
],
permissions: [
GATTCharacteristicPermission.read,
],
);
_asyncCharacteristic = GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.notify,
],
permissions: [],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
case ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID:
print('Handling read request for SYNC TX characteristic');
break;
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
await peripheralManager.respondReadRequestWithValue(
eventArgs.request,
value: Uint8List.fromList([100]),
);
break;
default:
print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}');
}
final central = eventArgs.central;
final characteristic = eventArgs.characteristic;
final request = eventArgs.request;
final offset = request.offset;
final trimmedValue = Uint8List.fromList([]);
await peripheralManager.respondReadRequestWithValue(
request,
value: trimmedValue,
);
// You can respond to read requests here if needed
});
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
print(
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
);
});
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
_central = eventArgs.central;
final characteristic = eventArgs.characteristic;
final request = eventArgs.request;
final offset = request.offset;
final value = request.value;
print(
'Write request for characteristic: ${characteristic.uuid}',
);
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
case ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID:
print(
'Handling write request for SYNC RX characteristic, value: ${value.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}\n${String.fromCharCodes(value)}',
);
final handshake = [...ZwiftConstants.RIDE_ON, ...ZwiftConstants.RESPONSE_START_CLICK_V2];
if (value.contentEquals(handshake)) {
await peripheralManager.notifyCharacteristic(
_central!,
syncTxCharacteristic,
value: ZwiftConstants.RIDE_ON,
);
}
break;
default:
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
}
await peripheralManager.respondWriteRequest(request);
});
}
// Device Information
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString('180A'),
isPrimary: true,
characteristics: [
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A29'),
value: Uint8List.fromList('Zwift Inc'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A25'),
value: Uint8List.fromList('09-B48123283828F1337'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A27'),
value: Uint8List.fromList('A.0'.codeUnits),
descriptors: [],
),
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A26'),
value: Uint8List.fromList('1.1.0'.codeUnits),
descriptors: [],
),
],
includedServices: [],
),
);
// Battery Service
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
characteristics: [
GATTCharacteristic.mutable(
uuid: UUID.fromString('2A19'),
descriptors: [],
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.notify,
],
permissions: [
GATTCharacteristicPermission.read,
],
),
],
includedServices: [],
),
);
// Unknown Service
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT),
isPrimary: true,
characteristics: [
_asyncCharacteristic!,
GATTCharacteristic.mutable(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID),
descriptors: [],
properties: [
GATTCharacteristicProperty.writeWithoutResponse,
],
permissions: [],
),
syncTxCharacteristic,
GATTCharacteristic.mutable(
uuid: UUID.fromString('00000005-19CA-4651-86E5-FA29DCDD09D1'),
descriptors: [],
properties: [
GATTCharacteristicProperty.notify,
],
permissions: [],
),
GATTCharacteristic.mutable(
uuid: UUID.fromString('00000006-19CA-4651-86E5-FA29DCDD09D1'),
descriptors: [],
properties: [
GATTCharacteristicProperty.indicate,
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.writeWithoutResponse,
GATTCharacteristicProperty.write,
],
permissions: [
GATTCharacteristicPermission.read,
GATTCharacteristicPermission.write,
],
),
],
includedServices: [],
),
);
_isServiceAdded = true;
}
final advertisement = Advertisement(
name: 'SwiftControl',
serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT)],
serviceData: {
UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT): Uint8List.fromList([0x02]),
},
manufacturerSpecificData: [
ManufacturerSpecificData(
id: 0x094A,
data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x13, 0x37]),
),
],
);
print('Starting advertising with HID service...');
await peripheralManager.startAdvertising(advertisement);
_isAdvertising = true;
onUpdate();
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return _PairWidget(onUpdate: onUpdate, requirement: this);
@@ -315,33 +30,7 @@ class ZwiftRequirement extends PlatformRequirement {
@override
Future<void> getStatus() async {
status = (actionHandler as RemoteActions).isConnected || screenshotMode;
}
int counter = 0;
void writeCommand() {
final status = RideKeyPadStatus()
//..buttonMap = (~RideButtonMask.SHFT_UP_R_BTN.mask) & 0xFFFFFFFF
..buttonMap = (~RideButtonMask.SHFT_UP_L_BTN.mask) & 0xFFFFFFFF
..buttonMap = (~RideButtonMask.LEFT_BTN.mask) & 0xFFFFFFFF
..analogPaddles.clear();
// Serialize to bytes if you need to send it
final bytes = status.writeToBuffer();
//..buttonMinus = !down ? PlayButtonStatus.ON : PlayButtonStatus.OFF;
final commandProto = Uint8List.fromList([
Opcode.CONTROLLER_NOTIFICATION.value,
...bytes,
]);
print('Constructed proto : ${commandProto.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}');
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: commandProto);
final zero = Uint8List.fromList([0x23, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
status = zwiftEmulator.isConnected.value || screenshotMode;
}
}
@@ -383,22 +72,13 @@ class _PairWidgetState extends State<_PairWidget> {
print('Error toggling advertising: $e');
}
},
child: Text(_isAdvertising ? 'Stop Pairing' : 'Start Pairing'),
child: Text(zwiftEmulator.isAdvertising ? 'Stop Pairing' : 'Start Pairing'),
),
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
if (zwiftEmulator.isAdvertising || zwiftEmulator.isLoading)
SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
],
),
if (settings.getTrainerApp() is MyWhoosh)
ElevatedButton(
onPressed: () async {
widget.requirement.writeCommand();
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text('Send command'),
),
),
if (_isAdvertising) ...[
if (zwiftEmulator.isAdvertising) ...[
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));
@@ -411,18 +91,12 @@ class _PairWidgetState extends State<_PairWidget> {
}
Future<void> toggle() async {
if (_isAdvertising) {
await peripheralManager.stopAdvertising();
_isAdvertising = false;
(actionHandler as RemoteActions).setConnectedCentral(null, null);
if (zwiftEmulator.isAdvertising) {
await zwiftEmulator.stopAdvertising();
widget.onUpdate();
_isLoading = false;
setState(() {});
} else {
_isLoading = true;
setState(() {});
await widget.requirement.startAdvertising(widget.onUpdate);
_isLoading = false;
await zwiftEmulator.startAdvertising(widget.onUpdate);
if (mounted) setState(() {});
}
}

View File

@@ -15,7 +15,7 @@ class Settings {
Future<void> init() async {
prefs = await SharedPreferences.getInstance();
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
initializeActions(getTrainerApp()?.connectionType ?? getLastTarget()?.connectionType ?? ConnectionType.unknown);
if (actionHandler is DesktopActions) {
// Must add this line.

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/device.dart';
import 'package:swift_control/utils/actions/base_actions.dart';
@@ -189,6 +190,52 @@ class _ButtonEditor extends StatelessWidget {
),
),
),
if (zwiftEmulator.isConnected.value)
PopupMenuItem<PhysicalKeyboardKey>(
child: PopupMenuButton(
itemBuilder: (_) => ZwiftEmulator.supportedActions.map(
(ingame) {
return PopupMenuItem(
value: ingame,
child: ingame.possibleValues != null
? PopupMenuButton(
itemBuilder: (c) => ingame.possibleValues!
.map(
(value) => PopupMenuItem(
value: value,
child: Text(value.toString()),
onTap: () {
keyPair.inGameAction = ingame;
keyPair.inGameActionValue = value;
onUpdate();
},
),
)
.toList(),
child: Row(
children: [
Expanded(child: Text(ingame.toString())),
Icon(Icons.arrow_right),
],
),
)
: Text(ingame.toString()),
onTap: () {
keyPair.inGameAction = ingame;
keyPair.inGameActionValue = null;
onUpdate();
},
);
},
).toList(),
child: Row(
children: [
Expanded(child: Text('Zwift Action')),
Icon(Icons.arrow_right),
],
),
),
),
if (actionHandler.supportedModes.contains(SupportedMode.keyboard))
PopupMenuItem<PhysicalKeyboardKey>(
value: null,