Merge pull request #145 from jonasbark/zwift

Zwift Support
This commit is contained in:
jonasbark
2025-10-31 13:16:07 +01:00
committed by GitHub
36 changed files with 1057 additions and 205 deletions

View File

@@ -1,4 +1,4 @@
### 3.3.0 (unreleased)
### 3.3.0 (31-10-2025)
**New Feature:**
- Support for Elite Sterzo (thanks @michidk)
@@ -6,11 +6,13 @@
- Support for cheap bluetooth remotes (such as [these](https://www.amazon.com/s?k=bluetooth+remote))
- you can now customize the Keymap right from the Customize section
- show signal strength of connected devices (thanks @michidk)
- Android and Windows only: simulate bluetooth controllers
- enables gamepad and bluetooth remotes support for Zwift, Rouvy and Biketerra
**Fixes:**
- fix firmware version display for Zwift Click V2 devices
- fix touch position on some Android devices
- Wahoo Kickr Bike Shift can now be connected correctly
- Wahoo Kickr Bike Shift can now be connected
- update default keymap for TrainingPeaks
### 3.2.0 (2025-10-22)

View File

@@ -4,7 +4,7 @@
## Description
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, or other similar devices. Here's what you can do with it, depending on your configuration:
- Virtual Gear shifting
- Steering / turning
- adjust workout intensity
@@ -33,7 +33,11 @@ Check the compatibility matrix below!
- TrainingPeaks Virtual / indieVelo
- Biketerra.com
- Rouvy
- any other! You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
- Zwift
- only Android and Windows support virtual shifting and in-app-navigation
- iOS / macOS only support controlling Zwift via keyboard shortcuts or touch controls
- any other!
- you can add custom mapping and adjust touch points or keyboard shortcuts to your liking
## Supported Devices
- Zwift Click
@@ -44,7 +48,9 @@ Check the compatibility matrix below!
- Elite Sterzo Smart (for steering support)
- Elite Square Smart Frame (beta)
- Gamepads (beta)
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta, confirmed on Android)
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
- works on Android
- on iOS it would require playing back an audio file - let me know if that is of interest to you
Support for other devices can be added - check the issues tab here on GithUb.
@@ -80,7 +86,6 @@ The app connects to your Controller devices (such as Zwift ones) automatically.
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
- you can also create your own Keymaps for any other app
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
</details>
## Alternatives
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app). This can be useful if your trainer app does not support virtual shifting.

View File

@@ -12,6 +12,13 @@ class MainActivity: FlutterActivity(), GamepadsCompatibleActivity {
var keyListener: ((KeyEvent) -> Boolean)? = null
var motionListener: ((MotionEvent) -> Boolean)? = null
override fun isGamepadsInputDevice(device: InputDevice): Boolean {
return device.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|| device.sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
// Some bluetooth keyboards are identified as GamePad. Check if it is ALPHABETIC keyboard.
// && device.keyboardType != InputDevice.KEYBOARD_TYPE_ALPHABETIC
}
override fun dispatchGenericMotionEvent(motionEvent: MotionEvent): Boolean {
return motionListener?.invoke(motionEvent) ?: false
}

View File

@@ -1,8 +1,7 @@
class BleUuid {
static final DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb".toLowerCase();
static final DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb"
.toLowerCase();
static const DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb";
static const DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb";
static final DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb".toLowerCase();
static final DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb".toLowerCase();
static const DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb";
static const DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002A19-0000-1000-8000-00805F9B34FB";
}

View File

@@ -156,7 +156,7 @@ class Connection {
_addDevices(pads);
});
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh) {
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
startMyWhooshServer();
}
}

View File

