Compare commits

...

55 Commits

Author SHA1 Message Date
Jonas Bark
fb1ffec37d version++ 2025-11-16 21:04:28 +01:00
Jonas Bark
9ea4f7157a some possible fixes 2025-11-16 20:53:56 +01:00
Jonas Bark
a9b43bd347 windows build fix 2025-11-16 12:51:32 +01:00
Jonas Bark
b9ac193e77 windows build fix 2025-11-16 12:47:13 +01:00
Jonas Bark
1d4947b3ae windows build fix 2025-11-16 12:46:17 +01:00
Jonas Bark
0339089972 version++ 2025-11-16 11:58:05 +01:00
Jonas Bark
1e8bd61264 update changelog 2025-11-16 10:20:47 +01:00
Jonas Bark
79613bc8de resolve issue #179 2025-11-16 10:15:46 +01:00
Jonas Bark
d0ec785e32 remove settings file when corrupted #180 2025-11-16 10:13:37 +01:00
Jonas Bark
020b91fd21 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-15 12:54:53 +01:00
Jonas Bark
f2406152fd Merge branch 'copilot/update-cycplus-bc2-implementation' 2025-11-15 12:54:45 +01:00
Jonas Bark
ab3ef7be53 resolve #186 2025-11-15 12:54:30 +01:00
jonasbark
bb7484ff2e Merge pull request #185 from jonasbark/copilot/update-cycplus-bc2-implementation
Simplify Cycplus BC2 implementation to match reference state machine
2025-11-15 10:35:28 +01:00
copilot-swe-agent[bot]
80061fd076 Update Cycplus BC2 implementation to match reference
- Only look at bytes at index 6 and 7 (no full frame parsing)
- Implement state machine for pressed/released states
- Track state independently for each index
- Trigger on state transitions (pressed to different pressed)
- Reset state on release (0x00) or after successful trigger

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-15 09:27:45 +00:00
Jonas Bark
124e005fb1 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-15 10:27:11 +01:00
Jonas Bark
8e760ef202 more error checking 2025-11-15 10:27:07 +01:00
copilot-swe-agent[bot]
de740f6453 Initial plan 2025-11-15 09:20:32 +00:00
jonasbark
3bde90ae62 Merge pull request #182 from jonasbark/copilot/fix-ble-device-reconnection
Persist ignored BLE devices across app restarts
2025-11-15 09:04:53 +01:00
copilot-swe-agent[bot]
aee8dc2e07 Make getIgnoredDeviceIds and getIgnoredDeviceNames private
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-15 07:57:35 +00:00
jonasbark
b3952542f8 Fix grammar and formatting in README.md 2025-11-14 15:08:05 +01:00
Jonas Bark
910f23a3f6 Merge branch 'copilot/fix-cycplus-bc2-button-logic' 2025-11-14 14:57:47 +01:00
Jonas Bark
5cc9ac85af attempt to handle https://github.com/jonasbark/swiftcontrol/issues/179 2025-11-14 14:57:35 +01:00
copilot-swe-agent[bot]
e10e22d038 Add support for 0xfe button codes in CYCPLUS BC2 device
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-14 13:34:36 +00:00
copilot-swe-agent[bot]
aaeaec36a2 Initial plan 2025-11-14 13:29:59 +00:00
Jonas Bark
c16a593f3c potential fix for https://github.com/jonasbark/swiftcontrol/issues/183 2025-11-14 14:28:47 +01:00
Jonas Bark
50d9f47576 potential fix for https://github.com/jonasbark/swiftcontrol/issues/183 2025-11-14 12:23:30 +01:00
copilot-swe-agent[bot]
a53fb578ef Fix disconnect logic and improve UI flow
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-13 15:25:14 +00:00
copilot-swe-agent[bot]
1f89859a03 Implement persistent ignored devices feature
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-13 15:23:12 +00:00
copilot-swe-agent[bot]
7c1cee6748 Initial plan 2025-11-13 15:17:47 +00:00
Jonas Bark
a4949ad615 error handling 2025-11-12 22:22:44 +01:00
Jonas Bark
a57f4654b0 possible fix for https://github.com/jonasbark/swiftcontrol/issues/180#issuecomment-3523836191 2025-11-12 21:49:01 +01:00
Jonas Bark
8192d3addf show to user when MyWhoosh Link can't be started, cleanup 2025-11-12 09:22:41 +01:00
Jonas Bark
69f47fa984 bluetooth naming 2025-11-11 09:06:07 +01:00
Jonas Bark
2b106fd1c9 change mail address 2025-11-09 19:33:32 +01:00
Jonas Bark
1784e008ee a few fixes 2025-11-09 19:17:35 +01:00
Jonas Bark
33ccdbd7af a few fixes 2025-11-09 16:38:13 +01:00
Jonas Bark
d15f1ddc13 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-09 16:14:34 +01:00
Jonas Bark
e7ea01cd60 Merge branch 'copilot/create-landing-page-for-apps' 2025-11-09 16:14:29 +01:00
Jonas Bark
f7ed426441 cleanup 2025-11-09 16:14:15 +01:00
Jonas Bark
d30485b82e add a clarifying selection to help users choose where SwiftControl should run 2025-11-09 16:13:07 +01:00
Jonas Bark
4e646ab922 add dark mode, logs to the support entry, link to new landing page 2025-11-09 15:57:54 +01:00
jonasbark
4183ede58d Updated .gitignore 2025-11-09 08:54:17 +01:00
copilot-swe-agent[bot]
dd85e99e4b Update landing page per feedback: blue background, black headings, restructured compatibility checker, and instructions section
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-09 07:42:42 +00:00
jonasbark
2334a88452 Merge pull request #174 from jonasbark/copilot/add-changelog-to-releases
Add changelog to GitHub release body
2025-11-09 08:23:40 +01:00
copilot-swe-agent[bot]
ee64b18f75 Refactor: reuse get_latest_changelog.sh output
- Removed duplicate awk logic from generate_release_body.sh
- Now calls get_latest_changelog.sh for changelog content
- Simplified script by removing unused variables
- Adds version header separately for GitHub releases

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-09 07:21:17 +00:00
copilot-swe-agent[bot]
647dac9e7c Add interactive landing page with compatibility checker
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-09 07:14:46 +00:00
copilot-swe-agent[bot]
df2496eb67 Add changelog to GitHub release body
- Created generate_release_body.sh script to combine changelog with store links
- Updated build.yml workflow to use new release body
- Updated patch.yml workflow to use new release body
- Release body now includes latest changelog entry followed by store download links

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-09 07:12:43 +00:00
copilot-swe-agent[bot]
859424b895 Initial plan 2025-11-09 07:08:36 +00:00
copilot-swe-agent[bot]
dde3f38bde Initial plan 2025-11-09 07:07:37 +00:00
Jonas Bark
01744c258e attempt to improve UX 2025-11-08 22:45:25 +01:00
Jonas Bark
231aadbc27 detect media key source - don't handle it when coming from phone #110 2025-11-08 22:31:12 +01:00
Jonas Bark
a806a628bd Merge remote-tracking branch 'origin/main' 2025-11-08 20:18:58 +01:00
Jonas Bark
c529fee1fa fix execution on Web 2025-11-08 20:18:42 +01:00
jonasbark
c36a0252e6 Aktualisieren von WINDOWS_STORE_VERSION.txt 2025-11-08 15:58:21 +01:00
Jonas Bark
66486ec38e make Di2 custom keymap requirement clearer #170 2025-11-08 12:55:05 +01:00
36 changed files with 1041 additions and 905 deletions

