mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
700afb1050 | ||
|
|
d943544e56 | ||
|
|
e994eb01dd | ||
|
|
59d953bbc4 | ||
|
|
12ecbf80e1 | ||
|
|
a5b76b43bf | ||
|
|
383055bfe2 | ||
|
|
24212e8e4c | ||
|
|
1513a53dd4 | ||
|
|
3ca63c0523 | ||
|
|
b46982918d | ||
|
|
13b075a1f3 | ||
|
|
f71a417ac5 | ||
|
|
27065f2906 | ||
|
|
b5d938fd47 | ||
|
|
46300fc0d4 | ||
|
|
93882b8b36 | ||
|
|
968e2c5928 | ||
|
|
c09ab5482e | ||
|
|
5cc49bd246 | ||
|
|
c3c49decd1 | ||
|
|
127c997ea1 | ||
|
|
724d52ba10 | ||
|
|
5cee0fbf55 | ||
|
|
e44760d0e3 | ||
|
|
29be3c4411 | ||
|
|
0eba068910 | ||
|
|
04dee3f14c | ||
|
|
b4b3f5db67 | ||
|
|
b291f59e10 | ||
|
|
02de453952 | ||
|
|
4c53f6e408 | ||
|
|
4f4d67cccc | ||
|
|
ef056f0503 | ||
|
|
a6f5755b42 | ||
|
|
5ce6e37973 | ||
|
|
ebebd7ad8b | ||
|
|
f6c47e3dab | ||
|
|
de711e12dc | ||
|
|
b94fed2f21 | ||
|
|
9316881048 | ||
|
|
c60a990938 | ||
|
|
e9aaa96185 | ||
|
|
cb497daee4 | ||
|
|
4881fe4778 | ||
|
|
5d5d8ffb18 | ||
|
|
b707812a7e | ||
|
|
9de50dc6fc | ||
|
|
11d308b53b | ||
|
|
0e03ec4a03 | ||
|
|
68a04fad96 | ||
|
|
ce75fd0f34 | ||
|
|
d46b71b2d0 | ||
|
|
6492afc46f | ||
|
|
c9b068e1b3 | ||
|
|
5cdf15a419 | ||
|
|
24db720927 | ||
|
|
94754d3d9b | ||
|
|
bffdae1a9b | ||
|
|
a8b68c2d89 | ||
|
|
84f70f13d8 | ||
|
|
ef1048ec08 | ||
|
|
37bc2110f5 | ||
|
|
84fd828d36 | ||
|
|
a51b4d7958 | ||
|
|
117467d708 | ||
|
|
789509f9cf | ||
|
|
a323dc213d | ||
|
|
0ec998a618 | ||
|
|
0f5e9d59a8 | ||
|
|
2280fda916 | ||
|
|
8d4db788a3 | ||
|
|
c2bfc472fe | ||
|
|
09ffd258b7 | ||
|
|
4ae92ca557 | ||
|
|
e066054681 | ||
|
|
a0f4aadd37 | ||
|
|
3566dbc37c | ||
|
|
73a23e06ba | ||
|
|
a03576d415 | ||
|
|
079db14127 | ||
|
|
2f4764a01f | ||
|
|
2671a9807b | ||
|
|
da46deb495 |
52
.github/workflows/build.yml
vendored
52
.github/workflows/build.yml
vendored
@@ -36,7 +36,7 @@ on:
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.35.5
|
||||
FLUTTER_VERSION: 3.38.4
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -162,20 +162,6 @@ jobs:
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
- name: Set Up Flutter for Screenshots
|
||||
if: inputs.build_github
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate screenshots for App Stores
|
||||
if: inputs.build_github
|
||||
run: |
|
||||
flutter test --update-goldens
|
||||
zip -r BikeControl.screenshots.zip screenshots
|
||||
echo "Screenshots generated successfully"
|
||||
|
||||
- name: 🚀 Shorebird Release iOS
|
||||
if: inputs.build_ios
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
@@ -256,18 +242,6 @@ jobs:
|
||||
path: |
|
||||
build/macos/Build/Products/Release/BikeControl.macos.zip
|
||||
|
||||
- name: Upload Screenshots Artifacts
|
||||
if: inputs.build_github
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
screenshots/device-noFrame-1100x2390.png
|
||||
screenshots/trainer-noFrame-1100x2390.png
|
||||
screenshots/customization-noFrame-1100x2390.png
|
||||
build/BikeControl.screenshots.zip
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
if: inputs.build_github
|
||||
@@ -281,7 +255,7 @@ jobs:
|
||||
if: inputs.build_github
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/flutter-apk/BikeControl.android.apk,build/macos/Build/Products/Release/BikeControl.macos.zip,build/BikeControl.screenshots.zip,screenshots/device-noFrame-1100x2390.png,screenshots/trainer-noFrame-1100x2390.png,screenshots/customization-noFrame-1100x2390.png"
|
||||
artifacts: "build/app/outputs/flutter-apk/BikeControl.android.apk,build/macos/Build/Products/Release/BikeControl.macos.zip"
|
||||
allowUpdates: true
|
||||
prerelease: true
|
||||
bodyFile: /tmp/release_body.md
|
||||
@@ -350,7 +324,7 @@ jobs:
|
||||
Write-Warning "$dll not found in $source"
|
||||
}
|
||||
}
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/BikeControl.windows.zip"
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/bike_control.windows.zip"
|
||||
|
||||
- uses: microsoft/setup-msstore-cli@v1
|
||||
if: false
|
||||
@@ -366,34 +340,20 @@ jobs:
|
||||
if: false
|
||||
run: msstore publish -v "build/windows/x64/runner/Release/"
|
||||
|
||||
- name: Rename swift_control.msix to BikeControl.windows.msix
|
||||
shell: pwsh
|
||||
run: |
|
||||
Rename-Item -Path "build/windows/x64/runner/Release/swift_control.msix" -NewName "BikeControl.windows.msix"
|
||||
|
||||
- name: Upload Artifacts
|
||||
if: false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/windows/x64/runner/Release/BikeControl.windows.zip
|
||||
build/windows/x64/runner/Release/BikeControl.windows.msix
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/windows/x64/runner/Release/BikeControl.windows.msix
|
||||
build/windows/x64/runner/Release/bike_control.windows.zip
|
||||
build/windows/x64/runner/Release/bike_control.msix
|
||||
|
||||
- name: Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/windows/x64/runner/Release/BikeControl.windows.zip,build/windows/x64/runner/Release/BikeControl.windows.msix"
|
||||
artifacts: "build/windows/x64/runner/Release/bike_control.msix"
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
13
.github/workflows/patch.yml
vendored
13
.github/workflows/patch.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.35.5
|
||||
FLUTTER_VERSION: 3.38.5
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -165,6 +165,17 @@ jobs:
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: 🚀 Shorebird Patch Windows
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,4 +52,3 @@ lib/gen/
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
/screenshots/
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,4 +1,14 @@
|
||||
### 4.0.0 (unreleased)
|
||||
### 4.1.0 (16-12-2025)
|
||||
|
||||
**Features**:
|
||||
- control your trainer manually without requiring a controller - just like a Companion app
|
||||
- support for Wahoo KICKR HEADWIND: control the fan via your controller
|
||||
|
||||
**Fixes**:
|
||||
- Gamepads: handle analog values correctly on Windows
|
||||
- MyWhoosh: updated default keymap to use the new A+D keys for steering
|
||||
|
||||
### 4.0.0 (07-12-2025)
|
||||
|
||||
- a brand-new design
|
||||
- Accessibility Permission is now optional on Android
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
**Instructions for using the MyWhoosh "Link" connection method**
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) launch MyWhoosh Link, check if the "Link" connection works
|
||||
3) close MyWhoosh Link
|
||||
4) open BikeControl, follow on screen instructions
|
||||
|
||||
Once you've confirmed the connection in BikeControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
|
||||
|
||||
And here's a video with a few explanations:
|
||||
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
Moved to [INSTRUCTIONS_MYWHOOSH_LINK.md](INSTRUCTIONS_MYWHOOSH_LINK.md)
|
||||
|
||||
31
INSTRUCTIONS_MYWHOOSH_LINK.md
Normal file
31
INSTRUCTIONS_MYWHOOSH_LINK.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## Instructions for using the MyWhoosh "Link" connection method
|
||||
*
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) launch MyWhoosh Link, check if the "Link" connection works
|
||||
3) **close MyWhoosh Link** - very important!
|
||||
4) open BikeControl, follow on screen instructions
|
||||
|
||||
Once you've confirmed the connection in BikeControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
|
||||
|
||||
And here's a video with a few explanations:
|
||||
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
|
||||
## The MyWhoosh Link app itself works fine, but BikeControl doesn't connect
|
||||
*
|
||||
The MyWhoosh Link app must not run simultaneously with BikeControl. Make sure the MyWhoosh Link app is fully closed, then reopen BikeControl and try connecting again.
|
||||
|
||||
## MyWhoosh "Link" method never connects
|
||||
*
|
||||
The same network restrictions apply for BikeControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if a connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
|
||||
In essence:
|
||||
- your two devices (phone, tablet) need to be on the same WiFi network
|
||||
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
|
||||
- Limit IP Address Tracking may need to be disabled
|
||||
- mesh networks may not work
|
||||
13
INSTRUCTIONS_REMOTE_CONTROL.md
Normal file
13
INSTRUCTIONS_REMOTE_CONTROL.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## Remote control is not working - nothing happens
|
||||
*
|
||||
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
|
||||
- Try restarting the pairing process in BikeControl
|
||||
- try restarting Bluetooth on your phone and on the device you want to control
|
||||
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
|
||||
- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in BikeControl / restart BikeControl.
|
||||
|
||||
## Remote control only clicks on a single coordinate on my iPad
|
||||
*
|
||||
iOS seems to be buggy here - try this in the iOS settings:
|
||||
AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or BikeControl iOS) > Button 1
|
||||
switch the setting to None, then back to Single-Tap and it should work again
|
||||
4
INSTRUCTIONS_ROUVY.md
Normal file
4
INSTRUCTIONS_ROUVY.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## Local Connection method
|
||||
*
|
||||
The local connection method (avalable on Android, Windows and macOS) allows BikeControl to directly control Rouvy either using touch or keyboard keys. This way you don't need to select any "Controllers" at all in Rouvy.
|
||||
Make sure the "Virtual Shifting Controls" are enabled: https://support.rouvy.com/hc/en-us/articles/32452137189393-Virtual-Shifting#h_01K9SWGWYMAVQV108SQ9KWQAKC
|
||||
0
INSTRUCTIONS_ZWIFT.md
Normal file
0
INSTRUCTIONS_ZWIFT.md
Normal file
@@ -51,6 +51,9 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
- Elite Sterzo Smart (for steering support)
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Gamepads
|
||||
- Keyboard input
|
||||
- some trainers do not support keyboard input for all functions - now they do!
|
||||
- useful when remapping keys from other devices using e.g. AutoHotkey
|
||||
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
|
||||
- BLE HID devices and classic Bluetooth HID devices are supported
|
||||
- works out of the box on Android
|
||||
@@ -58,7 +61,11 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
- We're working on creating an affordable alternative based on an open standard, supported by all major trainer apps
|
||||
- register your interest [here](https://openbikecontrol.org/#HARDWARE)
|
||||
|
||||
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 Accessories
|
||||
- Wahoo KICKR HEADWIND (beta)
|
||||
- control fan speed using your controller
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
## Click / Ride device cannot be found
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## Click / Ride device does not send any data
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## My Click v2 disconnects after a minute
|
||||
Check [this](https://github.com/OpenBikeControl/bikecontrol/issues/68) discussion.
|
||||
*
|
||||
Check [this](https://github.com/jonasbark/swiftcontrol/issues/68) discussion.
|
||||
|
||||
To make your Click V2 work best you should connect it in the Zwift app once each day.
|
||||
If you don't do that BikeControl will need to reconnect every minute.
|
||||
@@ -13,41 +16,22 @@ If you don't do that BikeControl will need to reconnect every minute.
|
||||
1. Open Zwift app (not the Companion)
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Click V2
|
||||
4. Close the Zwift app again and connect again in BikeControl
|
||||
4. Optional: some users report that keeping the Click connected for more than a few seconds is more reliable.
|
||||
5. Close the Zwift app again and connect again in BikeControl
|
||||
|
||||
## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
|
||||
*
|
||||
- especially for Redmi and other chinese Android devices please follow the instructions on [https://dontkillmyapp.com/](https://dontkillmyapp.com/):
|
||||
- disable battery optimization for BikeControl
|
||||
- enable auto start of BikeControl
|
||||
- grant accessibility permission for BikeControl
|
||||
- see [https://github.com/OpenBikeControl/bikecontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
|
||||
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
|
||||
|
||||
## Remote control is not working - nothing happens
|
||||
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
|
||||
- Try restarting the pairing process in BikeControl
|
||||
- try restarting Bluetooth on your phone and on the device you want to control
|
||||
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
|
||||
- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in BikeControl / restart BikeControl.
|
||||
|
||||
## Remote control only clicks on a single coordinate on my iPad
|
||||
iOS seems to be buggy here - try this in the iOS settings:
|
||||
AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or BikeControl iOS) > Button 1
|
||||
switch the setting to None, then back to Single-Tap and it should work again
|
||||
|
||||
## BikeControl crashes on Windows when searching for the device
|
||||
## BikeControl crashes on Windows when searching for the device
|
||||
*
|
||||
You're probably running into [this](https://github.com/OpenBikeControl/bikecontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
|
||||
|
||||
## MyWhoosh "Link" method never connects
|
||||
The same network restrictions apply for BikeControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
[INSTRUCTIONS_IOS.md](INSTRUCTIONS_IOS.md)
|
||||
|
||||
In essence:
|
||||
- your two devices (phone, tablet) need to be on the same WiFi network
|
||||
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
|
||||
- Limit IP Address Tracking may need to be disabled
|
||||
- mesh networks may not work
|
||||
|
||||
## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control
|
||||
*
|
||||
Make sure you've enabled Virtual Shifting in MyWhoosh's settings
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.6.1
|
||||
4.1.0
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/gamepad/gamepad_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/utils/requirements/android.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gamepad/gamepad_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import 'devices/base_device.dart';
|
||||
@@ -26,7 +28,12 @@ class Connection {
|
||||
|
||||
List<BluetoothDevice> get bluetoothDevices => devices.whereType<BluetoothDevice>().toList();
|
||||
List<GamepadDevice> get gamepadDevices => devices.whereType<GamepadDevice>().toList();
|
||||
List<BaseDevice> get controllerDevices => [...bluetoothDevices, ...gamepadDevices, ...devices.whereType<HidDevice>()];
|
||||
List<WahooKickrHeadwind> get accessories => devices.whereType<WahooKickrHeadwind>().toList();
|
||||
List<BaseDevice> get controllerDevices => [
|
||||
...bluetoothDevices.where((d) => d is! WahooKickrHeadwind),
|
||||
...gamepadDevices,
|
||||
...devices.whereType<HidDevice>(),
|
||||
];
|
||||
|
||||
var _androidNotificationsSetup = false;
|
||||
|
||||
@@ -86,7 +93,7 @@ class Connection {
|
||||
final scanResult = BluetoothDevice.fromScanResult(result);
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${scanResult.runtimeType}'));
|
||||
_actionStreams.add(LogNotification('Found new device: ${kIsWeb ? scanResult.name : scanResult.runtimeType}'));
|
||||
addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
@@ -109,6 +116,14 @@ class Connection {
|
||||
UniversalBle.disconnect(deviceId);
|
||||
return;
|
||||
} else {
|
||||
if (kIsWeb) {
|
||||
// on web, log all characteristic changes for debugging
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
'Characteristic update for device ${device.name}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await device.processCharacteristic(characteristicUuid, value);
|
||||
} catch (e, backtrace) {
|
||||
@@ -170,7 +185,7 @@ class Connection {
|
||||
if (!kIsWeb) {
|
||||
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name.isEmpty ? 'Gamepad' : pad.name, id: pad.id)).toList();
|
||||
addDevices(pads);
|
||||
|
||||
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
|
||||
@@ -184,11 +199,8 @@ class Connection {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
addDevices(pads);
|
||||
});
|
||||
} else {
|
||||
isScanning.value = false;
|
||||
}
|
||||
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/manager.dart';
|
||||
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
@@ -113,7 +113,7 @@ abstract class BaseDevice {
|
||||
for (final action in buttonsClicked) {
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: false);
|
||||
actionStreamInternal.add(LogNotification(result.message));
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ abstract class BaseDevice {
|
||||
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: false, isKeyUp: true);
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
actionStreamInternal.add(LogNotification(result.message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,11 +143,11 @@ abstract class BaseDevice {
|
||||
|
||||
Widget showInformation(BuildContext context);
|
||||
|
||||
Future<ControllerButton> getOrAddButton(String key, ControllerButton Function() creator) async {
|
||||
ControllerButton getOrAddButton(String key, ControllerButton Function() creator) {
|
||||
if (core.actionHandler.supportedApp is! CustomApp) {
|
||||
final currentProfile = core.actionHandler.supportedApp!.name;
|
||||
// should we display this to the user?
|
||||
await KeymapManager().duplicate(null, currentProfile, skipName: '$currentProfile (Copy)');
|
||||
KeymapManager().duplicateSync(currentProfile, '$currentProfile (Copy)');
|
||||
}
|
||||
final button = core.actionHandler.supportedApp!.keymap.getOrAddButton(key, creator);
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/loading_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.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';
|
||||
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/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/ui/loading_widget.dart';
|
||||
import 'package:swift_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import 'cycplus/cycplus_bc2.dart';
|
||||
@@ -45,9 +46,11 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
WahooKickrHeadwindConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
CycplusBc2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE,
|
||||
OpenBikeControlConstants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
@@ -62,6 +65,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
'SQUARE' => EliteSquare(scanResult),
|
||||
'OpenBike' => OpenBikeControlDevice(scanResult),
|
||||
null => null,
|
||||
_ when scanResult.name!.toUpperCase().startsWith('HEADWIND') => WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult),
|
||||
@@ -77,6 +81,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
// 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('HEADWIND') => WahooKickrHeadwind(scanResult),
|
||||
_ 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),
|
||||
@@ -85,8 +90,13 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE.toLowerCase()) => ShimanoDi2(
|
||||
scanResult,
|
||||
),
|
||||
_ when scanResult.services.contains(OpenBikeControlConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
OpenBikeControlDevice(scanResult),
|
||||
_ when scanResult.services.contains(WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
WahooKickrHeadwind(scanResult),
|
||||
// otherwise the service UUIDs will be used
|
||||
_ => null,
|
||||
};
|
||||
@@ -117,7 +127,11 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ when scanResult.name == 'Zwift Ride' => ZwiftRide(scanResult), // e.g. old firmware
|
||||
_
|
||||
when scanResult.name == 'Zwift Ride' &&
|
||||
type != ZwiftDeviceType.rideRight &&
|
||||
type != ZwiftDeviceType.rideLeft =>
|
||||
ZwiftRide(scanResult), // e.g. old firmware
|
||||
_ => null,
|
||||
};
|
||||
} else {
|
||||
@@ -142,10 +156,6 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
actionStream.listen((message) {
|
||||
print("Received message: $message");
|
||||
});
|
||||
|
||||
try {
|
||||
await UniversalBle.connect(device.deviceId);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../messages/notification.dart';
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../messages/notification.dart';
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
|
||||
class GamepadDevice extends BaseDevice {
|
||||
final String id;
|
||||
@@ -26,20 +28,23 @@ class GamepadDevice extends BaseDevice {
|
||||
};
|
||||
|
||||
final buttonKey = event.type == KeyType.analog ? '${event.key}_$normalizedValue' : event.key;
|
||||
ControllerButton button = await getOrAddButton(
|
||||
ControllerButton button = getOrAddButton(
|
||||
buttonKey,
|
||||
() => ControllerButton(buttonKey),
|
||||
);
|
||||
|
||||
switch (event.type) {
|
||||
case KeyType.analog:
|
||||
if (event.value.toInt() != 0) {
|
||||
final buttonsClicked = event.value.toInt() != 0 ? [button] : <ControllerButton>[];
|
||||
final releasedValue = Platform.isWindows ? 1 : 0;
|
||||
|
||||
if (event.value.round().abs() != releasedValue) {
|
||||
final buttonsClicked = [button];
|
||||
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
|
||||
handleButtonsClicked(buttonsClicked);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
} else {
|
||||
_lastButtonsClicked = [];
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
case KeyType.button:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
|
||||
class HidDevice extends BaseDevice {
|
||||
HidDevice(super.name) : super(availableButtons: []);
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
class WhooshLink {
|
||||
class WhooshLink extends TrainerConnection {
|
||||
Socket? _socket;
|
||||
ServerSocket? _server;
|
||||
|
||||
static final List<InGameAction> supportedActions = [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.cameraAngle,
|
||||
InGameAction.emote,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
];
|
||||
|
||||
final ValueNotifier<bool> isStarted = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isConnected = ValueNotifier(false);
|
||||
WhooshLink()
|
||||
: super(
|
||||
title: 'MyWhoosh Link',
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.cameraAngle,
|
||||
InGameAction.emote,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
],
|
||||
);
|
||||
|
||||
void stopServer() async {
|
||||
if (isStarted.value) {
|
||||
await _socket?.close();
|
||||
await _server?.close();
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
if (kDebugMode) {
|
||||
print('Server stopped.');
|
||||
}
|
||||
await _socket?.close();
|
||||
await _server?.close();
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
if (kDebugMode) {
|
||||
print('Server stopped.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
try {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
@@ -56,7 +58,6 @@ class WhooshLink {
|
||||
isStarted.value = false;
|
||||
rethrow;
|
||||
}
|
||||
isStarted.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_server!.port}');
|
||||
}
|
||||
@@ -96,8 +97,9 @@ class WhooshLink {
|
||||
);
|
||||
}
|
||||
|
||||
ActionResult sendAction(InGameAction action, int? value, {required bool isKeyDown, required bool isKeyUp}) {
|
||||
final jsonObject = switch (action) {
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final jsonObject = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
@@ -113,13 +115,13 @@ class WhooshLink {
|
||||
InGameAction.cameraAngle => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'CameraAngle': '$value',
|
||||
'CameraAngle': '${keyPair.inGameActionValue}',
|
||||
},
|
||||
},
|
||||
InGameAction.emote => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Emote': '$value',
|
||||
'Emote': '${keyPair.inGameActionValue}',
|
||||
},
|
||||
},
|
||||
InGameAction.uturn => {
|
||||
@@ -152,14 +154,14 @@ class WhooshLink {
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
];
|
||||
if (jsonObject != null && !isKeyDown && !supportsIsKeyUpActions.contains(action)) {
|
||||
return Success('No Action sent on key down for action: $action');
|
||||
if (jsonObject != null && !isKeyDown && !supportsIsKeyUpActions.contains(keyPair.inGameAction)) {
|
||||
return Ignored('No Action sent on key down for action: ${keyPair.inGameAction}');
|
||||
} else if (jsonObject != null) {
|
||||
final jsonString = jsonEncode(jsonObject);
|
||||
_socket?.writeln(jsonString);
|
||||
return Success('Sent action to MyWhoosh: $action ${value ?? ''}');
|
||||
return Success('Sent action to MyWhoosh: ${keyPair.inGameAction} ${keyPair.inGameActionValue ?? ''}');
|
||||
} else {
|
||||
return NotHandled('No action available for button: $action');
|
||||
return NotHandled('No action available for button: ${keyPair.inGameAction}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +169,7 @@ class WhooshLink {
|
||||
return kIsWeb
|
||||
? false
|
||||
: switch (target) {
|
||||
Target.thisDevice => !Platform.isIOS,
|
||||
Target.thisDevice => !Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
import '../../messages/notification.dart' show AlertNotification;
|
||||
|
||||
class OpenBikeControlBluetoothEmulator {
|
||||
class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
final ValueNotifier<bool> isStarted = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<AppInfo?> isConnected = ValueNotifier<AppInfo?>(null);
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier<AppInfo?>(null);
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
Central? _central;
|
||||
|
||||
late GATTCharacteristic _buttonCharacteristic;
|
||||
|
||||
OpenBikeControlBluetoothEmulator()
|
||||
: super(
|
||||
title: 'OpenBikeControl BLE Emulator',
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
|
||||
@@ -35,10 +42,13 @@ class OpenBikeControlBluetoothEmulator {
|
||||
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
|
||||
if (state.state == ConnectionState.connected) {
|
||||
} else if (state.state == ConnectionState.disconnected) {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${isConnected.value?.appId}'),
|
||||
);
|
||||
isConnected.value = null;
|
||||
if (connectedApp.value != null) {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'),
|
||||
);
|
||||
}
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_central = null;
|
||||
}
|
||||
});
|
||||
@@ -98,7 +108,9 @@ class OpenBikeControlBluetoothEmulator {
|
||||
case OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID:
|
||||
try {
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(value);
|
||||
isConnected.value = appInfo;
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
@@ -115,37 +127,38 @@ class OpenBikeControlBluetoothEmulator {
|
||||
});
|
||||
}
|
||||
|
||||
// Device Information
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('1.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
|
||||
if (!Platform.isWindows) {
|
||||
// Device Information
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('1.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Battery Service
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
@@ -209,34 +222,44 @@ class OpenBikeControlBluetoothEmulator {
|
||||
}
|
||||
await _peripheralManager.stopAdvertising();
|
||||
isStarted.value = false;
|
||||
isConnected.value = null;
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
}
|
||||
|
||||
Future<ActionResult> sendButtonPress(
|
||||
List<ControllerButton> buttons, {
|
||||
required bool isKeyDown,
|
||||
required bool isKeyUp,
|
||||
}) async {
|
||||
if (_central == null) {
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final inGameAction = keyPair.inGameAction;
|
||||
|
||||
final mappedButtons = connectedApp.value!.supportedButtons.filter(
|
||||
(supportedButton) => supportedButton.action == inGameAction,
|
||||
);
|
||||
|
||||
if (inGameAction == null) {
|
||||
return Error('Invalid in-game action for key pair: $keyPair');
|
||||
} else if (_central == null) {
|
||||
return Error('No central connected');
|
||||
} else if (isConnected.value == null) {
|
||||
} else if (connectedApp.value == null) {
|
||||
return Error('No app info received from central');
|
||||
} else if (!isConnected.value!.supportedButtons.containsAll(buttons)) {
|
||||
return NotHandled('App does not support all buttons: ${buttons.map((b) => b.name).join(', ')}');
|
||||
} else if (mappedButtons.isEmpty) {
|
||||
return NotHandled('App does not support all buttons for action: ${inGameAction.title}');
|
||||
}
|
||||
|
||||
final responseData = OpenBikeProtocolParser.encodeButtonState(
|
||||
buttons
|
||||
.map(
|
||||
(b) => ButtonState(
|
||||
b,
|
||||
isKeyDown ? 1 : 0,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseData);
|
||||
if (isKeyDown && isKeyUp) {
|
||||
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataDown);
|
||||
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataUp);
|
||||
} else {
|
||||
final responseData = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseData);
|
||||
}
|
||||
|
||||
return Success('Buttons ${buttons.map((b) => b.name).join(', ')} sent');
|
||||
return Success('Buttons ${inGameAction.title} sent');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
import 'package:swift_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class OpenBikeControlMdnsEmulator {
|
||||
class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _server;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
final ValueNotifier<bool> isStarted = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<AppInfo?> isConnected = ValueNotifier<AppInfo?>(null);
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier(null);
|
||||
|
||||
Socket? _socket;
|
||||
|
||||
OpenBikeControlMdnsEmulator()
|
||||
: super(
|
||||
title: 'OpenBikeControl mDNS Emulator',
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
print('Starting mDNS server...');
|
||||
isStarted.value = true;
|
||||
|
||||
// Get local IP
|
||||
final interfaces = await NetworkInterface.list();
|
||||
@@ -70,7 +78,6 @@ class OpenBikeControlMdnsEmulator {
|
||||
),
|
||||
);
|
||||
print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration');
|
||||
isStarted.value = true;
|
||||
print('Server started - advertising service!');
|
||||
} catch (e, s) {
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start mDNS server: $e'));
|
||||
@@ -87,7 +94,8 @@ class OpenBikeControlMdnsEmulator {
|
||||
_mdnsRegistration = null;
|
||||
}
|
||||
isStarted.value = false;
|
||||
isConnected.value = null;
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_socket?.destroy();
|
||||
_socket = null;
|
||||
}
|
||||
@@ -120,12 +128,17 @@ class OpenBikeControlMdnsEmulator {
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
print('Received message: ${bytesToHex(data)}');
|
||||
if (kDebugMode) {
|
||||
print('Received message: ${bytesToHex(data)}');
|
||||
}
|
||||
final messageType = data[0];
|
||||
switch (messageType) {
|
||||
case OpenBikeProtocolParser.MSG_TYPE_APP_INFO:
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data));
|
||||
isConnected.value = appInfo;
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
|
||||
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
@@ -136,9 +149,10 @@ class OpenBikeControlMdnsEmulator {
|
||||
},
|
||||
onDone: () {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${isConnected.value?.appId}'),
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'),
|
||||
);
|
||||
isConnected.value = null;
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_socket = null;
|
||||
},
|
||||
);
|
||||
@@ -146,26 +160,46 @@ class OpenBikeControlMdnsEmulator {
|
||||
);
|
||||
}
|
||||
|
||||
ActionResult sendButtonPress(List<ControllerButton> buttons, {required bool isKeyDown, required bool isKeyUp}) {
|
||||
if (_socket == null) {
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final inGameAction = keyPair.inGameAction;
|
||||
|
||||
final mappedButtons = connectedApp.value!.supportedButtons.filter(
|
||||
(supportedButton) => supportedButton.action == inGameAction,
|
||||
);
|
||||
|
||||
if (inGameAction == null) {
|
||||
return Error('Invalid in-game action for key pair: $keyPair');
|
||||
} else if (_socket == null) {
|
||||
print('No client connected, cannot send button press');
|
||||
return Error('No client connected');
|
||||
} else if (isConnected.value == null) {
|
||||
} else if (connectedApp.value == null) {
|
||||
return Error('No app info received from central');
|
||||
} else if (!isConnected.value!.supportedButtons.containsAll(buttons)) {
|
||||
return NotHandled('App does not support all buttons: ${buttons.map((b) => b.name).join(', ')}');
|
||||
} else if (mappedButtons.isEmpty) {
|
||||
return NotHandled('App does not support: ${inGameAction.title}');
|
||||
}
|
||||
|
||||
final responseData = OpenBikeProtocolParser.encodeButtonState(
|
||||
buttons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
|
||||
);
|
||||
_write(_socket!, responseData);
|
||||
if (isKeyDown && isKeyUp) {
|
||||
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
|
||||
);
|
||||
_write(_socket!, responseDataDown);
|
||||
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
|
||||
);
|
||||
_write(_socket!, responseDataUp);
|
||||
} else {
|
||||
final responseData = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
|
||||
);
|
||||
_write(_socket!, responseData);
|
||||
}
|
||||
|
||||
return Success('Sent ${buttons.map((b) => b.name).join(', ')} button press');
|
||||
return Success('Sent ${inGameAction.title} button press');
|
||||
}
|
||||
|
||||
void _write(Socket socket, List<int> responseData) {
|
||||
print('Sending response: ${bytesToHex(responseData)}');
|
||||
debugPrint('Sending response: ${bytesToHex(responseData)}');
|
||||
socket.add(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class ProtocolParseException implements Exception {
|
||||
final String message;
|
||||
@@ -224,7 +224,12 @@ class OpenBikeProtocolParser {
|
||||
|
||||
final controllerButtons = buttonIds.mapNotNull((id) => BUTTON_NAMES[id]).toList();
|
||||
|
||||
return AppInfo(appId: appId, appVersion: appVersion, supportedButtons: controllerButtons);
|
||||
return AppInfo(
|
||||
appId: appId,
|
||||
appVersion: appVersion,
|
||||
supportedButtons: controllerButtons,
|
||||
supportedActions: controllerButtons.mapNotNull((b) => b.action).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,11 +237,18 @@ class AppInfo {
|
||||
final String appId;
|
||||
final String appVersion;
|
||||
final List<ControllerButton> supportedButtons;
|
||||
final List<InGameAction> supportedActions;
|
||||
|
||||
AppInfo({required this.appId, required this.appVersion, required this.supportedButtons});
|
||||
AppInfo({
|
||||
required this.appId,
|
||||
required this.appVersion,
|
||||
required this.supportedButtons,
|
||||
required this.supportedActions,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'AppInfo(appId: $appId, appVersion: $appVersion, supportedButtons: $supportedButtons)';
|
||||
String toString() =>
|
||||
'AppInfo(appId: $appId, appVersion: $appVersion, supportedButtons: $supportedButtons, supportedActions: $supportedActions)';
|
||||
}
|
||||
|
||||
/// DeviceStatus message representation
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/core.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';
|
||||
@@ -51,13 +51,13 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
|
||||
final clickedButtons = <ControllerButton>[];
|
||||
|
||||
channels.forEachIndexed((int value, int index) async {
|
||||
channels.forEachIndexed((int value, int index) {
|
||||
final didChange = _lastButtons[index] != value;
|
||||
_lastButtons[index] = value;
|
||||
|
||||
final readableIndex = index + 1;
|
||||
|
||||
final button = await getOrAddButton(
|
||||
final button = getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
);
|
||||
@@ -96,6 +96,7 @@ class ShimanoDi2 extends BluetoothDevice {
|
||||
|
||||
class ShimanoDi2Constants {
|
||||
static const String SERVICE_UUID = "000018ef-5348-494d-414e-4f5f424c4500";
|
||||
static const String SERVICE_UUID_ALTERNATIVE = "000018ff-5348-494d-414e-4f5f424c4500";
|
||||
|
||||
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
|
||||
}
|
||||
|
||||
16
lib/bluetooth/devices/trainer_connection.dart
Normal file
16
lib/bluetooth/devices/trainer_connection.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
abstract class TrainerConnection {
|
||||
final String title;
|
||||
List<InGameAction> supportedActions;
|
||||
|
||||
final ValueNotifier<bool> isStarted = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isConnected = ValueNotifier(false);
|
||||
|
||||
TrainerConnection({required this.title, required this.supportedActions});
|
||||
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
|
||||
import '../zwift/constants.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
151
lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart
Normal file
151
lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class WahooKickrHeadwind extends BluetoothDevice {
|
||||
// Current mode state
|
||||
HeadwindMode _currentMode = HeadwindMode.unknown;
|
||||
int _currentSpeed = 0;
|
||||
|
||||
WahooKickrHeadwind(super.scanResult)
|
||||
: super(
|
||||
availableButtons: const [],
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid == WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${WahooKickrHeadwindConstants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid == WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${WahooKickrHeadwindConstants.CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
// Subscribe to notifications for status updates
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
// Analyze the received bytes to determine current state
|
||||
actionStreamInternal.add(LogNotification('Received ${bytesToHex(bytes)} from Headwind $characteristic'));
|
||||
if (bytes.length >= 4 && bytes[0] == 0xFD && bytes[1] == 0x01) {
|
||||
final mode = bytes[3];
|
||||
final speed = bytes[2];
|
||||
|
||||
switch (mode) {
|
||||
case 0x02:
|
||||
_currentMode = HeadwindMode.heartRate;
|
||||
break;
|
||||
case 0x03:
|
||||
_currentMode = HeadwindMode.speed;
|
||||
break;
|
||||
case 0x01:
|
||||
_currentMode = HeadwindMode.off;
|
||||
break;
|
||||
case 0x04:
|
||||
_currentMode = HeadwindMode.manual;
|
||||
_currentSpeed = speed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
Future<void> setSpeed(int speedPercent) async {
|
||||
// Validate against the allowed speed values
|
||||
const allowedSpeeds = [0, 25, 50, 75, 100];
|
||||
if (!allowedSpeeds.contains(speedPercent)) {
|
||||
throw ArgumentError('Speed must be one of: ${allowedSpeeds.join(", ")}');
|
||||
}
|
||||
|
||||
final service = WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase();
|
||||
final characteristic = WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase();
|
||||
|
||||
// Check if manual mode is enabled, if not enable it first
|
||||
if (_currentMode != HeadwindMode.manual) {
|
||||
final manualModeData = Uint8List.fromList([0x04, 0x04]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
manualModeData,
|
||||
);
|
||||
_currentMode = HeadwindMode.manual;
|
||||
}
|
||||
|
||||
// Command format: [0x02, speed_value]
|
||||
// Speed value: 0x00 to 0x64 (0-100 in hex)
|
||||
final data = Uint8List.fromList([0x02, speedPercent]);
|
||||
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
);
|
||||
_currentSpeed = speedPercent;
|
||||
}
|
||||
|
||||
Future<void> setHeartRateMode() async {
|
||||
final service = WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase();
|
||||
final characteristic = WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase();
|
||||
|
||||
// Command format: [0x04, 0x02] for HR mode
|
||||
final data = Uint8List.fromList([0x04, 0x02]);
|
||||
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
);
|
||||
_currentMode = HeadwindMode.heartRate;
|
||||
}
|
||||
|
||||
Future<ActionResult> handleKeypair(KeyPair keyPair, {required bool isKeyDown}) async {
|
||||
if (!isKeyDown) {
|
||||
return NotHandled('');
|
||||
}
|
||||
|
||||
try {
|
||||
if (keyPair.inGameAction == InGameAction.headwindSpeed) {
|
||||
final speed = keyPair.inGameActionValue ?? 0;
|
||||
await setSpeed(speed);
|
||||
return Success('Headwind speed set to $speed%');
|
||||
} else if (keyPair.inGameAction == InGameAction.headwindHeartRateMode) {
|
||||
await setHeartRateMode();
|
||||
return Success('Headwind set to Heart Rate mode');
|
||||
}
|
||||
} catch (e) {
|
||||
return Error('Failed to control Headwind: $e');
|
||||
}
|
||||
|
||||
return NotHandled('');
|
||||
}
|
||||
}
|
||||
|
||||
class WahooKickrHeadwindConstants {
|
||||
// Wahoo KICKR Headwind service and characteristic UUIDs
|
||||
// These are standard Wahoo fitness equipment UUIDs
|
||||
static const String SERVICE_UUID = "A026EE0C-0A7D-4AB3-97FA-F1500F9FEB8B";
|
||||
static const String CHARACTERISTIC_UUID = "A026E038-0A7D-4AB3-97FA-F1500F9FEB8B";
|
||||
}
|
||||
|
||||
enum HeadwindMode {
|
||||
unknown,
|
||||
heartRate, // HR mode (0x02)
|
||||
speed, // Speed mode (0x03)
|
||||
off, // OFF mode (0x01)
|
||||
manual, // Manual speed mode (0x04)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
|
||||
class ZwiftConstants {
|
||||
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
|
||||
@@ -71,13 +71,13 @@ class ZwiftButtons {
|
||||
);
|
||||
static const ControllerButton navigationLeft = ControllerButton(
|
||||
'navigationLeft',
|
||||
action: InGameAction.navigateLeft,
|
||||
action: InGameAction.steerLeft,
|
||||
icon: Icons.keyboard_arrow_left,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton navigationRight = ControllerButton(
|
||||
'navigationRight',
|
||||
action: InGameAction.navigateRight,
|
||||
action: InGameAction.steerRight,
|
||||
icon: Icons.keyboard_arrow_right,
|
||||
color: Colors.black,
|
||||
);
|
||||
@@ -99,8 +99,8 @@ class ZwiftButtons {
|
||||
static const ControllerButton powerUpLeft = ControllerButton('powerUpLeft', action: InGameAction.shiftDown);
|
||||
|
||||
// right controller
|
||||
static const ControllerButton a = ControllerButton('a', action: null, color: Colors.lightGreen);
|
||||
static const ControllerButton b = ControllerButton('b', action: null, color: Colors.pinkAccent);
|
||||
static const ControllerButton a = ControllerButton('a', action: InGameAction.select, color: Colors.lightGreen);
|
||||
static const ControllerButton b = ControllerButton('b', action: InGameAction.back, color: Colors.pinkAccent);
|
||||
static const ControllerButton z = ControllerButton('z', action: null, color: Colors.deepOrangeAccent);
|
||||
static const ControllerButton y = ControllerButton('y', action: null, color: Colors.lightBlue);
|
||||
static const ControllerButton onOffRight = ControllerButton('onOffRight', action: InGameAction.toggleUi);
|
||||
|
||||
@@ -3,27 +3,44 @@ import 'dart:io';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' show RideKeyPadStatus;
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' show RideKeyPadStatus;
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
|
||||
class FtmsMdnsEmulator {
|
||||
class FtmsMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _tcpServer;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
Socket? _socket;
|
||||
var lastMessageId = 0;
|
||||
|
||||
ValueNotifier<bool> isConnected = ValueNotifier(false);
|
||||
ValueNotifier<bool> isStarted = ValueNotifier(false);
|
||||
FtmsMdnsEmulator()
|
||||
: super(
|
||||
title: 'Zwift Network Emulator',
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
print('Starting mDNS server...');
|
||||
|
||||
// Get local IP
|
||||
@@ -65,7 +82,6 @@ class FtmsMdnsEmulator {
|
||||
},
|
||||
),
|
||||
);
|
||||
isStarted.value = true;
|
||||
print('Server started - advertising service!');
|
||||
}
|
||||
|
||||
@@ -115,7 +131,9 @@ class FtmsMdnsEmulator {
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
print('Received message: ${bytesToHex(data)}');
|
||||
if (kDebugMode) {
|
||||
print('Received message: ${bytesToHex(data)}');
|
||||
}
|
||||
|
||||
final mutable = data.toList();
|
||||
while (mutable.isNotEmpty) {
|
||||
@@ -127,7 +145,9 @@ class FtmsMdnsEmulator {
|
||||
final length = mutable.takeUInt16BE(); // Length of the message body
|
||||
|
||||
final body = mutable.takeBytes(length);
|
||||
print('Parsed message: ID: $msgId, Body: ${bytesToHex(body)}');
|
||||
if (kDebugMode) {
|
||||
print('Parsed message: ID: $msgId, Body: ${bytesToHex(body)}');
|
||||
}
|
||||
|
||||
Uint8List buildHeader(int responseCode, int bodyLength) {
|
||||
return Uint8List.fromList([
|
||||
@@ -294,7 +314,9 @@ class FtmsMdnsEmulator {
|
||||
}
|
||||
|
||||
void _write(Socket socket, List<int> responseData) {
|
||||
print('Sending response: ${bytesToHex(responseData)}');
|
||||
if (kDebugMode) {
|
||||
print('Sending response: ${bytesToHex(responseData)}');
|
||||
}
|
||||
socket.add(responseData);
|
||||
}
|
||||
|
||||
@@ -309,13 +331,9 @@ class FtmsMdnsEmulator {
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<ActionResult> sendAction(
|
||||
InGameAction inGameAction,
|
||||
int? inGameActionValue, {
|
||||
required bool isKeyDown,
|
||||
required bool isKeyUp,
|
||||
}) async {
|
||||
final button = switch (inGameAction) {
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final button = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
|
||||
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
|
||||
InGameAction.uturn => RideButtonMask.DOWN_BTN,
|
||||
@@ -330,7 +348,7 @@ class FtmsMdnsEmulator {
|
||||
};
|
||||
|
||||
if (button == null) {
|
||||
return NotHandled('Action ${inGameAction.name} not supported by Zwift Emulator');
|
||||
return NotHandled('Action ${keyPair.inGameAction!.name} not supported by Zwift Emulator');
|
||||
}
|
||||
|
||||
if (isKeyDown) {
|
||||
@@ -359,8 +377,10 @@ class FtmsMdnsEmulator {
|
||||
|
||||
_write(_socket!, zero);
|
||||
}
|
||||
print('Sent action ${inGameAction.name} to Zwift Emulator');
|
||||
return Success('Sent action: ${inGameAction.name}');
|
||||
if (kDebugMode) {
|
||||
print('Sent action ${keyPair.inGameAction!.title} to Zwift Emulator');
|
||||
}
|
||||
return Success('Sent action: ${keyPair.inGameAction!.title}');
|
||||
}
|
||||
|
||||
List<int> _buildNotify(String uuid, final List<int> data) {
|
||||
@@ -446,6 +466,10 @@ String bytesToHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
String bytesToReadableHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
List<int> hexToBytes(String hex) {
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < hex.length; i += 2) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/warning.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult)
|
||||
@@ -51,12 +50,6 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
if (bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_1) ||
|
||||
bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_2)) {
|
||||
_noLongerSendsEvents = true;
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_WARNING,
|
||||
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once each session.',
|
||||
),
|
||||
);
|
||||
}
|
||||
return super.processData(bytes);
|
||||
}
|
||||
@@ -72,53 +65,74 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (isConnected && _noLongerSendsEvents && core.settings.getShowZwiftClickV2ReconnectWarning())
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).clickV2Instructions,
|
||||
).xSmall,
|
||||
),
|
||||
IconButton.link(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () {
|
||||
core.settings.setShowZwiftClickV2ReconnectWarning(false);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
GhostButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isConnected && _noLongerSendsEvents)
|
||||
if (core.settings.getShowZwiftClickV2ReconnectWarning())
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).clickV2Instructions,
|
||||
).xSmall,
|
||||
),
|
||||
IconButton.link(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () {
|
||||
core.settings.setShowZwiftClickV2ReconnectWarning(false);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
GhostButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).clickV2EventInfo,
|
||||
).xSmall,
|
||||
LinkButton(
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,13 +2,13 @@ import 'dart:async';
|
||||
|
||||
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/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/single_line_exception.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@@ -65,7 +65,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
|
||||
await setupHandshake();
|
||||
|
||||
if (firmwareVersion != latestFirmwareVersion) {
|
||||
if (firmwareVersion != latestFirmwareVersion && firmwareVersion != null) {
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_WARNING,
|
||||
|
||||
@@ -1,38 +1,25 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pbserver.dart' hide RideButtonMask;
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pbserver.dart' hide RideButtonMask;
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
class ZwiftEmulator {
|
||||
static final List<InGameAction> supportedActions = [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
];
|
||||
|
||||
ValueNotifier<bool> isConnected = ValueNotifier<bool>(false);
|
||||
ValueNotifier<bool> isStarted = ValueNotifier<bool>(false);
|
||||
class ZwiftEmulator extends TrainerConnection {
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
@@ -43,6 +30,23 @@ class ZwiftEmulator {
|
||||
GATTCharacteristic? _asyncCharacteristic;
|
||||
GATTCharacteristic? _syncTxCharacteristic;
|
||||
|
||||
ZwiftEmulator()
|
||||
: super(
|
||||
title: 'Zwift BLE Emulator',
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await _peripheralManager.stopAdvertising();
|
||||
await _peripheralManager.removeAllServices();
|
||||
@@ -174,37 +178,38 @@ class ZwiftEmulator {
|
||||
});
|
||||
}
|
||||
|
||||
// Device Information
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('09-B48123283828F1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('A.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
|
||||
if (!Platform.isWindows) {
|
||||
// Device Information
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('09-B48123283828F1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('A.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Battery Service
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
@@ -308,13 +313,9 @@ class ZwiftEmulator {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> sendAction(
|
||||
InGameAction inGameAction,
|
||||
int? inGameActionValue, {
|
||||
required bool isKeyDown,
|
||||
required bool isKeyUp,
|
||||
}) async {
|
||||
final button = switch (inGameAction) {
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final button = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
|
||||
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
|
||||
InGameAction.uturn => RideButtonMask.DOWN_BTN,
|
||||
@@ -329,7 +330,7 @@ class ZwiftEmulator {
|
||||
};
|
||||
|
||||
if (button == null) {
|
||||
return NotHandled('Action ${inGameAction.name} not supported by Zwift Emulator');
|
||||
return NotHandled('Action ${keyPair.inGameAction!.name} not supported by Zwift Emulator');
|
||||
}
|
||||
|
||||
final status = RideKeyPadStatus()
|
||||
@@ -356,7 +357,7 @@ class ZwiftEmulator {
|
||||
_peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
|
||||
}
|
||||
|
||||
return Success('Sent action: ${inGameAction.name}');
|
||||
return Success('Sent action: ${keyPair.inGameAction!.name}');
|
||||
}
|
||||
|
||||
Uint8List? handleWriteRequest(String characteristic, Uint8List value) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
|
||||
class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
@@ -59,4 +62,23 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.3.1';
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
Checkbox(
|
||||
trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)),
|
||||
state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
onChanged: (value) async {
|
||||
await core.settings.setVibrationEnabled(value == CheckboxState.checked);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
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_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp_vendor.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class ZwiftRide extends ZwiftDevice {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
|
||||
class BaseNotification {}
|
||||
|
||||
|
||||
@@ -3,15 +3,19 @@ import 'dart:io';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/actions/remote.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
|
||||
class RemotePairing {
|
||||
ValueNotifier<bool> isConnected = ValueNotifier<bool>(false);
|
||||
ValueNotifier<bool> isStarted = ValueNotifier<bool>(false);
|
||||
import '../utils/keymap/keymap.dart';
|
||||
|
||||
class RemotePairing extends TrainerConnection {
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
@@ -22,6 +26,12 @@ class RemotePairing {
|
||||
Central? _central;
|
||||
GATTCharacteristic? _inputReport;
|
||||
|
||||
RemotePairing()
|
||||
: super(
|
||||
title: 'Remote Control',
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await _peripheralManager.stopAdvertising();
|
||||
await _peripheralManager.removeAllServices();
|
||||
@@ -263,4 +273,36 @@ class RemotePairing {
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _inputReport!, value: value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final point = await (core.actionHandler as RemoteActions).resolveTouchPosition(keyPair: keyPair, windowInfo: null);
|
||||
final point2 = point; //Offset(100, 99.0);
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
|
||||
return Success('Mouse clicked at: ${point2.dx.toInt()} ${point2.dy.toInt()}');
|
||||
}
|
||||
|
||||
Uint8List absMouseReport(int buttons3bit, int x, int y) {
|
||||
final b = buttons3bit & 0x07;
|
||||
final xi = x.clamp(0, 100);
|
||||
final yi = y.clamp(0, 100);
|
||||
return Uint8List.fromList([b, xi, yi]);
|
||||
}
|
||||
|
||||
// Send a relative mouse move + button state as 3-byte report: [buttons, dx, dy]
|
||||
Future<void> sendAbsMouseReport(int buttons, int dx, int dy) async {
|
||||
final bytes = absMouseReport(buttons, dx, dy);
|
||||
if (kDebugMode) {
|
||||
print('Preparing to send abs mouse report: buttons=$buttons, dx=$dx, dy=$dy');
|
||||
print('Sending abs mouse report: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0'))}');
|
||||
}
|
||||
|
||||
await notifyCharacteristic(bytes);
|
||||
|
||||
// we don't want to overwhelm the target device
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"ignoredDevices": "Ignorierte Geräte",
|
||||
"importAction": "Import",
|
||||
"importProfile": "Profil importieren",
|
||||
"instructions": "Anleitung",
|
||||
"jsonData": "JSON-Daten",
|
||||
"keyboardAccess": "Tastaturzugriff",
|
||||
"latestVersion": "aktuell: {version}",
|
||||
@@ -245,6 +246,7 @@
|
||||
"logsHaveBeenCopiedToClipboard": "Die Protokolle wurden in die Zwischenablage kopiert.",
|
||||
"longPress": "langes\nDrücken",
|
||||
"longPressMode": "Modus „Langes Drücken“ (statt Wiederholen)",
|
||||
"mailSupportExplanation": "Die individuelle Unterstützung per E-Mail ist für mich sehr aufwendig.\n\nBitte nutze daher Reddit, Facebook oder GitHub für Fragen und Probleme, damit die gesamte Community davon profitieren kann.",
|
||||
"manageIgnoredDevices": "Ignorierte Geräte verwalten",
|
||||
"manageProfile": "Profil verwalten",
|
||||
"mediaKeyDetectionTooltip": "Aktiviere diese Option, damit BikeControl Bluetooth-Fernbedienungen erkennt. \nDazu muss BikeControl als Mediaplayer fungieren.",
|
||||
@@ -260,6 +262,8 @@
|
||||
"myWhooshDirectConnection": " z. B. mit MyWhoosh „Link”.",
|
||||
"myWhooshLinkConnected": "MyWhoosh „Link“ verbunden",
|
||||
"myWhooshLinkDescriptionLocal": "Verbinde dich direkt mit MyWhoosh über die „Link”-Methode. Unterstützte Aktionen sind unter anderem Schalten, Emotes und Richtungswechsel. Die MyWhoosh Link-Begleit-App darf dabei NICHT gleichzeitig laufen.",
|
||||
"myWhooshLinkDescriptionRemote": "Du kannst dich über das Netzwerk mit MyWhoosh verbinden, indem du die „Link”-Verbindung nutzt. Die MyWhoosh Link-Begleit-App darf dabei NICHT gleichzeitig laufen.",
|
||||
"myWhooshLinkInfo": "Schau mal im Abschnitt zur Fehlerbehebung nach, wenn du Probleme hast. Eine deutlich zuverlässigere Verbindungsmethode kommt bald!",
|
||||
"nameChangeNotice": "SwiftControl heißt jetzt BikeControl! Es ist Teil des OpenBikeControl-Projekts, das sich für offene Standards bei intelligenten Fahrradtrainern einsetzt und erschwingliche Hardware-Controller entwickelt!",
|
||||
"needHelpClickHelp": "Hilfe benötigt? Klicke auf",
|
||||
"needHelpDontHesitate": "den Button oben und zögere nicht, uns zu kontaktieren.",
|
||||
|
||||
@@ -454,5 +454,9 @@
|
||||
"noConnectionMethodSelected": "No Connection Method selected",
|
||||
"noControllerConnected": "None connected",
|
||||
"notConnected": "Not connected",
|
||||
"noTrainerSelected": "No Trainer selected"
|
||||
"noTrainerSelected": "No Trainer selected",
|
||||
"instructions": "Instructions",
|
||||
"mailSupportExplanation": "Providing individual support via email is a lot of work for me.\n\nPlease consider using Reddit, Facebook or GitHub for questions and issues so that the whole community can benefit from it.",
|
||||
"myWhooshLinkInfo": "Please check the troubleshooting section if you encounter any issues. A much more reliable connection method is coming soon!",
|
||||
"clickV2EventInfo": "Your Click V2 may no longer send button events. Please check by tapping a few buttons and see if they are visible in BikeControl."
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"ignoredDevices": "Appareils ignorés",
|
||||
"importAction": "Importer",
|
||||
"importProfile": "Profil d'importation",
|
||||
"instructions": "Mode d'emploi",
|
||||
"jsonData": "Données JSON",
|
||||
"keyboardAccess": "Accès clavier",
|
||||
"latestVersion": "dernier: {version}",
|
||||
@@ -245,6 +246,7 @@
|
||||
"logsHaveBeenCopiedToClipboard": "Les journaux ont été copiés dans le presse-papiers.",
|
||||
"longPress": "long\nappui",
|
||||
"longPressMode": "Mode appui long (par rapport à la répétition)",
|
||||
"mailSupportExplanation": "Répondre à tout le monde individuellement par e-mail, ça me prend beaucoup de temps.\n\nSi t'as des questions ou des problèmes, pense à utiliser Reddit, Facebook ou GitHub pour que tout le monde puisse en profiter.",
|
||||
"manageIgnoredDevices": "Gérer les périphériques ignorés",
|
||||
"manageProfile": "Gérer mon profil",
|
||||
"mediaKeyDetectionTooltip": "Activez cette option pour permettre à BikeControl de détecter les télécommandes Bluetooth. Pour ce faire, BikeControl doit fonctionner comme un lecteur multimédia.",
|
||||
@@ -256,10 +258,12 @@
|
||||
"miuiWarningDescription": "Votre appareil fonctionne sous MIUI, qui est connu pour supprimer de manière agressive les services d'arrière-plan et les services d'accessibilité.",
|
||||
"moreInformation": "Plus d'informations",
|
||||
"mustChooseAllowOrDeny": "Vous devez choisir d'autoriser ou de refuser cette autorisation pour continuer.",
|
||||
"myWhooshDirectConnectAction": "Action de connexion directe MyWhoosh",
|
||||
"myWhooshDirectConnectAction": "Action «Link» de MyWhoosh",
|
||||
"myWhooshDirectConnection": " par exemple en utilisant MyWhoosh «Link».",
|
||||
"myWhooshLinkConnected": "MyWhoosh « Link » connecté",
|
||||
"myWhooshLinkDescriptionLocal": "Connecte-toi directement à MyWhoosh avec la méthode « Link ». Tu peux faire des trucs comme changer de vitesse, utiliser des émoticônes, indiquer la direction à prendre, et plein d'autres choses. L'appli MyWhoosh Link ne doit PAS être ouverte en même temps.",
|
||||
"myWhooshLinkDescriptionRemote": "Ça te permet de te connecter à MyWhoosh via le réseau, en utilisant la connexion « Link ». L'appli MyWhoosh Link ne doit PAS être ouverte en même temps.",
|
||||
"myWhooshLinkInfo": "Si tu rencontres des problèmes, jette un œil à la section dépannage. Une méthode de connexion bien plus fiable sera bientôt disponible !",
|
||||
"nameChangeNotice": "SwiftControl devient BikeControl ! Ce logiciel fait partie du projet OpenBikeControl, qui promeut les standards ouverts pour les home trainers connectés et conçoit des contrôleurs matériels abordables !",
|
||||
"needHelpClickHelp": "Besoin d'aide ? Cliquez sur le",
|
||||
"needHelpDontHesitate": "bouton en haut et n'hésitez pas à nous contacter.",
|
||||
|
||||
@@ -2,17 +2,17 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/actions/remote.dart';
|
||||
import 'package:bike_control/widgets/menu.dart';
|
||||
import 'package:bike_control/widgets/testbed.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart'
|
||||
show GlobalMaterialLocalizations, GlobalWidgetsLocalizations;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/widgets/menu.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
import 'package:swift_control/widgets/ui/colors.dart';
|
||||
|
||||
import 'pages/navigation.dart';
|
||||
import 'utils/actions/base_actions.dart';
|
||||
@@ -31,7 +31,7 @@ void main() async {
|
||||
final List<dynamic> errorAndStack = pair as List<dynamic>;
|
||||
final error = errorAndStack.first;
|
||||
final stack = errorAndStack.last as StackTrace?;
|
||||
_recordError(error, stack, context: 'Isolate');
|
||||
recordError(error, stack, context: 'Isolate');
|
||||
}).sendPort,
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ void main() async {
|
||||
|
||||
// Catch errors from platform dispatcher (async)
|
||||
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
|
||||
_recordError(error, stack, context: 'PlatformDispatcher');
|
||||
recordError(error, stack, context: 'PlatformDispatcher');
|
||||
// Return true means "handled"
|
||||
return true;
|
||||
};
|
||||
@@ -63,7 +63,7 @@ void main() async {
|
||||
print('App crashed: $error');
|
||||
debugPrintStack(stackTrace: stack);
|
||||
}
|
||||
_recordError(error, stack, context: 'Zone');
|
||||
recordError(error, stack, context: 'Zone');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ Future<void> _recordFlutterError(FlutterErrorDetails details) async {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _recordError(
|
||||
Future<void> recordError(
|
||||
Object error,
|
||||
StackTrace? stack, {
|
||||
required String context,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/touch_area.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/custom_keymap_selector.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/mywhoosh/link.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/pages/touch_area.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/widgets/custom_keymap_selector.dart';
|
||||
import 'package:swift_control/widgets/ui/button_widget.dart';
|
||||
import 'package:swift_control/widgets/ui/colored_title.dart';
|
||||
import 'package:swift_control/widgets/ui/colors.dart';
|
||||
import 'package:swift_control/widgets/ui/warning.dart';
|
||||
|
||||
class ButtonEditPage extends StatefulWidget {
|
||||
final KeyPair keyPair;
|
||||
@@ -100,15 +99,15 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
: () {
|
||||
showDropdown(
|
||||
builder: (c) => DropdownMenu(
|
||||
children: core.logic.obpConnectedApp!.supportedButtons
|
||||
children: core.logic.obpConnectedApp!.supportedActions
|
||||
.map(
|
||||
(button) => MenuButton(
|
||||
child: Text(button.name),
|
||||
(action) => MenuButton(
|
||||
child: Text(action.name),
|
||||
onPressed: (_) {
|
||||
keyPair.touchPosition = Offset.zero;
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
keyPair.inGameAction = button.action!;
|
||||
keyPair.inGameAction = action;
|
||||
keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
@@ -131,13 +130,15 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.link,
|
||||
title: Text(context.i18n.myWhooshDirectConnectAction),
|
||||
isActive: keyPair.inGameAction != null,
|
||||
isActive:
|
||||
keyPair.inGameAction != null &&
|
||||
core.whooshLink.supportedActions.contains(keyPair.inGameAction),
|
||||
value: [keyPair.inGameAction.toString(), ?keyPair.inGameActionValue?.toString()].join(' '),
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: WhooshLink.supportedActions.map(
|
||||
children: core.whooshLink.supportedActions.map(
|
||||
(ingame) {
|
||||
return MenuButton(
|
||||
subMenu: ingame.possibleValues
|
||||
@@ -176,13 +177,15 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.link,
|
||||
title: Text(context.i18n.zwiftControllerAction),
|
||||
isActive: keyPair.inGameAction != null,
|
||||
isActive:
|
||||
keyPair.inGameAction != null &&
|
||||
core.zwiftEmulator.supportedActions.contains(keyPair.inGameAction),
|
||||
value: [keyPair.inGameAction.toString(), ?keyPair.inGameActionValue?.toString()].join(' '),
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: ZwiftEmulator.supportedActions.map(
|
||||
children: core.zwiftEmulator.supportedActions.map(
|
||||
(ingame) {
|
||||
return MenuButton(
|
||||
subMenu: ingame.possibleValues
|
||||
@@ -395,6 +398,58 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
),
|
||||
],
|
||||
|
||||
if (core.connection.accessories.isNotEmpty) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: 'Accessory Actions'),
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.air,
|
||||
title: Text('KICKR Headwind'),
|
||||
isActive:
|
||||
keyPair.inGameAction != null &&
|
||||
(keyPair.inGameAction == InGameAction.headwindSpeed ||
|
||||
keyPair.inGameAction == InGameAction.headwindHeartRateMode),
|
||||
value: keyPair.inGameAction != null
|
||||
? '${keyPair.inGameAction} ${keyPair.inGameActionValue ?? ""}'.trim()
|
||||
: null,
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
subMenu: [0, 25, 50, 75, 100]
|
||||
.map(
|
||||
(value) => MenuButton(
|
||||
child: Text('Set Speed to $value%'),
|
||||
onPressed: (_) {
|
||||
keyPair.inGameAction = InGameAction.headwindSpeed;
|
||||
keyPair.inGameActionValue = value;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Text('Set Speed'),
|
||||
),
|
||||
MenuButton(
|
||||
child: Text('Set to Heart Rate Mode'),
|
||||
onPressed: (_) {
|
||||
keyPair.inGameAction = InGameAction.headwindHeartRateMode;
|
||||
keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.setting),
|
||||
SelectableCard(
|
||||
|
||||
@@ -1,41 +1,535 @@
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/pages/touch_area.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart' show BackButton;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
|
||||
class ButtonSimulator extends StatelessWidget {
|
||||
class ButtonSimulator extends StatefulWidget {
|
||||
const ButtonSimulator({super.key});
|
||||
|
||||
@override
|
||||
State<ButtonSimulator> createState() => _ButtonSimulatorState();
|
||||
}
|
||||
|
||||
class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
late final FocusNode _focusNode;
|
||||
Map<InGameAction, String> _hotkeys = {};
|
||||
|
||||
// Default hotkeys for actions
|
||||
static const List<String> _defaultHotkeyOrder = [
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'q',
|
||||
'w',
|
||||
'e',
|
||||
'r',
|
||||
't',
|
||||
'y',
|
||||
'u',
|
||||
'i',
|
||||
'o',
|
||||
'p',
|
||||
'a',
|
||||
's',
|
||||
'd',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'z',
|
||||
'x',
|
||||
'c',
|
||||
'v',
|
||||
'b',
|
||||
'n',
|
||||
'm',
|
||||
];
|
||||
|
||||
static const Duration _keyPressDuration = Duration(milliseconds: 100);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode(debugLabel: 'ButtonSimulatorFocus', canRequestFocus: true);
|
||||
_loadHotkeys();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadHotkeys() async {
|
||||
final savedHotkeys = core.settings.getButtonSimulatorHotkeys();
|
||||
|
||||
// If no saved hotkeys, initialize with defaults
|
||||
if (savedHotkeys.isEmpty) {
|
||||
final connectedTrainers = core.logic.connectedTrainerConnections;
|
||||
final allActions = <InGameAction>[];
|
||||
|
||||
for (final connection in connectedTrainers) {
|
||||
allActions.addAll(connection.supportedActions);
|
||||
}
|
||||
|
||||
// Assign default hotkeys to actions
|
||||
final Map<InGameAction, String> defaultHotkeys = {};
|
||||
int hotkeyIndex = 0;
|
||||
for (final action in allActions.distinct()) {
|
||||
if (hotkeyIndex < _defaultHotkeyOrder.length) {
|
||||
defaultHotkeys[action] = _defaultHotkeyOrder[hotkeyIndex];
|
||||
hotkeyIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
await core.settings.setButtonSimulatorHotkeys(defaultHotkeys);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hotkeys = defaultHotkeys;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_hotkeys = savedHotkeys;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey.keyLabel.toLowerCase();
|
||||
|
||||
// Find the action associated with this key
|
||||
final action = _hotkeys.entries.firstOrNullWhere((entry) => entry.value == key)?.key;
|
||||
|
||||
if (action == null) return KeyEventResult.ignored;
|
||||
|
||||
// Find the connection that supports this action
|
||||
final connectedTrainers = core.logic.connectedTrainerConnections;
|
||||
final connection = connectedTrainers.firstOrNullWhere((c) => c.supportedActions.contains(action));
|
||||
|
||||
if (connection != null) {
|
||||
_sendKey(context, down: true, action: action, connection: connection);
|
||||
// Schedule key up event
|
||||
Future.delayed(
|
||||
_keyPressDuration,
|
||||
() {
|
||||
if (mounted) {
|
||||
_sendKey(context, down: false, action: action, connection: connection);
|
||||
}
|
||||
},
|
||||
);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
leading: [BackButton()],
|
||||
),
|
||||
],
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [...ZwiftButtons.values]
|
||||
.map(
|
||||
(e) => OutlineButton(
|
||||
child: Text(e.name),
|
||||
onPressed: () {
|
||||
if (core.connection.devices.isNotEmpty) {
|
||||
core.connection.devices.firstOrNull?.handleButtonsClicked([e]);
|
||||
core.connection.devices.firstOrNull?.handleButtonsClicked([]);
|
||||
} else {
|
||||
core.actionHandler.performAction(e, isKeyDown: true, isKeyUp: true);
|
||||
/*final point = Offset(300, 300);
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
// slight move to register clicks on some apps, see issue #116
|
||||
await keyPressSimulator.simulateMouseClickUp(point);*/
|
||||
}
|
||||
},
|
||||
final connectedTrainers = core.logic.connectedTrainerConnections;
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
leading: [BackButton()],
|
||||
title: Text(context.i18n.simulateButtons),
|
||||
trailing: [
|
||||
PrimaryButton(
|
||||
child: Icon(Icons.settings),
|
||||
onPressed: () => _showHotkeySettings(context, connectedTrainers),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
children: [
|
||||
if (connectedTrainers.isEmpty)
|
||||
Warning(
|
||||
children: [
|
||||
Text('No connected trainers found. Connect a trainer to simulate button presses.'),
|
||||
],
|
||||
),
|
||||
...connectedTrainers.map(
|
||||
(connection) {
|
||||
final supportedActions = connection.supportedActions;
|
||||
|
||||
final actionGroups = {
|
||||
if (supportedActions.contains(InGameAction.shiftUp) &&
|
||||
supportedActions.contains(InGameAction.shiftDown))
|
||||
'Shifting': [InGameAction.shiftUp, InGameAction.shiftDown],
|
||||
'Other': supportedActions
|
||||
.where((action) => action != InGameAction.shiftUp && action != InGameAction.shiftDown)
|
||||
.toList(),
|
||||
};
|
||||
|
||||
return [
|
||||
GradientText(connection.title).bold.large,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
for (final group in actionGroups.entries) ...[
|
||||
Text(group.key).bold,
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: group.value.map(
|
||||
(action) {
|
||||
final hotkey = _hotkeys[action];
|
||||
return PrimaryButton(
|
||||
size: ButtonSize(1.6),
|
||||
leading: hotkey != null
|
||||
? KeyWidget(
|
||||
label: hotkey.toUpperCase(),
|
||||
)
|
||||
: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(action.title),
|
||||
if (action.alternativeTitle != null)
|
||||
Text(
|
||||
action.alternativeTitle!,
|
||||
style: TextStyle(fontSize: 12, color: Colors.gray),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () {},
|
||||
onTapDown: (c) async {
|
||||
_sendKey(context, down: true, action: action, connection: connection);
|
||||
},
|
||||
onTapUp: (c) async {
|
||||
_sendKey(context, down: false, action: action, connection: connection);
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
];
|
||||
},
|
||||
).flatten(),
|
||||
// local control doesn't make much sense - it would send the key events to BikeControl itself
|
||||
if (false &&
|
||||
core.logic.showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
core.actionHandler.supportedApp != null) ...[
|
||||
GradientText('Local Control'),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: core.actionHandler.supportedApp!.keymap.keyPairs
|
||||
.map(
|
||||
(keyPair) => PrimaryButton(
|
||||
child: Text(keyPair.toString()),
|
||||
onPressed: () async {
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
await (core.actionHandler as AndroidActions).performAction(
|
||||
keyPair.buttons.first,
|
||||
isKeyDown: true,
|
||||
isKeyUp: false,
|
||||
);
|
||||
await (core.actionHandler as AndroidActions).performAction(
|
||||
keyPair.buttons.first,
|
||||
isKeyDown: false,
|
||||
isKeyUp: true,
|
||||
);
|
||||
} else {
|
||||
await (core.actionHandler as DesktopActions).performAction(
|
||||
keyPair.buttons.first,
|
||||
isKeyDown: true,
|
||||
isKeyUp: false,
|
||||
);
|
||||
await (core.actionHandler as DesktopActions).performAction(
|
||||
keyPair.buttons.first,
|
||||
isKeyDown: false,
|
||||
isKeyUp: true,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendKey(
|
||||
BuildContext context, {
|
||||
required bool down,
|
||||
required InGameAction action,
|
||||
required TrainerConnection connection,
|
||||
}) async {
|
||||
if (action.possibleValues != null) {
|
||||
if (down) return;
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (context) => DropdownMenu(
|
||||
children: action.possibleValues!
|
||||
.map(
|
||||
(e) => MenuButton(
|
||||
child: Text(e.toString()),
|
||||
onPressed: (c) async {
|
||||
await connection.sendAction(
|
||||
KeyPair(
|
||||
buttons: [],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: action,
|
||||
inGameActionValue: e,
|
||||
),
|
||||
isKeyDown: false,
|
||||
isKeyUp: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
final result = await connection.sendAction(
|
||||
KeyPair(
|
||||
buttons: [],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: action,
|
||||
),
|
||||
isKeyDown: down,
|
||||
isKeyUp: !down,
|
||||
);
|
||||
if (result is! Success) {
|
||||
buildToast(context, title: result.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showHotkeySettings(BuildContext context, List<TrainerConnection> connections) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _HotkeySettingsDialog(
|
||||
connections: connections,
|
||||
currentHotkeys: _hotkeys,
|
||||
onSave: (newHotkeys) {
|
||||
setState(() {
|
||||
_hotkeys = newHotkeys;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HotkeySettingsDialog extends StatefulWidget {
|
||||
final List<TrainerConnection> connections;
|
||||
final Map<InGameAction, String> currentHotkeys;
|
||||
final Function(Map<InGameAction, String>) onSave;
|
||||
|
||||
const _HotkeySettingsDialog({
|
||||
required this.connections,
|
||||
required this.currentHotkeys,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_HotkeySettingsDialog> createState() => _HotkeySettingsDialogState();
|
||||
}
|
||||
|
||||
class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> {
|
||||
late Map<InGameAction, String> _editableHotkeys;
|
||||
InGameAction? _editingAction;
|
||||
late FocusNode _focusNode;
|
||||
|
||||
static final _validHotkeyPattern = RegExp(r'[0-9a-z]');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_editableHotkeys = Map.from(widget.currentHotkeys);
|
||||
_focusNode = FocusNode(debugLabel: 'HotkeySettingsFocus', canRequestFocus: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (_editingAction == null || event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey.keyLabel.toLowerCase();
|
||||
|
||||
// Only allow single character 1-9 and a-z
|
||||
if (key.length == 1 && _validHotkeyPattern.hasMatch(key)) {
|
||||
setState(() {
|
||||
_editableHotkeys[_editingAction!] = key;
|
||||
_editingAction = null;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
setState(() {
|
||||
_editingAction = null;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allActions = <InGameAction>[];
|
||||
for (final connection in widget.connections) {
|
||||
allActions.addAll(connection.supportedActions);
|
||||
}
|
||||
final uniqueActions = allActions.distinct().toList();
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: AlertDialog(
|
||||
title: Text('Configure Keyboard Hotkeys'),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('Assign keyboard shortcuts to simulator buttons').muted,
|
||||
SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: uniqueActions.map((action) {
|
||||
final hotkey = _editableHotkeys[action];
|
||||
final isEditing = _editingAction == action;
|
||||
|
||||
return Card(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(action.title),
|
||||
),
|
||||
if (isEditing)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.blue),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('Press a key...', style: TextStyle(color: Colors.blue)),
|
||||
)
|
||||
else if (hotkey != null)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.gray.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(hotkey.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else
|
||||
Text('No hotkey', style: TextStyle(color: Colors.gray)),
|
||||
SizedBox(width: 8),
|
||||
OutlineButton(
|
||||
size: ButtonSize.small,
|
||||
child: Text(isEditing ? 'Cancel' : 'Set'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_editingAction = isEditing ? null : action;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (hotkey != null && !isEditing) ...[
|
||||
SizedBox(width: 4),
|
||||
OutlineButton(
|
||||
size: ButtonSize.small,
|
||||
child: Text('Clear'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_editableHotkeys.remove(action);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
SecondaryButton(
|
||||
child: Text('Cancel'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
PrimaryButton(
|
||||
child: Text('Save'),
|
||||
onPressed: () async {
|
||||
await core.settings.setButtonSimulatorHotkeys(_editableHotkeys);
|
||||
widget.onSave(_editableHotkeys);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/button_edit.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/ui/colored_title.dart';
|
||||
import 'package:swift_control/widgets/ui/warning.dart';
|
||||
|
||||
class ConfigurationPage extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
@@ -58,13 +61,25 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
children: [
|
||||
Select<SupportedApp>(
|
||||
constraints: BoxConstraints(maxWidth: 400, minWidth: 400),
|
||||
itemBuilder: (c, app) => Text(screenshotMode ? 'Trainer app' : app.name),
|
||||
itemBuilder: (c, app) => Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(screenshotMode ? 'Trainer app' : app.name),
|
||||
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
|
||||
],
|
||||
),
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: SupportedApp.supportedApps.map((app) {
|
||||
return SelectItemButton(
|
||||
value: app,
|
||||
child: Text(app.name),
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -110,6 +125,10 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
},
|
||||
),
|
||||
if (core.settings.getTrainerApp() != null) ...[
|
||||
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true)
|
||||
Text(
|
||||
'Great news - ${core.settings.getTrainerApp()!.name} supports the OpenBikeControl Protocol, so you\'ll the best possible experience!',
|
||||
).xSmall,
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.selectTargetWhereAppRuns(
|
||||
@@ -169,6 +188,14 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol == true && !core.logic.emulatorEnabled) {
|
||||
core.settings.setObpMdnsEnabled(true);
|
||||
}
|
||||
|
||||
// enable local connection on Windows if the app doesn't support OBP
|
||||
if (target == Target.thisDevice &&
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol == false &&
|
||||
!kIsWeb &&
|
||||
Platform.isWindows) {
|
||||
core.settings.setLocalEnabled(true);
|
||||
}
|
||||
core.logic.startEnabledConnectionMethod();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import 'package:flutter/material.dart' show SwitchListTile;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/manager.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/ui/colored_title.dart';
|
||||
import 'package:swift_control/widgets/ui/warning.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
|
||||
class CustomizePage extends StatefulWidget {
|
||||
const CustomizePage({super.key});
|
||||
@@ -137,20 +136,8 @@ class _CustomizeState extends State<CustomizePage> {
|
||||
)
|
||||
else if (core.connection.controllerDevices.isEmpty)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [Text(context.i18n.connectControllerToPreview).small],
|
||||
),
|
||||
if (canVibrate) ...[
|
||||
SwitchListTile(
|
||||
title: Text(context.i18n.enableVibrationFeedback),
|
||||
value: core.settings.getVibrationEnabled(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await core.settings.setVibrationEnabled(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/scan.dart';
|
||||
import 'package:swift_control/widgets/ui/colored_title.dart';
|
||||
import 'package:swift_control/widgets/ui/warning.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_simulator.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/scan.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
@@ -76,7 +81,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
|
||||
if (core.connection.controllerDevices.isEmpty) ScanWidget(),
|
||||
if (core.connection.controllerDevices.isEmpty || kIsWeb) ScanWidget(),
|
||||
...core.connection.controllerDevices.map(
|
||||
(device) => Card(
|
||||
filled: true,
|
||||
@@ -87,6 +92,24 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
|
||||
if (core.connection.accessories.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(
|
||||
text: 'Accessories',
|
||||
),
|
||||
),
|
||||
...core.connection.accessories.map(
|
||||
(device) => Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (core.settings.getIgnoredDevices().isNotEmpty)
|
||||
OutlineButton(
|
||||
child: Text(context.i18n.manageIgnoredDevices),
|
||||
@@ -99,8 +122,8 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
|
||||
if (core.connection.controllerDevices.isNotEmpty) ...[
|
||||
SizedBox(),
|
||||
SizedBox(),
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
@@ -111,8 +134,38 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
PrimaryButton(
|
||||
child: Text(
|
||||
'No Controller? Control ${core.settings.getTrainerApp()?.name ?? 'your trainer'} manually!',
|
||||
),
|
||||
onPressed: () {
|
||||
if (core.settings.getTrainerApp() == null) {
|
||||
buildToast(
|
||||
context,
|
||||
level: LogLevel.LOGLEVEL_WARNING,
|
||||
title: context.i18n.selectTrainerApp,
|
||||
);
|
||||
widget.onUpdate();
|
||||
} else if (core.logic.connectedTrainerConnections.isEmpty) {
|
||||
buildToast(
|
||||
context,
|
||||
level: LogLevel.LOGLEVEL_WARNING,
|
||||
title:
|
||||
'Please connect to ${core.settings.getTrainerApp()?.name ?? 'your trainer'} with ${core.logic.trainerConnections.joinToString(transform: (t) => t.title, separator: ' or ')}, first.',
|
||||
);
|
||||
widget.onUpdate();
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => ButtonSimulator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_md/flutter_md.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class MarkdownPage extends StatefulWidget {
|
||||
@@ -15,7 +16,7 @@ class MarkdownPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChangelogPageState extends State<MarkdownPage> {
|
||||
Markdown? _markdown;
|
||||
List<_Group>? _groups;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
@@ -27,26 +28,13 @@ class _ChangelogPageState extends State<MarkdownPage> {
|
||||
Future<void> _loadChangelog() async {
|
||||
try {
|
||||
final md = await rootBundle.loadString(widget.assetPath);
|
||||
setState(() {
|
||||
_markdown = Markdown.fromString(md);
|
||||
});
|
||||
|
||||
// load latest version
|
||||
final response = await http.get(
|
||||
Uri.parse('https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/${widget.assetPath}'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final latestMd = response.body;
|
||||
if (latestMd != md) {
|
||||
setState(() {
|
||||
_markdown = Markdown.fromString(md);
|
||||
});
|
||||
}
|
||||
}
|
||||
_parseMarkdown(md);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Failed to load changelog: $e';
|
||||
});
|
||||
} finally {
|
||||
_loadOnlineVersion();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,36 +46,81 @@ class _ChangelogPageState extends State<MarkdownPage> {
|
||||
leading: [
|
||||
BackButton(),
|
||||
],
|
||||
title: Text(widget.assetPath.replaceAll('.md', '').toLowerCase().capitalize()),
|
||||
title: Text(
|
||||
widget.assetPath
|
||||
.replaceAll('.md', '')
|
||||
.split('_')
|
||||
.joinToString(separator: ' ', transform: (s) => s.toLowerCase().capitalize()),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _markdown == null
|
||||
: _groups == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MarkdownWidget(
|
||||
markdown: _markdown!,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Theme.of(context).colorScheme.brightness == Brightness.dark
|
||||
? Colors.white.withAlpha(255 * 70)
|
||||
: Colors.black.withAlpha(87 * 255),
|
||||
child: Accordion(
|
||||
items: _groups!
|
||||
.map(
|
||||
(group) => AccordionItem(
|
||||
trigger: AccordionTrigger(child: ColoredTitle(text: group.title)),
|
||||
content: MarkdownWidget(
|
||||
markdown: group.markdown,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Theme.of(context).colorScheme.brightness == Brightness.dark
|
||||
? Colors.white.withAlpha(255 * 70)
|
||||
: Colors.black.withAlpha(87 * 255),
|
||||
),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _parseMarkdown(String md) {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_groups = md
|
||||
.split('## ')
|
||||
.map((section) {
|
||||
final lines = section.split('\n');
|
||||
final title = lines.first.replaceFirst('# ', '').trim();
|
||||
final content = lines.skip(1).join('\n').trim();
|
||||
return _Group(
|
||||
title: title,
|
||||
markdown: Markdown.fromString('## $content'),
|
||||
);
|
||||
})
|
||||
.where((group) => group.title.isNotEmpty)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadOnlineVersion() async {
|
||||
// load latest version
|
||||
final response = await http.get(
|
||||
Uri.parse('https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/${widget.assetPath}'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final latestMd = response.body;
|
||||
_parseMarkdown(latestMd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Group {
|
||||
final String title;
|
||||
final Markdown markdown;
|
||||
|
||||
_Group({required this.title, required this.markdown});
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/customize.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/pages/trainer.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
import 'package:swift_control/widgets/menu.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:swift_control/widgets/ui/colors.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/customize.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/pages/trainer.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/logviewer.dart';
|
||||
import 'package:bike_control/widgets/menu.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
|
||||
import '../widgets/changelog_dialog.dart';
|
||||
|
||||
@@ -373,8 +373,9 @@ class _NavigationState extends State<Navigation> {
|
||||
),
|
||||
enabled: _isPageEnabled(page),
|
||||
child: SizedBox(
|
||||
width: 152,
|
||||
width: screenshotMode ? 180 : 152,
|
||||
child: Basic(
|
||||
padding: screenshotMode ? EdgeInsets.all(0) : null,
|
||||
leading: _buildIcon(page),
|
||||
leadingAlignment: Alignment.centerLeft,
|
||||
title: Text(
|
||||
|
||||
@@ -2,6 +2,13 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/widgets/testbed.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
@@ -9,13 +16,6 @@ import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
import 'package:swift_control/widgets/ui/button_widget.dart';
|
||||
import 'package:swift_control/widgets/ui/colors.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../utils/actions/base_actions.dart';
|
||||
@@ -405,14 +405,14 @@ class KeypairExplanation extends StatelessWidget {
|
||||
else
|
||||
Icon(keyPair.icon),
|
||||
if (keyPair.inGameAction != null && core.logic.emulatorEnabled)
|
||||
_KeyWidget(
|
||||
KeyWidget(
|
||||
label: [
|
||||
keyPair.inGameAction.toString().split('.').last,
|
||||
if (keyPair.inGameActionValue != null) ': ${keyPair.inGameActionValue}',
|
||||
].joinToString(separator: ''),
|
||||
keyPair.inGameAction!.title,
|
||||
if (keyPair.inGameActionValue != null) '${keyPair.inGameActionValue}',
|
||||
].joinToString(separator: ': '),
|
||||
)
|
||||
else if (keyPair.isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
_KeyWidget(
|
||||
KeyWidget(
|
||||
label: switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaStop => 'Stop',
|
||||
@@ -424,12 +424,12 @@ class KeypairExplanation extends StatelessWidget {
|
||||
},
|
||||
)
|
||||
else if (keyPair.physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
|
||||
_KeyWidget(
|
||||
KeyWidget(
|
||||
label: keyPair.toString(),
|
||||
),
|
||||
] else ...[
|
||||
if (!withKey && keyPair.touchPosition != Offset.zero && core.logic.showLocalRemoteOptions)
|
||||
_KeyWidget(label: 'X:${keyPair.touchPosition.dx.toInt()}, Y:${keyPair.touchPosition.dy.toInt()}'),
|
||||
KeyWidget(label: 'X:${keyPair.touchPosition.dx.toInt()}, Y:${keyPair.touchPosition.dy.toInt()}'),
|
||||
],
|
||||
if (keyPair.isLongPress) Text(context.i18n.longPress, style: TextStyle(fontSize: 10)),
|
||||
],
|
||||
@@ -437,9 +437,9 @@ class KeypairExplanation extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyWidget extends StatelessWidget {
|
||||
class KeyWidget extends StatelessWidget {
|
||||
final String label;
|
||||
const _KeyWidget({super.key, required this.label});
|
||||
const KeyWidget({super.key, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:device_auto_rotate_checker/device_auto_rotate_checker.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/configuration.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/widgets/apps/local_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/zwift_mdns_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/zwift_tile.dart';
|
||||
import 'package:bike_control/widgets/pair_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/configuration.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.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/openbikecontrol_ble_tile.dart';
|
||||
import 'package:swift_control/widgets/apps/openbikecontrol_mdns_tile.dart';
|
||||
import 'package:swift_control/widgets/apps/zwift_mdns_tile.dart';
|
||||
import 'package:swift_control/widgets/apps/zwift_tile.dart';
|
||||
import 'package:swift_control/widgets/pair_widget.dart';
|
||||
import 'package:swift_control/widgets/ui/colored_title.dart';
|
||||
import 'package:swift_control/widgets/ui/connection_method.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
import 'package:swift_control/widgets/ui/warning.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart' show launchUrlString;
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class TrainerPage extends StatefulWidget {
|
||||
@@ -39,11 +29,6 @@ class TrainerPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
bool? _isRunningAndroidService;
|
||||
bool _showAutoRotationWarning = false;
|
||||
bool _showMiuiWarning = false;
|
||||
StreamSubscription<bool>? _autoRotateStream;
|
||||
|
||||
late final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
@@ -71,44 +56,13 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
core.zwiftEmulator.isConnected.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
if (core.logic.canRunAndroidService) {
|
||||
core.logic.isAndroidServiceRunning().then((isRunning) {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $isRunning'));
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
DeviceAutoRotateChecker.checkAutoRotate().then((isEnabled) {
|
||||
if (!isEnabled) {
|
||||
setState(() {
|
||||
_showAutoRotationWarning = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
_autoRotateStream = DeviceAutoRotateChecker.autoRotateStream.listen((isEnabled) {
|
||||
setState(() {
|
||||
_showAutoRotationWarning = !isEnabled;
|
||||
});
|
||||
});
|
||||
|
||||
// Check if device is MIUI and using local accessibility service
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
_checkMiuiDevice();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
_scrollController.dispose();
|
||||
_autoRotateStream?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -126,31 +80,13 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkMiuiDevice() async {
|
||||
try {
|
||||
// Don't show if user has dismissed the warning
|
||||
if (core.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
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showLocalAsOther =
|
||||
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showLocalControl;
|
||||
final showWhooshLinkAsOther =
|
||||
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showMyWhooshLink;
|
||||
|
||||
return Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
@@ -180,14 +116,11 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
if (core.settings.getTrainerApp() != null) ...[
|
||||
SizedBox(height: 8),
|
||||
if (core.logic.hasRecommendedConnectionMethods)
|
||||
ColoredTitle(
|
||||
text: context.i18n.recommendedConnectionMethods,
|
||||
),
|
||||
ColoredTitle(text: context.i18n.recommendedConnectionMethods),
|
||||
|
||||
if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(),
|
||||
if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(),
|
||||
|
||||
if (core.logic.showMyWhooshLink) MyWhooshLinkTile(),
|
||||
if (core.logic.showZwiftMsdnEmulator)
|
||||
ZwiftMdnsTile(
|
||||
onUpdate: () {
|
||||
@@ -205,140 +138,24 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (core.logic.showLocalControl)
|
||||
ConnectionMethod(
|
||||
isEnabled: core.settings.getLocalEnabled(),
|
||||
type: ConnectionMethodType.local,
|
||||
showTroubleshooting: true,
|
||||
title: context.i18n.controlAppUsingModes(
|
||||
core.settings.getTrainerApp()?.name ?? '',
|
||||
core.actionHandler.supportedModes.joinToString(transform: (e) => e.name.capitalize()),
|
||||
),
|
||||
description: context.i18n.enableKeyboardMouseControl(core.settings.getTrainerApp()?.name ?? ''),
|
||||
requirements: core.permissions.getLocalControlRequirements(),
|
||||
isStarted: core.logic.canRunAndroidService
|
||||
? _isRunningAndroidService == true
|
||||
: core.settings.getLocalEnabled(),
|
||||
onChange: (value) {
|
||||
core.settings.setLocalEnabled(value);
|
||||
setState(() {});
|
||||
if (core.logic.canRunAndroidService) {
|
||||
core.logic.canRunAndroidService.then((isRunning) {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $isRunning'));
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $value'));
|
||||
}
|
||||
},
|
||||
additionalChild: Column(
|
||||
children: [
|
||||
// show warning only for android when using local accessibility service
|
||||
if (_showAutoRotationWarning)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(context.i18n.enableAutoRotation),
|
||||
],
|
||||
),
|
||||
if (_showMiuiWarning)
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(context.i18n.miuiDeviceDetected).bold,
|
||||
),
|
||||
IconButton.destructive(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () async {
|
||||
await core.settings.setMiuiWarningDismissed(true);
|
||||
setState(() {
|
||||
_showMiuiWarning = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.miuiWarningDescription,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.miuiEnsureProperWorking,
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiDisableBatteryOptimization,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiEnableAutostart,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiLockInRecentApps,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
OutlineButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://dontkillmyapp.com/xiaomi');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
leading: Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.viewDetailedInstructions),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isRunningAndroidService == false)
|
||||
Warning(
|
||||
children: [
|
||||
Text(context.i18n.accessibilityServiceNotRunning).xSmall,
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlineButton(
|
||||
child: Text('dontkillmyapp.com'),
|
||||
onPressed: () {
|
||||
launchUrlString('https://dontkillmyapp.com/');
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton.secondary(
|
||||
onPressed: () {
|
||||
core.logic.isAndroidServiceRunning().then((
|
||||
isRunning,
|
||||
) {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $isRunning'));
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
|
||||
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
|
||||
if (core.logic.showRemote || showLocalAsOther || showWhooshLinkAsOther) ...[
|
||||
SizedBox(height: 16),
|
||||
Accordion(
|
||||
items: [
|
||||
AccordionItem(
|
||||
trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)),
|
||||
content: Column(
|
||||
children: [
|
||||
if (core.logic.showRemote) RemotePairingWidget(),
|
||||
if (showLocalAsOther) LocalTile(),
|
||||
if (showWhooshLinkAsOther) MyWhooshLinkTile(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (core.logic.showRemote) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.otherConnectionMethods),
|
||||
RemotePairingWidget(),
|
||||
],
|
||||
|
||||
SizedBox(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
|
||||
import '../keymap/apps/supported_app.dart';
|
||||
import '../single_line_exception.dart';
|
||||
@@ -28,7 +28,7 @@ class AndroidActions extends BaseActions {
|
||||
|
||||
hidKeyPressed().listen((keyPressed) async {
|
||||
final hidDevice = HidDevice(keyPressed.source);
|
||||
final button = await hidDevice.getOrAddButton(keyPressed.hidKey, () => ControllerButton(keyPressed.hidKey))!;
|
||||
final button = hidDevice.getOrAddButton(keyPressed.hidKey, () => ControllerButton(keyPressed.hidKey));
|
||||
|
||||
var availableDevice = core.connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
|
||||
if (availableDevice == null) {
|
||||
|
||||
@@ -2,18 +2,18 @@ import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
|
||||
import '../keymap/apps/supported_app.dart';
|
||||
|
||||
@@ -32,6 +32,10 @@ class NotHandled extends ActionResult {
|
||||
const NotHandled(super.message);
|
||||
}
|
||||
|
||||
class Ignored extends ActionResult {
|
||||
const Ignored(super.message);
|
||||
}
|
||||
|
||||
class Error extends ActionResult {
|
||||
const Error(super.message);
|
||||
}
|
||||
@@ -45,7 +49,7 @@ abstract class BaseActions {
|
||||
|
||||
void init(SupportedApp? supportedApp) {
|
||||
this.supportedApp = supportedApp;
|
||||
print('Supported app: ${supportedApp?.name ?? "None"}');
|
||||
debugPrint('Supported app: ${supportedApp?.name ?? "None"}');
|
||||
|
||||
if (supportedApp != null) {
|
||||
final allButtons = core.connection.devices.map((e) => e.availableButtons).flatten().distinct();
|
||||
@@ -58,6 +62,7 @@ abstract class BaseActions {
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
inGameAction: button.action,
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
@@ -130,6 +135,17 @@ abstract class BaseActions {
|
||||
return Error('No action assigned for ${button.toString().splitByUpperCase()}');
|
||||
}
|
||||
|
||||
// Handle Headwind actions
|
||||
if (keyPair.inGameAction == InGameAction.headwindSpeed ||
|
||||
keyPair.inGameAction == InGameAction.headwindHeartRateMode) {
|
||||
final headwind = core.connection.accessories.where((h) => h.isConnected).firstOrNull;
|
||||
if (headwind == null) {
|
||||
return Error('No Headwind connected');
|
||||
}
|
||||
|
||||
return await headwind.handleKeypair(keyPair, isKeyDown: isKeyDown);
|
||||
}
|
||||
|
||||
final directConnectHandled = await _handleDirectConnect(keyPair, button, isKeyUp: isKeyUp, isKeyDown: isKeyDown);
|
||||
if (directConnectHandled is NotHandled && directConnectHandled.message.isNotEmpty) {
|
||||
core.connection.signalNotification(LogNotification(directConnectHandled.message));
|
||||
@@ -144,43 +160,17 @@ abstract class BaseActions {
|
||||
required bool isKeyUp,
|
||||
}) async {
|
||||
if (keyPair.inGameAction != null) {
|
||||
if (core.obpBluetoothEmulator.isConnected.value != null) {
|
||||
return core.obpBluetoothEmulator.sendButtonPress(
|
||||
[button],
|
||||
isKeyDown: isKeyDown,
|
||||
isKeyUp: isKeyUp,
|
||||
);
|
||||
} else if (core.obpMdnsEmulator.isConnected.value != null) {
|
||||
return Future.value(
|
||||
core.obpMdnsEmulator.sendButtonPress(
|
||||
[button],
|
||||
isKeyDown: isKeyDown,
|
||||
isKeyUp: isKeyUp,
|
||||
),
|
||||
);
|
||||
} else if (core.whooshLink.isConnected.value) {
|
||||
return Future.value(
|
||||
core.whooshLink.sendAction(
|
||||
keyPair.inGameAction!,
|
||||
keyPair.inGameActionValue,
|
||||
isKeyDown: isKeyDown,
|
||||
isKeyUp: isKeyUp,
|
||||
),
|
||||
);
|
||||
} else if (core.zwiftMdnsEmulator.isConnected.value) {
|
||||
return core.zwiftMdnsEmulator.sendAction(
|
||||
keyPair.inGameAction!,
|
||||
keyPair.inGameActionValue,
|
||||
isKeyDown: isKeyDown,
|
||||
isKeyUp: isKeyUp,
|
||||
);
|
||||
} else if (core.zwiftEmulator.isConnected.value) {
|
||||
return core.zwiftEmulator.sendAction(
|
||||
keyPair.inGameAction!,
|
||||
keyPair.inGameActionValue,
|
||||
final actions = <ActionResult>[];
|
||||
for (final connectedTrainer in core.logic.connectedTrainerConnections) {
|
||||
final result = await connectedTrainer.sendAction(
|
||||
keyPair,
|
||||
isKeyDown: isKeyDown,
|
||||
isKeyUp: isKeyUp,
|
||||
);
|
||||
actions.add(result);
|
||||
}
|
||||
if (actions.isNotEmpty) {
|
||||
return actions.first;
|
||||
}
|
||||
}
|
||||
return NotHandled('');
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
|
||||
class DesktopActions extends BaseActions {
|
||||
DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
|
||||
class RemoteActions extends BaseActions {
|
||||
RemoteActions({super.supportedModes = const [SupportedMode.touch]});
|
||||
@@ -24,13 +23,7 @@ class RemoteActions extends BaseActions {
|
||||
if (keyPair.physicalKey != null && keyPair.touchPosition == Offset.zero) {
|
||||
return Error('Physical key actions are not supported, yet');
|
||||
} else {
|
||||
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: null);
|
||||
final point2 = point; //Offset(100, 99.0);
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
|
||||
return Success('Mouse clicked at: ${point2.dx.toInt()} ${point2.dy.toInt()}');
|
||||
return core.remotePairing.sendAction(keyPair, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,25 +32,4 @@ class RemoteActions extends BaseActions {
|
||||
// for remote actions we use the relative position only
|
||||
return keyPair.touchPosition;
|
||||
}
|
||||
|
||||
Uint8List absMouseReport(int buttons3bit, int x, int y) {
|
||||
final b = buttons3bit & 0x07;
|
||||
final xi = x.clamp(0, 100);
|
||||
final yi = y.clamp(0, 100);
|
||||
return Uint8List.fromList([b, xi, yi]);
|
||||
}
|
||||
|
||||
// Send a relative mouse move + button state as 3-byte report: [buttons, dx, dy]
|
||||
Future<void> sendAbsMouseReport(int buttons, int dx, int dy) async {
|
||||
final bytes = absMouseReport(buttons, dx, dy);
|
||||
if (kDebugMode) {
|
||||
print('Preparing to send abs mouse report: buttons=$buttons, dx=$dx, dy=$dy');
|
||||
print('Sending abs mouse report: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0'))}');
|
||||
}
|
||||
|
||||
await core.remotePairing.notifyCharacteristic(bytes);
|
||||
|
||||
// we don't want to overwhelm the target device
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/bluetooth/remote_pairing.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/actions/remote.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
import 'package:bike_control/utils/settings/settings.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:media_key_detector/media_key_detector.dart';
|
||||
import 'package:swift_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart';
|
||||
import 'package:swift_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart';
|
||||
import 'package:swift_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/bluetooth/remote_pairing.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/android.dart';
|
||||
import 'package:swift_control/utils/settings/settings.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth/connection.dart';
|
||||
import '../bluetooth/devices/mywhoosh/link.dart';
|
||||
import 'keymap/apps/rouvy.dart';
|
||||
import 'requirements/multi.dart';
|
||||
import 'requirements/platform.dart';
|
||||
import 'smtc_stub.dart' if (dart.library.io) 'package:smtc_windows/smtc_windows.dart';
|
||||
@@ -74,14 +76,14 @@ class Permissions {
|
||||
final deviceInfoPlugin = DeviceInfoPlugin();
|
||||
final deviceInfo = await deviceInfoPlugin.androidInfo;
|
||||
list = [
|
||||
BluetoothTurnedOn(),
|
||||
NotificationRequirement(),
|
||||
if (deviceInfo.version.sdkInt <= 30)
|
||||
LocationRequirement()
|
||||
else ...[
|
||||
BluetoothScanRequirement(),
|
||||
BluetoothConnectRequirement(),
|
||||
],
|
||||
BluetoothTurnedOn(),
|
||||
NotificationRequirement(),
|
||||
];
|
||||
} else {
|
||||
list = [UnsupportedPlatform()];
|
||||
@@ -99,8 +101,7 @@ class Permissions {
|
||||
return [
|
||||
BluetoothTurnedOn(),
|
||||
if (Platform.isAndroid) ...[
|
||||
BluetoothScanRequirement(),
|
||||
BluetoothConnectRequirement(),
|
||||
BluetoothAdvertiseRequirement(),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -156,7 +157,7 @@ class CoreLogic {
|
||||
}
|
||||
|
||||
bool get showZwiftMsdnEmulator {
|
||||
return core.settings.getTrainerApp()?.supportsZwiftEmulation == true;
|
||||
return core.settings.getTrainerApp()?.supportsZwiftEmulation == true && core.settings.getTrainerApp() is! Rouvy;
|
||||
}
|
||||
|
||||
bool get showObpMdnsEmulator {
|
||||
@@ -172,14 +173,18 @@ class CoreLogic {
|
||||
return core.settings.getRemoteControlEnabled() && showRemote;
|
||||
}
|
||||
|
||||
bool get showMyWhooshLink => core.settings.getTrainerApp() is MyWhoosh && core.settings.getLastTarget() != null;
|
||||
bool get showMyWhooshLink =>
|
||||
core.settings.getTrainerApp() is MyWhoosh &&
|
||||
core.settings.getLastTarget() != null &&
|
||||
core.whooshLink.isCompatible(core.settings.getLastTarget()!);
|
||||
|
||||
bool get showRemote => core.settings.getLastTarget() != Target.thisDevice && core.actionHandler is RemoteActions;
|
||||
|
||||
bool get showForegroundMessage =>
|
||||
core.actionHandler is RemoteActions && !kIsWeb && Platform.isIOS && core.remotePairing.isConnected.value;
|
||||
|
||||
AppInfo? get obpConnectedApp => core.obpMdnsEmulator.isConnected.value ?? core.obpBluetoothEmulator.isConnected.value;
|
||||
AppInfo? get obpConnectedApp =>
|
||||
core.obpMdnsEmulator.connectedApp.value ?? core.obpBluetoothEmulator.connectedApp.value;
|
||||
|
||||
bool get emulatorEnabled =>
|
||||
screenshotMode ||
|
||||
@@ -217,27 +222,37 @@ class CoreLogic {
|
||||
showZwiftMsdnEmulator ||
|
||||
showMyWhooshLink;
|
||||
|
||||
List<TrainerConnection> get connectedTrainerConnections => [
|
||||
if (isMyWhooshLinkEnabled) core.whooshLink,
|
||||
if (isObpMdnsEnabled) core.obpMdnsEmulator,
|
||||
if (isObpBleEnabled) core.obpBluetoothEmulator,
|
||||
if (isZwiftBleEnabled) core.zwiftEmulator,
|
||||
if (isZwiftMdnsEnabled) core.zwiftMdnsEmulator,
|
||||
if (isRemoteControlEnabled) core.remotePairing,
|
||||
].filter((e) => e.isConnected.value).toList();
|
||||
|
||||
List<TrainerConnection> get trainerConnections => [
|
||||
if (showMyWhooshLink) core.whooshLink,
|
||||
if (showObpMdnsEmulator) core.obpMdnsEmulator,
|
||||
if (showObpBluetoothEmulator) core.obpBluetoothEmulator,
|
||||
if (showZwiftBleEmulator) core.zwiftEmulator,
|
||||
if (showZwiftMsdnEmulator) core.zwiftMdnsEmulator,
|
||||
if (showRemote) core.remotePairing,
|
||||
];
|
||||
|
||||
Future<bool> isTrainerConnected() async {
|
||||
if (screenshotMode) {
|
||||
return true;
|
||||
} else if (showLocalControl && core.settings.getLocalEnabled()) {
|
||||
} else if (showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol == false) {
|
||||
if (canRunAndroidService) {
|
||||
return isAndroidServiceRunning();
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
} else if (isMyWhooshLinkEnabled) {
|
||||
return core.whooshLink.isConnected.value;
|
||||
} else if (isObpMdnsEnabled) {
|
||||
return core.obpMdnsEmulator.isConnected.value != null;
|
||||
} else if (isObpBleEnabled) {
|
||||
return core.obpBluetoothEmulator.isConnected.value != null;
|
||||
} else if (isZwiftBleEnabled) {
|
||||
return core.zwiftEmulator.isConnected.value;
|
||||
} else if (isZwiftMdnsEnabled) {
|
||||
return core.zwiftMdnsEmulator.isConnected.value == true;
|
||||
} else if (isRemoteControlEnabled) {
|
||||
return core.remotePairing.isConnected.value;
|
||||
} else if (connectedTrainerConnections.isNotEmpty) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -250,7 +265,8 @@ class CoreLogic {
|
||||
if (isZwiftBleEnabled &&
|
||||
await core.permissions.getRemoteControlRequirements().allGranted &&
|
||||
!core.zwiftEmulator.isStarted.value) {
|
||||
core.zwiftEmulator.startAdvertising(() {}).catchError((e) {
|
||||
core.zwiftEmulator.startAdvertising(() {}).catchError((e, s) {
|
||||
recordError(e, s, context: 'Zwift BLE Emulator');
|
||||
core.settings.setZwiftBleEmulatorEnabled(false);
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Zwift mDNS Emulator: $e'),
|
||||
@@ -258,7 +274,8 @@ class CoreLogic {
|
||||
});
|
||||
}
|
||||
if (isZwiftMdnsEnabled && !core.zwiftMdnsEmulator.isStarted.value) {
|
||||
core.zwiftMdnsEmulator.startServer().catchError((e) {
|
||||
core.zwiftMdnsEmulator.startServer().catchError((e, s) {
|
||||
recordError(e, s, context: 'Zwift mDNS Emulator');
|
||||
core.settings.setZwiftMdnsEmulatorEnabled(false);
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Zwift mDNS Emulator: $e'),
|
||||
@@ -266,7 +283,8 @@ class CoreLogic {
|
||||
});
|
||||
}
|
||||
if (isObpMdnsEnabled && !core.obpMdnsEmulator.isStarted.value) {
|
||||
core.obpMdnsEmulator.startServer().catchError((e) {
|
||||
core.obpMdnsEmulator.startServer().catchError((e, s) {
|
||||
recordError(e, s, context: 'OBP mDNS Emulator');
|
||||
core.settings.setObpMdnsEnabled(false);
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start OpenBikeControl mDNS Emulator: $e'),
|
||||
@@ -276,7 +294,8 @@ class CoreLogic {
|
||||
if (isObpBleEnabled &&
|
||||
await core.permissions.getRemoteControlRequirements().allGranted &&
|
||||
!core.obpBluetoothEmulator.isStarted.value) {
|
||||
core.obpBluetoothEmulator.startServer().catchError((e) {
|
||||
core.obpBluetoothEmulator.startServer().catchError((e, s) {
|
||||
recordError(e, s, context: 'OBP BLE Emulator');
|
||||
core.settings.setObpBleEnabled(false);
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start OpenBikeControl BLE Emulator: $e'),
|
||||
@@ -289,7 +308,8 @@ class CoreLogic {
|
||||
}
|
||||
|
||||
if (isRemoteControlEnabled && !core.remotePairing.isStarted.value) {
|
||||
core.remotePairing.startAdvertising().catchError((e) {
|
||||
core.remotePairing.startAdvertising().catchError((e, s) {
|
||||
recordError(e, s, context: 'Remote Pairing');
|
||||
core.settings.setRemoteControlEnabled(false);
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Remote Control pairing: $e'),
|
||||
@@ -369,7 +389,7 @@ class MediaKeyHandler {
|
||||
final hidDevice = HidDevice('HID Device');
|
||||
final keyPressed = mediaKey.name;
|
||||
|
||||
final button = await hidDevice.getOrAddButton(
|
||||
final button = hidDevice.getOrAddButton(
|
||||
keyPressed,
|
||||
() => ControllerButton(keyPressed),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
|
||||
extension Intl on BuildContext {
|
||||
AppLocalizations get i18n => AppLocalizations.of(this);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
|
||||
@@ -3,8 +3,8 @@ 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';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
@@ -40,15 +40,27 @@ class MyWhoosh extends SupportedApp {
|
||||
),
|
||||
),
|
||||
...ControllerButton.values
|
||||
.filter((e) => e.action == InGameAction.navigateRight)
|
||||
.filter((e) => e.action == InGameAction.steerRight)
|
||||
.map(
|
||||
(b) => KeyPair(
|
||||
buttons: [b],
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
physicalKey: PhysicalKeyboardKey.keyD,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
touchPosition: Offset(60, 80),
|
||||
isLongPress: true,
|
||||
inGameAction: InGameAction.navigateRight,
|
||||
inGameAction: InGameAction.steerRight,
|
||||
),
|
||||
),
|
||||
...ControllerButton.values
|
||||
.filter((e) => e.action == InGameAction.steerLeft)
|
||||
.map(
|
||||
(b) => KeyPair(
|
||||
buttons: [b],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
touchPosition: Offset(32, 80),
|
||||
isLongPress: true,
|
||||
inGameAction: InGameAction.steerLeft,
|
||||
),
|
||||
),
|
||||
...ControllerButton.values
|
||||
@@ -60,7 +72,19 @@ class MyWhoosh extends SupportedApp {
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
touchPosition: Offset(32, 80),
|
||||
isLongPress: true,
|
||||
inGameAction: InGameAction.navigateLeft,
|
||||
inGameAction: InGameAction.steerLeft,
|
||||
),
|
||||
),
|
||||
...ControllerButton.values
|
||||
.filter((e) => e.action == InGameAction.navigateRight)
|
||||
.map(
|
||||
(b) => KeyPair(
|
||||
buttons: [b],
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
touchPosition: Offset(32, 80),
|
||||
isLongPress: true,
|
||||
inGameAction: InGameAction.steerLeft,
|
||||
),
|
||||
),
|
||||
...ControllerButton.values
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import 'dart:io';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:swift_control/utils/keymap/apps/biketerra.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/openbikecontrol.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/training_peaks.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/biketerra.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/openbikecontrol.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/rouvy.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/training_peaks.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
import 'custom_app.dart';
|
||||
|
||||
@@ -3,11 +3,11 @@ import 'dart:io';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
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/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:bike_control/bluetooth/devices/elite/elite_sterzo.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
|
||||
enum InGameAction {
|
||||
shiftUp('Shift Up'),
|
||||
shiftDown('Shift Down'),
|
||||
uturn('U-Turn'),
|
||||
steerLeft('Steer Left'),
|
||||
steerRight('Steer Right'),
|
||||
uturn('U-Turn', alternativeTitle: 'Down'),
|
||||
steerLeft('Steer Left', alternativeTitle: 'Left'),
|
||||
steerRight('Steer Right', alternativeTitle: 'Right'),
|
||||
|
||||
// mywhoosh
|
||||
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
||||
@@ -24,16 +24,21 @@ enum InGameAction {
|
||||
decreaseResistance('Decrease Resistance'),
|
||||
|
||||
// zwift
|
||||
openActionBar('Open Action Bar'),
|
||||
openActionBar('Open Action Bar', alternativeTitle: 'Up'),
|
||||
usePowerUp('Use Power-Up'),
|
||||
select('Select'),
|
||||
back('Back'),
|
||||
rideOnBomb('Ride On Bomb');
|
||||
rideOnBomb('Ride On Bomb'),
|
||||
|
||||
// headwind
|
||||
headwindSpeed('Headwind Speed', possibleValues: [0, 25, 50, 75, 100]),
|
||||
headwindHeartRateMode('Headwind HR Mode');
|
||||
|
||||
final String title;
|
||||
final String? alternativeTitle;
|
||||
final List<int>? possibleValues;
|
||||
|
||||
const InGameAction(this.title, {this.possibleValues});
|
||||
const InGameAction(this.title, {this.possibleValues, this.alternativeTitle});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../actions/base_actions.dart';
|
||||
import 'apps/custom_app.dart';
|
||||
@@ -137,7 +137,21 @@ class KeyPair {
|
||||
(touchPosition != Offset.zero &&
|
||||
core.logic.showLocalRemoteOptions &&
|
||||
core.actionHandler.supportedModes.contains(SupportedMode.touch)) ||
|
||||
(inGameAction != null && core.logic.emulatorEnabled);
|
||||
(inGameAction != null &&
|
||||
core.logic.obpConnectedApp != null &&
|
||||
core.logic.obpConnectedApp!.supportedActions.contains(inGameAction)) ||
|
||||
(inGameAction != null &&
|
||||
core.logic.showMyWhooshLink &&
|
||||
core.settings.getMyWhooshLinkEnabled() &&
|
||||
core.whooshLink.supportedActions.contains(inGameAction)) ||
|
||||
(inGameAction != null &&
|
||||
core.logic.showZwiftBleEmulator &&
|
||||
core.settings.getZwiftBleEmulatorEnabled() &&
|
||||
core.zwiftEmulator.supportedActions.contains(inGameAction)) ||
|
||||
(inGameAction != null &&
|
||||
core.logic.showZwiftMsdnEmulator &&
|
||||
core.settings.getZwiftMdnsEmulatorEnabled() &&
|
||||
core.zwiftMdnsEmulator.supportedActions.contains(inGameAction));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
|
||||
import 'apps/custom_app.dart';
|
||||
|
||||
@@ -273,4 +273,43 @@ class KeymapManager {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String duplicateSync(String currentProfile, String newName) {
|
||||
if (core.actionHandler.supportedApp is CustomApp) {
|
||||
core.settings.duplicateCustomAppProfile(currentProfile, newName);
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
final savedKeymap = core.settings.getCustomAppKeymap(newName);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
core.actionHandler.supportedApp = customApp;
|
||||
core.settings.setKeyMap(customApp);
|
||||
return newName;
|
||||
} else {
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
|
||||
final connectedDevice = core.connection.devices.firstOrNull;
|
||||
core.actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
|
||||
pair.buttons.filter((button) => connectedDevice?.availableButtons.contains(button) == true).forEachIndexed((
|
||||
button,
|
||||
indexB,
|
||||
) {
|
||||
customApp.setKey(
|
||||
button,
|
||||
physicalKey: pair.physicalKey,
|
||||
logicalKey: pair.logicalKey,
|
||||
isLongPress: pair.isLongPress,
|
||||
touchPosition: pair.touchPosition,
|
||||
inGameAction: pair.inGameAction,
|
||||
inGameActionValue: pair.inGameActionValue,
|
||||
modifiers: pair.modifiers,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
core.actionHandler.supportedApp = customApp;
|
||||
core.settings.setKeyMap(customApp);
|
||||
return newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import 'dart:ui';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class AccessibilityRequirement extends PlatformRequirement {
|
||||
|
||||
@@ -5,15 +5,15 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class KeyboardRequirement extends PlatformRequirement {
|
||||
|
||||
@@ -6,14 +6,15 @@ 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/core.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../actions/desktop.dart';
|
||||
import '../keymap/apps/custom_app.dart';
|
||||
import '../keymap/buttons.dart';
|
||||
|
||||
class Settings {
|
||||
late final SharedPreferences prefs;
|
||||
@@ -303,4 +304,37 @@ class Settings {
|
||||
void setLocalEnabled(bool value) {
|
||||
prefs.setBool('local_control_enabled', value);
|
||||
}
|
||||
|
||||
// Button Simulator Hotkey Settings
|
||||
Map<InGameAction, String> getButtonSimulatorHotkeys() {
|
||||
final json = prefs.getString('button_simulator_hotkeys');
|
||||
if (json == null) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||
return decoded.map(
|
||||
(key, value) => MapEntry(InGameAction.values.firstWhere((e) => e.name == key), value.toString()),
|
||||
);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setButtonSimulatorHotkeys(Map<InGameAction, String> hotkeys) async {
|
||||
await prefs.setString(
|
||||
'button_simulator_hotkeys',
|
||||
jsonEncode(hotkeys.map((key, value) => MapEntry(key.name, value))),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setButtonSimulatorHotkey(InGameAction action, String hotkey) async {
|
||||
final hotkeys = getButtonSimulatorHotkeys();
|
||||
hotkeys[action] = hotkey;
|
||||
await setButtonSimulatorHotkeys(hotkeys);
|
||||
}
|
||||
|
||||
Future<void> removeButtonSimulatorHotkey(InGameAction action) async {
|
||||
final hotkeys = getButtonSimulatorHotkeys();
|
||||
hotkeys.remove(action);
|
||||
await setButtonSimulatorHotkeys(hotkeys);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
|
||||
class AccessibilityDisclosureDialog extends StatelessWidget {
|
||||
final VoidCallback onAccept;
|
||||
|
||||
220
lib/widgets/apps/local_tile.dart
Normal file
220
lib/widgets/apps/local_tile.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:device_auto_rotate_checker/device_auto_rotate_checker.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class LocalTile extends StatefulWidget {
|
||||
const LocalTile({super.key});
|
||||
|
||||
@override
|
||||
State<LocalTile> createState() => _LocalTileState();
|
||||
}
|
||||
|
||||
class _LocalTileState extends State<LocalTile> {
|
||||
bool? _isRunningAndroidService;
|
||||
bool _showAutoRotationWarning = false;
|
||||
bool _showMiuiWarning = false;
|
||||
StreamSubscription<bool>? _autoRotateStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (core.logic.canRunAndroidService) {
|
||||
core.logic.isAndroidServiceRunning().then((isRunning) {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $isRunning'));
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
DeviceAutoRotateChecker.checkAutoRotate().then((isEnabled) {
|
||||
if (!isEnabled) {
|
||||
setState(() {
|
||||
_showAutoRotationWarning = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
_autoRotateStream = DeviceAutoRotateChecker.autoRotateStream.listen((isEnabled) {
|
||||
setState(() {
|
||||
_showAutoRotationWarning = !isEnabled;
|
||||
});
|
||||
});
|
||||
|
||||
// Check if device is MIUI and using local accessibility service
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
_checkMiuiDevice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoRotateStream?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkMiuiDevice() async {
|
||||
try {
|
||||
// Don't show if user has dismissed the warning
|
||||
if (core.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
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConnectionMethod(
|
||||
isEnabled: core.settings.getLocalEnabled(),
|
||||
type: ConnectionMethodType.local,
|
||||
showTroubleshooting: true,
|
||||
title: context.i18n.controlAppUsingModes(
|
||||
core.settings.getTrainerApp()?.name ?? '',
|
||||
core.actionHandler.supportedModes.joinToString(transform: (e) => e.name.capitalize()),
|
||||
),
|
||||
description: context.i18n.enableKeyboardMouseControl(core.settings.getTrainerApp()?.name ?? ''),
|
||||
requirements: core.permissions.getLocalControlRequirements(),
|
||||
isStarted: core.logic.canRunAndroidService ? _isRunningAndroidService == true : core.settings.getLocalEnabled(),
|
||||
onChange: (value) {
|
||||
core.settings.setLocalEnabled(value);
|
||||
setState(() {});
|
||||
if (core.logic.canRunAndroidService) {
|
||||
core.logic.isAndroidServiceRunning().then((isRunning) {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $isRunning'));
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $value'));
|
||||
}
|
||||
},
|
||||
additionalChild: Column(
|
||||
children: [
|
||||
// show warning only for android when using local accessibility service
|
||||
if (_showAutoRotationWarning)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(context.i18n.enableAutoRotation),
|
||||
],
|
||||
),
|
||||
if (_showMiuiWarning)
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(context.i18n.miuiDeviceDetected).bold,
|
||||
),
|
||||
IconButton.destructive(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () async {
|
||||
await core.settings.setMiuiWarningDismissed(true);
|
||||
setState(() {
|
||||
_showMiuiWarning = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.miuiWarningDescription,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
context.i18n.miuiEnsureProperWorking,
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiDisableBatteryOptimization,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiEnableAutostart,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
context.i18n.miuiLockInRecentApps,
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
OutlineButton(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://dontkillmyapp.com/xiaomi');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
leading: Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.viewDetailedInstructions),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isRunningAndroidService == false)
|
||||
Warning(
|
||||
children: [
|
||||
Text(context.i18n.accessibilityServiceNotRunning).xSmall,
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlineButton(
|
||||
child: Text('dontkillmyapp.com'),
|
||||
onPressed: () {
|
||||
launchUrlString('https://dontkillmyapp.com/');
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton.secondary(
|
||||
onPressed: () {
|
||||
core.logic.isAndroidServiceRunning().then((isRunning) {
|
||||
core.connection.signalNotification(LogNotification('Local Control: $isRunning'));
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/connection_method.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
|
||||
class MyWhooshLinkTile extends StatefulWidget {
|
||||
const MyWhooshLinkTile({super.key});
|
||||
@@ -24,7 +27,7 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
|
||||
isEnabled: core.settings.getMyWhooshLinkEnabled(),
|
||||
type: ConnectionMethodType.network,
|
||||
title: context.i18n.connectUsingMyWhooshLink,
|
||||
instructionLink: 'https://github.com/jonasbark/swiftcontrol/blob/main/INSTRUCTIONS_IOS.md',
|
||||
instructionLink: 'INSTRUCTIONS_MYWHOOSH_LINK.md',
|
||||
description: isConnected
|
||||
? context.i18n.myWhooshLinkConnected
|
||||
: isStarted
|
||||
@@ -37,7 +40,14 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
|
||||
if (!value) {
|
||||
core.whooshLink.stopServer();
|
||||
} else if (value) {
|
||||
core.connection.startMyWhooshServer().catchError((e) {
|
||||
buildToast(
|
||||
context,
|
||||
title: AppLocalizations.of(context).myWhooshLinkInfo,
|
||||
level: LogLevel.LOGLEVEL_WARNING,
|
||||
duration: Duration(seconds: 12),
|
||||
);
|
||||
core.connection.startMyWhooshServer().catchError((e, s) {
|
||||
recordError(e, s, context: 'MyWhoosh Link Server');
|
||||
core.settings.setMyWhooshLinkEnabled(false);
|
||||
buildToast(
|
||||
context,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/connection_method.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
|
||||
class OpenBikeControlBluetoothTile extends StatefulWidget {
|
||||
const OpenBikeControlBluetoothTile({super.key});
|
||||
@@ -19,7 +20,7 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlBluetoothTile> {
|
||||
valueListenable: core.obpBluetoothEmulator.isStarted,
|
||||
builder: (context, isStarted, _) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: core.obpBluetoothEmulator.isConnected,
|
||||
valueListenable: core.obpBluetoothEmulator.connectedApp,
|
||||
builder: (context, isConnected, _) {
|
||||
return ConnectionMethod(
|
||||
isEnabled: core.settings.getObpBleEnabled(),
|
||||
@@ -36,7 +37,8 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlBluetoothTile> {
|
||||
if (!value) {
|
||||
core.obpBluetoothEmulator.stopServer();
|
||||
} else if (value) {
|
||||
core.obpBluetoothEmulator.startServer().catchError((e) {
|
||||
core.obpBluetoothEmulator.startServer().catchError((e, s) {
|
||||
recordError(e, s, context: 'OBP BLE Emulator');
|
||||
core.settings.setObpBleEnabled(false);
|
||||
buildToast(
|
||||
context,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/connection_method.dart';
|
||||
|
||||
class OpenBikeControlMdnsTile extends StatefulWidget {
|
||||
const OpenBikeControlMdnsTile({super.key});
|
||||
@@ -19,7 +20,7 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlMdnsTile> {
|
||||
valueListenable: core.obpMdnsEmulator.isStarted,
|
||||
builder: (context, isStarted, _) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: core.obpMdnsEmulator.isConnected,
|
||||
valueListenable: core.obpMdnsEmulator.connectedApp,
|
||||
builder: (context, isConnected, _) {
|
||||
return ConnectionMethod(
|
||||
isEnabled: core.settings.getObpMdnsEnabled(),
|
||||
@@ -37,7 +38,8 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlMdnsTile> {
|
||||
if (!value) {
|
||||
core.obpMdnsEmulator.stopServer();
|
||||
} else if (value) {
|
||||
core.obpMdnsEmulator.startServer().catchError((e) {
|
||||
core.obpMdnsEmulator.startServer().catchError((e, s) {
|
||||
recordError(e, s, context: 'OBP mDNS Emulator');
|
||||
core.settings.setObpMdnsEnabled(false);
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/connection_method.dart';
|
||||
|
||||
class ZwiftMdnsTile extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
@@ -34,12 +35,14 @@ class _ZwiftTileState extends State<ZwiftMdnsTile> {
|
||||
: isConnected
|
||||
? context.i18n.connected
|
||||
: context.i18n.waitingForConnectionKickrBike(core.settings.getTrainerApp()?.name ?? ''),
|
||||
instructionLink: 'INSTRUCTIONS_ZWIFT.md',
|
||||
isStarted: isStarted,
|
||||
isConnected: isConnected,
|
||||
onChange: (start) {
|
||||
core.settings.setZwiftMdnsEmulatorEnabled(start);
|
||||
if (start) {
|
||||
core.zwiftMdnsEmulator.startServer().catchError((e) {
|
||||
core.zwiftMdnsEmulator.startServer().catchError((e, s) {
|
||||
recordError(e, s, context: 'Zwift mDNS Emulator');
|
||||
core.settings.setZwiftMdnsEmulatorEnabled(false);
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString()));
|
||||
setState(() {});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/connection_method.dart';
|
||||
|
||||
class ZwiftTile extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
@@ -28,6 +29,7 @@ class _ZwiftTileState extends State<ZwiftTile> {
|
||||
return ConnectionMethod(
|
||||
isEnabled: core.settings.getZwiftBleEmulatorEnabled(),
|
||||
type: ConnectionMethodType.bluetooth,
|
||||
instructionLink: 'INSTRUCTIONS_ZWIFT.md',
|
||||
isStarted: isStarted,
|
||||
isConnected: isConnected,
|
||||
onChange: (value) {
|
||||
@@ -35,7 +37,9 @@ class _ZwiftTileState extends State<ZwiftTile> {
|
||||
if (!value) {
|
||||
core.zwiftEmulator.stopAdvertising();
|
||||
} else if (value) {
|
||||
core.zwiftEmulator.startAdvertising(widget.onUpdate).catchError((e) {
|
||||
core.zwiftEmulator.startAdvertising(widget.onUpdate).catchError((e, s) {
|
||||
recordError(e, s, context: 'Zwift BLE Emulator');
|
||||
core.zwiftEmulator.isStarted.value = false;
|
||||
core.settings.setZwiftBleEmulatorEnabled(false);
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString()));
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_md/flutter_md.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
|
||||
class ChangelogDialog extends StatelessWidget {
|
||||
final Markdown entry;
|
||||
|
||||
@@ -3,11 +3,11 @@ import 'dart:async';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
|
||||
class IgnoredDevicesDialog extends StatefulWidget {
|
||||
const IgnoredDevicesDialog({super.key});
|
||||
|
||||
@@ -2,16 +2,16 @@ import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/pages/button_edit.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/utils/keymap/manager.dart';
|
||||
import 'package:swift_control/widgets/ui/button_widget.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
|
||||
import '../pages/touch_area.dart';
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' show SelectionArea;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
|
||||
@@ -111,20 +112,23 @@ class _LogviewerState extends State<LogViewer> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(context.i18n.logsAreAlsoAt).muted.small,
|
||||
CodeSnippet(
|
||||
code: SelectableText(File('${Directory.current.path}/app.logs').path),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy),
|
||||
variance: ButtonVariance.outline,
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: File('${Directory.current.path}/app.logs').path));
|
||||
buildToast(context, title: context.i18n.pathCopiedToClipboard);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (!kIsWeb) ...[
|
||||
Text(context.i18n.logsAreAlsoAt).muted.small,
|
||||
CodeSnippet(
|
||||
code: SelectableText(File('${Directory.current.path}/app.logs').path),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy),
|
||||
variance: ButtonVariance.outline,
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: File('${Directory.current.path}/app.logs').path));
|
||||
buildToast(context, title: context.i18n.pathCopiedToClipboard);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,12 +6,12 @@ import 'package:flutter/material.dart' show showLicensePage;
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/pages/button_simulator.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -99,35 +99,105 @@ List<Widget> buildMenuButtons(BuildContext context, VoidCallback? openLogs) {
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuDivider(),
|
||||
MenuLabel(child: Text(context.i18n.getSupport)),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.bug_report_outlined),
|
||||
child: Text(context.i18n.provideFeedback),
|
||||
leading: Icon(Icons.reddit_outlined),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://www.reddit.com/r/BikeControl/');
|
||||
},
|
||||
child: Text('Reddit'),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(Icons.facebook_outlined),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://www.facebook.com/groups/1892836898778912');
|
||||
},
|
||||
child: Text('Facebook'),
|
||||
),
|
||||
MenuButton(
|
||||
leading: Icon(RadixIcons.githubLogo),
|
||||
onPressed: (c) {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
child: Text('GitHub'),
|
||||
),
|
||||
MenuDivider(),
|
||||
if (!kIsWeb)
|
||||
if (!kIsWeb) ...[
|
||||
MenuButton(
|
||||
leading: Icon(Icons.email_outlined),
|
||||
child: Text(context.i18n.getSupport),
|
||||
child: Text('Mail'),
|
||||
onPressed: (c) {
|
||||
final isFromStore = (Platform.isAndroid ? isFromPlayStore == true : Platform.isIOS);
|
||||
final suffix = isFromStore ? '' : '-sw';
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Mail Support'),
|
||||
content: Container(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).mailSupportExplanation,
|
||||
),
|
||||
...[
|
||||
OutlineButton(
|
||||
leading: Icon(Icons.reddit_outlined),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://www.reddit.com/r/BikeControl/');
|
||||
},
|
||||
child: const Text('Reddit'),
|
||||
),
|
||||
OutlineButton(
|
||||
leading: Icon(Icons.facebook_outlined),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://www.facebook.com/groups/1892836898778912');
|
||||
},
|
||||
child: const Text('Facebook'),
|
||||
),
|
||||
OutlineButton(
|
||||
leading: Icon(RadixIcons.githubLogo),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
child: const Text('GitHub'),
|
||||
),
|
||||
SecondaryButton(
|
||||
leading: Icon(Icons.mail_outlined),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
|
||||
String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app');
|
||||
String subject = Uri.encodeComponent(
|
||||
context.i18n.helpRequested(packageInfoValue?.version ?? ''),
|
||||
final isFromStore = (Platform.isAndroid
|
||||
? isFromPlayStore == true
|
||||
: Platform.isIOS);
|
||||
final suffix = isFromStore ? '' : '-sw';
|
||||
|
||||
String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app');
|
||||
String subject = Uri.encodeComponent(
|
||||
context.i18n.helpRequested(packageInfoValue?.version ?? ''),
|
||||
);
|
||||
String body = Uri.encodeComponent("""
|
||||
${debugText()}""");
|
||||
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
|
||||
|
||||
launchUrl(mail);
|
||||
},
|
||||
child: const Text('Mail'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
String body = Uri.encodeComponent("""
|
||||
${debugText()}
|
||||
|
||||
${context.i18n.attachLogFile(File('${Directory.current.path}/app.logs').path)}""");
|
||||
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
|
||||
|
||||
launchUrl(mail);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -153,6 +223,7 @@ Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}
|
||||
Target: ${core.settings.getLastTarget()?.name ?? '-'}
|
||||
Trainer App: ${core.settings.getTrainerApp()?.name ?? '-'}
|
||||
Connected Controllers: ${core.connection.devices.map((e) => e.toString()).join(', ')}
|
||||
Connected Trainers: ${core.logic.connectedTrainerConnections.map((e) => e.title).join(', ')}
|
||||
Logs:
|
||||
${core.connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')}
|
||||
''';
|
||||
@@ -172,17 +243,6 @@ class BKMenuButton extends StatelessWidget {
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
if (kDebugMode) ...[
|
||||
MenuButton(
|
||||
onPressed: (_) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => ButtonSimulator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(context.i18n.simulateButtons),
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.continueAction),
|
||||
onPressed: (c) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart' show LogLevel;
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/widgets/ui/connection_method.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart' show LogLevel;
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
|
||||
import '../utils/requirements/multi.dart';
|
||||
|
||||
@@ -30,6 +30,7 @@ class _PairWidgetState extends State<RemotePairingWidget> {
|
||||
isStarted: isStarted,
|
||||
showTroubleshooting: true,
|
||||
type: ConnectionMethodType.bluetooth,
|
||||
instructionLink: 'INSTRUCTIONS_REMOTE_CONTROL.md',
|
||||
title: context.i18n.enablePairingProcess,
|
||||
description: context.i18n.pairingDescription,
|
||||
isConnected: isConnected,
|
||||
|
||||
@@ -2,12 +2,12 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/ui/connection_method.dart';
|
||||
import 'package:swift_control/widgets/ui/wifi_animation.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:bike_control/widgets/ui/wifi_animation.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ScanWidget extends StatefulWidget {
|
||||
|
||||
@@ -2,17 +2,17 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart' as actions;
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart' as actions;
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/ui/button_widget.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
|
||||
@@ -125,7 +125,7 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (data is ActionNotification) {
|
||||
} else if (data is ActionNotification && data.result is! actions.Ignored) {
|
||||
buildToast(
|
||||
context,
|
||||
location: ToastLocation.bottomLeft,
|
||||
|
||||
@@ -8,12 +8,12 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:restart_app/restart_app.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:shorebird_code_push/shorebird_code_push.dart';
|
||||
import 'package:swift_control/gen/l10n.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/core.dart';
|
||||
import 'package:swift_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:swift_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
@@ -124,7 +124,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
}
|
||||
} else if (Platform.isWindows) {
|
||||
final url = Uri.parse(
|
||||
'https://raw.githubusercontent.com/OpenBikeControl/bikecontrol/refs/heads/main/WINDOWS_STORE_VERSION.txt',
|
||||
'https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/WINDOWS_STORE_VERSION.txt',
|
||||
);
|
||||
final res = await http.get(url, headers: {'User-Agent': 'Mozilla/5.0'});
|
||||
if (res.statusCode != 200) return null;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/ui/colors.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
|
||||
class ButtonWidget extends StatelessWidget {
|
||||
final ControllerButton button;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
|
||||
class ColoredTitle extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/pages/button_edit.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/utils/i18n_extension.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:swift_control/widgets/ui/toast.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
|
||||
enum ConnectionMethodType {
|
||||
bluetooth,
|
||||
@@ -154,13 +154,16 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
|
||||
style: widget.isEnabled && Theme.of(context).brightness == Brightness.light
|
||||
? ButtonStyle.outline().withBorder(border: Border.all(color: Colors.gray.shade500))
|
||||
: ButtonStyle.outline(),
|
||||
leading: Icon(Icons.play_circle_outline_outlined),
|
||||
leading: Icon(Icons.help_outline),
|
||||
onPressed: () {
|
||||
launchUrlString(widget.instructionLink!);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: widget.instructionLink!)),
|
||||
);
|
||||
},
|
||||
child: Text(context.i18n.videoInstructions),
|
||||
child: Text(AppLocalizations.of(context).instructions),
|
||||
),
|
||||
if (widget.showTroubleshooting)
|
||||
if (widget.showTroubleshooting && widget.instructionLink == null)
|
||||
Button(
|
||||
style: widget.isEnabled && Theme.of(context).brightness == Brightness.light
|
||||
? ButtonStyle.outline().withBorder(border: Border.all(color: Colors.gray.shade500))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/widgets/ui/colors.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
|
||||
class GradientText extends StatelessWidget {
|
||||
const GradientText(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
|
||||
void buildToast(
|
||||
BuildContext context, {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 251 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 168 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user