mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc12711ad | ||
|
|
076e729e39 | ||
|
|
570f5ca82d | ||
|
|
9749a9018d | ||
|
|
4724f55e6b | ||
|
|
8f68636bfc | ||
|
|
fefcb1db53 | ||
|
|
98fd5c5d7c | ||
|
|
e97a76e488 | ||
|
|
02f25899e9 | ||
|
|
3a0ef5110d | ||
|
|
36c5df7d03 | ||
|
|
4252ed05ae | ||
|
|
2adf3026fd | ||
|
|
2cc705c556 | ||
|
|
faff8ab219 | ||
|
|
e63e647ebc | ||
|
|
e2339321bb | ||
|
|
f7c57b1f15 | ||
|
|
a1fad509cf | ||
|
|
daec33f827 | ||
|
|
cf66845a38 | ||
|
|
7199f5b545 | ||
|
|
0490956551 | ||
|
|
8be2108cdc | ||
|
|
4595e509c9 | ||
|
|
17d7953d11 | ||
|
|
4adfe0812d | ||
|
|
7ca9c8752b | ||
|
|
ab53d23404 | ||
|
|
9894271145 | ||
|
|
5d9960156c | ||
|
|
aa6782d29b | ||
|
|
98d683a6a5 | ||
|
|
55a2e4db79 | ||
|
|
4db985e2e5 | ||
|
|
bbd95beb36 | ||
|
|
a9ee0dc9a1 | ||
|
|
46d3770a28 | ||
|
|
99ee63ce1f | ||
|
|
cb10ad685e | ||
|
|
748a21fb54 | ||
|
|
b3ffe867c6 | ||
|
|
74e098e9b1 | ||
|
|
e5dae225f1 | ||
|
|
308f461ad4 | ||
|
|
513b2ba367 | ||
|
|
4daf553514 | ||
|
|
aeae148e0b | ||
|
|
1a9a265671 | ||
|
|
b06e9ad4b3 |
53
.github/workflows/build.yml
vendored
53
.github/workflows/build.yml
vendored
@@ -158,6 +158,20 @@ 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 test/screenshot_test.dart
|
||||
zip -r BikeControl.storeassets.zip screenshots
|
||||
echo "Screenshots generated successfully"
|
||||
|
||||
- name: 🚀 Shorebird Release iOS
|
||||
if: inputs.build_ios
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
@@ -191,8 +205,8 @@ jobs:
|
||||
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
run: |
|
||||
productbuild --component "build/macos/Build/Products/Release/SwiftControl.app" /Applications "SwiftControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
|
||||
xcrun altool --upload-app -f SwiftControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
productbuild --component "build/macos/Build/Products/Release/BikeControl.app" /Applications "BikeControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
|
||||
xcrun altool --upload-app -f BikeControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
|
||||
- name: Upload to iOS App Store
|
||||
if: inputs.build_ios
|
||||
@@ -205,11 +219,11 @@ jobs:
|
||||
- name: Handle Android archives
|
||||
if: inputs.build_android && inputs.build_github
|
||||
run: |
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/BikeControl.android.apk
|
||||
|
||||
- name: Code Signing of macOS app
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime BikeControl.app -v
|
||||
working-directory: build/macos/Build/Products/Release
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
@@ -218,7 +232,7 @@ jobs:
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
run: |
|
||||
cd build/macos/Build/Products/Release/
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/
|
||||
zip -r BikeControl.macos.zip BikeControl.app/
|
||||
|
||||
- name: Upload Android Artifacts
|
||||
if: inputs.build_android && inputs.build_github
|
||||
@@ -227,7 +241,7 @@ jobs:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
build/app/outputs/flutter-apk/BikeControl.android.apk
|
||||
|
||||
- name: Upload macOS Artifacts
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
@@ -236,7 +250,17 @@ jobs:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
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-GitHub-600x900.png
|
||||
build/BikeControl.storeassets.zip
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
@@ -251,7 +275,7 @@ jobs:
|
||||
if: inputs.build_github
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
artifacts: "build/app/outputs/flutter-apk/BikeControl.android.apk,build/macos/Build/Products/Release/BikeControl.macos.zip,build/BikeControl.screenshots.zip"
|
||||
allowUpdates: true
|
||||
prerelease: true
|
||||
bodyFile: /tmp/release_body.md
|
||||
@@ -259,7 +283,6 @@ jobs:
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
windows:
|
||||
needs: build
|
||||
if: inputs.build_windows
|
||||
name: Build & Release on Windows
|
||||
runs-on: windows-latest
|
||||
@@ -311,7 +334,7 @@ jobs:
|
||||
Write-Warning "$dll not found in $source"
|
||||
}
|
||||
}
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/SwiftControl.windows.zip"
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/BikeControl.windows.zip"
|
||||
|
||||
- uses: microsoft/setup-msstore-cli@v1
|
||||
if: false
|
||||
@@ -333,10 +356,10 @@ jobs:
|
||||
if: false
|
||||
run: msstore publish -v "build/windows/x64/runner/Release/"
|
||||
|
||||
- name: Rename swift_control.msix to SwiftControl.windows.msix
|
||||
- name: Rename swift_control.msix to BikeControl.windows.msix
|
||||
shell: pwsh
|
||||
run: |
|
||||
Rename-Item -Path "build/windows/x64/runner/Release/swift_control.msix" -NewName "SwiftControl.windows.msix"
|
||||
Rename-Item -Path "build/windows/x64/runner/Release/swift_control.msix" -NewName "BikeControl.windows.msix"
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -344,14 +367,14 @@ jobs:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.zip
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.msix
|
||||
build/windows/x64/runner/Release/BikeControl.windows.zip
|
||||
build/windows/x64/runner/Release/BikeControl.windows.msix
|
||||
|
||||
- name: Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip,build/windows/x64/runner/Release/SwiftControl.windows.msix"
|
||||
artifacts: "build/windows/x64/runner/Release/BikeControl.windows.zip,build/windows/x64/runner/Release/BikeControl.windows.msix"
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
14
.github/workflows/patch.yml
vendored
14
.github/workflows/patch.yml
vendored
@@ -9,7 +9,6 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: false
|
||||
name: Patch iOS, Android & macOS
|
||||
runs-on: macos-latest
|
||||
|
||||
@@ -105,38 +104,43 @@ jobs:
|
||||
|
||||
# shorebird struggles with the app from GitHub
|
||||
- name: Build macOS
|
||||
if: false
|
||||
run: flutter build macos --release;
|
||||
|
||||
- name: Sign macOS build
|
||||
if: false
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r');
|
||||
echo "VERSION=$version" >> $GITHUB_ENV;
|
||||
cd build/macos/Build/Products/Release/;
|
||||
/usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v;
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/;
|
||||
/usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime BikeControl.app -v;
|
||||
zip -r BikeControl.macos.zip BikeControl.app/;
|
||||
|
||||
#9 Upload Artifacts
|
||||
- name: Upload Artifacts
|
||||
if: false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
build/macos/Build/Products/Release/BikeControl.macos.zip
|
||||
|
||||
- name: Generate release body
|
||||
if: false
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
# add artifact to release
|
||||
- name: Create Release
|
||||
if: false
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
artifacts: "build/macos/Build/Products/Release/BikeControl.macos.zip"
|
||||
bodyFile: /tmp/release_body.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,3 +49,4 @@ app.*.map.json
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
/screenshots/
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,3 +1,14 @@
|
||||
### 3.6.0 (23-11-2025)
|
||||
|
||||
SwiftControl is now called BikeControl!
|
||||
|
||||
**Features:**
|
||||
- show a list of predefined keymaps for the selected trainer app when using a custom keymap
|
||||
- status icons so it's clear what's missing
|
||||
|
||||
**Fixes:**
|
||||
- Update Rouvy keymap to support virtual shifting in their latest version
|
||||
|
||||
### 3.5.0 (16-11-2025)
|
||||
**New Features:**
|
||||
- Dark mode support
|
||||
@@ -16,7 +27,7 @@
|
||||
|
||||
**Fixes:**
|
||||
- fix detection of Elite Square Sterzo devices
|
||||
- recognize cheap Bluetooth device clicks also when SwiftControl is in the background
|
||||
- recognize cheap Bluetooth device clicks also when BikeControl is in the background
|
||||
|
||||
### 3.3.0 (31-10-2025)
|
||||
|
||||
@@ -38,7 +49,7 @@
|
||||
### 3.2.0 (2025-10-22)
|
||||
- a brand-new way of controlling MyWhoosh:
|
||||
- device pairing no longer required as mouse emulation is no longer needed
|
||||
- SwiftControl can now stay in the background
|
||||
- BikeControl can now stay in the background
|
||||
- more devices can be controlled
|
||||
- do more, such as define Emotes, Camera angles and steering
|
||||
|
||||
@@ -48,16 +59,16 @@
|
||||
- support for Wahook Kickr Bike Shift (thanks @MattW2)
|
||||
- initial support for Elite Square Smart Frame
|
||||
- reconnects to your device automatically when connection is lost
|
||||
- SwiftControl now warns you if your device firmware is outdated
|
||||
- SwiftControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
|
||||
- BikeControl now warns you if your device firmware is outdated
|
||||
- BikeControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
|
||||
|
||||
### 3.0.3 (2025-10-12)
|
||||
- SwiftControl now supports iOS!
|
||||
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations but...:
|
||||
- You can now use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Click devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
- BikeControl now supports iOS!
|
||||
- Note that you can't run BikeControl and your trainer app on the same iPhone due to iOS limitations but...:
|
||||
- You can now use BikeControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs BikeControl and connects to your Click devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have BikeControl installed)
|
||||
- after pairing BikeControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
- Ride: analog paddles are now supported thanks to contributor @jmoro
|
||||
- you can now zoom in and out in the Keymap customization screen
|
||||
|
||||
@@ -77,8 +88,8 @@
|
||||
|
||||
### 2.5.0 (2025-09-25)
|
||||
- Improve usability
|
||||
- SwiftControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
|
||||
- SwiftControl will continue to be available to download for free on GitHub
|
||||
- BikeControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
|
||||
- BikeControl will continue to be available to download for free on GitHub
|
||||
- contact me if you already donated and I'll get a voucher for you :)
|
||||
|
||||
### 2.4.0+1 (2025-09-17)
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
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 SwiftControl, follow on screen instructions
|
||||
4) open BikeControl, follow on screen instructions
|
||||
|
||||
Once you've confirmed the connection in SwiftControl 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.
|
||||
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](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
|
||||
26
README.md
26
README.md
@@ -1,15 +1,15 @@
|
||||
# SwiftControl
|
||||
# BikeControl (formerly SwiftControl)
|
||||
|
||||
<img src="logo.png" alt="SwiftControl Logo"/>
|
||||
<img src="logo.png" alt="BikeControl Logo"/>
|
||||
|
||||
## Description
|
||||
|
||||
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, or other similar devices. Here's what you can do with it, depending on your configuration:
|
||||
With BikeControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, or other similar devices. Here's what you can do with it, depending on your configuration:
|
||||
- Virtual Gear shifting
|
||||
- Steering/turning
|
||||
- adjust workout intensity
|
||||
- control music on your device
|
||||
- more? If you can do it via keyboard, mouse, or touch, you can do it with SwiftControl
|
||||
- more? If you can do it via keyboard, mouse, or touch, you can do it with BikeControl
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
@@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
|
||||
## Downloads
|
||||
Best follow our landing page and the "Get Started" button: [swiftcontrol.app](https://swiftcontrol.app/) to understand on which platform you want to run SwiftControl.
|
||||
Best follow our landing page and the "Get Started" button: [bikecontrol.app](https://bikecontrol.app/) to understand on which platform you want to run BikeControl.
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
|
||||
|
||||
@@ -34,7 +34,7 @@ Best follow our landing page and the "Get Started" button: [swiftcontrol.app](ht
|
||||
- Biketerra.com
|
||||
- Rouvy
|
||||
- Zwift
|
||||
- running SwiftControl on Android or Windows is required to act as a "Controllable" in Zwift - iOS and macOS are not able to do so
|
||||
- running BikeControl on Android or Windows is required to act as a "Controllable" in Zwift - iOS and macOS are not able to do so
|
||||
- any other!
|
||||
- You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
|
||||
|
||||
@@ -53,13 +53,13 @@ Best follow our landing page and the "Get Started" button: [swiftcontrol.app](ht
|
||||
- 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 on Android
|
||||
- on iOS and macOS requires SwiftControl to act as media player
|
||||
- on iOS and macOS requires BikeControl to act as media player
|
||||
|
||||
Support for other devices can be added; check the issues tab here on GitHub.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
Follow the "Get Started" button over at [swiftcontrol.app](https://swiftcontrol.app) to understand on which platform you want to run SwiftControl.
|
||||
Follow the "Get Started" button over at [swiftcontrol.app](https://swiftcontrol.app) to understand on which platform you want to run BikeControl.
|
||||
You can even try it out in your [Browser](https://jonasbark.github.io/swiftcontrol/), if it supports Bluetooth connections. No controlling possible, though.
|
||||
|
||||
## Troubleshooting
|
||||
@@ -68,12 +68,12 @@ Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
## How does it work?
|
||||
The app connects to your Controller devices (such as Zwift ones) automatically. It does not connect to your trainer itself.
|
||||
|
||||
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- **iOS**: use SwiftControl as a "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- **Android**: BikeControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- **iOS**: use BikeControl as a "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs BikeControl and connects to your Controller devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have BikeControl installed)
|
||||
- If you want to use MyWhoosh, you can use the Link method to connect to MyWhoosh directly
|
||||
- For other trainer apps, you need to pair SwiftControl to your iPad / tablet via Bluetooth, and your phone will send the button presses to your iPad / tablet
|
||||
- For other trainer apps, you need to pair BikeControl to your iPad / tablet via Bluetooth, and your phone will send the button presses to your iPad / tablet
|
||||
- **macOS** / **Windows** A keyboard or mouse click is used to trigger the action.
|
||||
- There are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
|
||||
- You can also create your own Keymaps for any other app
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
## Click device cannot be found
|
||||
## Click / Ride device cannot be found
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## Click device does not send any data
|
||||
## 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/jonasbark/swiftcontrol/issues/68) discussion.
|
||||
Check [this](https://github.com/OpenBikeControl/bikecontrol/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 SwiftControl will need to reconnect every minute.
|
||||
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 SwiftControl
|
||||
4. 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 SwiftControl
|
||||
- enable auto start of SwiftControl
|
||||
- grant accessibility permission for SwiftControl
|
||||
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/jonasbark/swiftcontrol/issues/38) for more details
|
||||
- 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
|
||||
|
||||
## 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 SwiftControl
|
||||
- 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 SwiftControl / restart SwiftControl.
|
||||
- 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 SwiftControl iOS) > Button 1
|
||||
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
|
||||
|
||||
## SwiftControl crashes on Windows when searching for the device
|
||||
You're probably running into [this](https://github.com/jonasbark/swiftcontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
|
||||
## 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 Direct Connect never connects
|
||||
The same network restrictions apply for SwiftControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
|
||||
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/)
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.4.0
|
||||
3.5.0
|
||||
|
||||
@@ -168,6 +168,7 @@ interface Accessibility {
|
||||
fun openPermissions()
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
fun controlMedia(action: MediaAction)
|
||||
fun isRunning(): Boolean
|
||||
fun ignoreHidDevices()
|
||||
|
||||
companion object {
|
||||
@@ -249,6 +250,21 @@ interface Accessibility {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.isRunning$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.isRunning())
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
|
||||
@@ -51,6 +51,10 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
return enabledServices != null && enabledServices.contains(context.packageName)
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean {
|
||||
return Observable.toService != null
|
||||
}
|
||||
|
||||
override fun openPermissions() {
|
||||
startActivity(context, Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
@@ -10,6 +10,8 @@ abstract class Accessibility {
|
||||
|
||||
void controlMedia(MediaAction action);
|
||||
|
||||
bool isRunning();
|
||||
|
||||
void ignoreHidDevices();
|
||||
}
|
||||
|
||||
|
||||
@@ -242,6 +242,34 @@ class Accessibility {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isRunning() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.isRunning$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as bool?)!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ignoreHidDevices() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="SwiftControl"
|
||||
android:label="BikeControl"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -11,6 +11,8 @@ PODS:
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- media_key_detector_ios (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
@@ -40,6 +42,7 @@ DEPENDENCIES:
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- media_key_detector_ios (from `.symlinks/plugins/media_key_detector_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
@@ -63,6 +66,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/gamepads_ios/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
media_key_detector_ios:
|
||||
:path: ".symlinks/plugins/media_key_detector_ios/ios"
|
||||
package_info_plus:
|
||||
@@ -89,6 +94,7 @@ SPEC CHECKSUMS:
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>SwiftControl</string>
|
||||
<string>BikeControl</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -29,7 +29,7 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>SwiftControl uses Bluetooth to connect to accessories.</string>
|
||||
<string>BikeControl uses Bluetooth to connect to accessories.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app connects to your trainer app on your local network.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
|
||||
@@ -28,8 +28,6 @@ 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<BaseDevice> get remoteDevices =>
|
||||
devices.whereNot((d) => d is BluetoothDevice || d is GamepadDevice || d is HidDevice).toList();
|
||||
|
||||
var _androidNotificationsSetup = false;
|
||||
|
||||
@@ -245,7 +243,7 @@ class Connection {
|
||||
|
||||
void _handleConnectionQueue() {
|
||||
// windows apparently has issues when connecting to multiple devices at once, so don't
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue) {
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue && !screenshotMode) {
|
||||
_handlingConnectionQueue = true;
|
||||
final device = _connectionQueue.removeAt(0);
|
||||
_actionStreams.add(LogNotification('Connecting to: ${device.name}'));
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
@@ -103,24 +104,27 @@ abstract class BaseDevice {
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
final result = await actionHandler.performAction(action, isKeyDown: true, isKeyUp: false);
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: false)),
|
||||
ActionNotification(result),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
final result = await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true);
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true)),
|
||||
ActionNotification(result),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
final result = await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true);
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true)),
|
||||
ActionNotification(result),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
@@ -90,7 +91,7 @@ class WhooshLink {
|
||||
);
|
||||
}
|
||||
|
||||
String sendAction(InGameAction action, int? value) {
|
||||
ActionResult sendAction(InGameAction action, int? value) {
|
||||
final jsonObject = switch (action) {
|
||||
InGameAction.shiftUp => {
|
||||
'MessageType': 'Controls',
|
||||
@@ -145,9 +146,9 @@ class WhooshLink {
|
||||
if (jsonObject != null) {
|
||||
final jsonString = jsonEncode(jsonObject);
|
||||
_socket?.writeln(jsonString);
|
||||
return 'Sent action to MyWhoosh: $action ${value ?? ''}';
|
||||
return Success('Sent action to MyWhoosh: $action ${value ?? ''}');
|
||||
} else {
|
||||
return 'No action available for button: $action';
|
||||
return Error('No action available for button: $action');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.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/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/widgets/warning.dart';
|
||||
|
||||
@@ -24,18 +27,37 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
],
|
||||
);
|
||||
|
||||
bool _noLongerSendsEvents = false;
|
||||
|
||||
@override
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK_V2;
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.1.0';
|
||||
|
||||
@override
|
||||
bool get canVibrate => false;
|
||||
|
||||
@override
|
||||
Future<void> setupHandshake() async {
|
||||
super.setupHandshake();
|
||||
await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00]));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processData(Uint8List bytes) {
|
||||
if (bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_1) ||
|
||||
bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_2)) {
|
||||
_noLongerSendsEvents = true;
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once each session.',
|
||||
),
|
||||
);
|
||||
}
|
||||
return super.processData(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
@@ -43,16 +65,16 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (isConnected)
|
||||
if (isConnected && _noLongerSendsEvents && settings.getShowZwiftClickV2ReconnectWarning())
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that SwiftControl will need to reconnect every minute.
|
||||
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that BikeControl will need to reconnect every minute.
|
||||
|
||||
1. Open Zwift app
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Zwift Click V2
|
||||
4. Close the Zwift app again and connect again in SwiftControl''',
|
||||
4. Close the Zwift app again and connect again in BikeControl''',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
@@ -73,13 +95,20 @@ class ZwiftClickV2 extends ZwiftRide {
|
||||
},
|
||||
child: Text('Troubleshooting'),
|
||||
),
|
||||
if (kDebugMode)
|
||||
if (kDebugMode && false)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
test();
|
||||
},
|
||||
child: Text('Test'),
|
||||
),
|
||||
Expanded(child: SizedBox()),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
settings.setShowZwiftClickV2ReconnectWarning(false);
|
||||
},
|
||||
child: Text('Dismiss'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -4,8 +4,6 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
@@ -22,6 +20,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
String get latestFirmwareVersion;
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK;
|
||||
String get customServiceId => ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
bool get canVibrate => false;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
@@ -159,7 +158,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled() &&
|
||||
(this is ZwiftPlay || this is ZwiftRide)) {
|
||||
canVibrate) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performClick(buttonsClicked);
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
@@ -36,7 +37,7 @@ class ZwiftEmulator {
|
||||
bool get isAdvertising => _isAdvertising;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
final peripheralManager = PeripheralManager();
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
bool _isAdvertising = false;
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
@@ -45,8 +46,8 @@ class ZwiftEmulator {
|
||||
GATTCharacteristic? _asyncCharacteristic;
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
await _peripheralManager.stopAdvertising();
|
||||
await _peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
startAdvertising(() {});
|
||||
@@ -56,13 +57,13 @@ class ZwiftEmulator {
|
||||
_isLoading = true;
|
||||
onUpdate();
|
||||
|
||||
peripheralManager.stateChanged.forEach((state) {
|
||||
_peripheralManager.stateChanged.forEach((state) {
|
||||
print('Peripheral manager state: ${state.state}');
|
||||
});
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
if (Platform.isAndroid) {
|
||||
peripheralManager.connectionStateChanged.forEach((state) {
|
||||
_peripheralManager.connectionStateChanged.forEach((state) {
|
||||
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
|
||||
if (state.state == ConnectionState.connected) {
|
||||
} else if (state.state == ConnectionState.disconnected) {
|
||||
@@ -82,7 +83,7 @@ class ZwiftEmulator {
|
||||
}
|
||||
}
|
||||
|
||||
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
|
||||
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
if (settings.getLastTarget() == Target.thisDevice) {
|
||||
return;
|
||||
@@ -116,7 +117,7 @@ class ZwiftEmulator {
|
||||
|
||||
if (!_isSubscribedToEvents) {
|
||||
_isSubscribedToEvents = true;
|
||||
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
|
||||
_peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
|
||||
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
|
||||
@@ -124,7 +125,7 @@ class ZwiftEmulator {
|
||||
print('Handling read request for SYNC TX characteristic');
|
||||
break;
|
||||
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
|
||||
await peripheralManager.respondReadRequestWithValue(
|
||||
await _peripheralManager.respondReadRequestWithValue(
|
||||
eventArgs.request,
|
||||
value: Uint8List.fromList([100]),
|
||||
);
|
||||
@@ -135,19 +136,19 @@ class ZwiftEmulator {
|
||||
|
||||
final request = eventArgs.request;
|
||||
final trimmedValue = Uint8List.fromList([]);
|
||||
await peripheralManager.respondReadRequestWithValue(
|
||||
await _peripheralManager.respondReadRequestWithValue(
|
||||
request,
|
||||
value: trimmedValue,
|
||||
);
|
||||
// You can respond to read requests here if needed
|
||||
});
|
||||
|
||||
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
print(
|
||||
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
|
||||
);
|
||||
});
|
||||
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
|
||||
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
|
||||
_central = eventArgs.central;
|
||||
isConnected.value = true;
|
||||
|
||||
@@ -169,7 +170,7 @@ class ZwiftEmulator {
|
||||
|
||||
if (value.contentEquals(handshake) || value.contentEquals(handshakeAlternative)) {
|
||||
print('Sending handshake');
|
||||
await peripheralManager.notifyCharacteristic(
|
||||
await _peripheralManager.notifyCharacteristic(
|
||||
_central!,
|
||||
syncTxCharacteristic,
|
||||
value: ZwiftConstants.RIDE_ON,
|
||||
@@ -181,19 +182,19 @@ class ZwiftEmulator {
|
||||
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
}
|
||||
|
||||
await peripheralManager.respondWriteRequest(request);
|
||||
await _peripheralManager.respondWriteRequest(request);
|
||||
});
|
||||
}
|
||||
|
||||
// Device Information
|
||||
await peripheralManager.addService(
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('SwiftControl'.codeUnits),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
@@ -217,7 +218,7 @@ class ZwiftEmulator {
|
||||
);
|
||||
|
||||
// Battery Service
|
||||
await peripheralManager.addService(
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180F'),
|
||||
isPrimary: true,
|
||||
@@ -239,7 +240,7 @@ class ZwiftEmulator {
|
||||
);
|
||||
|
||||
// Unknown Service
|
||||
await peripheralManager.addService(
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT),
|
||||
isPrimary: true,
|
||||
@@ -284,7 +285,7 @@ class ZwiftEmulator {
|
||||
}
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name: 'SwiftControl',
|
||||
name: 'BikeControl',
|
||||
serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT)],
|
||||
serviceData: {
|
||||
UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT): Uint8List.fromList([0x02]),
|
||||
@@ -298,19 +299,19 @@ class ZwiftEmulator {
|
||||
);
|
||||
print('Starting advertising with Zwift service...');
|
||||
|
||||
await peripheralManager.startAdvertising(advertisement);
|
||||
await _peripheralManager.startAdvertising(advertisement);
|
||||
_isAdvertising = true;
|
||||
_isLoading = false;
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
Future<void> stopAdvertising() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await _peripheralManager.stopAdvertising();
|
||||
_isAdvertising = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
Future<String> sendAction(InGameAction inGameAction, int? inGameActionValue) async {
|
||||
Future<ActionResult> sendAction(InGameAction inGameAction, int? inGameActionValue) async {
|
||||
final button = switch (inGameAction) {
|
||||
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
|
||||
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
|
||||
@@ -326,7 +327,7 @@ class ZwiftEmulator {
|
||||
};
|
||||
|
||||
if (button == null) {
|
||||
return 'Action ${inGameAction.name} not supported by Zwift Emulator';
|
||||
return Error('Action ${inGameAction.name} not supported by Zwift Emulator');
|
||||
}
|
||||
|
||||
final status = RideKeyPadStatus()
|
||||
@@ -340,11 +341,11 @@ class ZwiftEmulator {
|
||||
...bytes,
|
||||
]);
|
||||
|
||||
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: commandProto);
|
||||
_peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: commandProto);
|
||||
|
||||
final zero = Uint8List.fromList([0x23, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
|
||||
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
|
||||
return 'Sent action: ${inGameAction.name}';
|
||||
_peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
|
||||
return Success('Sent action: ${inGameAction.name}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
@override
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_PLAY;
|
||||
|
||||
@override
|
||||
bool get canVibrate => true;
|
||||
|
||||
@override
|
||||
List<ControllerButton> processClickNotification(Uint8List message) {
|
||||
final status = PlayKeyPadStatus.fromBuffer(message);
|
||||
|
||||
@@ -49,6 +49,9 @@ class ZwiftRide extends ZwiftDevice {
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.2.0';
|
||||
|
||||
@override
|
||||
bool get canVibrate => true;
|
||||
|
||||
@override
|
||||
Future<void> processData(Uint8List bytes) async {
|
||||
Opcode? opcode = Opcode.valueOf(bytes[0]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:dartx/dartx.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';
|
||||
|
||||
@@ -46,3 +47,14 @@ class ButtonNotification extends BaseNotification {
|
||||
@override
|
||||
int get hashCode => buttonsClicked.hashCode;
|
||||
}
|
||||
|
||||
class ActionNotification extends BaseNotification {
|
||||
final ActionResult result;
|
||||
|
||||
ActionNotification(this.result);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return result.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ late BaseActions actionHandler;
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
final settings = Settings();
|
||||
final whooshLink = WhooshLink();
|
||||
const screenshotMode = false;
|
||||
var screenshotMode = false;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -68,7 +68,7 @@ class SwiftPlayApp extends StatelessWidget {
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'SwiftControl',
|
||||
title: 'BikeControl',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.system,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
@@ -19,11 +20,13 @@ import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
import 'package:swift_control/widgets/scan.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:swift_control/widgets/status.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:swift_control/widgets/warning.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
@@ -48,6 +51,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
bool _showAutoRotationWarning = false;
|
||||
bool _showMiuiWarning = false;
|
||||
bool _showNameChangeWarning = false;
|
||||
StreamSubscription<bool>? _autoRotateStream;
|
||||
|
||||
@override
|
||||
@@ -55,7 +59,10 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
super.initState();
|
||||
|
||||
// keep screen on - this is required for iOS to keep the bluetooth connection alive
|
||||
WakelockPlus.enable();
|
||||
if (!screenshotMode) {
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
_showNameChangeWarning = !settings.knowsAboutNameChange();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -187,7 +194,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canVibrate = connection.bluetoothDevices.any(
|
||||
(device) => (device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') && device.isConnected,
|
||||
(device) => device.isConnected && device is ZwiftDevice && device.canVibrate,
|
||||
);
|
||||
|
||||
final paddingMultiplicator = actionHandler is DesktopActions ? 2.5 : 1.0;
|
||||
@@ -218,8 +225,28 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_showNameChangeWarning && !screenshotMode)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(
|
||||
'SwiftControl is now BikeControl!\nIt is part of the OpenBikeControl project, advocating for open standards in smart bike trainers - and building affordable hardware controllers!',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showNameChangeWarning = false;
|
||||
});
|
||||
launchUrlString('https://openbikecontrol.org');
|
||||
},
|
||||
child: Text('More Information'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_showAutoRotationWarning)
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text('Enable auto-rotation on your device to make sure the app works correctly.'),
|
||||
],
|
||||
@@ -261,15 +288,15 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'To ensure SwiftControl works properly:',
|
||||
'To ensure BikeControl works properly:',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'• Disable battery optimization for SwiftControl',
|
||||
'• Disable battery optimization for BikeControl',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'• Enable autostart for SwiftControl',
|
||||
'• Enable autostart for BikeControl',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
@@ -289,10 +316,6 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -335,8 +358,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
(device) => device.showInformation(context),
|
||||
),
|
||||
|
||||
if (connection.remoteDevices.isNotEmpty ||
|
||||
actionHandler is RemoteActions ||
|
||||
if (actionHandler is RemoteActions ||
|
||||
whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) ||
|
||||
actionHandler.supportedApp?.supportsZwiftEmulation == true)
|
||||
Container(
|
||||
@@ -357,9 +379,6 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
...connection.remoteDevices.map(
|
||||
(device) => device.showInformation(context),
|
||||
),
|
||||
|
||||
if (settings.getTrainerApp() is MyWhoosh &&
|
||||
whooshLink.isCompatible(settings.getLastTarget()!))
|
||||
@@ -371,7 +390,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
},
|
||||
),
|
||||
|
||||
if (actionHandler is RemoteActions)
|
||||
if (actionHandler is RemoteActions && isAdvertisingPeripheral)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -390,21 +409,18 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20),
|
||||
StatusWidget(),
|
||||
SizedBox(height: 20),
|
||||
if (!kIsWeb) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
'Customize ${settings.getTrainerApp()?.name} on ${settings.getLastTarget()?.title}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -418,6 +434,39 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
'Customize ${screenshotMode ? 'Trainer app' : settings.getTrainerApp()?.name} on ${settings.getLastTarget()?.title}',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (settings.getLastTarget()?.warning != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
Text(
|
||||
settings.getLastTarget()!.warning!,
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
@@ -429,7 +478,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(
|
||||
value: app,
|
||||
label: app.name,
|
||||
label: screenshotMode ? 'Trainer app' : app.name,
|
||||
labelWidget: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -521,11 +570,16 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
],
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Logs', style: Theme.of(context).textTheme.titleMedium),
|
||||
ExpansionTile(
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Logs', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
maintainState: true,
|
||||
children: [
|
||||
SizedBox(height: 500, child: LogViewer()),
|
||||
],
|
||||
),
|
||||
LogViewer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -79,7 +79,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Welcome to SwiftControl!', style: Theme.of(context).textTheme.titleMedium),
|
||||
Text('Welcome to BikeControl!', style: Theme.of(context).textTheme.titleMedium),
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width - 140),
|
||||
|
||||
|
||||
@@ -454,7 +454,7 @@ class _KeyWidget extends StatelessWidget {
|
||||
child: Text(
|
||||
label.splitByUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: screenshotMode ? null : 'monospace',
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
|
||||
@@ -2,7 +2,6 @@ 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/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
@@ -45,21 +44,23 @@ class AndroidActions extends BaseActions {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<ActionResult> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ("Could not perform ${button.name.splitByUpperCase()}: No keymap set");
|
||||
return Error("Could not perform ${button.name.splitByUpperCase()}: No keymap set");
|
||||
}
|
||||
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(button);
|
||||
|
||||
if (keyPair == null) {
|
||||
return ("Could not perform ${button.name.splitByUpperCase()}: No action assigned");
|
||||
return Error("Could not perform ${button.name.splitByUpperCase()}: No action assigned");
|
||||
} else if (keyPair.hasNoAction) {
|
||||
return Error('No action assigned for ${button.toString().splitByUpperCase()}');
|
||||
}
|
||||
|
||||
if (keyPair.inGameAction != null && whooshLink.isConnected.value) {
|
||||
return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
|
||||
} else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) {
|
||||
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
|
||||
final directConnectHandled = await handleDirectConnect(keyPair);
|
||||
|
||||
if (directConnectHandled != null) {
|
||||
return directConnectHandled;
|
||||
} else if (keyPair.isSpecialKey) {
|
||||
await accessibilityHandler.controlMedia(switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaTrackNext => MediaAction.next,
|
||||
@@ -68,7 +69,7 @@ class AndroidActions extends BaseActions {
|
||||
PhysicalKeyboardKey.audioVolumeDown => MediaAction.volumeDown,
|
||||
_ => throw SingleLineException("No action for key: ${keyPair.physicalKey}"),
|
||||
});
|
||||
return "Key pressed: ${keyPair.toString()}";
|
||||
return Success("Key pressed: ${keyPair.toString()}");
|
||||
}
|
||||
|
||||
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: windowInfo);
|
||||
@@ -76,15 +77,17 @@ class AndroidActions extends BaseActions {
|
||||
try {
|
||||
await accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
|
||||
} on PlatformException catch (e) {
|
||||
return "Accessibility Service not working. Follow instructions at https://dontkillmyapp.com/";
|
||||
return Error("Accessibility Service not working. Follow instructions at https://dontkillmyapp.com/");
|
||||
}
|
||||
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
|
||||
? "click"
|
||||
: isKeyDown
|
||||
? "down"
|
||||
: "up"}";
|
||||
return Success(
|
||||
"Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
|
||||
? "click"
|
||||
: isKeyDown
|
||||
? "down"
|
||||
: "up"}",
|
||||
);
|
||||
}
|
||||
return "No action assigned";
|
||||
return Error('No action assigned for ${button.toString().splitByUpperCase()}');
|
||||
}
|
||||
|
||||
void ignoreHidDevices() {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
@@ -16,6 +17,19 @@ import '../keymap/apps/supported_app.dart';
|
||||
|
||||
enum SupportedMode { keyboard, touch, media }
|
||||
|
||||
sealed class ActionResult {
|
||||
final String message;
|
||||
const ActionResult(this.message);
|
||||
}
|
||||
|
||||
class Success extends ActionResult {
|
||||
const Success(super.message);
|
||||
}
|
||||
|
||||
class Error extends ActionResult {
|
||||
const Error(super.message);
|
||||
}
|
||||
|
||||
abstract class BaseActions {
|
||||
final List<SupportedMode> supportedModes;
|
||||
|
||||
@@ -93,7 +107,22 @@ abstract class BaseActions {
|
||||
return Offset.zero;
|
||||
}
|
||||
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
Future<ActionResult> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
|
||||
Future<ActionResult>? handleDirectConnect(KeyPair keyPair) {
|
||||
if (keyPair.inGameAction != null) {
|
||||
if (whooshLink.isConnected.value) {
|
||||
return Future.value(whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue));
|
||||
} else if (whooshLink.isStarted.value) {
|
||||
return Future.value(Error('MyWhoosh Direct Connect not connected'));
|
||||
} else if (zwiftEmulator.isConnected.value) {
|
||||
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
|
||||
} else if (zwiftEmulator.isAdvertising) {
|
||||
return Future.value(Error('Zwift Emulator not connected'));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class StubActions extends BaseActions {
|
||||
@@ -102,8 +131,8 @@ class StubActions extends BaseActions {
|
||||
final List<ControllerButton> performedActions = [];
|
||||
|
||||
@override
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
Future<ActionResult> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
performedActions.add(action);
|
||||
return Future.value(action.name);
|
||||
return Future.value(Success(action.name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
@@ -13,32 +11,33 @@ class DesktopActions extends BaseActions {
|
||||
// Track keys that are currently held down in long press mode
|
||||
|
||||
@override
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<ActionResult> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ('Supported app is not set');
|
||||
return Error('Supported app is not set');
|
||||
}
|
||||
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair == null) {
|
||||
return ('Keymap entry not found for action: ${action.toString().splitByUpperCase()}');
|
||||
return Error('Keymap entry not found for action: ${action.toString().splitByUpperCase()}');
|
||||
} else if (keyPair.hasNoAction) {
|
||||
return Error('No action assigned for ${action.toString().splitByUpperCase()}');
|
||||
}
|
||||
|
||||
// Handle regular key press mode (existing behavior)
|
||||
if (keyPair.inGameAction != null && whooshLink.isConnected.value) {
|
||||
return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
|
||||
} else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) {
|
||||
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
|
||||
final directConnectHandled = await handleDirectConnect(keyPair);
|
||||
|
||||
if (directConnectHandled != null) {
|
||||
return directConnectHandled;
|
||||
} else if (keyPair.physicalKey != null) {
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
|
||||
return 'Key clicked: $keyPair';
|
||||
return Success('Key clicked: $keyPair');
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
return 'Key pressed: $keyPair';
|
||||
return Success('Key pressed: $keyPair');
|
||||
} else {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
|
||||
return 'Key released: $keyPair';
|
||||
return Success('Key released: $keyPair');
|
||||
}
|
||||
} else {
|
||||
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: null);
|
||||
@@ -47,16 +46,16 @@ class DesktopActions extends BaseActions {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
// slight move to register clicks on some apps, see issue #116
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse clicked at: ${point.dx.toInt()} ${point.dy.toInt()}';
|
||||
return Success('Mouse clicked at: ${point.dx.toInt()} ${point.dy.toInt()}');
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
return 'Mouse down at: ${point.dx.toInt()} ${point.dy.toInt()}';
|
||||
return Success('Mouse down at: ${point.dx.toInt()} ${point.dy.toInt()}');
|
||||
} else {
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse up at: ${point.dx.toInt()} ${point.dy.toInt()}';
|
||||
return Success('Mouse up at: ${point.dx.toInt()} ${point.dy.toInt()}');
|
||||
}
|
||||
} else {
|
||||
return 'No action assigned';
|
||||
return Error('No action assigned for ${action.toString().splitByUpperCase()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
@@ -18,26 +17,28 @@ class RemoteActions extends BaseActions {
|
||||
RemoteActions({super.supportedModes = const [SupportedMode.touch]});
|
||||
|
||||
@override
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<ActionResult> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return 'Supported app is not set';
|
||||
return Error('Supported app is not set');
|
||||
}
|
||||
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair == null) {
|
||||
return 'Keymap entry not found for action: ${action.toString().splitByUpperCase()}';
|
||||
return Error('Keymap entry not found for action: ${action.toString().splitByUpperCase()}');
|
||||
} else if (keyPair.hasNoAction) {
|
||||
return Error('No action assigned for ${action.toString().splitByUpperCase()}');
|
||||
}
|
||||
|
||||
if (keyPair.inGameAction != null && whooshLink.isConnected.value) {
|
||||
return whooshLink.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
|
||||
} else if (keyPair.inGameAction != null && zwiftEmulator.isConnected.value) {
|
||||
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
|
||||
final directConnectHandled = await handleDirectConnect(keyPair);
|
||||
|
||||
if (directConnectHandled != null) {
|
||||
return directConnectHandled;
|
||||
} else if (!(actionHandler as RemoteActions).isConnected) {
|
||||
return 'Not connected to a ${settings.getLastTarget()?.name ?? 'remote'} device';
|
||||
return Error('Not connected to a ${settings.getLastTarget()?.name ?? 'remote'} device');
|
||||
}
|
||||
|
||||
if (keyPair.physicalKey != null && keyPair.touchPosition == Offset.zero) {
|
||||
return ('Physical key actions are not supported, yet');
|
||||
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);
|
||||
@@ -45,7 +46,7 @@ class RemoteActions extends BaseActions {
|
||||
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
|
||||
return 'Mouse clicked at: ${point2.dx.toInt()} ${point2.dy.toInt()}';
|
||||
return Success('Mouse clicked at: ${point2.dx.toInt()} ${point2.dy.toInt()}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
@@ -11,7 +14,9 @@ class MyWhoosh extends SupportedApp {
|
||||
: super(
|
||||
name: 'MyWhoosh',
|
||||
packageName: "com.mywhoosh.whooshgame",
|
||||
compatibleTargets: Target.values,
|
||||
compatibleTargets: !kIsWeb && Platform.isIOS
|
||||
? Target.values.filterNot((e) => e == Target.thisDevice).toList()
|
||||
: Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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';
|
||||
@@ -12,22 +15,26 @@ class Rouvy extends SupportedApp {
|
||||
: super(
|
||||
name: 'Rouvy',
|
||||
packageName: "eu.virtualtraining.rouvy.android",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: true,
|
||||
compatibleTargets: !kIsWeb && Platform.isIOS
|
||||
? Target.values.filterNot((e) => e == Target.thisDevice).toList()
|
||||
: Target.values,
|
||||
supportsZwiftEmulation: !kIsWeb && Platform.isAndroid,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
// https://support.rouvy.com/hc/de/articles/32452137189393-Virtuelles-Schalten#h_01K5GMVG4KVYZ0Y6W7RBRZC9MA
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
inGameAction: InGameAction.shiftDown,
|
||||
physicalKey: PhysicalKeyboardKey.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
physicalKey: PhysicalKeyboardKey.comma,
|
||||
logicalKey: LogicalKeyboardKey.comma,
|
||||
touchPosition: Offset(94, 80),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
inGameAction: InGameAction.shiftUp,
|
||||
physicalKey: PhysicalKeyboardKey.numpadAdd,
|
||||
logicalKey: LogicalKeyboardKey.numpadAdd,
|
||||
physicalKey: PhysicalKeyboardKey.period,
|
||||
logicalKey: LogicalKeyboardKey.period,
|
||||
touchPosition: Offset(94, 72),
|
||||
),
|
||||
// like escape
|
||||
KeyPair(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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';
|
||||
@@ -13,7 +16,9 @@ class TrainingPeaks extends SupportedApp {
|
||||
: super(
|
||||
name: 'TrainingPeaks Virtual / IndieVelo',
|
||||
packageName: "com.indieVelo.client",
|
||||
compatibleTargets: Target.values,
|
||||
compatibleTargets: !kIsWeb && Platform.isIOS
|
||||
? Target.values.filterNot((e) => e == Target.thisDevice).toList()
|
||||
: Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
|
||||
@@ -16,6 +16,7 @@ class Zwift extends SupportedApp {
|
||||
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
|
||||
compatibleTargets: [
|
||||
if (!Platform.isIOS) Target.thisDevice,
|
||||
if (Platform.isAndroid) Target.otherDevice,
|
||||
Target.macOS,
|
||||
Target.windows,
|
||||
Target.iOS,
|
||||
|
||||
@@ -128,9 +128,13 @@ class KeyPair {
|
||||
};
|
||||
}
|
||||
|
||||
bool get hasNoAction =>
|
||||
logicalKey == null && physicalKey == null && touchPosition == Offset.zero && inGameAction == null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final baseKey = logicalKey?.keyLabel ??
|
||||
final baseKey =
|
||||
logicalKey?.keyLabel ??
|
||||
switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Next Track',
|
||||
@@ -140,11 +144,11 @@ class KeyPair {
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
|
||||
_ => 'Not assigned',
|
||||
};
|
||||
|
||||
|
||||
if (modifiers.isEmpty || baseKey == 'Not assigned') {
|
||||
return baseKey;
|
||||
}
|
||||
|
||||
|
||||
// Format modifiers + key (e.g., "Ctrl+Alt+R")
|
||||
final modifierStrings = modifiers.map((m) {
|
||||
return switch (m) {
|
||||
@@ -156,7 +160,7 @@ class KeyPair {
|
||||
_ => m.name,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
|
||||
return '${modifierStrings.join('+')}+$baseKey';
|
||||
}
|
||||
|
||||
@@ -196,15 +200,15 @@ class KeyPair {
|
||||
if (buttons.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Decode modifiers if present
|
||||
final List<ModifierKey> modifiers = decoded.containsKey('modifiers')
|
||||
? (decoded['modifiers'] as List)
|
||||
.map<ModifierKey?>((e) => ModifierKey.values.firstOrNullWhere((element) => element.name == e))
|
||||
.whereType<ModifierKey>()
|
||||
.toList()
|
||||
.map<ModifierKey?>((e) => ModifierKey.values.firstOrNullWhere((element) => element.name == e))
|
||||
.whereType<ModifierKey>()
|
||||
.toList()
|
||||
: [];
|
||||
|
||||
|
||||
return KeyPair(
|
||||
buttons: buttons,
|
||||
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
|
||||
|
||||
@@ -14,7 +14,7 @@ class AccessibilityRequirement extends PlatformRequirement {
|
||||
AccessibilityRequirement()
|
||||
: super(
|
||||
'Allow Accessibility Service',
|
||||
description: 'SwiftControl needs accessibility permission to control your training apps.',
|
||||
description: 'BikeControl needs accessibility permission to control your training apps.',
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -130,7 +130,7 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
},
|
||||
);
|
||||
|
||||
const String channelGroupId = 'SwiftControl';
|
||||
const String channelGroupId = 'BikeControl';
|
||||
// create the group first
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()!
|
||||
@@ -153,7 +153,7 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
await AndroidFlutterLocalNotificationsPlugin().startForegroundService(
|
||||
1,
|
||||
channelGroupId,
|
||||
'Allows SwiftControl to keep running in background',
|
||||
'Allows BikeControl to keep running in background',
|
||||
foregroundServiceTypes: {AndroidServiceForegroundType.foregroundServiceTypeConnectedDevice},
|
||||
startType: AndroidServiceStartType.startRedeliverIntent,
|
||||
notificationDetails: AndroidNotificationDetails(
|
||||
|
||||
@@ -22,7 +22,7 @@ class KeyboardRequirement extends PlatformRequirement {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Enable keyboard access in the following screen for SwiftControl. If you don\'t see SwiftControl, please add it manually.',
|
||||
'Enable keyboard access in the following screen for BikeControl. If you don\'t see BikeControl, please add it manually.',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -66,7 +66,9 @@ class BluetoothTurnedOn extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
final currentState = await UniversalBle.getBluetoothAvailabilityState();
|
||||
final currentState = screenshotMode
|
||||
? AvailabilityState.poweredOn
|
||||
: await UniversalBle.getBluetoothAvailabilityState();
|
||||
status = currentState == AvailabilityState.poweredOn || screenshotMode;
|
||||
}
|
||||
}
|
||||
@@ -179,9 +181,9 @@ enum Target {
|
||||
"Select 'This device' unless you want to control another macOS device. Are you sure?",
|
||||
Target.windows when Platform.isWindows =>
|
||||
"Select 'This device' unless you want to control another Windows device. Are you sure?",
|
||||
Target.android => "We highly recommended to download and use SwiftControl on that Android device.",
|
||||
Target.macOS => "We highly recommended to download and use SwiftControl on that macOS device.",
|
||||
Target.windows => "We highly recommended to download and use SwiftControl on that Windows device.",
|
||||
Target.android => "We highly recommended to download and use BikeControl on that Android device.",
|
||||
Target.macOS => "We highly recommended to download and use BikeControl on that macOS device.",
|
||||
Target.windows => "We highly recommended to download and use BikeControl on that Windows device.",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -236,7 +238,7 @@ class TargetRequirement extends PlatformRequirement {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
|
||||
'When running BikeControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
@@ -280,6 +282,7 @@ class TargetRequirement extends PlatformRequirement {
|
||||
value: target,
|
||||
label: target.title,
|
||||
leadingIcon: Icon(target.icon),
|
||||
enabled: target.isCompatible,
|
||||
labelWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
|
||||
@@ -16,7 +16,7 @@ import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import '../../pages/markdown.dart';
|
||||
|
||||
final peripheralManager = PeripheralManager();
|
||||
bool _isAdvertising = false;
|
||||
bool isAdvertisingPeripheral = false;
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
@@ -39,7 +39,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
isAdvertisingPeripheral = false;
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
startAdvertising(() {});
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
final status = await Permission.bluetoothAdvertise.request();
|
||||
if (!status.isGranted) {
|
||||
print('Bluetooth advertise permission not granted');
|
||||
_isAdvertising = false;
|
||||
isAdvertisingPeripheral = false;
|
||||
onUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -251,7 +251,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name:
|
||||
'SwiftControl ${Platform.isIOS
|
||||
'BikeControl ${Platform.isIOS
|
||||
? 'iOS'
|
||||
: Platform.isAndroid
|
||||
? 'Android'
|
||||
@@ -264,7 +264,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
print('Starting advertising with HID service...');
|
||||
|
||||
await peripheralManager.startAdvertising(advertisement);
|
||||
_isAdvertising = true;
|
||||
isAdvertisingPeripheral = true;
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
@@ -289,23 +289,66 @@ class _PairWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PairWidgetState extends State<_PairWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (actionHandler.supportedApp?.supportsZwiftEmulation == false) {
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 10,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await toggle();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isAdvertisingPeripheral
|
||||
? 'Stop Pairing process'
|
||||
: 'Start connecting to ${settings.getLastTarget()?.name ?? 'remote'} device',
|
||||
),
|
||||
Text(
|
||||
'Pairing allows full customizability,\nbut may not work on all devices.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
BetaPill(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isAdvertisingPeripheral || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
|
||||
],
|
||||
),
|
||||
if (isAdvertisingPeripheral)
|
||||
Text(
|
||||
switch (settings.getLastTarget()) {
|
||||
Target.iOS =>
|
||||
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
|
||||
_ =>
|
||||
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for BikeControl or your machines name. Pairing is required if you want to use the remote control feature.',
|
||||
},
|
||||
),
|
||||
if (isAdvertisingPeripheral) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));
|
||||
},
|
||||
child: Text('Check the troubleshooting guide'),
|
||||
),
|
||||
],
|
||||
if (settings.getTrainerApp() is MyWhoosh)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
@@ -366,67 +409,14 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
spacing: 10,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await toggle();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_isAdvertising ? 'Stop Pairing process' : 'Start Pairing',
|
||||
),
|
||||
Text(
|
||||
'Pairing allows full customizability,\nbut may not work on all devices.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
BetaPill(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
|
||||
],
|
||||
),
|
||||
if (_isAdvertising)
|
||||
Text(
|
||||
switch (settings.getLastTarget()) {
|
||||
Target.iOS =>
|
||||
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
|
||||
_ =>
|
||||
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required if you want to use the remote control feature.',
|
||||
},
|
||||
),
|
||||
if (_isAdvertising) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));
|
||||
},
|
||||
child: Text('Check the troubleshooting guide'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggle() async {
|
||||
if (_isAdvertising) {
|
||||
if (isAdvertisingPeripheral) {
|
||||
await peripheralManager.stopAdvertising();
|
||||
_isAdvertising = false;
|
||||
isAdvertisingPeripheral = false;
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
widget.onUpdate();
|
||||
_isLoading = false;
|
||||
|
||||
@@ -71,6 +71,12 @@ class Settings {
|
||||
return SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
|
||||
}
|
||||
|
||||
bool knowsAboutNameChange() {
|
||||
final knows = prefs.getBool('name_change') == true;
|
||||
prefs.setBool('name_change', true);
|
||||
return knows;
|
||||
}
|
||||
|
||||
Future<void> setKeyMap(SupportedApp app) async {
|
||||
if (app is CustomApp) {
|
||||
await prefs.setStringList('customapp_${app.profileName}', app.encodeKeymap());
|
||||
@@ -248,4 +254,12 @@ class Settings {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool getShowZwiftClickV2ReconnectWarning() {
|
||||
return prefs.getBool('zwift_click_v2_reconnect_warning') ?? true;
|
||||
}
|
||||
|
||||
Future<void> setShowZwiftClickV2ReconnectWarning(bool show) async {
|
||||
await prefs.setBool('zwift_click_v2_reconnect_warning', show);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class AccessibilityDisclosureDialog extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'SwiftControl needs to use Android\'s AccessibilityService API to function properly.',
|
||||
'BikeControl needs to use Android\'s AccessibilityService API to function properly.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
@@ -34,16 +34,16 @@ class AccessibilityDisclosureDialog extends StatelessWidget {
|
||||
Text('• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'How does SwiftControl use this permission?',
|
||||
'How does BikeControl use this permission?',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('• When you press buttons on your Zwift Click, Zwift Ride, or Zwift Play devices, SwiftControl simulates touch gestures at specific screen locations'),
|
||||
Text('• When you press buttons on your Zwift Click, Zwift Ride, or Zwift Play devices, BikeControl simulates touch gestures at specific screen locations'),
|
||||
Text('• The app monitors which training app window is active to ensure gestures are sent to the correct app'),
|
||||
Text('• No personal data is accessed or collected through this service'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'SwiftControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.',
|
||||
'BikeControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
@@ -41,11 +40,7 @@ class _ZwiftTileState extends State<ZwiftTile> {
|
||||
if (!settings.getZwiftEmulatorEnabled())
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Disabled. ${settings.getTrainerApp() is Zwift
|
||||
? 'Virtual shifting and on screen navigation will not work.'
|
||||
: settings.getTrainerApp() is Rouvy
|
||||
? 'Virtual shifting will not work.'
|
||||
: ''}',
|
||||
'Disabled. ${settings.getTrainerApp() is Zwift ? 'Virtual shifting and on screen navigation will not work.' : ''}',
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
@@ -53,7 +48,7 @@ class _ZwiftTileState extends State<ZwiftTile> {
|
||||
child: Text(
|
||||
isConnected
|
||||
? "Connected"
|
||||
: "Waiting for connection. Choose SwiftControl in ${settings.getTrainerApp()?.name}'s controller pairing menu.",
|
||||
: "Waiting for connection. Choose BikeControl in ${settings.getTrainerApp()?.name}'s controller pairing menu.",
|
||||
),
|
||||
),
|
||||
if (!isConnected) SmallProgressIndicator(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
|
||||
@@ -32,7 +33,7 @@ class ButtonWidget extends StatelessWidget {
|
||||
: Text(
|
||||
button.name.splitByUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: screenshotMode ? null : 'monospace',
|
||||
fontSize: big && button.color != null ? 20 : 12,
|
||||
fontWeight: button.color != null ? FontWeight.bold : null,
|
||||
color: button.color != null ? Colors.white : Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_md/flutter_md.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
|
||||
class ChangelogDialog extends StatelessWidget {
|
||||
final Markdown entry;
|
||||
@@ -41,7 +42,7 @@ class ChangelogDialog extends StatelessWidget {
|
||||
|
||||
static Future<void> showIfNeeded(BuildContext context, String currentVersion, String? lastSeenVersion) async {
|
||||
// Show dialog if this is a new version
|
||||
if (lastSeenVersion != currentVersion) {
|
||||
if (lastSeenVersion != currentVersion && !screenshotMode) {
|
||||
try {
|
||||
final entry = await rootBundle.loadString('CHANGELOG.md');
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -143,6 +143,7 @@ class _ButtonEditor extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trainerApp = settings.getTrainerApp();
|
||||
final actions = <PopupMenuEntry>[
|
||||
if (settings.getMyWhooshLinkEnabled() && whooshLink.isCompatible(settings.getLastTarget()!))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
@@ -246,6 +247,64 @@ class _ButtonEditor extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trainerApp != null && trainerApp is! CustomApp)
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton(
|
||||
itemBuilder: (_) {
|
||||
// Get actions from the current trainer app's keymap that have inGameAction
|
||||
final actionsWithInGameAction = trainerApp.keymap.keyPairs
|
||||
.where((kp) => kp.inGameAction != null)
|
||||
.toList();
|
||||
|
||||
if (actionsWithInGameAction.isEmpty) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
enabled: false,
|
||||
child: Text('No predefined actions available'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return actionsWithInGameAction.map((keyPairAction) {
|
||||
return PopupMenuItem(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(_formatActionDescription(keyPairAction).split(' = ').first),
|
||||
Text(
|
||||
_formatActionDescription(keyPairAction).split(' = ').last,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
// Copy all properties from the selected predefined action
|
||||
keyPair.physicalKey = keyPairAction.physicalKey;
|
||||
keyPair.logicalKey = keyPairAction.logicalKey;
|
||||
keyPair.modifiers = List.of(keyPairAction.modifiers);
|
||||
keyPair.touchPosition = keyPairAction.touchPosition;
|
||||
keyPair.isLongPress = keyPairAction.isLongPress;
|
||||
keyPair.inGameAction = keyPairAction.inGameAction;
|
||||
keyPair.inGameActionValue = keyPairAction.inGameActionValue;
|
||||
onUpdate();
|
||||
},
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.file_copy_outlined),
|
||||
Expanded(child: Text('${trainerApp.name} action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
@@ -393,28 +452,9 @@ class _ButtonEditor extends StatelessWidget {
|
||||
child: PopupMenuButton<dynamic>(
|
||||
itemBuilder: (c) => actions,
|
||||
enabled: actionHandler.supportedApp is CustomApp,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (keyPair.buttons.isNotEmpty &&
|
||||
(keyPair.physicalKey != null || keyPair.touchPosition != Offset.zero || keyPair.inGameAction != null))
|
||||
Expanded(
|
||||
child: KeypairExplanation(
|
||||
keyPair: keyPair,
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: Text(
|
||||
'No action assigned',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
child: InkWell(
|
||||
onTap: actionHandler.supportedApp is! CustomApp
|
||||
? () async {
|
||||
final currentProfile = actionHandler.supportedApp!.name;
|
||||
final newName = await KeymapManager().duplicate(
|
||||
context,
|
||||
@@ -427,16 +467,62 @@ class _ButtonEditor extends StatelessWidget {
|
||||
).showSnackBar(SnackBar(content: Text('Created a new custom profile: $newName')));
|
||||
}
|
||||
onUpdate();
|
||||
},
|
||||
icon: Icon(Icons.edit),
|
||||
)
|
||||
else
|
||||
}
|
||||
: null,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (keyPair.buttons.isNotEmpty &&
|
||||
(keyPair.physicalKey != null || keyPair.touchPosition != Offset.zero || keyPair.inGameAction != null))
|
||||
Expanded(
|
||||
child: KeypairExplanation(
|
||||
keyPair: keyPair,
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: Text(
|
||||
'No action assigned',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Icon(Icons.edit, size: 14),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatActionDescription(KeyPair keyPairAction) {
|
||||
final parts = <String>[];
|
||||
|
||||
if (keyPairAction.inGameAction != null) {
|
||||
parts.add(keyPairAction.inGameAction!.toString());
|
||||
if (keyPairAction.inGameActionValue != null) {
|
||||
parts.add('(${keyPairAction.inGameActionValue})');
|
||||
}
|
||||
}
|
||||
|
||||
// Use KeyPair's toString() which formats the key with modifiers (e.g., "Ctrl+Alt+R")
|
||||
final keyLabel = keyPairAction.toString();
|
||||
if (keyLabel != 'Not assigned') {
|
||||
parts.add('Key: $keyLabel');
|
||||
}
|
||||
|
||||
if (keyPairAction.touchPosition != Offset.zero) {
|
||||
parts.add(
|
||||
'Touch: (${keyPairAction.touchPosition.dx.toStringAsFixed(1)}, ${keyPairAction.touchPosition.dy.toStringAsFixed(1)})',
|
||||
);
|
||||
}
|
||||
|
||||
if (keyPairAction.isLongPress) {
|
||||
parts.add('[Long Press]');
|
||||
}
|
||||
|
||||
return parts.isNotEmpty ? [parts.first, ' = ', parts.skip(1).join(' • ')].join() : 'Action';
|
||||
}
|
||||
}
|
||||
|
||||
extension SplitByUppercase on String {
|
||||
|
||||
@@ -4,15 +4,15 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_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';
|
||||
|
||||
import '../pages/device.dart';
|
||||
import 'ignored_devices_dialog.dart';
|
||||
|
||||
List<Widget> buildMenuButtons() {
|
||||
@@ -85,7 +85,7 @@ List<Widget> buildMenuButtons() {
|
||||
PopupMenuItem(
|
||||
child: Text('Provide Feedback'),
|
||||
onTap: () {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
launchUrlString('https://github.com/OpenBikeControl/bikecontrol/issues');
|
||||
},
|
||||
),
|
||||
if (!kIsWeb)
|
||||
@@ -95,8 +95,8 @@ List<Widget> buildMenuButtons() {
|
||||
final isFromStore = (Platform.isAndroid ? isFromPlayStore == true : Platform.isIOS);
|
||||
final suffix = isFromStore ? '' : '-sw';
|
||||
|
||||
String email = Uri.encodeComponent('jonas$suffix@swiftcontrol.app');
|
||||
String subject = Uri.encodeComponent("Help requested for SwiftControl v${packageInfoValue?.version}");
|
||||
String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app');
|
||||
String subject = Uri.encodeComponent("Help requested for BikeControl v${packageInfoValue?.version}");
|
||||
String body = Uri.encodeComponent("""
|
||||
|
||||
|
||||
@@ -143,10 +143,12 @@ class MenuButton extends StatelessWidget {
|
||||
child: Text(e.name),
|
||||
onTap: () {
|
||||
Future.delayed(Duration(seconds: 2)).then((_) {
|
||||
connection.signalNotification(
|
||||
ButtonNotification(buttonsClicked: [e]),
|
||||
);
|
||||
connection.devices.firstOrNull?.handleButtonsClicked([e]);
|
||||
if (connection.devices.isNotEmpty) {
|
||||
connection.devices.firstOrNull?.handleButtonsClicked([e]);
|
||||
connection.devices.firstOrNull?.handleButtonsClicked([]);
|
||||
} else {
|
||||
actionHandler.performAction(e);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -158,7 +160,18 @@ class MenuButton extends StatelessWidget {
|
||||
PopupMenuItem(
|
||||
child: Text('Continue'),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
|
||||
//Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
|
||||
connection.addDevices([
|
||||
ZwiftClick(
|
||||
BleDevice(
|
||||
name: 'Controller',
|
||||
deviceId: '00:11:22:33:44:55',
|
||||
),
|
||||
)
|
||||
..firmwareVersion = '1.2.0'
|
||||
..rssi = -51
|
||||
..batteryLevel = 81,
|
||||
]);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
|
||||
@@ -68,7 +68,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
subtitle: Text(
|
||||
'Enable this option to allow Swift Control to detect bluetooth remotes. In order to do so SwiftControl needs to act as a media player.',
|
||||
'Enable this option to allow BikeControl to detect bluetooth remotes. In order to do so BikeControl needs to act as a media player.',
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
|
||||
146
lib/widgets/status.dart
Normal file
146
lib/widgets/status.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/utils/requirements/remote.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class StatusWidget extends StatefulWidget {
|
||||
const StatusWidget({super.key});
|
||||
|
||||
@override
|
||||
State<StatusWidget> createState() => _StatusWidgetState();
|
||||
}
|
||||
|
||||
class _StatusWidgetState extends State<StatusWidget> {
|
||||
bool? _isRunningAndroidService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!kIsWeb && Platform.isAndroid && actionHandler is AndroidActions) {
|
||||
(actionHandler as AndroidActions).accessibilityHandler.isRunning().then((isRunning) {
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isRemote = actionHandler is RemoteActions && isAdvertisingPeripheral;
|
||||
final isZwift = settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled();
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (connection.controllerDevices.isEmpty)
|
||||
_Status(color: Colors.red, text: 'No connected controllers')
|
||||
else
|
||||
_Status(
|
||||
color: Colors.green,
|
||||
text:
|
||||
'${connection.controllerDevices.length == 1 ? 'Controller connected' : '${connection.controllerDevices.length} controllers connected'} ',
|
||||
),
|
||||
if (whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) &&
|
||||
settings.getMyWhooshLinkEnabled())
|
||||
_Status(
|
||||
color: whooshLink.isConnected.value
|
||||
? Colors.green
|
||||
: Platform.isAndroid
|
||||
? Colors.yellow
|
||||
: Colors.red,
|
||||
text: 'MyWhoosh Direct Connect ${whooshLink.isConnected.value ? "connected" : "not connected"}',
|
||||
),
|
||||
|
||||
if (isRemote)
|
||||
_Status(
|
||||
color: (actionHandler as RemoteActions).isConnected ? Colors.green : Colors.red,
|
||||
text: 'Remote ${(actionHandler as RemoteActions).isConnected ? "connected" : "not connected"}',
|
||||
),
|
||||
if (isZwift)
|
||||
_Status(
|
||||
color: zwiftEmulator.isConnected.value ? Colors.green : Colors.red,
|
||||
text: 'Zwift Emulation ${zwiftEmulator.isConnected.value ? "connected" : "not connected"}',
|
||||
),
|
||||
if (!isRemote && !isZwift && !screenshotMode)
|
||||
_Status(
|
||||
color: Colors.red,
|
||||
text: 'Not connected to a remote device',
|
||||
),
|
||||
if (_isRunningAndroidService != null)
|
||||
_Status(
|
||||
color: _isRunningAndroidService! ? Colors.green : Colors.red,
|
||||
text: 'Accessibility service is ${_isRunningAndroidService! ? 'available' : 'not available'}',
|
||||
trailing: !_isRunningAndroidService!
|
||||
? Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('Follow instructions at'),
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(padding: EdgeInsets.zero, minimumSize: Size(0, 0)),
|
||||
child: Text('https://dontkillmyapp.com/'),
|
||||
onPressed: () {
|
||||
launchUrlString('https://dontkillmyapp.com/');
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
(actionHandler as AndroidActions).accessibilityHandler.isRunning().then((
|
||||
isRunning,
|
||||
) {
|
||||
setState(() {
|
||||
_isRunningAndroidService = isRunning;
|
||||
});
|
||||
});
|
||||
},
|
||||
icon: Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Status extends StatelessWidget {
|
||||
final Color color;
|
||||
final String text;
|
||||
final Widget? trailing;
|
||||
const _Status({super.key, required this.color, required this.text, this.trailing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.circle, color: color, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(text),
|
||||
],
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16 + 8.0),
|
||||
child: trailing!,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart' as actions;
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/button_widget.dart';
|
||||
@@ -53,8 +54,10 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
final Map<int, _TouchSample> _active = <int, _TouchSample>{};
|
||||
final List<_TouchSample> _history = <_TouchSample>[];
|
||||
|
||||
// ----- Keyboard tracking -----
|
||||
// ----- Keyboard tracking -----
|
||||
final List<_KeySample> _keys = <_KeySample>[];
|
||||
final List<_ActionSample> _actions = <_ActionSample>[];
|
||||
|
||||
// Focus to receive key events without stealing focus from inputs.
|
||||
late final FocusNode _focusNode;
|
||||
@@ -104,6 +107,17 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
} else if (data is ActionNotification) {
|
||||
final sample = _ActionSample(
|
||||
text: data.result.message,
|
||||
timestamp: DateTime.now(),
|
||||
isError: data.result is actions.Error,
|
||||
);
|
||||
_actions.insert(0, sample);
|
||||
if (_actions.length > widget.maxKeyboardEvents) {
|
||||
_actions.removeLast();
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -112,6 +126,7 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
final now = DateTime.now();
|
||||
_history.removeWhere((s) => now.difference(s.timestamp) > widget.touchRevealDuration);
|
||||
_keys.removeWhere((k) => now.difference(k.timestamp) > widget.keyboardRevealDuration);
|
||||
_actions.removeWhere((k) => now.difference(k.timestamp) > widget.keyboardRevealDuration);
|
||||
|
||||
if (mounted) setState(() {});
|
||||
})..start();
|
||||
@@ -236,6 +251,19 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showKeyboard)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
child: IgnorePointer(
|
||||
child: _ActionOverlay(
|
||||
items: _actions,
|
||||
duration: widget.keyboardRevealDuration,
|
||||
badgeColor: widget.keyboardBadgeColor,
|
||||
textStyle: widget.keyboardTextStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -391,3 +419,84 @@ class _KeyboardToast extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Action overlay =====
|
||||
|
||||
class _ActionSample {
|
||||
_ActionSample({required this.text, required this.timestamp, required this.isError});
|
||||
final String text;
|
||||
final DateTime timestamp;
|
||||
final bool isError;
|
||||
}
|
||||
|
||||
class _ActionOverlay extends StatelessWidget {
|
||||
const _ActionOverlay({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.duration,
|
||||
required this.badgeColor,
|
||||
required this.textStyle,
|
||||
});
|
||||
|
||||
final List<_ActionSample> items;
|
||||
final Duration duration;
|
||||
final Color badgeColor;
|
||||
final TextStyle textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final item in items)
|
||||
_ActionToast(
|
||||
item: item,
|
||||
age: now.difference(item.timestamp),
|
||||
duration: duration,
|
||||
badgeColor: badgeColor,
|
||||
textStyle: textStyle,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionToast extends StatelessWidget {
|
||||
const _ActionToast({
|
||||
required this.item,
|
||||
required this.age,
|
||||
required this.duration,
|
||||
required this.badgeColor,
|
||||
required this.textStyle,
|
||||
});
|
||||
|
||||
final _ActionSample item;
|
||||
final Duration age;
|
||||
final Duration duration;
|
||||
final Color badgeColor;
|
||||
final TextStyle textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = (age.inMilliseconds / duration.inMilliseconds.clamp(1, 1 << 30)).clamp(0.0, 1.0);
|
||||
final fade = 1.0 - t;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Opacity(
|
||||
opacity: fade,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: item.isError ? Colors.red.withOpacity(0.8) : badgeColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(item.text, style: textStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@ class _AppTitleState extends State<AppTitle> {
|
||||
}
|
||||
|
||||
void _checkForUpdate() async {
|
||||
if (updater.isAvailable) {
|
||||
if (screenshotMode) {
|
||||
return;
|
||||
} else if (updater.isAvailable) {
|
||||
final updateStatus = await updater.checkForUpdate();
|
||||
if (updateStatus == UpdateStatus.outdated) {
|
||||
updater
|
||||
@@ -127,7 +129,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
}
|
||||
} else if (Platform.isWindows) {
|
||||
final url = Uri.parse(
|
||||
'https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/WINDOWS_STORE_VERSION.txt',
|
||||
'https://raw.githubusercontent.com/OpenBikeControl/bikecontrol/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;
|
||||
@@ -142,11 +144,13 @@ class _AppTitleState extends State<AppTitle> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('SwiftControl', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('BikeControl', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (packageInfoValue != null)
|
||||
Text(
|
||||
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
|
||||
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
|
||||
style: screenshotMode
|
||||
? TextStyle(fontSize: 12)
|
||||
: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
|
||||
)
|
||||
else
|
||||
SmallProgressIndicator(),
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Warning extends StatelessWidget {
|
||||
final bool important;
|
||||
final List<Widget> children;
|
||||
const Warning({super.key, required this.children});
|
||||
const Warning({super.key, required this.children, this.important = true});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(bottom: 6),
|
||||
margin: EdgeInsets.all(4),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
color: important
|
||||
? Theme.of(context).colorScheme.errorContainer
|
||||
: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
|
||||
BIN
logo.png
BIN
logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
@@ -67,7 +67,7 @@
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||
33CC10ED2044A3C60003C045 /* SwiftControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10ED2044A3C60003C045 /* BikeControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BikeControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
|
||||
@@ -157,7 +157,7 @@
|
||||
33CC10EE2044A3C60003C045 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
33CC10ED2044A3C60003C045 /* SwiftControl.app */,
|
||||
33CC10ED2044A3C60003C045 /* BikeControl.app */,
|
||||
331C80D5294CF71000263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -248,7 +248,7 @@
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* SwiftControl.app */;
|
||||
productReference = 33CC10ED2044A3C60003C045 /* BikeControl.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -579,13 +579,14 @@
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = UZRHKPVWN9;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BikeControl;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = BikeControl;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "mac app store";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -719,13 +720,14 @@
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BikeControl;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = BikeControl;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -748,13 +750,14 @@
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = UZRHKPVWN9;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = BikeControl;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = BikeControl;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "mac app store";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "SwiftControl.app"
|
||||
BuildableName = "BikeControl.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -31,7 +31,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "SwiftControl.app"
|
||||
BuildableName = "BikeControl.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -66,7 +66,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "SwiftControl.app"
|
||||
BuildableName = "BikeControl.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -83,7 +83,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "SwiftControl.app"
|
||||
BuildableName = "BikeControl.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>SwiftControl requires Bluetooth to connect to your devices.</string>
|
||||
<string>BikeControl requires Bluetooth to connect to your devices.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app connects to your trainer app on your local network.</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
@@ -42,6 +42,6 @@
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
<string>SwiftControl needs to send keys to your trainer app.</string>
|
||||
<string>BikeControl needs to send keys to your trainer app.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -50,8 +50,8 @@ public class MediaKeyDetectorPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
// 2) Seed Now Playing info
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: "SwiftControl",
|
||||
MPMediaItemPropertyArtist: "SwiftControl",
|
||||
MPMediaItemPropertyTitle: "BikeControl",
|
||||
MPMediaItemPropertyArtist: "BikeControl",
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: 0,
|
||||
MPMediaItemPropertyPlaybackDuration: 1337, // nonzero duration helps
|
||||
MPNowPlayingInfoPropertyPlaybackRate: 1 // paused
|
||||
|
||||
47
pubspec.lock
47
pubspec.lock
@@ -278,6 +278,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -352,6 +357,11 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
gamepads:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -520,6 +530,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.5"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -846,6 +861,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
protobuf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1012,6 +1035,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1028,6 +1059,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
test_screenshot:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: test_screenshot
|
||||
sha256: "2a7620f404cf514601b5181a154c7af7495015e51c52e0175c397ac579371b4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.8"
|
||||
time:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1181,6 +1220,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: swift_control
|
||||
description: "SwiftControl - Control your virtual riding"
|
||||
description: "BikeControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 3.5.1+41
|
||||
version: 3.6.0+43
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.0
|
||||
@@ -57,7 +57,10 @@ dependencies:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
|
||||
test_screenshot: 0.0.8
|
||||
flutter_lints: ^6.0.0
|
||||
msix: ^3.16.12
|
||||
|
||||
@@ -74,7 +77,7 @@ flutter:
|
||||
- icon.png
|
||||
|
||||
msix_config:
|
||||
display_name: SwiftControl
|
||||
display_name: BikeControl
|
||||
publisher_display_name: Jonas Bark
|
||||
identity_name: JonasBark.SwiftControl
|
||||
publisher: CN=EDA6D86E-3E52-4054-9F3A-4277AFCCB2C4
|
||||
|
||||
124
test/bluetooth_device_detection.dart
Normal file
124
test/bluetooth_device_detection.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.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/shimano/shimano_di2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
void main() {
|
||||
group('Detect Zwift devices', () {
|
||||
test('Detect Zwift Play', () {
|
||||
final device = _createBleDevice(
|
||||
name: 'Zwift Play',
|
||||
manufacturerData: [
|
||||
ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RC1_RIGHT_SIDE])),
|
||||
],
|
||||
);
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ZwiftPlay>());
|
||||
});
|
||||
|
||||
test('Detect Zwift Ride', () {
|
||||
final device = _createBleDevice(
|
||||
name: 'Zwift Ride',
|
||||
manufacturerData: [
|
||||
ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RIDE_LEFT_SIDE])),
|
||||
],
|
||||
services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase()],
|
||||
);
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ZwiftRide>());
|
||||
});
|
||||
test('Detect Zwift Ride old firmware', () {
|
||||
final device = _createBleDevice(
|
||||
name: 'Zwift Ride',
|
||||
manufacturerData: [
|
||||
ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RIDE_LEFT_SIDE])),
|
||||
],
|
||||
services: [ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase()],
|
||||
);
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ZwiftRide>());
|
||||
});
|
||||
|
||||
test('Detect Zwift Click V1', () {
|
||||
final device = _createBleDevice(
|
||||
name: 'Zwift Click',
|
||||
manufacturerData: [
|
||||
ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.BC1])),
|
||||
],
|
||||
services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase()],
|
||||
);
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ZwiftClick>());
|
||||
});
|
||||
|
||||
test('Detect Zwift Click V2', () {
|
||||
final device = _createBleDevice(
|
||||
name: 'Zwift Click',
|
||||
manufacturerData: [
|
||||
ManufacturerData(
|
||||
ZwiftConstants.ZWIFT_MANUFACTURER_ID,
|
||||
Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE]),
|
||||
),
|
||||
],
|
||||
services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase()],
|
||||
);
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ZwiftClickV2>());
|
||||
});
|
||||
});
|
||||
|
||||
group('Detect Elite devices', () {
|
||||
test('Elite Square', () {
|
||||
final device = _createBleDevice(name: 'SQUARE 1337');
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<EliteSquare>());
|
||||
});
|
||||
test('Elite Sterzo', () {
|
||||
final device = _createBleDevice(name: 'STERZO 1337');
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<EliteSterzo>());
|
||||
});
|
||||
});
|
||||
|
||||
group('Detect Wahoo devices', () {
|
||||
test('Kickr Bike Shift', () {
|
||||
final device = _createBleDevice(name: '133 KICKR BIKE SHIFT 133');
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<WahooKickrBikeShift>());
|
||||
});
|
||||
});
|
||||
|
||||
group('Detect Cycplus devices', () {
|
||||
test('Cycplus BC2', () {
|
||||
final device = _createBleDevice(name: 'Cycplus BC2');
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<CycplusBc2>());
|
||||
});
|
||||
test('Other cycplus', () {
|
||||
final device = _createBleDevice(name: 'Cycplus 1337');
|
||||
expect(BluetoothDevice.fromScanResult(device), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Detect Shimano Di2', () {
|
||||
test('Shimano Di2', () {
|
||||
final device = _createBleDevice(name: 'RDR 1337', services: [ShimanoDi2Constants.SERVICE_UUID.toLowerCase()]);
|
||||
expect(BluetoothDevice.fromScanResult(device), isInstanceOf<ShimanoDi2>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
BleDevice _createBleDevice({
|
||||
required String name,
|
||||
List<ManufacturerData> manufacturerData = const <ManufacturerData>[],
|
||||
List<String> services = const [],
|
||||
}) {
|
||||
return BleDevice(
|
||||
deviceId: '1337',
|
||||
name: name,
|
||||
manufacturerDataList: manufacturerData,
|
||||
services: services,
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,21 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/settings/settings.dart';
|
||||
|
||||
void main() {
|
||||
group('Custom Profile Tests', () {
|
||||
late Settings settings;
|
||||
|
||||
setUp(() async {
|
||||
// Initialize SharedPreferences with in-memory storage for testing
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
settings = Settings();
|
||||
await settings.init();
|
||||
});
|
||||
|
||||
test('Should create custom app with default profile name', () {
|
||||
final customApp = CustomApp();
|
||||
expect(customApp.profileName, 'Custom');
|
||||
expect(customApp.name, 'Custom');
|
||||
expect(customApp.profileName, 'Other');
|
||||
expect(customApp.name, 'Other');
|
||||
});
|
||||
|
||||
test('Should create custom app with custom profile name', () {
|
||||
@@ -51,6 +49,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('Should duplicate custom profile', () async {
|
||||
await settings.reset();
|
||||
final original = CustomApp(profileName: 'Original');
|
||||
await settings.setKeyMap(original);
|
||||
|
||||
@@ -75,21 +74,6 @@ void main() {
|
||||
expect(profiles.contains('ToDelete'), false);
|
||||
});
|
||||
|
||||
test('Should migrate old custom keymap to new format', () async {
|
||||
// Simulate old storage format
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'customapp': ['test_data'],
|
||||
'app': 'Custom',
|
||||
});
|
||||
|
||||
final newSettings = Settings();
|
||||
await newSettings.init();
|
||||
|
||||
// Check that migration happened
|
||||
expect(newSettings.prefs.containsKey('customapp'), false);
|
||||
expect(newSettings.prefs.containsKey('customapp_Custom'), true);
|
||||
});
|
||||
|
||||
test('Should not duplicate migration if already migrated', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'customapp': ['old_data'],
|
||||
|
||||
@@ -14,14 +14,14 @@ void main() {
|
||||
final stubActions = actionHandler as StubActions;
|
||||
|
||||
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
|
||||
|
||||
|
||||
// Packet 0: [6]=01 [7]=03 -> No trigger (lock state)
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206010397565E000155'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
|
||||
// Packet 1: [6]=03 [7]=03 -> Trigger: shiftUp
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
@@ -30,7 +30,7 @@ void main() {
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
|
||||
stubActions.performedActions.clear();
|
||||
|
||||
|
||||
// Packet 2: [6]=03 [7]=01 -> Trigger: shiftDown
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
@@ -39,14 +39,14 @@ void main() {
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftDown);
|
||||
stubActions.performedActions.clear();
|
||||
|
||||
|
||||
// Packet 3: [6]=03 [7]=03 -> No trigger (lock state)
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206030398585E00015A'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
|
||||
// Packet 4: [6]=01 [7]=03 -> Trigger: shiftUp
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
@@ -61,28 +61,28 @@ void main() {
|
||||
actionHandler = StubActions();
|
||||
final stubActions = actionHandler as StubActions;
|
||||
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
|
||||
|
||||
|
||||
// Press: lock state
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206010300005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
|
||||
// Release: reset state
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206000000005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
|
||||
// Press again: lock state (no trigger since we reset)
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206020300005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
|
||||
// Change to different pressed value: trigger
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
@@ -91,27 +91,26 @@ void main() {
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, CycplusBc2Buttons.shiftUp);
|
||||
});
|
||||
|
||||
|
||||
test('Test both buttons can trigger simultaneously', () {
|
||||
actionHandler = StubActions();
|
||||
final stubActions = actionHandler as StubActions;
|
||||
final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name'));
|
||||
|
||||
|
||||
// Lock both states
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206010100005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
|
||||
|
||||
// Change both: trigger both
|
||||
device.processCharacteristic(
|
||||
CycplusBc2Constants.TX_CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('FEEFFFEE0206020200005E000100'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 2);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.contains(CycplusBc2Buttons.shiftUp), true);
|
||||
expect(stubActions.performedActions.contains(CycplusBc2Buttons.shiftDown), true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ void main() {
|
||||
// Test that NaN values are filtered out
|
||||
final samples = [double.nan, 1.5, 2.0, 2.5, 3.0, double.nan, 3.5, 4.0, 4.5, 5.0, 5.5];
|
||||
final validSamples = samples.where((s) => !s.isNaN).take(10).toList();
|
||||
|
||||
expect(validSamples.length, equals(10));
|
||||
|
||||
expect(validSamples.length, equals(9));
|
||||
expect(validSamples.every((s) => !s.isNaN), isTrue);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ void main() {
|
||||
// Test offset calculation
|
||||
final samples = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
|
||||
final offset = samples.reduce((a, b) => a + b) / samples.length;
|
||||
|
||||
|
||||
expect(offset, equals(5.5));
|
||||
});
|
||||
|
||||
@@ -40,18 +40,18 @@ void main() {
|
||||
return levels.clamp(1, maxLevels);
|
||||
}
|
||||
|
||||
expect(calculateLevels(5), equals(1)); // Below threshold but level 1
|
||||
expect(calculateLevels(10), equals(1)); // 10 / 10 = 1
|
||||
expect(calculateLevels(15), equals(1)); // 15 / 10 = 1.5 floor = 1
|
||||
expect(calculateLevels(20), equals(2)); // 20 / 10 = 2
|
||||
expect(calculateLevels(35), equals(3)); // 35 / 10 = 3.5 floor = 3
|
||||
expect(calculateLevels(50), equals(5)); // 50 / 10 = 5 (max)
|
||||
expect(calculateLevels(5), equals(1)); // Below threshold but level 1
|
||||
expect(calculateLevels(10), equals(1)); // 10 / 10 = 1
|
||||
expect(calculateLevels(15), equals(1)); // 15 / 10 = 1.5 floor = 1
|
||||
expect(calculateLevels(20), equals(2)); // 20 / 10 = 2
|
||||
expect(calculateLevels(35), equals(3)); // 35 / 10 = 3.5 floor = 3
|
||||
expect(calculateLevels(50), equals(5)); // 50 / 10 = 5 (max)
|
||||
expect(calculateLevels(100), equals(5)); // 100 / 10 = 10 but clamped to 5
|
||||
});
|
||||
|
||||
test('Should determine correct steering direction', () {
|
||||
// Test direction determination
|
||||
expect(25 > 0, isTrue); // Positive = RIGHT
|
||||
expect(25 > 0, isTrue); // Positive = RIGHT
|
||||
expect(-25 > 0, isFalse); // Negative = LEFT
|
||||
});
|
||||
});
|
||||
@@ -61,9 +61,9 @@ void main() {
|
||||
const steeringThreshold = 10.0;
|
||||
|
||||
// Test threshold logic
|
||||
expect(5.abs() > steeringThreshold, isFalse); // Below threshold
|
||||
expect(10.abs() > steeringThreshold, isFalse); // At threshold
|
||||
expect(11.abs() > steeringThreshold, isTrue); // Above threshold
|
||||
expect(5.abs() > steeringThreshold, isFalse); // Below threshold
|
||||
expect(10.abs() > steeringThreshold, isFalse); // At threshold
|
||||
expect(11.abs() > steeringThreshold, isTrue); // Above threshold
|
||||
expect((-11).abs() > steeringThreshold, isTrue); // Above threshold (negative)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/pages/touch_area.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
void main() {
|
||||
group('TouchAreaSetupPage Orientation Tests', () {
|
||||
testWidgets('TouchAreaSetupPage should force landscape orientation on init', (WidgetTester tester) async {
|
||||
// Track system chrome method calls
|
||||
final List<MethodCall> systemChromeCalls = [];
|
||||
|
||||
// Mock SystemChrome.setPreferredOrientations
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(MethodCall methodCall) async {
|
||||
systemChromeCalls.add(methodCall);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
// Build the TouchAreaSetupPage
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: TouchAreaSetupPage(
|
||||
keyPair: KeyPair(buttons: [], physicalKey: null, logicalKey: null),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify that setPreferredOrientations was called with landscape orientations
|
||||
final orientationCalls = systemChromeCalls
|
||||
.where((call) => call.method == 'SystemChrome.setPreferredOrientations')
|
||||
.toList();
|
||||
|
||||
expect(orientationCalls, isNotEmpty);
|
||||
|
||||
// Check if landscape orientations were set
|
||||
final lastOrientationCall = orientationCalls.last;
|
||||
final orientations = lastOrientationCall.arguments as List<String>;
|
||||
|
||||
expect(orientations, contains('DeviceOrientation.landscapeLeft'));
|
||||
expect(orientations, contains('DeviceOrientation.landscapeRight'));
|
||||
expect(orientations, hasLength(2)); // Only landscape orientations
|
||||
});
|
||||
|
||||
test('DeviceOrientation enum values are accessible', () {
|
||||
// Test that we can access the DeviceOrientation enum values
|
||||
final orientations = [
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
];
|
||||
|
||||
expect(orientations, hasLength(4));
|
||||
expect(orientations, contains(DeviceOrientation.landscapeLeft));
|
||||
expect(orientations, contains(DeviceOrientation.landscapeRight));
|
||||
expect(orientations, contains(DeviceOrientation.portraitUp));
|
||||
expect(orientations, contains(DeviceOrientation.portraitDown));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
void main() {
|
||||
group('Percentage-based Keymap Tests', () {
|
||||
test('Should encode touch position as percentage using fallback screen size', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButtons.shiftUpRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
|
||||
);
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
expect(encoded, contains('x_percent'));
|
||||
expect(encoded, contains('y_percent'));
|
||||
// Should use fallback screen size of 1920x1080
|
||||
expect(encoded, contains('0.5')); // 960/1920 and 540/1080 = 0.5
|
||||
});
|
||||
|
||||
test('Should encode touch position as percentages with fallback when screen size not available', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButtons.shiftDownLeft],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
|
||||
);
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
expect(encoded, contains('x_percent'));
|
||||
expect(encoded, contains('y_percent'));
|
||||
// Should use fallback screen size of 1920x1080
|
||||
expect(encoded, contains('0.5')); // 960/1920 and 540/1080 = 0.5
|
||||
});
|
||||
|
||||
test('Should decode percentage-based touch position correctly', () {
|
||||
final encoded =
|
||||
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x_percent":0.5,"y_percent":0.5},"isLongPress":false}';
|
||||
|
||||
final keyPair = KeyPair.decode(encoded);
|
||||
expect(keyPair, isNotNull);
|
||||
// Since no real screen is available in tests, it should return Offset.zero or use fallback
|
||||
expect(keyPair!.touchPosition, isNotNull);
|
||||
});
|
||||
|
||||
test('Should decode pixel-based touch position correctly (backward compatibility)', () {
|
||||
final encoded =
|
||||
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x":300,"y":600},"isLongPress":false}';
|
||||
|
||||
final keyPair = KeyPair.decode(encoded);
|
||||
expect(keyPair, isNotNull);
|
||||
expect(keyPair!.touchPosition.dx, 300);
|
||||
expect(keyPair.touchPosition.dy, 600);
|
||||
});
|
||||
|
||||
test('Should handle zero touch position correctly', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButtons.shiftUpLeft],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
touchPosition: Offset.zero,
|
||||
);
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
// Should encode as percentages even when position is zero
|
||||
expect(encoded, contains('x_percent'));
|
||||
expect(encoded, contains('y_percent'));
|
||||
expect(encoded, contains('0.0'));
|
||||
});
|
||||
|
||||
test('Should encode and decode with fallback screen size', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButtons.shiftUpRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(480, 270), // 25% of 1920x1080
|
||||
);
|
||||
|
||||
// Encode (will use fallback screen size)
|
||||
final encoded = keyPair.encode();
|
||||
|
||||
// Decode (will also use fallback or available screen size)
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.touchPosition, isNotNull);
|
||||
});
|
||||
|
||||
test('Should handle decoding when no screen size available', () {
|
||||
final encoded =
|
||||
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x_percent":0.5,"y_percent":0.5},"isLongPress":false}';
|
||||
|
||||
final keyPair = KeyPair.decode(encoded);
|
||||
expect(keyPair, isNotNull);
|
||||
// When no screen size is available, it may return Offset.zero as fallback
|
||||
expect(keyPair!.touchPosition, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
134
test/screenshot_test.dart
Normal file
134
test/screenshot_test.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:file/file.dart';
|
||||
import 'package:file/local.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.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/theme.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:test_screenshot/test_screenshot.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
PackageInfo.setMockInitialValues(
|
||||
appName: 'BikeControl',
|
||||
packageName: 'de.jonasbark.swiftcontrol',
|
||||
version: '3.5.0',
|
||||
buildNumber: '1',
|
||||
buildSignature: '',
|
||||
);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
group('Screenshot Tests', () {
|
||||
final List<(String type, Size size)> sizes = [
|
||||
('Phone', Size(400, 800)),
|
||||
('iPhone', Size(1242, 2688)),
|
||||
('macOS', Size(1280, 800)),
|
||||
('GitHub', Size(600, 900)),
|
||||
];
|
||||
|
||||
testWidgets('Requirements', (WidgetTester tester) async {
|
||||
await tester.loadFonts();
|
||||
for (final size in sizes) {
|
||||
await _createRequirementScreenshot(tester, size);
|
||||
}
|
||||
|
||||
// Reset
|
||||
});
|
||||
testWidgets('Device', (WidgetTester tester) async {
|
||||
await tester.loadFonts();
|
||||
for (final size in sizes) {
|
||||
await _createDeviceScreenshot(tester, size);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _createDeviceScreenshot(WidgetTester tester, (String type, Size size) spec) async {
|
||||
// Set phone screen size (typical Android phone - 1140x2616 to match existing)
|
||||
tester.view.physicalSize = spec.$2;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
||||
screenshotMode = true;
|
||||
|
||||
await settings.init();
|
||||
await settings.reset();
|
||||
settings.setTrainerApp(MyWhoosh());
|
||||
settings.setKeyMap(MyWhoosh());
|
||||
settings.setLastTarget(Target.thisDevice);
|
||||
|
||||
connection.addDevices([
|
||||
ZwiftRide(
|
||||
BleDevice(
|
||||
name: 'Controller',
|
||||
deviceId: '00:11:22:33:44:55',
|
||||
),
|
||||
)
|
||||
..firmwareVersion = '1.2.0'
|
||||
..rssi = -51
|
||||
..batteryLevel = 81,
|
||||
]);
|
||||
|
||||
await tester.pumpWidget(
|
||||
Screenshotter(
|
||||
child: MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'BikeControl',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.light,
|
||||
home: const DevicePage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const wait = 1;
|
||||
|
||||
try {
|
||||
await tester.pumpAndSettle(Duration(seconds: wait), EnginePhase.sendSemanticsUpdate, Duration(seconds: wait));
|
||||
} catch (e) {
|
||||
// Ignore timeout errors
|
||||
}
|
||||
|
||||
await _takeScreenshot(tester, 'device-${spec.$1}-${spec.$2.width.toInt()}x${spec.$2.height.toInt()}.png', spec.$2);
|
||||
}
|
||||
|
||||
Future<void> _createRequirementScreenshot(WidgetTester tester, (String type, Size size) spec) async {
|
||||
// Set phone screen size (typical Android phone - 1140x2616 to match existing)
|
||||
tester.view.physicalSize = spec.$2;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
||||
await settings.init();
|
||||
await settings.reset();
|
||||
screenshotMode = true;
|
||||
await tester.pumpWidget(
|
||||
Screenshotter(
|
||||
child: SwiftPlayApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await _takeScreenshot(
|
||||
tester,
|
||||
'screenshot-${spec.$1}-${spec.$2.width.toInt()}x${spec.$2.height.toInt()}.png',
|
||||
spec.$2,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _takeScreenshot(WidgetTester tester, String path, Size size) async {
|
||||
const FileSystem fs = LocalFileSystem();
|
||||
final file = fs.file('screenshots/$path');
|
||||
await fs.directory('screenshots').create();
|
||||
print('File path: ${file.absolute.path}');
|
||||
|
||||
await tester.screenshot(path: 'screenshots/$path');
|
||||
final decodedImage = await decodeImageFromList(file.readAsBytesSync());
|
||||
// resize image
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/utils/settings/settings.dart';
|
||||
|
||||
void main() {
|
||||
group('Vibration Setting Tests', () {
|
||||
late Settings settings;
|
||||
|
||||
setUp(() async {
|
||||
// Initialize SharedPreferences with in-memory storage for testing
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
settings = Settings();
|
||||
await settings.init();
|
||||
});
|
||||
|
||||
test('Vibration setting should default to true', () {
|
||||
expect(settings.getVibrationEnabled(), true);
|
||||
});
|
||||
|
||||
test('Vibration setting should persist when set to false', () async {
|
||||
await settings.setVibrationEnabled(false);
|
||||
expect(settings.getVibrationEnabled(), false);
|
||||
});
|
||||
|
||||
test('Vibration setting should persist when set to true', () async {
|
||||
await settings.setVibrationEnabled(true);
|
||||
expect(settings.getVibrationEnabled(), true);
|
||||
});
|
||||
|
||||
test('Vibration setting should toggle correctly', () async {
|
||||
// Start with default (true)
|
||||
expect(settings.getVibrationEnabled(), true);
|
||||
|
||||
// Toggle to false
|
||||
await settings.setVibrationEnabled(false);
|
||||
expect(settings.getVibrationEnabled(), false);
|
||||
|
||||
// Toggle back to true
|
||||
await settings.setVibrationEnabled(true);
|
||||
expect(settings.getVibrationEnabled(), true);
|
||||
});
|
||||
|
||||
test('Vibration setting should persist across Settings instances', () async {
|
||||
// Set vibration to false
|
||||
await settings.setVibrationEnabled(false);
|
||||
expect(settings.getVibrationEnabled(), false);
|
||||
|
||||
// Create a new Settings instance
|
||||
final newSettings = Settings();
|
||||
await newSettings.init();
|
||||
|
||||
// Should still be false
|
||||
expect(newSettings.getVibrationEnabled(), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const SwiftPlayApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
|
||||
void main() {
|
||||
group('Zwift Ride Analog Paddle - ZigZag Encoding Tests', () {
|
||||
test('Should correctly decode positive ZigZag values', () {
|
||||
// Test ZigZag decoding algorithm: (n >>> 1) ^ -(n & 1)
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
|
||||
expect(_zigzagDecode(0), 0); // 0 -> 0
|
||||
expect(_zigzagDecode(2), 1); // 2 -> 1
|
||||
expect(_zigzagDecode(4), 2); // 4 -> 2
|
||||
expect(_zigzagDecode(threshold * 2), threshold); // threshold value
|
||||
expect(_zigzagDecode(200), 100); // 200 -> 100 (max positive)
|
||||
});
|
||||
|
||||
test('Should correctly decode negative ZigZag values', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
|
||||
expect(_zigzagDecode(1), -1); // 1 -> -1
|
||||
expect(_zigzagDecode(3), -2); // 3 -> -2
|
||||
expect(_zigzagDecode(threshold * 2 - 1), -threshold); // negative threshold
|
||||
expect(_zigzagDecode(199), -100); // 199 -> -100 (max negative)
|
||||
});
|
||||
|
||||
test('Should handle boundary values correctly', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
|
||||
// Test values at the detection threshold
|
||||
expect(_zigzagDecode(threshold * 2).abs(), threshold);
|
||||
expect(_zigzagDecode(threshold * 2 - 1).abs(), threshold);
|
||||
|
||||
// Test maximum paddle values (±100)
|
||||
expect(_zigzagDecode(200), 100);
|
||||
expect(_zigzagDecode(199), -100);
|
||||
});
|
||||
});
|
||||
|
||||
group('Zwift Ride Analog Paddle - Protocol Buffer Varint Decoding', () {
|
||||
test('Should decode single-byte varint values', () {
|
||||
// Values 0-127 fit in a single byte
|
||||
final buffer1 = Uint8List.fromList([0x00]); // 0
|
||||
expect(_decodeVarint(buffer1, 0).$1, 0);
|
||||
expect(_decodeVarint(buffer1, 0).$2, 1); // Consumed 1 byte
|
||||
|
||||
final buffer2 = Uint8List.fromList([0x0A]); // 10
|
||||
expect(_decodeVarint(buffer2, 0).$1, 10);
|
||||
|
||||
final buffer3 = Uint8List.fromList([0x7F]); // 127
|
||||
expect(_decodeVarint(buffer3, 0).$1, 127);
|
||||
});
|
||||
|
||||
test('Should decode multi-byte varint values', () {
|
||||
// Values >= 128 require multiple bytes
|
||||
final buffer1 = Uint8List.fromList([0xC7, 0x01]); // ZigZag encoded -100 (199)
|
||||
expect(_decodeVarint(buffer1, 0).$1, 199);
|
||||
expect(_decodeVarint(buffer1, 0).$2, 2); // Consumed 2 bytes
|
||||
|
||||
final buffer2 = Uint8List.fromList([0xC8, 0x01]); // ZigZag encoded 100 (200)
|
||||
expect(_decodeVarint(buffer2, 0).$1, 200);
|
||||
expect(_decodeVarint(buffer2, 0).$2, 2);
|
||||
});
|
||||
|
||||
test('Should handle varint decoding with offset', () {
|
||||
// Test decoding from a specific offset in the buffer
|
||||
final buffer = Uint8List.fromList([0xFF, 0xFF, 0xC8, 0x01]); // Garbage + 200
|
||||
expect(_decodeVarint(buffer, 2).$1, 200);
|
||||
expect(_decodeVarint(buffer, 2).$2, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('Zwift Ride Analog Paddle - Protocol Buffer Wire Type Parsing', () {
|
||||
test('Should correctly extract field number and wire type from tag', () {
|
||||
// Tag format: (field_number << 3) | wire_type
|
||||
|
||||
// Field 1, wire type 0 (varint)
|
||||
final tag1 = 0x08; // 1 << 3 | 0
|
||||
expect(tag1 >> 3, 1); // field number
|
||||
expect(tag1 & 0x7, 0); // wire type
|
||||
|
||||
// Field 2, wire type 0 (varint)
|
||||
final tag2 = 0x10; // 2 << 3 | 0
|
||||
expect(tag2 >> 3, 2);
|
||||
expect(tag2 & 0x7, 0);
|
||||
|
||||
// Field 3, wire type 2 (length-delimited)
|
||||
final tag3 = 0x1a; // 3 << 3 | 2
|
||||
expect(tag3 >> 3, 3);
|
||||
expect(tag3 & 0x7, 2);
|
||||
});
|
||||
|
||||
test('Should identify location and value field tags', () {
|
||||
const locationTag = 0x08; // Field 1 (location), wire type 0
|
||||
const valueTag = 0x10; // Field 2 (value), wire type 0
|
||||
const nestedMessageTag = 0x1a; // Field 3 (nested), wire type 2
|
||||
|
||||
expect(locationTag >> 3, 1);
|
||||
expect(valueTag >> 3, 2);
|
||||
expect(nestedMessageTag >> 3, 3);
|
||||
expect(nestedMessageTag & 0x7, 2); // Length-delimited
|
||||
});
|
||||
});
|
||||
|
||||
group('Zwift Ride Analog Paddle - Real-world Scenarios', () {
|
||||
test('Threshold value should trigger paddle detection', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
// At threshold: ZigZag encoding of threshold
|
||||
final zigzagValue = threshold * 2;
|
||||
final decodedValue = _zigzagDecode(zigzagValue);
|
||||
expect(decodedValue, threshold);
|
||||
expect(decodedValue.abs() >= threshold, isTrue);
|
||||
});
|
||||
|
||||
test('Below threshold value should not trigger paddle detection', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
// Below threshold: value = threshold - 1
|
||||
final zigzagValue = (threshold - 1) * 2;
|
||||
final decodedValue = _zigzagDecode(zigzagValue);
|
||||
expect(decodedValue, threshold - 1);
|
||||
expect(decodedValue.abs() >= threshold, isFalse);
|
||||
});
|
||||
|
||||
test('Maximum paddle press (-100) should trigger left paddle', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
// Max left: value = -100, ZigZag = 199 = 0xC7 0x01
|
||||
final zigzagValue = 199;
|
||||
final decodedValue = _zigzagDecode(zigzagValue);
|
||||
expect(decodedValue, -100);
|
||||
expect(decodedValue.abs() >= threshold, isTrue);
|
||||
});
|
||||
|
||||
test('Maximum paddle press (100) should trigger right paddle', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
// Max right: value = 100, ZigZag = 200 = 0xC8 0x01
|
||||
final zigzagValue = 200;
|
||||
final decodedValue = _zigzagDecode(zigzagValue);
|
||||
expect(decodedValue, 100);
|
||||
expect(decodedValue.abs() >= threshold, isTrue);
|
||||
});
|
||||
|
||||
test('Paddle location mapping is correct', () {
|
||||
// Location 0 = left paddle
|
||||
// Location 1 = right paddle
|
||||
const leftPaddleLocation = 0;
|
||||
const rightPaddleLocation = 1;
|
||||
|
||||
expect(leftPaddleLocation, 0);
|
||||
expect(rightPaddleLocation, 1);
|
||||
});
|
||||
|
||||
test('Analog paddle threshold constant is accessible', () {
|
||||
expect(ZwiftRide.analogPaddleThreshold, 25);
|
||||
});
|
||||
});
|
||||
|
||||
group('Zwift Ride Analog Paddle - Message Structure Documentation', () {
|
||||
test('0x1a marker identifies analog message sections', () {
|
||||
const analogSectionMarker = 0x1a;
|
||||
// Field 3 << 3 | wire type 2 = 3 << 3 | 2 = 26 = 0x1a
|
||||
expect(analogSectionMarker, 0x1a);
|
||||
expect(analogSectionMarker >> 3, 3); // Field number
|
||||
expect(analogSectionMarker & 0x7, 2); // Wire type (length-delimited)
|
||||
});
|
||||
|
||||
test('Message offset 7 skips header and button map', () {
|
||||
// Offset breakdown:
|
||||
// [0]: Message type (0x23 for controller notification)
|
||||
// [1]: Button map field tag (0x05)
|
||||
// [2-6]: Button map (5 bytes)
|
||||
// [7]: Start of analog data
|
||||
const messageTypeOffset = 0;
|
||||
const buttonMapTagOffset = 1;
|
||||
const buttonMapOffset = 2;
|
||||
const buttonMapSize = 5;
|
||||
const analogDataOffset = 7;
|
||||
|
||||
expect(analogDataOffset, buttonMapOffset + buttonMapSize);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to test ZigZag decoding algorithm.
|
||||
/// ZigZag encoding maps signed integers to unsigned integers:
|
||||
/// 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3, 2 -> 4, etc.
|
||||
int _zigzagDecode(int n) {
|
||||
return (n >>> 1) ^ -(n & 1);
|
||||
}
|
||||
|
||||
/// Helper function to decode a Protocol Buffer varint from a buffer.
|
||||
/// Returns a record of (value, bytesConsumed).
|
||||
(int, int) _decodeVarint(Uint8List buffer, int offset) {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
var bytesRead = 0;
|
||||
|
||||
while (offset + bytesRead < buffer.length) {
|
||||
final byte = buffer[offset + bytesRead];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
bytesRead++;
|
||||
|
||||
if ((byte & 0x80) == 0) {
|
||||
// MSB is 0, we're done
|
||||
break;
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
return (value, bytesRead);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="SwiftControl - Control your virtual riding">
|
||||
<meta name="description" content="BikeControl - Control your virtual riding">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/ico" href="favicon.ico"/>
|
||||
|
||||
<title>SwiftControl</title>
|
||||
<title>BikeControl</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "SwiftControl",
|
||||
"short_name": "SwiftControl",
|
||||
"name": "BikeControl",
|
||||
"short_name": "BikeControl",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"description": "SwiftControl - Control your virtual riding",
|
||||
"description": "BikeControl - Control your virtual riding",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
|
||||
Reference in New Issue
Block a user