View File

@@ -152,6 +152,12 @@ jobs:
mkdir -p whatsnew
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
- name: Generate release body
if: inputs.build_github
run: |
chmod +x scripts/generate_release_body.sh
./scripts/generate_release_body.sh > /tmp/release_body.md
- name: 🚀 Shorebird Release iOS
if: inputs.build_ios
uses: shorebirdtech/shorebird-release@v1
@@ -248,7 +254,7 @@ jobs:
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
allowUpdates: true
prerelease: true
bodyFile: scripts/RELEASE_NOTES.md
bodyFile: /tmp/release_body.md
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
@@ -263,6 +269,15 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Extract version from pubspec.yaml (Windows)
shell: pwsh
run: |
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
($_ -split ' ')[1].Trim()
}
echo "VERSION=$version" >> $env:GITHUB_ENV
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:
@@ -332,21 +347,11 @@ jobs:
build/windows/x64/runner/Release/SwiftControl.windows.zip
build/windows/x64/runner/Release/SwiftControl.windows.msix
- name: Extract version from pubspec.yaml (Windows)
shell: pwsh
run: |
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
($_ -split ' ')[1].Trim()
}
echo "VERSION=$version" >> $env:GITHUB_ENV
- name: Update Release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip,build/windows/x64/runner/Release/SwiftControl.windows.msix"
bodyFile: scripts/RELEASE_NOTES.md
prerelease: true
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}

View File

@@ -9,6 +9,7 @@ env:
jobs:
build:
if: false
name: Patch iOS, Android & macOS
runs-on: macos-latest
@@ -125,13 +126,18 @@ jobs:
path: |
build/macos/Build/Products/Release/SwiftControl.macos.zip
- name: Generate release body
run: |
chmod +x scripts/generate_release_body.sh
./scripts/generate_release_body.sh > /tmp/release_body.md
# add artifact to release
- name: Create Release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
bodyFile: scripts/RELEASE_NOTES.md
bodyFile: /tmp/release_body.md
prerelease: true
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
@@ -145,13 +151,6 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v3
#2 Setup Java
- name: Set Up Java
uses: actions/setup-java@v3.12.0
with:
distribution: 'oracle'
java-version: '17'
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:

1
.gitignore vendored
View File

@@ -48,3 +48,4 @@ app.*.map.json
/android/app/release
service-account.json
.env

View File

@@ -1,3 +1,12 @@
### 3.5.0 (16-11-2025)
**New Features:**
- Dark mode support
- Cycplus BC2 support (thanks @schneewoehner)
- Ignored devices now persist across app restarts - remove them from ignored devices via the menu
**Fixes:**
- resolve issues during app start
### 3.4.0 (08-11-2025)
**New Features:**
- Support for Shimano Di2

View File

