mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57961aec5d | ||
|
|
1675d7f2d0 | ||
|
|
baec8d24c3 | ||
|
|
a968723277 | ||
|
|
8668957738 | ||
|
|
ac550fad5b | ||
|
|
c511ac32b6 | ||
|
|
ee48ce0f4e | ||
|
|
8a3d64491b | ||
|
|
b72cc803f0 | ||
|
|
ea17b2e142 | ||
|
|
da62fc4dc6 | ||
|
|
239630f681 | ||
|
|
d95d0cf8cf | ||
|
|
2b25ba942c | ||
|
|
c65369a746 | ||
|
|
fa7d5e7853 | ||
|
|
8ac47cbd4d | ||
|
|
eb85844503 | ||
|
|
010d0ed331 | ||
|
|
1f8f7765a3 | ||
|
|
68f416dda3 | ||
|
|
49e45faec0 | ||
|
|
c81516350a | ||
|
|
890f393fd6 | ||
|
|
e46969c5c4 | ||
|
|
1ec9b55645 | ||
|
|
b0caf7c13b | ||
|
|
302fc15dd7 | ||
|
|
6a2cf1a1c9 | ||
|
|
8ea73bc54a | ||
|
|
7cbab3925f | ||
|
|
246a1bd2be | ||
|
|
f7e2a89ed6 | ||
|
|
f94252edb9 | ||
|
|
b7b6b9803f | ||
|
|
807d868b74 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -31,7 +31,7 @@ on:
|
||||
build_web:
|
||||
description: 'Build for Web'
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
|
||||
1
.github/workflows/patch.yml
vendored
1
.github/workflows/patch.yml
vendored
@@ -75,6 +75,7 @@ jobs:
|
||||
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
|
||||
|
||||
- name: 🚀 Shorebird Patch macOS
|
||||
if: false # patch doesn't work: https://github.com/jonasbark/swiftcontrol/issues/143
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: macos
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
debug/
|
||||
migrate_working_dir/
|
||||
|
||||
android/keystore.properties
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
### 3.3.1 (unreleased)
|
||||
**New Features:**
|
||||
- Support for Shimano Di2
|
||||
|
||||
**Fixes:**
|
||||
- fix detection of Elite Square Sterzo devices
|
||||
|
||||
### 3.3.0 (31-10-2025)
|
||||
|
||||
**New Feature:**
|
||||
**New Features:**
|
||||
- Support for Elite Sterzo (thanks @michidk)
|
||||
- Support for Gamepads
|
||||
- Support for cheap bluetooth remotes (such as [these](https://www.amazon.com/s?k=bluetooth+remote))
|
||||
|
||||
32
README.md
32
README.md
@@ -6,10 +6,10 @@
|
||||
|
||||
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
|
||||
- Steering/turning
|
||||
- adjust workout intensity
|
||||
- control music on your device
|
||||
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
|
||||
- more? If you can do it via keyboard, mouse, or touch, you can do it with SwiftControl
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
@@ -34,25 +34,27 @@ Check the compatibility matrix below!
|
||||
- Biketerra.com
|
||||
- Rouvy
|
||||
- Zwift
|
||||
- only Android and Windows support virtual shifting and in-app-navigation
|
||||
- iOS / macOS only support controlling Zwift via keyboard shortcuts or touch controls
|
||||
- running SwiftControl on Android or Windows is required to act as a "Controllable" in Zwift - iOS and macOS are not able to do so
|
||||
- any other!
|
||||
- you can add custom mapping and adjust touch points or keyboard shortcuts to your liking
|
||||
- You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
|
||||
|
||||
## Supported Devices
|
||||
- Zwift Click
|
||||
- Zwift Click v2 (mostly, see issue #68)
|
||||
- Zwift Ride
|
||||
- Zwift Play
|
||||
- Shimano Di2
|
||||
- Configure your levers to use D-Fly channels with Shimano E-Tube app
|
||||
- Wahoo Kickr Bike Shift
|
||||
- CYCPLUS BC2 Virtual Shifter
|
||||
- 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)
|
||||
- works on Android
|
||||
- on iOS it would require playing back an audio file - let me know if that is of interest to you
|
||||
- on iOS, macOS and Windows it would require SwiftControl to act as media player - let me know if that works for you
|
||||
|
||||
Support for other devices can be added - check the issues tab here on GithUb.
|
||||
Support for other devices can be added; check the issues tab here on GitHub.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
@@ -61,14 +63,14 @@ Follow this compatibility matrix. It all depends on where you want to run your t
|
||||
| Run Trainer app (MyWhoosh, ...) on: | Possible | Link | Information |
|
||||
|-------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Android | ✅ | <a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a> | |
|
||||
| iPad | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically you would use an iPhone or an Android phone for that. |
|
||||
| iPad | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically, you would use an iPhone or an Android phone for that. |
|
||||
| Windows | ✅ | <a href="https://apps.microsoft.com/detail/9NP42GS03Z26"><img width="270" alt="Microsoft Store" src="https://github.com/user-attachments/assets/7a8a3cd6-ec26-4678-a850-732eedd27c48" /></a> | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
|
||||
| macOS | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a> | |
|
||||
| iPhone | (✅) | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you could use the Link method on another device to control MyWhoosh (and only MyWhoosh) on an iPhone. |
|
||||
| Apple TV | (✅*) | | *only MyWhoosh using the Link method is supported - but you cannot also use MyWhoosh Link at the same time |
|
||||
|
||||
|
||||
For testing purposes you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/) but this is just a tech demo - you won't be able to control other apps.
|
||||
For testing purposes, you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/), but this is just a tech demo - you won't be able to control other apps.
|
||||
|
||||
## Troubleshooting
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
@@ -77,15 +79,15 @@ Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
The app connects to your Controller devices (such as Zwift ones) automatically. It does not connect to your trainer itself.
|
||||
|
||||
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- **iOS**: use SwiftControl as a "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- if you want to use MyWhoosh you can use the Link method to directly connect to MyWhoosh
|
||||
- for other trainer apps you need to pair SwiftControl to your iPad / tablet via Bluetooth and your phone will send the button presses to your iPad / tablet
|
||||
- If you want to use MyWhoosh, you can use the Link method to directly connect to MyWhoosh
|
||||
- For other trainer apps, you need to pair SwiftControl to your iPad / tablet via Bluetooth, and your phone will send the button presses to your iPad / tablet
|
||||
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.2.0
|
||||
3.3.0
|
||||
|
||||
@@ -66,7 +66,7 @@ class Connection {
|
||||
_connectionStreams.add(existingDevice); // Notify UI of update
|
||||
}
|
||||
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
|
||||
_lastScanResult.add(result);
|
||||
|
||||
if (kDebugMode) {
|
||||
@@ -135,26 +135,29 @@ class Connection {
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BluetoothDevice.servicesToScan)),
|
||||
);
|
||||
|
||||
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
|
||||
if (!kIsWeb) {
|
||||
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
_addDevices(pads);
|
||||
|
||||
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
|
||||
for (var device in removedDevices) {
|
||||
devices.remove(device);
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
_addDevices(pads);
|
||||
|
||||
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
|
||||
for (var device in removedDevices) {
|
||||
devices.remove(device);
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
});
|
||||
});
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
_addDevices(pads);
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
|
||||
startMyWhooshServer();
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
@@ -126,4 +129,27 @@ abstract class BaseDevice {
|
||||
}
|
||||
|
||||
Widget showInformation(BuildContext context);
|
||||
|
||||
ControllerButton? getOrAddButton(String name, ControllerButton Function() button) {
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
final allButtons = actionHandler.supportedApp!.keymap.keyPairs.expand((kp) => kp.buttons).toSet().toList();
|
||||
if (allButtons.none((b) => b.name == name)) {
|
||||
final newButton = button();
|
||||
availableButtons.add(newButton);
|
||||
actionHandler.supportedApp!.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [newButton],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
return newButton;
|
||||
} else {
|
||||
return allButtons.firstWhere((b) => b.name == name);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'dart:async';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
@@ -16,6 +18,7 @@ import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import 'cycplus/cycplus_bc2.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
import 'elite/elite_sterzo.dart';
|
||||
|
||||
@@ -37,6 +40,8 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
CycplusBc2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
|
||||
@@ -48,32 +53,31 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
'SQUARE' => EliteSquare(scanResult),
|
||||
null => null,
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
|
||||
device = WahooKickrBikeShift(scanResult);
|
||||
}
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
|
||||
device = EliteSterzo(scanResult);
|
||||
}
|
||||
} else {
|
||||
device = switch (scanResult.name) {
|
||||
null => null,
|
||||
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
|
||||
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') || scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
|
||||
// otherwise the service UUIDs will be used
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scanResult.name != null) {
|
||||
if (scanResult.name!.toUpperCase().startsWith('STERZO')) {
|
||||
device = EliteSterzo(scanResult);
|
||||
} else if (scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (device != null) {
|
||||
@@ -103,10 +107,6 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
} else if (scanResult.services.contains(SquareConstants.SERVICE_UUID)) {
|
||||
return EliteSquare(scanResult);
|
||||
} else if (scanResult.services.contains(SterzoConstants.SERVICE_UUID)) {
|
||||
return EliteSterzo(scanResult);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -140,6 +140,41 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
}
|
||||
|
||||
final services = await UniversalBle.discoverServices(device.deviceId);
|
||||
final deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
}
|
||||
|
||||
final batteryService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_BATTERY_SERVICE_UUID.toLowerCase(),
|
||||
);
|
||||
|
||||
final batteryCharacteristic = batteryService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL.toLowerCase(),
|
||||
);
|
||||
if (batteryCharacteristic != null) {
|
||||
final batteryData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
batteryService!.uuid,
|
||||
batteryCharacteristic.uuid,
|
||||
);
|
||||
if (batteryData.isNotEmpty) {
|
||||
batteryLevel = batteryData.first;
|
||||
connection.signalChange(this);
|
||||
}
|
||||
}
|
||||
|
||||
await handleServices(services);
|
||||
}
|
||||
|
||||
@@ -172,7 +207,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
}),
|
||||
Text('$batteryLevel%'),
|
||||
],
|
||||
if (firmwareVersion != null) Text(' - Firmware: $firmwareVersion'),
|
||||
if (firmwareVersion != null) Text(' - v$firmwareVersion'),
|
||||
if (firmwareVersion != null &&
|
||||
this is ZwiftDevice &&
|
||||
firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion) ...[
|
||||
|
||||
97
lib/bluetooth/devices/cycplus/cycplus_bc2.dart
Normal file
97
lib/bluetooth/devices/cycplus/cycplus_bc2.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class CycplusBc2 extends BluetoothDevice {
|
||||
CycplusBc2(super.scanResult)
|
||||
: super(
|
||||
availableButtons: CycplusBc2Buttons.values,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${CycplusBc2Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${CycplusBc2Constants.TX_CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
// Process CYCPLUS BC2 data
|
||||
// The BC2 typically sends button press data as simple byte values
|
||||
// Common patterns for virtual shifters:
|
||||
// - 0x01 or similar for shift up
|
||||
// - 0x02 or similar for shift down
|
||||
// - 0x00 for button release
|
||||
|
||||
if (bytes.isNotEmpty) {
|
||||
final buttonCode = bytes[0];
|
||||
|
||||
switch (buttonCode) {
|
||||
case 0x01:
|
||||
// Shift up button pressed
|
||||
handleButtonsClicked([CycplusBc2Buttons.shiftUp]);
|
||||
break;
|
||||
case 0x02:
|
||||
// Shift down button pressed
|
||||
handleButtonsClicked([CycplusBc2Buttons.shiftDown]);
|
||||
break;
|
||||
case 0x00:
|
||||
// Button released
|
||||
handleButtonsClicked([]);
|
||||
break;
|
||||
default:
|
||||
// Unknown button code - log for debugging
|
||||
actionStreamInternal.add(
|
||||
LogNotification('CYCPLUS BC2: Unknown button code: 0x${buttonCode.toRadixString(16)}'),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
}
|
||||
|
||||
class CycplusBc2Constants {
|
||||
// Nordic UART Service (NUS) - commonly used by CYCPLUS BC2
|
||||
static const String SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
// TX Characteristic - device sends data to app
|
||||
static const String TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
// RX Characteristic - app sends data to device (not used for button reading)
|
||||
static const String RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
}
|
||||
|
||||
class CycplusBc2Buttons {
|
||||
static const ControllerButton shiftUp = ControllerButton(
|
||||
'shiftUp',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
);
|
||||
|
||||
static const ControllerButton shiftDown = ControllerButton(
|
||||
'shiftDown',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
shiftUp,
|
||||
shiftDown,
|
||||
];
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
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';
|
||||
@@ -24,25 +23,9 @@ class GamepadDevice extends BaseDevice {
|
||||
Gamepads.eventsByGamepad(id).listen((event) {
|
||||
actionStreamInternal.add(LogNotification('Gamepad event: $event'));
|
||||
|
||||
ControllerButton? button = availableButtons.firstOrNullWhere((b) => b.name == event.key);
|
||||
ControllerButton? button = getOrAddButton(event.key, () => ControllerButton(event.key));
|
||||
|
||||
if (button == null) {
|
||||
button = ControllerButton(event.key);
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
availableButtons.add(button);
|
||||
actionHandler.supportedApp?.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final buttonsClicked = event.value == 0.0 ? [button] : <ControllerButton>[];
|
||||
final buttonsClicked = event.value == 0.0 && button != null ? [button] : <ControllerButton>[];
|
||||
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
|
||||
handleButtonsClicked(buttonsClicked);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
class WhooshLink {
|
||||
Socket? _socket;
|
||||
@@ -138,4 +139,11 @@ class WhooshLink {
|
||||
return 'No action available for button: $action';
|
||||
}
|
||||
}
|
||||
|
||||
bool isCompatible(Target target) {
|
||||
return switch (target) {
|
||||
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
@@ -28,6 +29,9 @@ class LinkDevice extends BaseDevice {
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final myWhooshExplanation = actionHandler is RemoteActions
|
||||
? 'MyWhoosh Link allows you to do some additional features such as Emotes and turn directions.'
|
||||
: 'MyWhoosh Link is optional, but allows you to do some additional features such as Emotes and turn directions.';
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -49,10 +53,19 @@ class LinkDevice extends BaseDevice {
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (!settings.getMyWhooshLinkEnabled())
|
||||
Text('Disabled')
|
||||
Expanded(
|
||||
child: Text(
|
||||
myWhooshExplanation,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Text(
|
||||
isConnected ? "Connected" : "Connecting to MyWhoosh...",
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnected ? "Connected" : "Connecting to MyWhoosh...\n$myWhooshExplanation",
|
||||
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (!isConnected) SmallProgressIndicator(),
|
||||
],
|
||||
|
||||
83
lib/bluetooth/devices/shimano/shimano_di2.dart
Normal file
83
lib/bluetooth/devices/shimano/shimano_di2.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class ShimanoDi2 extends BluetoothDevice {
|
||||
ShimanoDi2(super.scanResult) : super(availableButtons: []);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ShimanoDi2Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${ShimanoDi2Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${ShimanoDi2Constants.D_FLY_CHANNEL_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
|
||||
if (actionHandler.supportedApp is! CustomApp) {
|
||||
actionStreamInternal.add(LogNotification('Use a custom keymap to support ${scanResult.name}'));
|
||||
}
|
||||
}
|
||||
|
||||
final _lastButtons = <int, int>{};
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) {
|
||||
final channels = bytes.sublist(1);
|
||||
final clickedButtons = <ControllerButton>[];
|
||||
|
||||
channels.forEachIndexed((int value, int index) {
|
||||
final didChange = !_lastButtons.containsKey(index) || _lastButtons[index] != value;
|
||||
_lastButtons[index] = value;
|
||||
|
||||
final readableIndex = index + 1;
|
||||
|
||||
final button = getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
);
|
||||
if (didChange && button != null) {
|
||||
clickedButtons.add(button);
|
||||
}
|
||||
});
|
||||
|
||||
if (clickedButtons.isNotEmpty) {
|
||||
handleButtonsClicked(clickedButtons);
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
'Make sure to set your Di2 buttons to D-Fly channels in the Shimano E-TUBE app.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShimanoDi2Constants {
|
||||
static const String SERVICE_UUID = "000018ef-5348-494d-414e-4f5f424c4500";
|
||||
|
||||
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
|
||||
}
|
||||
@@ -6,14 +6,14 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'constants.dart';
|
||||
|
||||
class ZwiftClick extends ZwiftDevice {
|
||||
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftDownLeft]);
|
||||
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftUpLeft]);
|
||||
|
||||
@override
|
||||
List<ControllerButton> processClickNotification(Uint8List message) {
|
||||
final status = ClickKeyPadStatus.fromBuffer(message);
|
||||
final buttonsClicked = [
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButtons.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftDownLeft,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftUpLeft,
|
||||
];
|
||||
return buttonsClicked;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
@@ -32,29 +31,6 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
);
|
||||
}
|
||||
|
||||
final deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
if (firmwareVersion != latestFirmwareVersion) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
@@ -73,6 +49,14 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await setupHandshake();
|
||||
|
||||
if (firmwareVersion != latestFirmwareVersion) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setupHandshake() async {
|
||||
|
||||
@@ -5,7 +5,6 @@ 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/zp_vendor.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
@@ -61,19 +60,6 @@ class ZwiftRide extends ZwiftDevice {
|
||||
);
|
||||
}
|
||||
|
||||
if (this is ZwiftClickV2 &&
|
||||
(bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_1) ||
|
||||
bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_2))) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once per day. Resetting the device now.',
|
||||
),
|
||||
);
|
||||
if (!kDebugMode) {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
}
|
||||
}
|
||||
|
||||
switch (opcode) {
|
||||
case Opcode.RIDE_ON:
|
||||
//print("Empty RideOn response - unencrypted mode");
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_auto_rotate_checker/device_auto_rotate_checker.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -21,9 +22,11 @@ import 'package:swift_control/widgets/testbed.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:swift_control/widgets/warning.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
import '../utils/actions/android.dart';
|
||||
import '../utils/actions/remote.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/apps/supported_app.dart';
|
||||
@@ -43,6 +46,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
|
||||
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
bool _showAutoRotationWarning = false;
|
||||
bool _showMiuiWarning = false;
|
||||
StreamSubscription<bool>? _autoRotateStream;
|
||||
|
||||
@override
|
||||
@@ -57,18 +61,20 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
_checkAndShowChangelog();
|
||||
});
|
||||
|
||||
whooshLink.isStarted.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
zwiftEmulator.isConnected.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
if (settings.getZwiftEmulatorEnabled() && actionHandler.supportedApp?.supportsZwiftEmulation == true) {
|
||||
zwiftEmulator.startAdvertising(() {
|
||||
if (!kIsWeb) {
|
||||
whooshLink.isStarted.addListener(() {
|
||||
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) {
|
||||
@@ -99,6 +105,11 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
_showAutoRotationWarning = !isEnabled;
|
||||
});
|
||||
});
|
||||
|
||||
// Check if device is MIUI and using local accessibility service
|
||||
if (actionHandler is AndroidActions) {
|
||||
_checkMiuiDevice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +143,29 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkMiuiDevice() async {
|
||||
try {
|
||||
// Don't show if user has dismissed the warning
|
||||
if (settings.getMiuiWarningDismissed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final isMiui =
|
||||
deviceInfo.manufacturer.toLowerCase() == 'xiaomi' ||
|
||||
deviceInfo.brand.toLowerCase() == 'xiaomi' ||
|
||||
deviceInfo.brand.toLowerCase() == 'redmi' ||
|
||||
deviceInfo.brand.toLowerCase() == 'poco';
|
||||
if (isMiui && mounted) {
|
||||
setState(() {
|
||||
_showMiuiWarning = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail if device info is not available
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAndShowChangelog() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
@@ -187,6 +221,71 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
Text('Enable auto-rotation on your device to make sure the app works correctly.'),
|
||||
],
|
||||
),
|
||||
if (_showMiuiWarning)
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Theme.of(context).colorScheme.error),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'MIUI Device Detected',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () async {
|
||||
await settings.setMiuiWarningDismissed(true);
|
||||
setState(() {
|
||||
_showMiuiWarning = false;
|
||||
});
|
||||
},
|
||||
tooltip: 'Dismiss',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Your device is running MIUI, which is known to aggressively kill background services and accessibility services.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'To ensure SwiftControl works properly:',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'• Disable battery optimization for SwiftControl',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'• Enable autostart for SwiftControl',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'• Lock the app in recent apps',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://dontkillmyapp.com/xiaomi');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.open_in_new),
|
||||
label: Text('View Detailed Instructions'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
|
||||
@@ -235,7 +334,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
|
||||
if (connection.remoteDevices.isNotEmpty ||
|
||||
actionHandler is RemoteActions ||
|
||||
settings.getTrainerApp() is MyWhoosh ||
|
||||
whooshLink.isCompatible(settings.getLastTarget()!) ||
|
||||
actionHandler.supportedApp?.supportsZwiftEmulation == true)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -259,9 +358,11 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
(device) => device.showInformation(context),
|
||||
),
|
||||
|
||||
if (settings.getTrainerApp() is MyWhoosh && !whooshLink.isConnected.value)
|
||||
if (settings.getTrainerApp() is MyWhoosh &&
|
||||
!whooshLink.isConnected.value &&
|
||||
whooshLink.isCompatible(settings.getLastTarget()!))
|
||||
LinkDevice('').showInformation(context),
|
||||
if (actionHandler.supportedApp?.supportsZwiftEmulation == true)
|
||||
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
ZwiftRequirement().build(context, () {
|
||||
setState(() {});
|
||||
})!,
|
||||
@@ -291,127 +392,126 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
|
||||
if (!kIsWeb) ...[
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Customize', style: Theme.of(context).textTheme.titleMedium),
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
'Customize ${settings.getTrainerApp()?.name} on ${settings.getLastTarget()?.title}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: canVibrate ? 0 : 12,
|
||||
),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<SupportedApp?>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries: [
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: canVibrate ? 0 : 12,
|
||||
),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<SupportedApp?>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries: [
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
label: Text('Select Keymap'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await KeymapManager().showNewProfileDialog(context);
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
actionHandler.init(customApp);
|
||||
await settings.setKeyMap(customApp);
|
||||
controller.text = profileName;
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
KeymapManager().getManageProfileDialog(
|
||||
context,
|
||||
actionHandler.supportedApp is CustomApp
|
||||
? actionHandler.supportedApp?.name
|
||||
: null,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
label: Text('Select Keymap'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await KeymapManager().showNewProfileDialog(context);
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
actionHandler.init(customApp);
|
||||
await settings.setKeyMap(customApp);
|
||||
controller.text = profileName;
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
],
|
||||
),
|
||||
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(fontSize: 12),
|
||||
),
|
||||
if (actionHandler.supportedApp != null && connection.controllerDevices.isNotEmpty)
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
settings.setKeyMap(actionHandler.supportedApp!);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (canVibrate) ...[
|
||||
SwitchListTile(
|
||||
title: Text('Enable vibration feedback when shifting gears'),
|
||||
value: settings.getVibrationEnabled(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await settings.setVibrationEnabled(value);
|
||||
setState(() {});
|
||||
},
|
||||
Row(
|
||||
children: [
|
||||
KeymapManager().getManageProfileDialog(
|
||||
context,
|
||||
actionHandler.supportedApp is CustomApp ? actionHandler.supportedApp?.name : null,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
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(fontSize: 12),
|
||||
),
|
||||
if (actionHandler.supportedApp != null && connection.controllerDevices.isNotEmpty)
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
settings.setKeyMap(actionHandler.supportedApp!);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (canVibrate) ...[
|
||||
SwitchListTile(
|
||||
title: Text('Enable vibration feedback when shifting gears'),
|
||||
value: settings.getVibrationEnabled(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await settings.setVibrationEnabled(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/menu.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
import 'device.dart';
|
||||
@@ -101,7 +102,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
],
|
||||
),
|
||||
_requirements.isEmpty
|
||||
? Center(child: CircularProgressIndicator())
|
||||
? Center(child: SmallProgressIndicator())
|
||||
: Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Stepper(
|
||||
|
||||
@@ -9,8 +9,8 @@ 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/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/widgets/button_widget.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
@@ -30,12 +30,12 @@ class TouchAreaSetupPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
File? _backgroundImage;
|
||||
Uint8List? _backgroundImage;
|
||||
final TransformationController _transformationController = TransformationController();
|
||||
|
||||
late Rect _imageRect;
|
||||
|
||||
bool _showAll = false;
|
||||
bool _showFaded = true;
|
||||
|
||||
Future<void> _pickScreenshot() async {
|
||||
final picker = ImagePicker();
|
||||
@@ -45,7 +45,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
final tempImage = File('${tempDir.path}/${actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png');
|
||||
await image.copy(tempImage.path);
|
||||
_backgroundImage = tempImage;
|
||||
_backgroundImage = tempImage.readAsBytesSync();
|
||||
await _calculateBounds();
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
if (_backgroundImage == null) return;
|
||||
|
||||
// need to decode image to get its size so we can have a percentage mapping
|
||||
final decodedImage = await decodeImageFromList(_backgroundImage!.readAsBytesSync());
|
||||
final decodedImage = await decodeImageFromList(_backgroundImage!);
|
||||
// calculate image rectangle in the current screen, given it's boxfit contain
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final imageAspectRatio = decodedImage.width / decodedImage.height;
|
||||
@@ -115,7 +115,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
getTemporaryDirectory().then((tempDir) async {
|
||||
final tempImage = File('${tempDir.path}/${actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png');
|
||||
if (tempImage.existsSync()) {
|
||||
_backgroundImage = tempImage;
|
||||
_backgroundImage = tempImage.readAsBytesSync();
|
||||
setState(() {});
|
||||
|
||||
// wait a bit until device rotation is done
|
||||
@@ -198,41 +198,50 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
top: position.dy,
|
||||
child: Tooltip(
|
||||
message: 'Drag to reposition',
|
||||
child: Draggable(
|
||||
dragAnchorStrategy: (widget, context, position) {
|
||||
final scale = _transformationController.value.getMaxScaleOnAxis();
|
||||
final RenderBox renderObject = context.findRenderObject() as RenderBox;
|
||||
return renderObject.globalToLocal(position).scale(scale, scale);
|
||||
},
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showFaded && widget.keyPair != keyPair ? 0.2 : 1.0,
|
||||
duration: Duration(milliseconds: 300),
|
||||
child: Draggable(
|
||||
dragAnchorStrategy: (widget, context, position) {
|
||||
final scale = _transformationController.value.getMaxScaleOnAxis();
|
||||
final RenderBox renderObject = context.findRenderObject() as RenderBox;
|
||||
return renderObject.globalToLocal(position).scale(scale, scale);
|
||||
},
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: icon,
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDragStarted: () {
|
||||
// Capture the starting position to calculate drag distance later
|
||||
dragStartPosition = position;
|
||||
if (keyPair != widget.keyPair && _showFaded) {
|
||||
setState(() {
|
||||
_showFaded = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
// Calculate drag distance to prevent accidental repositioning from clicks
|
||||
// while allowing legitimate drags even with low velocity (e.g., when overlapping buttons)
|
||||
final dragDistance = dragStartPosition != null
|
||||
? (details.offset - dragStartPosition!).distance
|
||||
: double.infinity;
|
||||
|
||||
// Only update position if dragged more than 5 pixels (prevents accidental clicks)
|
||||
if (dragDistance > 5) {
|
||||
final matrix = Matrix4.inverted(_transformationController.value);
|
||||
final height = 0;
|
||||
final sceneY = details.offset.dy - height;
|
||||
final viewportPoint = MatrixUtils.transformPoint(
|
||||
matrix,
|
||||
Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2),
|
||||
);
|
||||
setState(() => onPositionChanged(viewportPoint));
|
||||
}
|
||||
},
|
||||
child: icon,
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDragStarted: () {
|
||||
// Capture the starting position to calculate drag distance later
|
||||
dragStartPosition = position;
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
// Calculate drag distance to prevent accidental repositioning from clicks
|
||||
// while allowing legitimate drags even with low velocity (e.g., when overlapping buttons)
|
||||
final dragDistance = dragStartPosition != null
|
||||
? (details.offset - dragStartPosition!).distance
|
||||
: double.infinity;
|
||||
|
||||
// Only update position if dragged more than 5 pixels (prevents accidental clicks)
|
||||
if (dragDistance > 5) {
|
||||
final matrix = Matrix4.inverted(_transformationController.value);
|
||||
final height = 0;
|
||||
final sceneY = details.offset.dy - height;
|
||||
final viewportPoint = MatrixUtils.transformPoint(
|
||||
matrix,
|
||||
Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2),
|
||||
);
|
||||
setState(() => onPositionChanged(viewportPoint));
|
||||
}
|
||||
},
|
||||
child: icon,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -246,12 +255,11 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
if (_backgroundImage == null && constraints.biggest != _imageRect.size) {
|
||||
_imageRect = Rect.fromLTWH(0, 0, constraints.maxWidth, constraints.maxHeight);
|
||||
}
|
||||
final keyPairsToShow = _showAll
|
||||
? actionHandler.supportedApp?.keymap.keyPairs
|
||||
.where((kp) => kp.touchPosition != Offset.zero && !kp.isSpecialKey)
|
||||
.toList() ??
|
||||
[]
|
||||
: [widget.keyPair];
|
||||
final keyPairsToShow =
|
||||
actionHandler.supportedApp?.keymap.keyPairs
|
||||
.where((kp) => kp.touchPosition != Offset.zero && !kp.isSpecialKey)
|
||||
.toList() ??
|
||||
[];
|
||||
return InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
child: Stack(
|
||||
@@ -260,7 +268,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Image.file(
|
||||
child: Image.memory(
|
||||
_backgroundImage!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
@@ -339,23 +347,9 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _showAll,
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_showAll = !_showAll;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text('Show all touch areas'),
|
||||
],
|
||||
),
|
||||
child: Text('Choose another screenshot'),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showAll = !_showAll;
|
||||
});
|
||||
_pickScreenshot();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
@@ -401,7 +395,9 @@ class KeypairExplanation extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Icon(keyPair.icon),
|
||||
if (keyPair.inGameAction != null && (whooshLink.isConnected.value || zwiftEmulator.isConnected.value))
|
||||
if (keyPair.inGameAction != null &&
|
||||
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
|
||||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())))
|
||||
_KeyWidget(
|
||||
label: [
|
||||
keyPair.inGameAction.toString().split('.').last,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
@@ -14,15 +15,17 @@ 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,
|
||||
],
|
||||
compatibleTargets: kIsWeb
|
||||
? [Target.thisDevice]
|
||||
: [
|
||||
if (!Platform.isIOS) Target.thisDevice,
|
||||
Target.macOS,
|
||||
Target.windows,
|
||||
Target.iOS,
|
||||
Target.android,
|
||||
],
|
||||
packageName: "custom_$profileName",
|
||||
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
|
||||
supportsZwiftEmulation: !kIsWeb && !(Platform.isIOS || Platform.isMacOS),
|
||||
keymap: Keymap(keyPairs: []),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:swift_control/bluetooth/devices/elite/elite_sterzo.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
@@ -75,5 +76,6 @@ class ControllerButton {
|
||||
...ZwiftButtons.values,
|
||||
...EliteSquareButtons.values,
|
||||
...WahooKickrShiftButtons.values,
|
||||
...CycplusBc2Buttons.values,
|
||||
].distinct().toList();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../actions/base_actions.dart';
|
||||
@@ -40,7 +41,14 @@ class Keymap {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
keyPairs = [];
|
||||
for (final keyPair in keyPairs) {
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
keyPair.touchPosition = Offset.zero;
|
||||
keyPair.isLongPress = false;
|
||||
keyPair.inGameAction = null;
|
||||
keyPair.inGameActionValue = null;
|
||||
}
|
||||
_updateStream.add(null);
|
||||
}
|
||||
|
||||
@@ -90,7 +98,11 @@ class KeyPair {
|
||||
PhysicalKeyboardKey.audioVolumeUp ||
|
||||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
|
||||
_ when physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard) => Icons.keyboard,
|
||||
_ when inGameAction != null && whooshLink.isConnected.value => Icons.link,
|
||||
_
|
||||
when inGameAction != null &&
|
||||
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
|
||||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())) =>
|
||||
Icons.link,
|
||||
_ => Icons.touch_app,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class AccessibilityRequirement extends PlatformRequirement {
|
||||
AccessibilityRequirement()
|
||||
@@ -152,20 +154,30 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
channelGroupId,
|
||||
'Allows SwiftControl to keep running in background',
|
||||
foregroundServiceTypes: {AndroidServiceForegroundType.foregroundServiceTypeConnectedDevice},
|
||||
startType: AndroidServiceStartType.startRedeliverIntent,
|
||||
notificationDetails: AndroidNotificationDetails(
|
||||
channelGroupId,
|
||||
'Keep Alive',
|
||||
actions: [AndroidNotificationAction('Exit', 'Exit', cancelNotification: true, showsUserInterface: false)],
|
||||
),
|
||||
);
|
||||
|
||||
final receivePort = ReceivePort();
|
||||
IsolateNameServer.registerPortWithName(receivePort.sendPort, '_backgroundChannelKey');
|
||||
final backgroundMessagePort = receivePort.asBroadcastStream();
|
||||
backgroundMessagePort.listen((_) {
|
||||
UniversalBle.onAvailabilityChange = null;
|
||||
connection.reset();
|
||||
//exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void notificationTapBackground(NotificationResponse notificationResponse) {
|
||||
if (notificationResponse.actionId != null) {
|
||||
AndroidFlutterLocalNotificationsPlugin().stopForegroundService().then((_) {
|
||||
exit(0);
|
||||
});
|
||||
final sendPort = IsolateNameServer.lookupPortByName('_backgroundChannelKey');
|
||||
sendPort?.send('notificationResponse');
|
||||
//exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
import '../../pages/markdown.dart';
|
||||
@@ -31,16 +32,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Widget? buildDescription() {
|
||||
return settings.getLastTarget() == null
|
||||
? null
|
||||
: Text(
|
||||
switch (settings.getLastTarget()) {
|
||||
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 if you want to use the remote control feature.',
|
||||
},
|
||||
);
|
||||
return Text('Choose your preferred connection method');
|
||||
}
|
||||
|
||||
Future<void> reconnect() async {
|
||||
@@ -311,24 +303,37 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 10,
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 10,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await toggle();
|
||||
},
|
||||
child: Text(
|
||||
_isAdvertising ? 'Stop Pairing' : 'Start Pairing',
|
||||
if (settings.getTrainerApp() is MyWhoosh)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
settings.setMyWhooshLinkEnabled(true);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => DevicePage(),
|
||||
settings: RouteSettings(name: '/device'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Connect via MyWhoosh Link'),
|
||||
Text(
|
||||
'Most reliable way to control MyWhoosh.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
|
||||
],
|
||||
),
|
||||
if (settings.getTrainerApp() is MyWhoosh)
|
||||
),
|
||||
|
||||
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.push(
|
||||
@@ -344,33 +349,56 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Use MyWhoosh Link only'),
|
||||
Text('Connect to ${settings.getTrainerApp()?.name} as controller'),
|
||||
Text(
|
||||
'No pairing required, connect directly via MyWhoosh Link.',
|
||||
style: TextStyle(fontSize: 10, color: Colors.black87),
|
||||
'Most reliable way to control ${settings.getTrainerApp()?.name}.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
|
||||
Row(
|
||||
spacing: 10,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await toggle();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_isAdvertising ? 'Stop Pairing process' : 'Start Pairing',
|
||||
),
|
||||
Text(
|
||||
'Pairing allows full customizability,\nbut may not work on all devices.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
BetaPill(),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
),
|
||||
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
|
||||
],
|
||||
),
|
||||
if (_isAdvertising)
|
||||
Text(
|
||||
switch (settings.getLastTarget()) {
|
||||
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 if you want to use the remote control feature.',
|
||||
},
|
||||
child: Text('Connect to ${settings.getTrainerApp()?.name} directly as controller'),
|
||||
),
|
||||
],
|
||||
if (_isAdvertising) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
|
||||
@@ -173,4 +173,12 @@ class Settings {
|
||||
Future<void> setZwiftEmulatorEnabled(bool enabled) async {
|
||||
await prefs.setBool('zwift_emulator_enabled', enabled);
|
||||
}
|
||||
|
||||
bool getMiuiWarningDismissed() {
|
||||
return prefs.getBool('miui_warning_dismissed') ?? false;
|
||||
}
|
||||
|
||||
Future<void> setMiuiWarningDismissed(bool dismissed) async {
|
||||
await prefs.setBool('miui_warning_dismissed', dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.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/keymap.dart';
|
||||
import 'package:swift_control/utils/keymap/manager.dart';
|
||||
import 'package:swift_control/widgets/button_widget.dart';
|
||||
@@ -143,8 +144,8 @@ class _ButtonEditor extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final actions = [
|
||||
if (whooshLink.isConnected.value)
|
||||
final actions = <PopupMenuEntry>[
|
||||
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh)
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton(
|
||||
itemBuilder: (_) => WhooshLink.supportedActions.map(
|
||||
@@ -182,15 +183,20 @@ class _ButtonEditor extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text('MyWhoosh Link Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.link),
|
||||
Expanded(child: Text('MyWhoosh Link Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (zwiftEmulator.isConnected.value)
|
||||
if (settings.getZwiftEmulatorEnabled() && settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton(
|
||||
itemBuilder: (_) => ZwiftEmulator.supportedActions.map(
|
||||
@@ -228,11 +234,16 @@ class _ButtonEditor extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text('Zwift Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.link),
|
||||
Expanded(child: Text('Zwift Controller Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -331,23 +342,28 @@ class _ButtonEditor extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
onTap: () {
|
||||
keyPair.isLongPress = !keyPair.isLongPress;
|
||||
onUpdate();
|
||||
},
|
||||
child: ListTile(
|
||||
title: const Text('Long Press Mode (vs. repeating)'),
|
||||
trailing: Checkbox(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
padding: EdgeInsets.zero,
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
|
||||
onUpdate();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
onUpdate();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const Text('Long Press Mode (vs. repeating)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
@@ -361,14 +377,20 @@ class _ButtonEditor extends StatelessWidget {
|
||||
keyPair.inGameActionValue = null;
|
||||
onUpdate();
|
||||
},
|
||||
child: const Text('Unassign action'),
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.delete_outline),
|
||||
const Text('Unassign action'),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: kMinInteractiveDimension - 6),
|
||||
padding: EdgeInsets.only(right: actionHandler.supportedApp is CustomApp ? 4 : 0),
|
||||
child: PopupMenuButton(
|
||||
child: PopupMenuButton<dynamic>(
|
||||
itemBuilder: (c) => actions,
|
||||
enabled: actionHandler.supportedApp is CustomApp,
|
||||
child: Row(
|
||||
|
||||
@@ -160,9 +160,9 @@ class _AppTitleState extends State<AppTitle> {
|
||||
content: Text('Force-close the app to use the new version'),
|
||||
duration: Duration(seconds: 10),
|
||||
action: SnackBarAction(
|
||||
label: 'Attempt Restart',
|
||||
label: 'Restart',
|
||||
onPressed: () {
|
||||
if (Platform.isIOS || Platform.isAndroid) {
|
||||
if (Platform.isIOS) {
|
||||
connection.reset();
|
||||
Restart.restartApp(delayBeforeRestart: 1000);
|
||||
} else {
|
||||
|
||||
@@ -13,7 +13,7 @@ class Warning extends StatelessWidget {
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: swift_control
|
||||
description: "SwiftControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 3.3.0+33
|
||||
version: 3.3.2+35
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.0
|
||||
|
||||
106
test/cycplus_bc2_test.dart
Normal file
106
test/cycplus_bc2_test.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('CYCPLUS BC2 Virtual Shifter Tests', () {
|
||||
test('Should recognize shift up button code', () {
|
||||
// Test button code recognition
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
const releaseCode = 0x00;
|
||||
|
||||
expect(shiftUpCode, equals(0x01));
|
||||
expect(shiftDownCode, equals(0x02));
|
||||
expect(releaseCode, equals(0x00));
|
||||
});
|
||||
|
||||
test('Should handle button press and release cycle', () {
|
||||
// Test button state transitions
|
||||
final states = [0x01, 0x00, 0x02, 0x00];
|
||||
|
||||
expect(states[0], equals(0x01)); // Shift up pressed
|
||||
expect(states[1], equals(0x00)); // Button released
|
||||
expect(states[2], equals(0x02)); // Shift down pressed
|
||||
expect(states[3], equals(0x00)); // Button released
|
||||
});
|
||||
|
||||
test('Should validate UART service UUID format', () {
|
||||
// Nordic UART Service UUID
|
||||
const serviceUuid = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
expect(serviceUuid.length, equals(36));
|
||||
expect(serviceUuid.contains('-'), isTrue);
|
||||
expect(serviceUuid.toLowerCase(), equals(serviceUuid));
|
||||
});
|
||||
|
||||
test('Should validate TX characteristic UUID format', () {
|
||||
// TX Characteristic UUID (device to app)
|
||||
const txCharUuid = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
expect(txCharUuid.length, equals(36));
|
||||
expect(txCharUuid.contains('-'), isTrue);
|
||||
expect(txCharUuid.toLowerCase(), equals(txCharUuid));
|
||||
});
|
||||
|
||||
test('Should validate RX characteristic UUID format', () {
|
||||
// RX Characteristic UUID (app to device)
|
||||
const rxCharUuid = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
expect(rxCharUuid.length, equals(36));
|
||||
expect(rxCharUuid.contains('-'), isTrue);
|
||||
expect(rxCharUuid.toLowerCase(), equals(rxCharUuid));
|
||||
});
|
||||
});
|
||||
|
||||
group('CYCPLUS BC2 Button Code Tests', () {
|
||||
test('Should differentiate between shift up and shift down', () {
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
|
||||
expect(shiftUpCode != shiftDownCode, isTrue);
|
||||
expect(shiftUpCode < shiftDownCode, isTrue);
|
||||
});
|
||||
|
||||
test('Should recognize release code as different from press codes', () {
|
||||
const releaseCode = 0x00;
|
||||
const shiftUpCode = 0x01;
|
||||
const shiftDownCode = 0x02;
|
||||
|
||||
expect(releaseCode != shiftUpCode, isTrue);
|
||||
expect(releaseCode != shiftDownCode, isTrue);
|
||||
expect(releaseCode < shiftUpCode, isTrue);
|
||||
expect(releaseCode < shiftDownCode, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('CYCPLUS BC2 Device Name Recognition Tests', () {
|
||||
test('Should recognize CYCPLUS device name', () {
|
||||
const deviceName1 = 'CYCPLUS BC2';
|
||||
const deviceName2 = 'Cycplus BC2';
|
||||
const deviceName3 = 'CYCPLUS';
|
||||
|
||||
expect(deviceName1.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
expect(deviceName2.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
expect(deviceName3.toUpperCase().startsWith('CYCPLUS'), isTrue);
|
||||
});
|
||||
|
||||
test('Should recognize BC2 in device name', () {
|
||||
const deviceName1 = 'CYCPLUS BC2';
|
||||
const deviceName2 = 'BC2 Shifter';
|
||||
const deviceName3 = 'Virtual BC2';
|
||||
|
||||
expect(deviceName1.toUpperCase().contains('BC2'), isTrue);
|
||||
expect(deviceName2.toUpperCase().contains('BC2'), isTrue);
|
||||
expect(deviceName3.toUpperCase().contains('BC2'), isTrue);
|
||||
});
|
||||
|
||||
test('Should not match non-CYCPLUS devices', () {
|
||||
const deviceName1 = 'Zwift Click';
|
||||
const deviceName2 = 'Elite Sterzo';
|
||||
const deviceName3 = 'Wahoo KICKR';
|
||||
|
||||
expect(deviceName1.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
expect(deviceName2.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
expect(deviceName3.toUpperCase().startsWith('CYCPLUS'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user