@@ -79,8 +79,8 @@ abstract class BluetoothDevice extends BaseDevice {
if (device != null) {
return device;
} else if (scanResult.services.containsAny([
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(),
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(),
])) {
// otherwise use the manufacturer data to identify the device
final manufacturerData = scanResult.manufacturerDataList;

View File

@@ -10,6 +10,8 @@ import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import '../../../widgets/warning.dart';
class GamepadDevice extends BaseDevice {
final String id;
@@ -50,14 +52,29 @@ class GamepadDevice extends BaseDevice {
@override
Widget showInformation(BuildContext context) {
return Row(
children: [
Text(
name.screenshot,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),
],
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
spacing: 8,
children: [
Row(
children: [
Text(
name.screenshot,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),
],
),
if (actionHandler.supportedApp is! CustomApp)
Warning(
children: [
Text('Use a custom keymap to use the buttons on $name.'),
],
),
],
),
);
}
}

View File

@@ -127,6 +127,7 @@ class WhooshLink {
InGameAction.navigateLeft => null,
InGameAction.navigateRight => null,
InGameAction.toggleUi => null,
_ => null,
};
if (jsonObject != null) {

View File

@@ -4,11 +4,12 @@ import 'package:flutter/material.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
class ZwiftConstants {
static final ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb".toLowerCase();
static final ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb";
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT = "fc82";
static const ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1";
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A
@@ -32,21 +33,21 @@ class ZwiftConstants {
static final VIBRATE_PATTERN = Uint8List.fromList([0x12, 0x12, 0x08, 0x0A, 0x06, 0x08, 0x02, 0x10, 0x00, 0x18]);
// these don't actually seem to matter, its just the header has to be 7 bytes RIDEON + 2
static final REQUEST_START = Uint8List.fromList([0, 9]); //byteArrayOf(1, 2)
static final RESPONSE_START_CLICK = Uint8List.fromList([1, 3]); // from device
static final RESPONSE_START_PLAY = Uint8List.fromList([1, 4]); // from device
static final REQUEST_START = Uint8List.fromList([0x00, 0x09]); //byteArrayOf(1, 2)
static final RESPONSE_START_CLICK = Uint8List.fromList([0x01, 0x03]); // from device
static final RESPONSE_START_PLAY = Uint8List.fromList([0x01, 0x04]); // from device
static final RESPONSE_START_CLICK_V2 = Uint8List.fromList([0x02, 0x03]); // from device
static final RESPONSE_STOPPED_CLICK_V2_VARIANT_1 = Uint8List.fromList([0xff, 0x05, 0x00, 0xea, 0x05]); // from device
static final RESPONSE_STOPPED_CLICK_V2_VARIANT_2 = Uint8List.fromList([0xff, 0x05, 0x00, 0xfa, 0x05]); // from device
// Message types received from device
static const CONTROLLER_NOTIFICATION_MESSAGE_TYPE = 07;
static const EMPTY_MESSAGE_TYPE = 21;
static const EMPTY_MESSAGE_TYPE = 21; // 0x15
static const BATTERY_LEVEL_TYPE = 25;
static const UNKNOWN_CLICKV2_TYPE = 0x3C;
// not figured out the protobuf type this really is, the content is just two varints.
static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55;
static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55; // 0x37
static const int PLAY_NOTIFICATION_MESSAGE_TYPE = 7;
static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35; // 0x23
@@ -58,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

@@ -24,7 +24,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
@override
Future<void> handleServices(List<BleService> services) async {
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId.toLowerCase());
if (customService == null) {
throw Exception(
@@ -33,10 +33,10 @@ abstract class ZwiftDevice extends BluetoothDevice {
}
final deviceInformationService = services.firstOrNullWhere(
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
);
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
);
if (firmwareCharacteristic != null) {
final firmwareData = await UniversalBle.read(
@@ -56,13 +56,13 @@ abstract class ZwiftDevice extends BluetoothDevice {
}
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(),
);
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toLowerCase(),
);
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID.toLowerCase(),
);
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
@@ -87,7 +87,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
if (kDebugMode && false) {
if (kDebugMode) {
print(
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
);
@@ -98,7 +98,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
try {
if (bytes.startsWith(startCommand)) {
_processDevicePublicKeyResponse(bytes);
processDevicePublicKeyResponse(bytes);
} else {
processData(bytes);
}
@@ -113,7 +113,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
}
}
void _processDevicePublicKeyResponse(Uint8List bytes) {
void processDevicePublicKeyResponse(Uint8List bytes) {
final devicePublicKeyBytes = bytes.sublist(
ZwiftConstants.RIDE_ON.length + ZwiftConstants.RESPONSE_START_CLICK.length,
);

View File

@@ -0,0 +1,367 @@
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 'package:swift_control/widgets/title.dart';
import 'protocol/zwift.pb.dart' show RideKeyPadStatus;
final zwiftEmulator = ZwiftEmulator();
class ZwiftEmulator {
static final List<InGameAction> supportedActions = [
InGameAction.shiftUp,
InGameAction.shiftDown,
InGameAction.uturn,
InGameAction.steerLeft,
InGameAction.steerRight,
InGameAction.openActionBar,
InGameAction.usePowerUp,
InGameAction.select,
InGameAction.back,
InGameAction.rideOnBomb,
];
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) {
} else if (state.state == ConnectionState.disconnected) {
_central = null;
isConnected.value = false;
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];
final handshakeAlternative = ZwiftConstants.RIDE_ON; // e.g. Rouvy
if (value.contentEquals(handshake) || value.contentEquals(handshakeAlternative)) {
print('Sending 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('SwiftControl'.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((packageInfoValue?.version ?? '1.0.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.uturn => RideButtonMask.DOWN_BTN,
InGameAction.steerLeft => RideButtonMask.LEFT_BTN,
InGameAction.steerRight => RideButtonMask.RIGHT_BTN,
InGameAction.openActionBar => RideButtonMask.UP_BTN,
InGameAction.usePowerUp => RideButtonMask.Y_BTN,
InGameAction.select => RideButtonMask.A_BTN,
InGameAction.back => RideButtonMask.B_BTN,
InGameAction.rideOnBomb => RideButtonMask.Z_BTN,
_ => null,
};
if (button == null) {
return 'Action ${inGameAction.name} not supported by Zwift Emulator';
}
final status = RideKeyPadStatus()
..buttonMap = (~button.mask) & 0xFFFFFFFF
..analogPaddles.clear();
final bytes = status.writeToBuffer();
final commandProto = Uint8List.fromList([
Opcode.CONTROLLER_NOTIFICATION.value,
...bytes,
]);
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

@@ -190,24 +190,23 @@ class ZwiftRide extends ZwiftDevice {
// Process DIGITAL buttons separately
final buttonsClicked = [
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationLeft,
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationRight,
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationUp,
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationDown,
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.a,
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.b,
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.y,
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.z,
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpLeft,
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value)
ZwiftButtons.shiftDownLeft,
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpRight,
if (status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
if (status.buttonMap & RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationLeft,
if (status.buttonMap & RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationRight,
if (status.buttonMap & RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationUp,
if (status.buttonMap & RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationDown,
if (status.buttonMap & RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.a,
if (status.buttonMap & RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.b,
if (status.buttonMap & RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.y,
if (status.buttonMap & RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.z,
if (status.buttonMap & RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpLeft,
if (status.buttonMap & RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftDownLeft,
if (status.buttonMap & RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpRight,
if (status.buttonMap & RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
ZwiftButtons.shiftDownRight,
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpLeft,
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpRight,
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffLeft,
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffRight,
if (status.buttonMap & RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpLeft,
if (status.buttonMap & RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpRight,
if (status.buttonMap & RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffLeft,
if (status.buttonMap & RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffRight,
];
// Process ANALOG inputs separately - now properly separated from digital
@@ -267,7 +266,7 @@ class ZwiftRide extends ZwiftDevice {
}
}
enum _RideButtonMask {
enum RideButtonMask {
LEFT_BTN(0x00001),
UP_BTN(0x00002),
RIGHT_BTN(0x00004),
@@ -290,5 +289,5 @@ enum _RideButtonMask {
final int mask;
const _RideButtonMask(this.mask);
const RideButtonMask(this.mask);
}

View File

@@ -58,7 +58,7 @@ Future<void> initializeActions(ConnectionType connectionType) async {
ConnectionType.unknown => StubActions(),
};
}
actionHandler.init(settings.getSupportedApp());
actionHandler.init(settings.getKeyMap());
}
class SwiftPlayApp extends StatelessWidget {

View File

@@ -6,10 +6,12 @@ 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/manager.dart';
import 'package:swift_control/utils/requirements/zwift.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/logviewer.dart';
@@ -59,6 +61,16 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (mounted) setState(() {});
});
zwiftEmulator.isConnected.addListener(() {
if (mounted) setState(() {});
});
if (settings.getZwiftEmulatorEnabled() && actionHandler.supportedApp?.supportsZwiftEmulation == true) {
zwiftEmulator.startAdvertising(() {
if (mounted) setState(() {});
});
}
if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS && (actionHandler as RemoteActions).isConnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// show snackbar to inform user that the app needs to stay in foreground
@@ -175,12 +187,6 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
Text('Enable auto-rotation on your device to make sure the app works correctly.'),
],
),
if (connection.gamepadDevices.isNotEmpty && actionHandler.supportedApp is! CustomApp)
Warning(
children: [
Text('Your gamepad requires a custom keymap to be able to use all buttons.'),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
@@ -229,7 +235,8 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (connection.remoteDevices.isNotEmpty ||
actionHandler is RemoteActions ||
settings.getTrainerApp() is MyWhoosh)
settings.getTrainerApp() is MyWhoosh ||
actionHandler.supportedApp?.supportsZwiftEmulation == true)
Container(
margin: const EdgeInsets.only(bottom: 8.0),
width: double.infinity,
@@ -254,6 +261,10 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (settings.getTrainerApp() is MyWhoosh && !whooshLink.isConnected.value)
LinkDevice('').showInformation(context),
if (actionHandler.supportedApp?.supportsZwiftEmulation == true)
ZwiftRequirement().build(context, () {
setState(() {});
})!,
if (actionHandler is RemoteActions)
Row(
@@ -336,14 +347,14 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (profileName != null && profileName.isNotEmpty) {
final customApp = CustomApp(profileName: profileName);
actionHandler.init(customApp);
await settings.setSupportedApp(customApp);
await settings.setKeyMap(customApp);
controller.text = profileName;
setState(() {});
}
} else {
controller.text = app.name ?? '';
actionHandler.supportedApp = app;
await settings.setSupportedApp(app);
await settings.setKeyMap(app);
setState(() {});
}
},
@@ -370,7 +381,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (actionHandler.supportedApp is! CustomApp)
Text(
'Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)',
style: TextStyle(fontStyle: FontStyle.italic),
style: TextStyle(fontSize: 12),
),
if (actionHandler.supportedApp != null && connection.controllerDevices.isNotEmpty)
KeymapExplanation(
@@ -381,7 +392,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
controller.text = actionHandler.supportedApp?.name ?? '';
if (actionHandler.supportedApp is CustomApp) {
settings.setSupportedApp(actionHandler.supportedApp!);
settings.setKeyMap(actionHandler.supportedApp!);
}
},
),

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.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,9 @@
import 'dart:io';
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 +13,8 @@ class Biketerra extends SupportedApp {
: super(
name: 'Biketerra',
packageName: "biketerra",
compatibleTargets: Target.values,
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
keymap: Keymap(
keyPairs: [
KeyPair(

View File

@@ -1,6 +1,9 @@
import 'dart:io';
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,7 +14,15 @@ class CustomApp extends SupportedApp {
CustomApp({this.profileName = 'Other'})
: super(
name: profileName,
compatibleTargets: [
if (!Platform.isIOS) Target.thisDevice,
Target.macOS,
Target.windows,
Target.iOS,
Target.android,
],
packageName: "custom_$profileName",
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
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,8 @@ class MyWhoosh extends SupportedApp {
: super(
name: 'MyWhoosh',
packageName: "com.mywhoosh.whooshgame",
compatibleTargets: Target.values,
supportsZwiftEmulation: false,
keymap: Keymap(
keyPairs: [
KeyPair(
@@ -17,12 +20,14 @@ class MyWhoosh extends SupportedApp {
physicalKey: PhysicalKeyboardKey.keyI,
logicalKey: LogicalKeyboardKey.keyI,
touchPosition: Offset(80, 94),
inGameAction: InGameAction.shiftDown,
),
KeyPair(
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
physicalKey: PhysicalKeyboardKey.keyK,
logicalKey: LogicalKeyboardKey.keyK,
touchPosition: Offset(97, 94),
inGameAction: InGameAction.shiftUp,
),
KeyPair(
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
@@ -30,6 +35,7 @@ class MyWhoosh extends SupportedApp {
logicalKey: LogicalKeyboardKey.arrowRight,
touchPosition: Offset(60, 80),
isLongPress: true,
inGameAction: InGameAction.navigateRight,
),
KeyPair(
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
@@ -37,11 +43,13 @@ class MyWhoosh extends SupportedApp {
logicalKey: LogicalKeyboardKey.arrowLeft,
touchPosition: Offset(32, 80),
isLongPress: true,
inGameAction: InGameAction.navigateLeft,
),
KeyPair(
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
physicalKey: PhysicalKeyboardKey.keyH,
logicalKey: LogicalKeyboardKey.keyH,
inGameAction: InGameAction.toggleUi,
),
],
),

View File

@@ -0,0 +1,32 @@
import 'package:dartx/dartx.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';
class Rouvy extends SupportedApp {
Rouvy()
: super(
name: 'Rouvy',
packageName: "eu.virtualtraining.rouvy.android",
compatibleTargets: Target.values,
supportsZwiftEmulation: true,
keymap: Keymap(
keyPairs: [
KeyPair(
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
inGameAction: InGameAction.shiftDown,
physicalKey: null,
logicalKey: null,
),
KeyPair(
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
inGameAction: InGameAction.shiftUp,
physicalKey: null,
logicalKey: null,
),
],
),
);
}

View File

@@ -1,18 +1,36 @@
import 'package:swift_control/utils/keymap/apps/biketerra.dart';
import 'package:swift_control/utils/keymap/apps/rouvy.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 bool supportsZwiftEmulation;
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,
required this.supportsZwiftEmulation,
});
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];
static final List<SupportedApp> supportedApps = [
MyWhoosh(),
Zwift(),
TrainingPeaks(),
Biketerra(),
Rouvy(),
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,8 @@ class TrainingPeaks extends SupportedApp {
: super(
name: 'TrainingPeaks Virtual / IndieVelo',
packageName: "com.indieVelo.client",
compatibleTargets: Target.values,
supportsZwiftEmulation: false,
keymap: Keymap(
keyPairs: [
// Explicit controller-button mappings with updated touch coordinates

View File

@@ -0,0 +1,113 @@
import 'dart:io';
import 'package:flutter/services.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';
class Zwift extends SupportedApp {
Zwift()
: super(
name: 'Zwift',
packageName: "com.zwift.zwiftgame",
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
compatibleTargets: [
if (!Platform.isIOS) Target.thisDevice,
Target.macOS,
Target.windows,
Target.iOS,
Target.android,
],
keymap: Keymap(
keyPairs: [
KeyPair(
buttons: [ZwiftButtons.navigationUp],
physicalKey: PhysicalKeyboardKey.arrowUp,
logicalKey: LogicalKeyboardKey.arrowUp,
inGameAction: InGameAction.openActionBar,
),
KeyPair(
buttons: [ZwiftButtons.navigationDown],
physicalKey: PhysicalKeyboardKey.arrowDown,
logicalKey: LogicalKeyboardKey.arrowDown,
inGameAction: InGameAction.uturn,
),
KeyPair(
buttons: [ZwiftButtons.navigationLeft],
physicalKey: PhysicalKeyboardKey.arrowLeft,
logicalKey: LogicalKeyboardKey.arrowLeft,
inGameAction: InGameAction.steerLeft,
),
KeyPair(
buttons: [ZwiftButtons.navigationRight],
physicalKey: PhysicalKeyboardKey.arrowRight,
logicalKey: LogicalKeyboardKey.arrowRight,
inGameAction: InGameAction.steerRight,
),
KeyPair(
buttons: [ZwiftButtons.shiftUpLeft],
physicalKey: null,
logicalKey: null,
inGameAction: InGameAction.shiftDown,
),
KeyPair(
buttons: [ZwiftButtons.shiftUpRight],
physicalKey: null,
logicalKey: null,
inGameAction: InGameAction.shiftUp,
),
KeyPair(
buttons: [ZwiftButtons.shiftDownLeft],
physicalKey: null,
logicalKey: null,
inGameAction: InGameAction.shiftDown,
),
KeyPair(
buttons: [ZwiftButtons.shiftDownRight],
physicalKey: null,
logicalKey: null,
inGameAction: InGameAction.shiftUp,
),
KeyPair(
buttons: [ZwiftButtons.paddleLeft],
physicalKey: null,
logicalKey: null,
inGameAction: InGameAction.shiftDown,
),
KeyPair(
buttons: [ZwiftButtons.paddleRight],
physicalKey: null,
logicalKey: null,
inGameAction: InGameAction.shiftUp,
),
KeyPair(
buttons: [ZwiftButtons.y],
physicalKey: PhysicalKeyboardKey.space,
logicalKey: LogicalKeyboardKey.space,
inGameAction: InGameAction.usePowerUp,
),
KeyPair(
buttons: [ZwiftButtons.a],
physicalKey: PhysicalKeyboardKey.enter,
logicalKey: LogicalKeyboardKey.enter,
inGameAction: InGameAction.select,
),
KeyPair(
buttons: [ZwiftButtons.b],
physicalKey: PhysicalKeyboardKey.escape,
logicalKey: LogicalKeyboardKey.escape,
inGameAction: InGameAction.back,
),
KeyPair(
buttons: [ZwiftButtons.z],
physicalKey: null,
logicalKey: null,
inGameAction: InGameAction.rideOnBomb,
),
],
),
);
}

View File

@@ -8,16 +8,25 @@ 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'),
// mywhoosh
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
emote('Emote', possibleValues: [1, 2, 3, 4, 5, 6]),
toggleUi('Toggle UI'),
navigateLeft('Navigate Left'),
navigateRight('Navigate Right'),
increaseResistance('Increase Resistance'),
decreaseResistance('Decrease Resistance'),
toggleUi('Toggle UI'),
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
emote('Emote', possibleValues: [1, 2, 3, 4, 5, 6]),
uturn('U-Turn'),
steerLeft('Steer Left'),
steerRight('Steer Right');
// zwift
openActionBar('Open Action Bar'),
usePowerUp('Use Power-Up'),
select('Select'),
back('Back'),
rideOnBomb('Ride On Bomb');
final String title;
final List<int>? possibleValues;

View File

@@ -49,7 +49,7 @@ class Keymap {
_updateStream.add(null);
if (actionHandler.supportedApp is CustomApp) {
settings.setSupportedApp(actionHandler.supportedApp!);
settings.setKeyMap(actionHandler.supportedApp!);
}
}
}

View File

@@ -60,7 +60,7 @@ class KeymapManager {
customApp.decodeKeymap(savedKeymap);
}
actionHandler.supportedApp = customApp;
await settings.setSupportedApp(customApp);
await settings.setKeyMap(customApp);
}
onDone();
},
@@ -244,7 +244,7 @@ class KeymapManager {
customApp.decodeKeymap(savedKeymap);
}
actionHandler.supportedApp = customApp;
await settings.setSupportedApp(customApp);
await settings.setKeyMap(customApp);
return newName;
} else {
final customApp = CustomApp(profileName: newName);
@@ -266,7 +266,7 @@ class KeymapManager {
});
actionHandler.supportedApp = customApp;
await settings.setSupportedApp(customApp);
await settings.setKeyMap(customApp);
return newName;
}
}

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 settings.getTrainerApp()?.compatibleTargets.contains(this) == true;
}
bool get isBeta {
final supportedApp = settings.getTrainerApp();
if (supportedApp is Zwift && !(Platform.isIOS || Platform.isMacOS)) {
// 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 (settings.getTrainerApp()?.supportsZwiftEmulation == true) {
// no warnings for zwift emulation
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,123 @@ 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,
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(app.name),
Row(
children: [
Expanded(
child: Text(
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Icon(Icons.warning_amber),
],
),
],
)
: null,
);
}).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.setKeyMap(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(settings.getTrainerApp()),
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(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

@@ -35,10 +35,10 @@ 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.',
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required if you want to use the remote control feature.',
},
);
}
@@ -94,13 +94,13 @@ class RemoteRequirement extends PlatformRequirement {
return;
}
}
if (kDebugMode) {
if (kDebugMode && false) {
print('Continuing');
return;
}
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
print('Waiting for peripheral manager to be powered on...');
print('Waiting for peripheral manager to be powered on... ${peripheralManager.state}');
if (settings.getLastTarget() == Target.thisDevice) {
return;
}
@@ -283,7 +283,7 @@ class RemoteRequirement extends PlatformRequirement {
@override
Future<void> getStatus() async {
status = (actionHandler as RemoteActions).isConnected || screenshotMode;
status = (actionHandler is RemoteActions && (actionHandler as RemoteActions).isConnected) || screenshotMode;
}
}
@@ -302,7 +302,9 @@ class _PairWidgetState extends State<_PairWidget> {
super.initState();
// after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
toggle();
if (actionHandler.supportedApp?.supportsZwiftEmulation == false) {
toggle();
}
});
}
@@ -319,7 +321,9 @@ class _PairWidgetState extends State<_PairWidget> {
onPressed: () async {
await toggle();
},
child: Text(_isAdvertising ? 'Stop Pairing' : 'Start Pairing'),
child: Text(
_isAdvertising ? 'Stop Pairing' : 'Start Pairing',
),
),
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
],
@@ -349,6 +353,24 @@ class _PairWidgetState extends State<_PairWidget> {
),
),
),
if (actionHandler.supportedApp?.supportsZwiftEmulation == true) ...[
Text(
'You can also skip pairing and directly connect to ${settings.getTrainerApp()?.name} by enabling the Zwift Controller.',
style: TextStyle(fontSize: 12),
),
ElevatedButton(
onPressed: () async {
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => DevicePage(),
settings: RouteSettings(name: '/device'),
),
);
},
child: Text('Connect to ${settings.getTrainerApp()?.name} directly as controller'),
),
],
if (_isAdvertising) ...[
TextButton(
onPressed: () {

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
import 'package:swift_control/utils/keymap/apps/zwift.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
class ZwiftRequirement extends PlatformRequirement {
ZwiftRequirement()
: super(
'Pair SwiftControl with Zwift',
);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Widget? buildDescription() {
return settings.getLastTarget() == null
? null
: Text(
'In Zwift on your ${settings.getLastTarget()?.title} go into the Pairing settings and select SwiftControl from the list of available controllers.',
);
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return ValueListenableBuilder(
valueListenable: zwiftEmulator.isConnected,
builder: (context, isConnected, _) {
return StatefulBuilder(
builder: (context, setState) {
return SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: settings.getZwiftEmulatorEnabled(),
onChanged: (value) {
settings.setZwiftEmulatorEnabled(value);
if (!value) {
zwiftEmulator.stopAdvertising();
} else if (value) {
zwiftEmulator.startAdvertising(onUpdate);
}
setState(() {});
},
title: Text('Enable Zwift Controller'),
subtitle: Row(
spacing: 12,
children: [
if (!settings.getZwiftEmulatorEnabled())
Expanded(
child: Text(
'Disabled. ${settings.getTrainerApp() is Zwift
? 'Virtual shifting and on screen navigation will not work.'
: settings.getTrainerApp() is Rouvy
? 'Virtual shifting will not work.'
: ''}',
),
)
else ...[
Expanded(
child: Text(
isConnected
? "Connected"
: "Waiting for connection. Choose SwiftControl in ${settings.getTrainerApp()?.name}'s controller pairing menu.",
),
),
if (!isConnected) SmallProgressIndicator(),
],
],
),
);
},
);
},
);
}
@override
Future<void> getStatus() async {
status = zwiftEmulator.isConnected.value || screenshotMode;
}
}

View File

@@ -23,7 +23,7 @@ class Settings {
}
try {
final app = getSupportedApp();
final app = getKeyMap();
actionHandler.init(app);
} catch (e) {
// couldn't decode, reset
@@ -49,14 +49,14 @@ class Settings {
return SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
}
Future<void> setSupportedApp(SupportedApp app) async {
Future<void> setKeyMap(SupportedApp app) async {
if (app is CustomApp) {
await prefs.setStringList('customapp_${app.profileName}', app.encodeKeymap());
}
await prefs.setString('app', app.name);
}
SupportedApp? getSupportedApp() {
SupportedApp? getKeyMap() {
final appName = prefs.getString('app');
if (appName == null) {
return null;
@@ -165,4 +165,12 @@ class Settings {
Future<void> setMyWhooshLinkEnabled(bool enabled) async {
await prefs.setBool('mywhoosh_link_enabled', enabled);
}
bool getZwiftEmulatorEnabled() {
return prefs.getBool('zwift_emulator_enabled') ?? true;
}
Future<void> setZwiftEmulatorEnabled(bool enabled) async {
await prefs.setBool('zwift_emulator_enabled', enabled);
}
}

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,
@@ -303,6 +350,19 @@ class _ButtonEditor extends StatelessWidget {
),
),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
onTap: () {
keyPair.isLongPress = false;
keyPair.physicalKey = null;
keyPair.logicalKey = null;
keyPair.touchPosition = Offset.zero;
keyPair.inGameAction = null;
keyPair.inGameActionValue = null;
onUpdate();
},
child: const Text('Unassign action'),
),
];
return Container(

View File

@@ -13,7 +13,7 @@ import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:version/version.dart';
PackageInfo? _packageInfoValue;
PackageInfo? packageInfoValue;
bool? isFromPlayStore;
class AppTitle extends StatefulWidget {
@@ -39,10 +39,10 @@ class _AppTitleState extends State<AppTitle> {
});
}
if (_packageInfoValue == null) {
if (packageInfoValue == null) {
PackageInfo.fromPlatform().then((value) {
setState(() {
_packageInfoValue = value;
packageInfoValue = value;
});
_checkForUpdate();
});
@@ -143,9 +143,9 @@ class _AppTitleState extends State<AppTitle> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SwiftControl', style: TextStyle(fontWeight: FontWeight.bold)),
if (_packageInfoValue != null)
if (packageInfoValue != null)
Text(
'v${_packageInfoValue!.version}${_shorebirdPatch != null ? '+${_shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
'v${packageInfoValue!.version}${_shorebirdPatch != null ? '+${_shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
)
else
@@ -177,7 +177,7 @@ class _AppTitleState extends State<AppTitle> {
void _compareVersion(String versionString) {
final parsed = Version.parse(versionString);
final current = Version.parse(_packageInfoValue!.version);
final current = Version.parse(packageInfoValue!.version);
if (parsed > current && mounted && !kDebugMode) {
if (Platform.isAndroid) {
_showUpdateSnackbar(parsed, 'https://play.google.com/store/apps/details?id=org.jonasbark.swiftcontrol');

View File

@@ -28,7 +28,7 @@ void main() {
test('Should save and retrieve custom profile', () async {
final customApp = CustomApp(profileName: 'Race');
await settings.setSupportedApp(customApp);
await settings.setKeyMap(customApp);
final profiles = settings.getCustomAppProfiles();
expect(profiles.contains('Race'), true);
@@ -39,9 +39,9 @@ void main() {
final race = CustomApp(profileName: 'Race');
final event = CustomApp(profileName: 'Event');
await settings.setSupportedApp(workout);
await settings.setSupportedApp(race);
await settings.setSupportedApp(event);
await settings.setKeyMap(workout);
await settings.setKeyMap(race);
await settings.setKeyMap(event);
final profiles = settings.getCustomAppProfiles();
expect(profiles.contains('Workout'), true);
@@ -52,7 +52,7 @@ void main() {
test('Should duplicate custom profile', () async {
final original = CustomApp(profileName: 'Original');
await settings.setSupportedApp(original);
await settings.setKeyMap(original);
await settings.duplicateCustomAppProfile('Original', 'Copy');
@@ -64,7 +64,7 @@ void main() {
test('Should delete custom profile', () async {
final customApp = CustomApp(profileName: 'ToDelete');
await settings.setSupportedApp(customApp);
await settings.setKeyMap(customApp);
var profiles = settings.getCustomAppProfiles();
expect(profiles.contains('ToDelete'), true);
@@ -108,7 +108,7 @@ void main() {
test('Should export custom profile as JSON', () async {
final customApp = CustomApp(profileName: 'TestProfile');
await settings.setSupportedApp(customApp);
await settings.setKeyMap(customApp);
final jsonData = settings.exportCustomAppProfile('TestProfile');
expect(jsonData, isNotNull);
@@ -120,7 +120,7 @@ void main() {
test('Should import custom profile from JSON', () async {
// First export a profile
final customApp = CustomApp(profileName: 'ExportTest');
await settings.setSupportedApp(customApp);
await settings.setKeyMap(customApp);
final jsonData = settings.exportCustomAppProfile('ExportTest');
// Import with a new name