@@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
## Downloads
Check the compatibility matrix below!
Best follow our landing page and the "Get Started" button: [swiftcontrol.app](https://swiftcontrol.app/) to understand on which platform you want to run SwiftControl.
<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>
@@ -59,19 +59,8 @@ Support for other devices can be added; check the issues tab here on GitHub.
## Supported Platforms
Follow this compatibility matrix. It all depends on where you want to run your trainer app (e.g. MyWhoosh on):
| 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. |
| 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.
Follow the "Get Started" button over at [swiftcontrol.app](https://swiftcontrol.app) to understand on which platform you want to run SwiftControl.
You can even try it out in your [Browser](https://jonasbark.github.io/swiftcontrol/), if it supports Bluetooth connections. No controlling possible, though.
## Troubleshooting
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
@@ -83,9 +72,9 @@ The app connects to your Controller devices (such as Zwift ones) automatically.
- **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
- If you want to use MyWhoosh, you can use the Link method to connect to MyWhoosh directly
- 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.
- **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

View File

@@ -1 +1 @@
3.3.0
3.4.0

View File

@@ -10,6 +10,7 @@ import android.graphics.Rect
import android.media.AudioManager
import android.os.Build
import android.util.Log
import android.view.InputDevice
import android.view.KeyEvent
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityEvent
@@ -69,7 +70,7 @@ class AccessibilityService : AccessibilityService(), Listener {
}
override fun onKeyEvent(event: KeyEvent): Boolean {
if (!Observable.ignoreHidDevices) {
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
// Handle media and volume keys from HID devices here
Log.d(
"AccessibilityService",
@@ -87,6 +88,15 @@ class AccessibilityService : AccessibilityService(), Listener {
}
}
private fun isBleRemote(event: KeyEvent): Boolean {
val dev = InputDevice.getDevice(event.deviceId) ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
dev.isExternal
} else {
true
}
}
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
val gestureBuilder = GestureDescription.Builder()
val path = Path()

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -19,7 +19,6 @@ import 'package:universal_ble/universal_ble.dart';
import '../utils/keymap/apps/my_whoosh.dart';
import 'devices/base_device.dart';
import 'devices/link/link_device.dart';
import 'devices/zwift/constants.dart';
import 'messages/notification.dart';
@@ -40,6 +39,7 @@ class Connection {
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
Stream<BaseNotification> get actionStream => _actionStreams.stream;
List<({DateTime date, String entry})> lastLogEntries = [];
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
@@ -52,9 +52,12 @@ class Connection {
Timer? _gamePadSearchTimer;
final _dontAllowReconnectDevices = <String>{};
void initialize() {
actionStream.listen((log) {
lastLogEntries.add((date: DateTime.now(), entry: log.toString()));
lastLogEntries = lastLogEntries.takeLast(20).toList();
});
isMediaKeyDetectionEnabled.addListener(() {
if (!isMediaKeyDetectionEnabled.value) {
mediaKeyDetector.setIsPlaying(isPlaying: false);
@@ -107,14 +110,26 @@ class Connection {
}
};
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) async {
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
if (device == null) {
_actionStreams.add(LogNotification('Device not found: $deviceId'));
UniversalBle.disconnect(deviceId);
return;
} else {
device.processCharacteristic(characteristicUuid, value);
try {
await device.processCharacteristic(characteristicUuid, value);
} catch (e, backtrace) {
_actionStreams.add(
LogNotification(
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
),
);
if (kDebugMode) {
print(e);
print("backtrace: $backtrace");
}
}
}
};
@@ -176,50 +191,56 @@ class Connection {
});
}
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
startMyWhooshServer();
if (settings.getMyWhooshLinkEnabled() &&
settings.getTrainerApp() is MyWhoosh &&
!whooshLink.isStarted.value &&
whooshLink.isCompatible(settings.getLastTarget()!)) {
startMyWhooshServer().catchError((e) {
_actionStreams.add(
LogNotification(
'Error starting MyWhoosh Direct Connect server. Please make sure the "MyWhoosh Link" app is not already running on this device.\n$e',
),
);
});
}
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
_androidNotificationsSetup = true;
// start foreground service only when app is in foreground
NotificationRequirement.setup().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
Future<void> startMyWhooshServer() {
return whooshLink.startServer(
onConnected: (socket) {
final existing = remoteDevices.firstOrNullWhere(
(e) => e is LinkDevice && e.identifier == socket.remoteAddress.address,
);
if (existing != null) {
existing.isConnected = true;
signalChange(existing);
}
},
onDisconnected: (socket) {
final device = devices.firstOrNullWhere(
(device) => device is LinkDevice && device.identifier == socket.remoteAddress.address,
);
if (device != null) {
devices.remove(device);
signalChange(device);
}
},
onConnected: (socket) {},
onDisconnected: (socket) {},
);
}
void addDevices(List<BaseDevice> dev) {
final newDevices = dev
.where((device) => !devices.contains(device) && !_dontAllowReconnectDevices.contains(device.name))
.toList();
final ignoredDevices = settings.getIgnoredDevices();
final ignoredDeviceIds = ignoredDevices.map((d) => d.id).toSet();
final newDevices = dev.where((device) {
if (devices.contains(device)) return false;
// Check if device is in the ignored list
if (device is BluetoothDevice) {
if (ignoredDeviceIds.contains(device.device.deviceId)) {
return false;
}
}
return true;
}).toList();
devices.addAll(newDevices);
_connectionQueue.addAll(newDevices);
_handleConnectionQueue();
hasDevices.value = devices.isNotEmpty;
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
_androidNotificationsSetup = true;
NotificationRequirement.setup().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
void _handleConnectionQueue() {
@@ -258,7 +279,7 @@ class Connection {
device.isConnected = state;
_connectionStreams.add(device);
if (!device.isConnected) {
disconnect(device, forget: true);
disconnect(device, forget: false);
// try reconnect
performScanning();
}
@@ -332,20 +353,26 @@ class Connection {
if (device.isConnected) {
await device.disconnect();
}
if (device is! LinkDevice) {
// keep it in the list to allow reconnect
devices.remove(device);
if (device is BluetoothDevice) {
if (forget) {
_dontAllowReconnectDevices.add(device.name);
// Add device to ignored list when forgetting
await settings.addIgnoredDevice(device.device.deviceId, device.name);
_actionStreams.add(LogNotification('Device ignored: ${device.name}'));
}
}
if (!forget && device is BluetoothDevice) {
// Clean up subscriptions and scan results for reconnection
_lastScanResult.removeWhere((b) => b.deviceId == device.device.deviceId);
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
// Remove device from the list
devices.remove(device);
hasDevices.value = devices.isNotEmpty;
}
signalChange(device);
}

View File

@@ -39,6 +39,16 @@ abstract class BaseDevice {
Future<void> connect();
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
try {
await _handleButtonsClickedInternal(buttonsClicked);
} catch (e, st) {
actionStreamInternal.add(
LogNotification('Error handling button clicks: $e\n$st'),
);
}
}
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked) async {
if (buttonsClicked == null) {
// ignore, no changes
} else if (buttonsClicked.isEmpty) {

View File

@@ -56,7 +56,7 @@ abstract class BluetoothDevice extends BaseDevice {
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') =>
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
CycplusBc2(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
_ => null,
@@ -71,7 +71,7 @@ abstract class BluetoothDevice extends BaseDevice {
_ 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') =>
_ 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),
@@ -115,10 +115,10 @@ abstract class BluetoothDevice extends BaseDevice {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult.deviceId == other.scanResult.deviceId;
@override
int get hashCode => scanResult.hashCode;
int get hashCode => scanResult.deviceId.hashCode;
@override
String toString() {
@@ -192,7 +192,7 @@ abstract class BluetoothDevice extends BaseDevice {
return Row(
children: [
Text(
device.name?.screenshot ?? device.runtimeType.toString(),
device.name?.screenshot ?? runtimeType.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),

View File

@@ -27,43 +27,74 @@ class CycplusBc2 extends BluetoothDevice {
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
}
// Track last state for index 6 and 7
int _lastStateIndex6 = 0x00;
int _lastStateIndex7 = 0x00;
@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.length > 7) {
final buttonsToPress = <ControllerButton>[];
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;
// Process index 6 (shift up)
final currentByte6 = bytes[6];
if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) {
buttonsToPress.add(CycplusBc2Buttons.shiftUp);
_lastStateIndex6 = 0x00; // Reset after successful press
} else {
_updateState(currentByte6, (val) => _lastStateIndex6 = val);
}
// Process index 7 (shift down)
final currentByte7 = bytes[7];
if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) {
buttonsToPress.add(CycplusBc2Buttons.shiftDown);
_lastStateIndex7 = 0x00; // Reset after successful press
} else {
_updateState(currentByte7, (val) => _lastStateIndex7 = val);
}
handleButtonsClicked(buttonsToPress);
} else {
actionStreamInternal.add(
LogNotification(
'CYCPLUS BC2 received unexpected packet: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join()}',
),
);
handleButtonsClicked([]);
}
}
return Future.value();
}
// Check if we should trigger a shift based on current and last state
bool _shouldTriggerShift(int currentByte, int lastByte) {
const pressedValues = {0x01, 0x02, 0x03};
// State change from one pressed value to another different pressed value
// This is the ONLY time we trigger a shift
if (pressedValues.contains(currentByte) && pressedValues.contains(lastByte) && currentByte != lastByte) {
return true;
}
return false;
}
// Update state tracking
void _updateState(int currentByte, void Function(int) setState) {
const pressedValues = {0x01, 0x02, 0x03};
const releaseValue = 0x00;
// Button released: current is 0x00 and last was pressed
if (currentByte == releaseValue) {
setState(releaseValue);
}
// Lock the new pressed state
else if (pressedValues.contains(currentByte)) {
setState(currentByte);
}
}
}
class CycplusBc2Constants {

View File

@@ -38,13 +38,22 @@ class WhooshLink {
required void Function(Socket socket) onConnected,
required void Function(Socket socket) onDisconnected,
}) async {
// Create and bind server socket
_server = await ServerSocket.bind(
InternetAddress.anyIPv6,
21587,
shared: true,
v6Only: false,
);
try {
// Create and bind server socket
_server = await ServerSocket.bind(
InternetAddress.anyIPv6,
21587,
shared: true,
v6Only: false,
);
} catch (e) {
if (kDebugMode) {
print('Failed to start server: $e');
}
isConnected.value = false;
isStarted.value = false;
rethrow;
}
isStarted.value = true;
if (kDebugMode) {
print('Server started on port ${_server!.port}');
@@ -143,9 +152,11 @@ class WhooshLink {
}
bool isCompatible(Target target) {
return switch (target) {
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
_ => true,
};
return kIsWeb
? false
: switch (target) {
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
_ => true,
};
}
}

View File

@@ -1,90 +0,0 @@
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';
class LinkDevice extends BaseDevice {
String identifier;
LinkDevice(this.identifier) : super('MyWhoosh Direct Connect', availableButtons: []);
@override
Future<void> connect() async {
isConnected = true;
}
@override
Future<void> disconnect() async {
super.disconnect();
whooshLink.stopServer();
isConnected = false;
}
@override
Widget showInformation(BuildContext context) {
return ValueListenableBuilder(
valueListenable: whooshLink.isConnected,
builder: (context, isConnected, _) {
return StatefulBuilder(
builder: (context, setState) {
final myWhooshExplanation = actionHandler is RemoteActions
? 'MyWhoosh Direct Connect allows you to do some additional features such as Emotes and turn directions.'
: 'MyWhoosh Direct Connect is optional, but allows you to do some additional features such as Emotes and turn directions.';
return Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: settings.getMyWhooshLinkEnabled(),
onChanged: (value) {
settings.setMyWhooshLinkEnabled(value);
if (!value) {
disconnect();
connection.disconnect(this, forget: true);
} else if (value) {
connection.startMyWhooshServer();
}
setState(() {});
},
title: Text('Enable MyWhoosh Direct Connect'),
subtitle: Row(
spacing: 12,
children: [
if (!settings.getMyWhooshLinkEnabled())
Expanded(
child: Text(
myWhooshExplanation,
style: TextStyle(fontSize: 12),
),
)
else ...[
Expanded(
child: Text(
isConnected ? "Connected" : "Connecting to MyWhoosh...\n$myWhooshExplanation",
style: TextStyle(fontSize: 12),
),
),
if (!isConnected) SmallProgressIndicator(),
],
],
),
),
),
IconButton(
onPressed: () {
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
},
icon: Icon(Icons.help_outline),
),
],
);
},
);
},
);
}
}

View File

@@ -2,7 +2,6 @@ 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';
@@ -25,10 +24,6 @@ class ShimanoDi2 extends BluetoothDevice {
);
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>{};
@@ -82,12 +77,18 @@ class ShimanoDi2 extends BluetoothDevice {
@override
Widget showInformation(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
),
if (actionHandler.supportedApp is! CustomApp)
Text(
'Use a custom keymap to support ${scanResult.name}',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
);
}

View File

@@ -4,6 +4,8 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
@@ -156,7 +158,8 @@ abstract class ZwiftDevice extends BluetoothDevice {
@override
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
settings.getVibrationEnabled()) {
settings.getVibrationEnabled() &&
(this is ZwiftPlay || this is ZwiftRide)) {
await _vibrate();
}
return super.performClick(buttonsClicked);

View File

@@ -24,8 +24,8 @@ const screenshotMode = false;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const SwiftPlayApp());
final error = await settings.init();
runApp(SwiftPlayApp(error: error));
}
enum ConnectionType {
@@ -60,7 +60,8 @@ Future<void> initializeActions(ConnectionType connectionType) async {
}
class SwiftPlayApp extends StatelessWidget {
const SwiftPlayApp({super.key});
final String? error;
const SwiftPlayApp({super.key, this.error});
@override
Widget build(BuildContext context) {
@@ -70,8 +71,10 @@ class SwiftPlayApp extends StatelessWidget {
title: 'SwiftControl',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.light,
home: const RequirementsPage(),
themeMode: ThemeMode.system,
home: error != null
? Text('There was an error starting the App. Please contact support:\n$error')
: const RequirementsPage(),
);
}
}

View File

@@ -6,13 +6,14 @@ 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';
import 'package:swift_control/bluetooth/devices/link/link_device.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/keymap/manager.dart';
import 'package:swift_control/utils/requirements/zwift.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/widgets/apps/mywhoosh_link_tile.dart';
import 'package:swift_control/widgets/apps/zwift_tile.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/logviewer.dart';
@@ -203,7 +204,9 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
appBar: AppBar(
title: AppTitle(),
actions: buildMenuButtons(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
backgroundColor: Theme.brightnessOf(context) == Brightness.light
? Theme.of(context).colorScheme.inversePrimary
: null,
),
body: SingleChildScrollView(
padding: EdgeInsets.only(
@@ -334,7 +337,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (connection.remoteDevices.isNotEmpty ||
actionHandler is RemoteActions ||
whooshLink.isCompatible(settings.getLastTarget()!) ||
whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) ||
actionHandler.supportedApp?.supportsZwiftEmulation == true)
Container(
margin: const EdgeInsets.only(bottom: 8.0),
@@ -360,18 +363,20 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (settings.getTrainerApp() is MyWhoosh &&
whooshLink.isCompatible(settings.getLastTarget()!))
LinkDevice('').showInformation(context),
MyWhooshLinkTile(),
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
ZwiftRequirement().build(context, () {
setState(() {});
})!,
ZwiftTile(
onUpdate: () {
setState(() {});
},
),
if (actionHandler is RemoteActions)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected (optional)'}',
),
PopupMenuButton(
itemBuilder: (_) => [
@@ -392,125 +397,129 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
),
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,
if (!kIsWeb) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
'Customize ${settings.getTrainerApp()?.name} on ${settings.getLastTarget()?.title}',
style: Theme.of(context).textTheme.titleMedium,
),
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;
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(() {});
}
} 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 ?? '';
},
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 ?? '';
},
),
],
),
],
),
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(() {});
},
),
],
),
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

