Compare commits

...

37 Commits

Author SHA1 Message Date
Jonas Bark
57961aec5d bump version 2025-11-04 17:36:01 +01:00
Jonas Bark
1675d7f2d0 bump version 2025-11-04 17:35:32 +01:00
Jonas Bark
baec8d24c3 fix detection of Sterzo devices https://github.com/jonasbark/swiftcontrol/issues/111#issuecomment-3486678629 2025-11-04 17:34:25 +01:00
Jonas Bark
a968723277 update gitignore 2025-11-04 08:58:54 +01:00
Jonas Bark
8668957738 fix name 2025-11-03 20:30:05 +01:00
Jonas Bark
ac550fad5b do not offer MyWhoosh Link when not compatible 2025-11-03 18:58:41 +01:00
Jonas Bark
c511ac32b6 fix 'Exit' behavior on Android notification tap 2025-11-03 10:02:37 +01:00
Jonas Bark
ee48ce0f4e Merge remote-tracking branch 'origin/main' 2025-11-03 09:30:45 +01:00
Jonas Bark
8a3d64491b version++ 2025-11-03 09:30:38 +01:00
jonasbark
b72cc803f0 Clarify iOS, macOS, and Windows requirements
Updated iOS, macOS, and Windows requirements for Bluetooth buttons.
2025-11-03 09:12:00 +01:00
Jonas Bark
ea17b2e142 Merge remote-tracking branch 'origin/main' 2025-11-03 08:45:50 +01:00
Jonas Bark
da62fc4dc6 fix resetting keymap, show all touches by default 2025-11-03 08:45:39 +01:00
jonasbark
239630f681 Fix formatting and grammar issues in README.md
Corrected formatting and grammar in the README file.
2025-11-02 17:54:51 +01:00
Jonas Bark
d95d0cf8cf exit app on Android to apply patch 2025-11-02 17:48:34 +01:00
Jonas Bark
2b25ba942c Merge remote-tracking branch 'origin/main' 2025-11-02 16:22:14 +01:00
Jonas Bark
c65369a746 clarify pairing vs controller vs MyWhoosh Link 2025-11-02 16:22:06 +01:00
Jonas Bark
fa7d5e7853 clarify pairing vs controller vs MyWhoosh Link 2025-11-02 16:20:32 +01:00
Jonas Bark
8ac47cbd4d make Link / emulation UI clearer 2025-11-02 16:02:07 +01:00
Jonas Bark
eb85844503 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-02 13:56:12 +01:00
Jonas Bark
010d0ed331 UI adjustments 2025-11-02 13:55:56 +01:00
Jonas Bark
1f8f7765a3 fix SwiftControl Web, more generic battery and firmware version reads 2025-11-02 13:24:19 +01:00
Jonas Bark
68f416dda3 fix SwiftControl Web, more generic battery and firmware version reads 2025-11-02 13:10:35 +01:00
Jonas Bark
49e45faec0 fix SwiftControl Web 2025-11-02 10:50:44 +01:00
Jonas Bark
c81516350a initial support for Shimano Di2 D-Fly channel buttons 2025-11-02 10:45:51 +01:00
jonasbark
890f393fd6 Merge pull request #151 from jonasbark/copilot/add-cycplus-bc2-support
Add CYCPLUS BC2 virtual shifter support via Nordic UART Service
2025-11-02 08:26:42 +01:00
copilot-swe-agent[bot]
e46969c5c4 Add CYCPLUS BC2 virtual shifter support
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-02 06:45:59 +00:00
copilot-swe-agent[bot]
1ec9b55645 Initial plan 2025-11-02 06:39:40 +00:00
Jonas Bark
b0caf7c13b UI adjustments 2025-11-01 19:43:30 +01:00
Jonas Bark
302fc15dd7 don't reset after a minute 2025-11-01 19:40:23 +01:00
jonasbark
6a2cf1a1c9 Merge pull request #147 from jonasbark/copilot/fix-miui-service-issue
Add MIUI device detection and battery optimization warning with dismissal
2025-11-01 19:36:16 +01:00
jonasbark
8ea73bc54a Update Windows Store version to 3.3.0 2025-11-01 19:27:07 +01:00
copilot-swe-agent[bot]
7cbab3925f Use AndroidActions type check instead of negated RemoteActions
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-01 10:15:16 +00:00
copilot-swe-agent[bot]
246a1bd2be Add dismiss button to MIUI warning with persistent state
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-01 09:54:20 +00:00
copilot-swe-agent[bot]
f7e2a89ed6 Move MIUI warning from requirements to DevicePage widget
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:48:58 +00:00
copilot-swe-agent[bot]
f94252edb9 Address code review feedback - improve comments and efficiency
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:37:58 +00:00
copilot-swe-agent[bot]
b7b6b9803f Add MIUI device detection and warning for accessibility service
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:31:30 +00:00
copilot-swe-agent[bot]
807d868b74 Initial plan 2025-10-31 18:25:43 +00:00
31 changed files with 908 additions and 389 deletions

View File

@@ -31,7 +31,7 @@ on:
build_web:
description: 'Build for Web'
required: false
default: true
default: false
type: boolean
env:

View File

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

@@ -10,6 +10,7 @@
.history
.svn/
.swiftpm/
debug/
migrate_working_dir/
android/keystore.properties

View File

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

View File

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

View File

@@ -1 +1 @@
3.2.0
3.3.0

View File

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

View File

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

View File

@@ -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) ...[

View 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,
];
}

View File

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

View File

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

View File

@@ -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(),
],

View 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";
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: []),
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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