@@ -54,7 +54,9 @@ class _ChangelogPageState extends State<MarkdownPage> {
return Scaffold(
appBar: AppBar(
title: Text(widget.assetPath.replaceAll('.md', '').toLowerCase().capitalize),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
backgroundColor: Theme.brightnessOf(context) == Brightness.light
? Theme.of(context).colorScheme.inversePrimary
: null,
),
body: _error != null
? Center(child: Text(_error!))
@@ -68,7 +70,10 @@ class _ChangelogPageState extends State<MarkdownPage> {
child: MarkdownWidget(
markdown: _markdown!,
theme: MarkdownThemeData(
textStyle: TextStyle(fontSize: 14.0, color: Colors.black87),
textStyle: TextStyle(
fontSize: 14.0,
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
),
onLinkTap: (title, url) {
launchUrlString(url);
},

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.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/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
@@ -31,16 +32,14 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
// call after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
settings.init().then((_) {
if (!kIsWeb && Platform.isMacOS) {
// add more delay due to CBManagerStateUnknown
Future.delayed(const Duration(seconds: 2), () {
_reloadRequirements();
});
} else {
if (!kIsWeb && Platform.isMacOS) {
// add more delay due to CBManagerStateUnknown
Future.delayed(const Duration(seconds: 2), () {
_reloadRequirements();
}
});
});
} else {
_reloadRequirements();
}
});
}
@@ -62,8 +61,10 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
return Scaffold(
appBar: AppBar(
title: AppTitle(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: buildMenuButtons(),
backgroundColor: Theme.brightnessOf(context) == Brightness.light
? Theme.of(context).colorScheme.inversePrimary
: null,
),
body: SingleChildScrollView(
child: Column(
@@ -106,6 +107,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
: Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Stepper(
key: ObjectKey(_requirements.length),
physics: NeverScrollableScrollPhysics(),
currentStep: _currentStep,
connectorColor: WidgetStateProperty.resolveWith<Color>(
@@ -170,16 +172,28 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
void _callRequirement(PlatformRequirement req, BuildContext context, VoidCallback onUpdate) {
req.call(context, onUpdate).then((_) {
_reloadRequirements();
});
req
.call(context, onUpdate)
.then((_) {
return _reloadRequirements();
})
.catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error handling requirement "${req.name}": $e'),
),
);
});
}
void _reloadRequirements() {
getRequirements(
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
).then((req) {
void _reloadRequirements() async {
try {
final req = await getRequirements(
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
);
_requirements = req;
_currentStep = _currentStep >= _requirements.length ? 0 : _currentStep;
setState(() {});
final unresolvedIndex = req.indexWhere((req) => !req.status);
if (unresolvedIndex != -1) {
_currentStep = unresolvedIndex;
@@ -199,9 +213,16 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
);
}
}
if (mounted) {
setState(() {});
}
});
} catch (e) {
connection.signalNotification(LogNotification('Error loading requirements: $e'));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading requirements: $e'),
),
);
_currentStep = 0;
_requirements = [ErrorRequirement('Error loading requirements: $e')];
setState(() {});
}
}
}

View File

@@ -10,7 +10,7 @@ import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/widgets/button_widget.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/testbed.dart';
@@ -396,7 +396,8 @@ class KeypairExplanation extends StatelessWidget {
else
Icon(keyPair.icon),
if (keyPair.inGameAction != null &&
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
((whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) &&
settings.getMyWhooshLinkEnabled()) ||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())))
_KeyWidget(
label: [

View File

@@ -30,6 +30,9 @@ abstract final class AppTheme {
FlexThemeData.dark(
// Using FlexColorScheme built-in FlexScheme enum based colors.
scheme: FlexScheme.redM3,
primary: Color(0xFF0E74B7),
primaryContainer: Color(0x7C0E9297),
onPrimaryContainer: Colors.white,
// Component theme configurations for dark mode.
subThemesData: const FlexSubThemesData(
interactionEffects: true,
@@ -45,9 +48,11 @@ abstract final class AppTheme {
visualDensity: FlexColorScheme.comfortablePlatformDensity,
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
).copyWith(
scaffoldBackgroundColor: Color(0xff0b1623),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
appBarTheme: AppBarTheme(
backgroundColor: Color(0xFF0E74B7),
),
cardTheme: CardThemeData(
color: Colors.white24,
),
);
}

View File

@@ -99,8 +99,11 @@ abstract class BaseActions {
class StubActions extends BaseActions {
StubActions({super.supportedModes = const []});
final List<ControllerButton> performedActions = [];
@override
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
performedActions.add(action);
return Future.value(action.name);
}
}

View File

@@ -159,7 +159,14 @@ class NotificationRequirement extends PlatformRequirement {
notificationDetails: AndroidNotificationDetails(
channelGroupId,
'Keep Alive',
actions: [AndroidNotificationAction('Exit', 'Exit', cancelNotification: true, showsUserInterface: false)],
actions: [
AndroidNotificationAction(
'Disconnect Devices',
'Disconnect Devices',
cancelNotification: true,
showsUserInterface: false,
),
],
),
);

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
@@ -83,6 +84,20 @@ class UnsupportedPlatform extends PlatformRequirement {
Future<void> getStatus() async {}
}
class ErrorRequirement extends PlatformRequirement {
ErrorRequirement(super.name) {
status = false;
}
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
onUpdate();
}
@override
Future<void> getStatus() async {}
}
typedef BoolFunction = bool Function();
enum Target {
@@ -90,6 +105,10 @@ enum Target {
title: 'This Device',
icon: Icons.devices,
),
otherDevice(
title: 'Other Device',
icon: Icons.settings_remote_outlined,
),
iOS(
title: 'iPhone / iPad / Apple TV',
icon: Icons.settings_remote_outlined,
@@ -136,13 +155,15 @@ enum Target {
'Due to platform restrictions only controlling ${app?.name ?? 'the Trainer app'} on other devices is supported.',
Target.thisDevice => 'Run ${app?.name ?? 'the Trainer app'} on this device.',
Target.iOS =>
'Run ${app?.name ?? 'the Trainer app'} on your Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
'Run ${app?.name ?? 'the Trainer app'} on an Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.android =>
'Run ${app?.name ?? 'the Trainer app'} on your Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
'Run ${app?.name ?? 'the Trainer app'} on an Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.macOS =>
'Run ${app?.name ?? 'the Trainer app'} on your Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
'Run ${app?.name ?? 'the Trainer app'} on a Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.windows =>
'Run ${app?.name ?? 'the Trainer app'} on your Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
'Run ${app?.name ?? 'the Trainer app'} on a Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.otherDevice =>
'Run ${app?.name ?? 'the Trainer app'} on another device and control it remotely from this device.',
};
}
@@ -158,9 +179,9 @@ enum Target {
"Select 'This device' unless you want to control another macOS device. Are you sure?",
Target.windows when Platform.isWindows =>
"Select 'This device' unless you want to control another Windows device. Are you sure?",
Target.android => "Download and use SwiftControl on that Android device.",
Target.macOS => "Download and use SwiftControl on that macOS device.",
Target.windows => "Download and use SwiftControl on that Windows device.",
Target.android => "We highly recommended to download and use SwiftControl on that Android device.",
Target.macOS => "We highly recommended to download and use SwiftControl on that macOS device.",
Target.windows => "We highly recommended to download and use SwiftControl on that Windows device.",
_ => null,
};
}
@@ -199,106 +220,167 @@ class TargetRequirement extends PlatformRequirement {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)),
DropdownMenu<SupportedApp>(
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
return DropdownMenuEntry(
value: app,
label: app.name,
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(app.name),
Row(
children: [
Expanded(
child: Text(
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
style: TextStyle(fontSize: 12, color: Colors.grey),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
child: DropdownMenu<SupportedApp>(
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
return DropdownMenuEntry(
value: app,
label: app.name,
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(app.name),
Row(
children: [
Expanded(
child: Text(
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
),
Icon(Icons.warning_amber),
],
),
],
)
: null,
);
}).toList(),
hintText: 'Select Trainer app',
initialSelection: settings.getTrainerApp(),
onSelected: (selectedApp) async {
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
whooshLink.stopServer();
}
settings.setTrainerApp(selectedApp!);
if (actionHandler.supportedApp == null ||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
actionHandler.init(selectedApp);
settings.setKeyMap(selectedApp);
}
setState(() {});
},
Icon(Icons.warning_amber),
],
),
],
)
: null,
);
}).toList(),
hintText: 'Select Trainer app',
initialSelection: settings.getTrainerApp(),
onSelected: (selectedApp) async {
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
whooshLink.stopServer();
}
settings.setTrainerApp(selectedApp!);
if (settings.getLastTarget() == null && Target.thisDevice.isCompatible) {
await settings.setLastTarget(Target.thisDevice);
}
if (actionHandler.supportedApp == null ||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
actionHandler.init(selectedApp);
settings.setKeyMap(selectedApp);
}
setState(() {});
},
),
),
SizedBox(height: 8),
Text(
'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
style: TextStyle(fontWeight: FontWeight.bold),
),
DropdownMenu<Target>(
dropdownMenuEntries: Target.values.map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
trailingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
if (target.isBeta) BetaPill(),
],
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
child: DropdownMenu<Target>(
dropdownMenuEntries: [Target.thisDevice, Target.otherDevice].map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
leadingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(target.title),
Text(
target.getDescription(settings.getTrainerApp()),
style: TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
),
);
}).toList(),
hintText: 'Select Target device',
initialSelection: settings.getLastTarget() != Target.thisDevice ? Target.otherDevice : Target.thisDevice,
enabled: settings.getTrainerApp() != null,
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
Text(
target.getDescription(settings.getTrainerApp()),
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (target == Target.thisDevice)
Container(
margin: EdgeInsets.only(top: 12),
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(4),
);
}
setState(() {});
}
},
),
),
if (settings.getLastTarget() != null && settings.getLastTarget() != Target.thisDevice) ...[
SizedBox(height: 8),
Text(
'Select the other device where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
style: TextStyle(fontWeight: FontWeight.bold),
),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
child: DropdownMenu<Target>(
dropdownMenuEntries: Target.values
.whereNot((e) => [Target.thisDevice, Target.otherDevice].contains(e))
.map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
leadingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
target.title,
style: TextStyle(
fontWeight: target == Target.thisDevice && target.isCompatible
? FontWeight.bold
: null,
),
),
if (target.isBeta) BetaPill(),
],
),
Text(
target.getDescription(settings.getTrainerApp()),
style: TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
),
],
),
),
);
}).toList(),
hintText: 'Select Target device',
initialSelection: settings.getLastTarget(),
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
initializeActions(target.connectionType);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
);
}
setState(() {});
}
},
),
);
})
.toList(),
hintText: 'Select Target device',
initialSelection: settings.getLastTarget(),
enabled: settings.getTrainerApp() != null,
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
initializeActions(target.connectionType);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
);
}
setState(() {});
}
},
),
),
],
SizedBox(height: 8),
ElevatedButton(
onPressed: settings.getTrainerApp() != null && settings.getLastTarget() != null
? () {

View File

@@ -326,7 +326,11 @@ class _PairWidgetState extends State<_PairWidget> {
Text('Connect via MyWhoosh Direct Connect'),
Text(
'Most reliable way to control MyWhoosh.',
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
style: TextStyle(
fontSize: 12,
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
fontWeight: FontWeight.normal,
),
),
],
),
@@ -352,7 +356,11 @@ class _PairWidgetState extends State<_PairWidget> {
Text('Connect to ${settings.getTrainerApp()?.name} as controller'),
Text(
'Most reliable way to control ${settings.getTrainerApp()?.name}.',
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
style: TextStyle(
fontSize: 12,
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
fontWeight: FontWeight.normal,
),
),
],
),
@@ -378,7 +386,11 @@ class _PairWidgetState extends State<_PairWidget> {
),
Text(
'Pairing allows full customizability,\nbut may not work on all devices.',
style: TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.normal),
style: TextStyle(
fontSize: 12,
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
fontWeight: FontWeight.normal,
),
),
],
),

View File

@@ -1,7 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider_windows/path_provider_windows.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_windows/shared_preferences_windows.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:window_manager/window_manager.dart';
@@ -13,22 +17,40 @@ import '../keymap/apps/custom_app.dart';
class Settings {
late final SharedPreferences prefs;
Future<void> init() async {
prefs = await SharedPreferences.getInstance();
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
}
Future<String?> init({bool retried = false}) async {
try {
prefs = await SharedPreferences.getInstance();
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
}
final app = getKeyMap();
actionHandler.init(app);
} catch (e) {
// couldn't decode, reset
await prefs.clear();
rethrow;
return null;
} catch (e, s) {
if (!retried) {
if (Platform.isWindows) {
// delete settings file
final fs = SharedPreferencesWindows.instance.fs;
final pathProvider = PathProviderWindows();
final String? directory = await pathProvider.getApplicationSupportPath();
if (directory == null) {
return null;
}
final String fileLocation = path.join(directory, 'shared_preferences.json');
final file = fs.file(fileLocation);
if (await file.exists()) {
await file.delete();
}
}
return init(retried: true);
} else {
return '$e\n$s';
}
}
}
@@ -144,6 +166,7 @@ class Settings {
Future<void> setLastTarget(Target target) async {
await prefs.setString('last_target', target.name);
initializeActions(target.connectionType);
}
Future<void> setLastSeenVersion(String version) async {
@@ -181,4 +204,48 @@ class Settings {
Future<void> setMiuiWarningDismissed(bool dismissed) async {
await prefs.setBool('miui_warning_dismissed', dismissed);
}
List<String> _getIgnoredDeviceIds() {
return prefs.getStringList('ignored_device_ids') ?? [];
}
List<String> _getIgnoredDeviceNames() {
return prefs.getStringList('ignored_device_names') ?? [];
}
Future<void> addIgnoredDevice(String deviceId, String deviceName) async {
final ids = _getIgnoredDeviceIds();
final names = _getIgnoredDeviceNames();
if (!ids.contains(deviceId)) {
ids.add(deviceId);
names.add(deviceName);
await prefs.setStringList('ignored_device_ids', ids);
await prefs.setStringList('ignored_device_names', names);
}
}
Future<void> removeIgnoredDevice(String deviceId) async {
final ids = _getIgnoredDeviceIds();
final names = _getIgnoredDeviceNames();
final index = ids.indexOf(deviceId);
if (index != -1) {
ids.removeAt(index);
names.removeAt(index);
await prefs.setStringList('ignored_device_ids', ids);
await prefs.setStringList('ignored_device_names', names);
}
}
List<({String id, String name})> getIgnoredDevices() {
final ids = _getIgnoredDeviceIds();
final names = _getIgnoredDeviceNames();
final result = <({String id, String name})>[];
for (int i = 0; i < ids.length && i < names.length; i++) {
result.add((id: ids[i], name: names[i]));
}
return result;
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../small_progress_indicator.dart';
class MyWhooshLinkTile extends StatefulWidget {
const MyWhooshLinkTile({super.key});
@override
State<MyWhooshLinkTile> createState() => _MywhooshLinkTileState();
}
class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: whooshLink.isStarted,
builder: (context, isStarted, _) {
return ValueListenableBuilder(
valueListenable: whooshLink.isConnected,
builder: (context, isConnected, _) {
return StatefulBuilder(
builder: (context, setState) {
final myWhooshExplanation = actionHandler is RemoteActions
? 'MyWhoosh Direct Connect allows you to do some additional features such as Emotes and turn directions.'
: 'MyWhoosh Direct Connect is optional, but allows you to do some additional features such as Emotes and turn directions.';
return Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: isStarted,
onChanged: (value) {
settings.setMyWhooshLinkEnabled(value);
if (!value) {
whooshLink.stopServer();
} else if (value) {
connection.startMyWhooshServer().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Error starting MyWhoosh Direct Connect server. Please make sure the "MyWhoosh Link" app is not already running on this device.',
),
),
);
});
}
setState(() {});
},
title: Text('Enable MyWhoosh Direct Connect'),
subtitle: Row(
spacing: 12,
children: [
if (!isStarted)
Expanded(
child: Text(
myWhooshExplanation,
style: TextStyle(fontSize: 12),
),
)
else ...[
Expanded(
child: Text(
isConnected ? "Connected" : "Connecting to MyWhoosh...\n$myWhooshExplanation",
style: TextStyle(fontSize: 12),
),
),
if (isStarted) SmallProgressIndicator(),
],
],
),
),
),
IconButton(
onPressed: () {
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
},
icon: Icon(Icons.help_outline),
),
],
);
},
);
},
);
},
);
}
}

View File

@@ -1,31 +1,22 @@
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
import 'package:swift_control/utils/keymap/apps/zwift.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
class ZwiftRequirement extends PlatformRequirement {
ZwiftRequirement()
: super(
'Pair SwiftControl with Zwift',
);
class ZwiftTile extends StatefulWidget {
final VoidCallback onUpdate;
const ZwiftTile({super.key, required this.onUpdate});
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
State<ZwiftTile> createState() => _ZwiftTileState();
}
class _ZwiftTileState extends State<ZwiftTile> {
@override
Widget? buildDescription() {
return settings.getLastTarget() == null
? null
: Text(
'In Zwift on your ${settings.getLastTarget()?.title} go into the Pairing settings and select SwiftControl from the list of available controllers.',
);
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: zwiftEmulator.isConnected,
builder: (context, isConnected, _) {
@@ -39,7 +30,7 @@ class ZwiftRequirement extends PlatformRequirement {
if (!value) {
zwiftEmulator.stopAdvertising();
} else if (value) {
zwiftEmulator.startAdvertising(onUpdate);
zwiftEmulator.startAdvertising(widget.onUpdate);
}
setState(() {});
},
@@ -75,9 +66,4 @@ class ZwiftRequirement extends PlatformRequirement {
},
);
}
@override
Future<void> getStatus() async {
status = zwiftEmulator.isConnected.value || screenshotMode;
}
}

View File

@@ -30,7 +30,12 @@ class ChangelogDialog extends StatelessWidget {
constraints: BoxConstraints(minWidth: 460),
child: MarkdownWidget(markdown: latestVersion),
),
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Got it!'))],
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Got it!'),
),
],
);
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
class IgnoredDevicesDialog extends StatefulWidget {
const IgnoredDevicesDialog({super.key});
@override
State<IgnoredDevicesDialog> createState() => _IgnoredDevicesDialogState();
}
class _IgnoredDevicesDialogState extends State<IgnoredDevicesDialog> {
List<({String id, String name})> _ignoredDevices = [];
@override
void initState() {
super.initState();
_loadIgnoredDevices();
}
void _loadIgnoredDevices() {
setState(() {
_ignoredDevices = settings.getIgnoredDevices();
});
}
Future<void> _removeDevice(String deviceId) async {
await settings.removeIgnoredDevice(deviceId);
_loadIgnoredDevices();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Ignored Devices'),
content: SizedBox(
width: double.maxFinite,
child: _ignoredDevices.isEmpty
? Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'No ignored devices.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
shrinkWrap: true,
itemCount: _ignoredDevices.length,
itemBuilder: (context, index) {
final device = _ignoredDevices[index];
return ListTile(
title: Text(device.name),
subtitle: Text(
device.id,
style: TextStyle(fontSize: 12),
),
trailing: IconButton(
icon: Icon(Icons.delete_outline),
tooltip: 'Remove from ignored list',
onPressed: () => _removeDevice(device.id),
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close'),
),
],
);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@@ -12,6 +13,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../pages/device.dart';
import 'ignored_devices_dialog.dart';
List<Widget> buildMenuButtons() {
return [
@@ -91,9 +93,9 @@ List<Widget> buildMenuButtons() {
child: Text('Get Support'),
onTap: () {
final isFromStore = (Platform.isAndroid ? isFromPlayStore == true : Platform.isIOS);
final suffix = isFromStore ? '' : 'ler';
final suffix = isFromStore ? '' : '-sw';
String email = Uri.encodeComponent('jonas.t.bark+swiftcontrol$suffix@gmail.com');
String email = Uri.encodeComponent('jonas$suffix@swiftcontrol.app');
String subject = Uri.encodeComponent("Help requested for SwiftControl v${packageInfoValue?.version}");
String body = Uri.encodeComponent("""
@@ -101,9 +103,11 @@ List<Widget> buildMenuButtons() {
---
App Version: ${packageInfoValue?.version}${shorebirdPatch?.number != null ? '+${shorebirdPatch!.number}' : ''}
Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}
Target: ${settings.getLastTarget()?.title}
Trainer App: ${settings.getTrainerApp()?.name}
Target: ${settings.getLastTarget()?.title ?? '-'}
Trainer App: ${settings.getTrainerApp()?.name ?? '-'}
Connected Controllers: ${connection.devices.map((e) => e.toString()).join(', ')}
Logs:
${connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')}
Please don't remove this information, it helps me to assist you better.""");
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
@@ -171,7 +175,15 @@ class MenuButton extends StatelessWidget {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
},
),
PopupMenuItem(
child: Text('Ignored Devices'),
onTap: () {
showDialog(
context: context,
builder: (context) => IgnoredDevicesDialog(),
);
},
),
PopupMenuItem(
child: Text('License'),
onTap: () {

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.4.0+37
version: 3.5.1+41
environment:
sdk: ^3.9.0

View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Script to generate GitHub release body with changelog and store links
# Usage: ./scripts/generate_release_body.sh
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
RELEASE_NOTES_FILE="$SCRIPT_DIR/RELEASE_NOTES.md"
if [ ! -f "$RELEASE_NOTES_FILE" ]; then
echo "Error: RELEASE_NOTES.md not found at $RELEASE_NOTES_FILE"
exit 1
fi
# Extract the first changelog entry using get_latest_changelog.sh
# For GitHub releases, we want to include the version header
VERSION_HEADER=$(awk '/^### / {print; exit}' "$SCRIPT_DIR/../CHANGELOG.md")
LATEST_CHANGELOG=$("$SCRIPT_DIR/get_latest_changelog.sh")
# Combine changelog with release notes
echo "$VERSION_HEADER"
echo "$LATEST_CHANGELOG"
echo ""
echo "---"
echo ""
cat "$RELEASE_NOTES_FILE"

View File

@@ -1,106 +1,126 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/base_actions.dart';
import 'package:universal_ble/universal_ble.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;
test('Test state machine with full sequence', () {
actionHandler = StubActions();
final stubActions = actionHandler as StubActions;
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
expect(shiftUpCode, equals(0x01));
expect(shiftDownCode, equals(0x02));
expect(releaseCode, equals(0x00));
// Packet 0: [6]=01 [7]=03 -> No trigger (lock state)
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206010397565E000155'),
);
expect(stubActions.performedActions.isEmpty, true);
// Packet 1: [6]=03 [7]=03 -> Trigger: shiftUp
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206030398565E000158'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
stubActions.performedActions.clear();
// Packet 2: [6]=03 [7]=01 -> Trigger: shiftDown
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206030198575E000157'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftDown);
stubActions.performedActions.clear();
// Packet 3: [6]=03 [7]=03 -> No trigger (lock state)
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206030398585E00015A'),
);
expect(stubActions.performedActions.isEmpty, true);
// Packet 4: [6]=01 [7]=03 -> Trigger: shiftUp
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206010399585E000159'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
stubActions.performedActions.clear();
});
test('Should handle button press and release cycle', () {
// Test button state transitions
final states = [0x01, 0x00, 0x02, 0x00];
test('Test release and re-press behavior', () {
actionHandler = StubActions();
final stubActions = actionHandler as StubActions;
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
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
// Press: lock state
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206010300005E000100'),
);
expect(stubActions.performedActions.isEmpty, true);
// Release: reset state
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206000000005E000100'),
);
expect(stubActions.performedActions.isEmpty, true);
// Press again: lock state (no trigger since we reset)
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206020300005E000100'),
);
expect(stubActions.performedActions.isEmpty, true);
// Change to different pressed value: trigger
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206010300005E000100'),
);
expect(stubActions.performedActions.length, 1);
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
});
test('Should validate UART service UUID format', () {
// Nordic UART Service UUID
const serviceUuid = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
test('Test both buttons can trigger simultaneously', () {
actionHandler = StubActions();
final stubActions = actionHandler as StubActions;
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
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";
// Lock both states
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206010100005E000100'),
);
expect(stubActions.performedActions.isEmpty, true);
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);
// Change both: trigger both
device.processCharacteristic(
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
_hexToUint8List('FEEFFFEE0206020200005E000100'),
);
expect(stubActions.performedActions.length, 2);
expect(stubActions.performedActions.contains(CycplusBc2Buttons.shiftUp), true);
expect(stubActions.performedActions.contains(CycplusBc2Buttons.shiftDown), true);
});
});
}
Uint8List _hexToUint8List(String seq) {
return Uint8List.fromList(
List.generate(
seq.length ~/ 2,
(i) => int.parse(seq.substring(i * 2, i * 2 + 2), radix: 16),
),
);
}

View File

@@ -1,294 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zwift Ride Scanner (Protocol Buffers)</title>
</head>
<body>
<!-- thanks to https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/ and https://www.makinolo.com/blog/2023/10/08/connecting-to-zwift-play-controllers/ -->
<h1>Zwift Ride Scanner</h1>
<button onclick="scanForDevices()">Scan for Devices</button>
<div id="status">Status: Disconnected</div>
<pre id="log" style="white-space: pre-wrap"></pre>
<script>
const statusDiv = document.getElementById("status");
const logDiv = document.getElementById("log");
let controlCharacteristic = null;
const BUTTON_MASKS = {
LEFT_BTN: 0x1,
UP_BTN: 0x2,
RIGHT_BTN: 0x4,
DOWN_BTN: 0x8,
A_BTN: 0x10,
B_BTN: 0x20,
Y_BTN: 0x40,
Z_BTN: 0x100,
SHFT_UP_L_BTN: 0x200,
SHFT_DN_L_BTN: 0x400,
POWERUP_L_BTN: 0x800,
ONOFF_L_BTN: 0x1000,
SHFT_UP_R_BTN: 0x2000,
SHFT_DN_R_BTN: 0x4000,
POWERUP_R_BTN: 0x10000,
ONOFF_R_BTN: 0x20000,
};
function log(message) {
const timestamp = new Date().toLocaleTimeString();
logDiv.textContent += `[${timestamp}] ${message}\n`;
console.log(message);
}
function parseKeyPress(buffer) {
let location = null;
let analogValue = null;
let offset = 0;
while (offset < buffer.length) {
const tag = buffer[offset];
const fieldNum = tag >> 3;
const wireType = tag & 0x7;
offset++;
switch (fieldNum) {
case 1: // Location
if (wireType === 0) {
let value = 0;
let shift = 0;
while (true) {
const byte = buffer[offset++];
value |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
location = value;
}
break;
case 2: // AnalogValue
if (wireType === 0) {
let value = 0;
let shift = 0;
while (true) {
const byte = buffer[offset++];
value |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
// ZigZag decode for sint32
analogValue = (value >>> 1) ^ -(value & 1);
}
break;
default:
// Skip unknown fields
if (wireType === 0) {
while (buffer[offset++] & 0x80);
} else if (wireType === 2) {
const length = buffer[offset++];
offset += length;
}
}
}
return { location: location, value: analogValue };
}
function parseKeyGroup(buffer) {
let groupStatus = {};
let offset = 0;
while (offset < buffer.length) {
const tag = buffer[offset];
const fieldNum = tag >> 3;
const wireType = tag & 0x7;
offset++;
if (fieldNum === 3 && wireType === 2) {
const length = buffer[offset++];
const messageBuffer = buffer.slice(
offset,
offset + length,
);
let res = parseKeyPress(messageBuffer);
groupStatus[res.location] = res.value;
offset += length;
} else {
// Skip unknown fields
if (wireType === 0) {
while (buffer[offset++] & 0x80);
} else if (wireType === 2) {
const length = buffer[offset++];
offset += length;
}
}
}
return groupStatus;
}
function parseButtonState(buttonMap) {
const pressedButtons = [];
for (const [button, mask] of Object.entries(BUTTON_MASKS)) {
if ((buttonMap & mask) === 0) {
pressedButtons.push(button);
}
}
return pressedButtons;
}
function parseAnalogMessage(data) {
// Each analog group starts with 0x1a
if (data[0] !== 0x1a) return null;
let res = parseKeyGroup(data);
return {
left: "0" in res ? res["0"] : 0,
right: "1" in res ? res["1"] : 0,
};
}
function handleMessage(value) {
const data = new Uint8Array(value.buffer);
const msgType = data[0];
switch (msgType) {
case 0x23: {
// Button status
const buttonMap =
data[2] |
(data[3] << 8) |
(data[4] << 16) |
(data[5] << 24);
const pressedButtons = parseButtonState(buttonMap);
if (pressedButtons.length > 0) {
log(
`Buttons pressed! ${pressedButtons.join(", ")}`,
);
}
// Find analog values section (after button map)
let startIndex = 7; // Skip message type, field number, and button map
while (startIndex < data.length) {
const analogData = parseAnalogMessage(
data.slice(startIndex),
);
if (!analogData) break;
log(
`Analog left:${analogData.left} right:${analogData.right}`,
);
startIndex = analogData.nextIndex;
}
break;
}
case 0x2a: // Initial status
log("Initial status received");
break;
case 0x15: // Idle
break;
case 0x19: // Status update
break;
default:
log(
`Unknown message: ${Array.from(data)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ")}`,
);
}
}
async function scanForDevices() {
if (!navigator.bluetooth) {
statusDiv.textContent =
"Status: Web Bluetooth API is not supported";
return;
}
try {
statusDiv.textContent = "Status: Scanning...";
logDiv.textContent = "";
const device = await navigator.bluetooth.requestDevice({
filters: [
{
name: "Zwift Ride",
},
],
optionalServices: [
"0000180f-0000-1000-8000-00805f9b34fb", // Battery Service
"0000180a-0000-1000-8000-00805f9b34fb", // Device Information
"0000fc82-0000-1000-8000-00805f9b34fb", // Custom Service
],
});
log(`Device name: ${device.name}`);
log(`Device ID: ${device.id}`);
statusDiv.textContent = "Status: Connecting...";
const server = await device.gatt.connect();
log("Connected to GATT server");
const service = await server.getPrimaryService(
"0000fc82-0000-1000-8000-00805f9b34fb",
);
log("Found custom service");
const measurementChar = await service.getCharacteristic(
"00000002-19ca-4651-86e5-fa29dcdd09d1",
);
controlCharacteristic = await service.getCharacteristic(
"00000003-19ca-4651-86e5-fa29dcdd09d1",
);
const responseChar = await service.getCharacteristic(
"00000004-19ca-4651-86e5-fa29dcdd09d1",
);
log("Got service characteristics");
// Initial handshake
const handshake = new TextEncoder().encode("RideOn");
await controlCharacteristic.writeValue(handshake);
log("Sent RideOn handshake");
// Set up notifications
await measurementChar.startNotifications();
measurementChar.addEventListener(
"characteristicvaluechanged",
(event) => {
handleMessage(event.target.value);
},
);
await responseChar.startNotifications();
responseChar.addEventListener(
"characteristicvaluechanged",
(event) => {
const value = new TextDecoder().decode(
event.target.value,
);
log(`Response: ${value}`);
},
);
device.addEventListener("gattserverdisconnected", () => {
statusDiv.textContent = "Status: Device disconnected";
log("Device disconnected");
});
statusDiv.textContent =
"Status: Connected and watching for input";
} catch (error) {
statusDiv.textContent = `Status: Error - ${error}`;
log(`Error: ${error.message}`);
console.error("Error:", error);
}
}
</script>
</body>
</html>