mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
179 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 | ||
|
|
fb1ffec37d | ||
|
|
9ea4f7157a | ||
|
|
a9b43bd347 | ||
|
|
b9ac193e77 | ||
|
|
1d4947b3ae | ||
|
|
74e098e9b1 | ||
|
|
e5dae225f1 | ||
|
|
0339089972 | ||
|
|
1e8bd61264 | ||
|
|
79613bc8de | ||
|
|
d0ec785e32 | ||
|
|
020b91fd21 | ||
|
|
f2406152fd | ||
|
|
ab3ef7be53 | ||
|
|
bb7484ff2e | ||
|
|
80061fd076 | ||
|
|
124e005fb1 | ||
|
|
8e760ef202 | ||
|
|
de740f6453 | ||
|
|
3bde90ae62 | ||
|
|
aee8dc2e07 | ||
|
|
b3952542f8 | ||
|
|
910f23a3f6 | ||
|
|
5cc9ac85af | ||
|
|
e10e22d038 | ||
|
|
aaeaec36a2 | ||
|
|
c16a593f3c | ||
|
|
50d9f47576 | ||
|
|
a53fb578ef | ||
|
|
1f89859a03 | ||
|
|
7c1cee6748 | ||
|
|
a4949ad615 | ||
|
|
a57f4654b0 | ||
|
|
8192d3addf | ||
|
|
308f461ad4 | ||
|
|
513b2ba367 | ||
|
|
69f47fa984 | ||
|
|
4daf553514 | ||
|
|
aeae148e0b | ||
|
|
1a9a265671 | ||
|
|
b06e9ad4b3 | ||
|
|
2b106fd1c9 | ||
|
|
1784e008ee | ||
|
|
33ccdbd7af | ||
|
|
d15f1ddc13 | ||
|
|
e7ea01cd60 | ||
|
|
f7ed426441 | ||
|
|
d30485b82e | ||
|
|
4e646ab922 | ||
|
|
4183ede58d | ||
|
|
dd85e99e4b | ||
|
|
2334a88452 | ||
|
|
ee64b18f75 | ||
|
|
647dac9e7c | ||
|
|
df2496eb67 | ||
|
|
859424b895 | ||
|
|
dde3f38bde | ||
|
|
01744c258e | ||
|
|
231aadbc27 | ||
|
|
a806a628bd | ||
|
|
c529fee1fa | ||
|
|
c36a0252e6 | ||
|
|
66486ec38e | ||
|
|
6f5c6bf1d9 | ||
|
|
8fc8f2dfda | ||
|
|
d36e031e87 | ||
|
|
efac0af4b9 | ||
|
|
d7e73524ad | ||
|
|
80998c955f | ||
|
|
d824cb6207 | ||
|
|
ab80d679e1 | ||
|
|
d4881faab1 | ||
|
|
7c74d61b43 | ||
|
|
8ad2906a17 | ||
|
|
0f4d19080a | ||
|
|
a9a13be6ca | ||
|
|
c66badf39e | ||
|
|
6c2fc54612 | ||
|
|
807c0eaa98 | ||
|
|
7d7b1e89e9 | ||
|
|
cafb7408d9 | ||
|
|
723f741bca | ||
|
|
6a3cc0f8be | ||
|
|
66c548fa75 | ||
|
|
0b42f7e9c5 | ||
|
|
35a995eddc | ||
|
|
c3afb23625 | ||
|
|
f15d97585b | ||
|
|
5f03c072ff | ||
|
|
ce94aea51a | ||
|
|
a27ae070fc | ||
|
|
7bbdc6a4e2 | ||
|
|
3188002ecb | ||
|
|
284d2ca70f | ||
|
|
57961aec5d | ||
|
|
1675d7f2d0 | ||
|
|
baec8d24c3 | ||
|
|
820d0b37db | ||
|
|
c18ac16208 | ||
|
|
2bbc09bf13 | ||
|
|
a968723277 | ||
|
|
8668957738 | ||
|
|
4498729e75 | ||
|
|
ac550fad5b | ||
|
|
c511ac32b6 | ||
|
|
ee48ce0f4e | ||
|
|
8a3d64491b | ||
|
|
b72cc803f0 | ||
|
|
69dd5c85ef | ||
|
|
ea17b2e142 | ||
|
|
da62fc4dc6 | ||
|
|
239630f681 | ||
|
|
d95d0cf8cf | ||
|
|
2b25ba942c | ||
|
|
c65369a746 | ||
|
|
fa7d5e7853 | ||
|
|
8ac47cbd4d | ||
|
|
eb85844503 | ||
|
|
010d0ed331 | ||
|
|
1f8f7765a3 | ||
|
|
68f416dda3 | ||
|
|
49e45faec0 | ||
|
|
c81516350a | ||
|
|
890f393fd6 | ||
|
|
e46969c5c4 | ||
|
|
1ec9b55645 | ||
|
|
b0caf7c13b | ||
|
|
302fc15dd7 | ||
|
|
6a2cf1a1c9 | ||
|
|
8ea73bc54a | ||
|
|
7cbab3925f | ||
|
|
246a1bd2be | ||
|
|
f7e2a89ed6 | ||
|
|
f94252edb9 | ||
|
|
b7b6b9803f | ||
|
|
807d868b74 |
82
.github/workflows/build.yml
vendored
82
.github/workflows/build.yml
vendored
@@ -31,7 +31,7 @@ on:
|
||||
build_web:
|
||||
description: 'Build for Web'
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
@@ -152,6 +152,26 @@ jobs:
|
||||
mkdir -p whatsnew
|
||||
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
|
||||
|
||||
- name: Generate release body
|
||||
if: inputs.build_github
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
- name: 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
|
||||
@@ -185,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
|
||||
@@ -199,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 }}
|
||||
@@ -212,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
|
||||
@@ -221,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
|
||||
@@ -230,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
|
||||
@@ -245,15 +275,14 @@ 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: scripts/RELEASE_NOTES.md
|
||||
bodyFile: /tmp/release_body.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
windows:
|
||||
needs: build
|
||||
if: inputs.build_windows
|
||||
name: Build & Release on Windows
|
||||
runs-on: windows-latest
|
||||
@@ -263,6 +292,15 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
|
||||
($_ -split ' ')[1].Trim()
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
@@ -296,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
|
||||
@@ -318,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
|
||||
@@ -329,24 +367,14 @@ jobs:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.zip
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.msix
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
|
||||
($_ -split ' ')[1].Trim()
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
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"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
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 }}
|
||||
|
||||
29
.github/workflows/patch.yml
vendored
29
.github/workflows/patch.yml
vendored
@@ -75,6 +75,7 @@ jobs:
|
||||
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
|
||||
|
||||
- name: 🚀 Shorebird Patch macOS
|
||||
if: false # patch doesn't work: https://github.com/jonasbark/swiftcontrol/issues/143
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: macos
|
||||
@@ -103,34 +104,45 @@ 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"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
artifacts: "build/macos/Build/Products/Release/BikeControl.macos.zip"
|
||||
bodyFile: /tmp/release_body.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
@@ -143,13 +155,6 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
#2 Setup Java
|
||||
- name: Set Up Java
|
||||
uses: actions/setup-java@v3.12.0
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -10,6 +10,7 @@
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
debug/
|
||||
migrate_working_dir/
|
||||
|
||||
android/keystore.properties
|
||||
@@ -47,3 +48,5 @@ app.*.map.json
|
||||
/android/app/release
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
/screenshots/
|
||||
|
||||
55
CHANGELOG.md
55
CHANGELOG.md
@@ -1,6 +1,37 @@
|
||||
### 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
|
||||
- Cycplus BC2 support (thanks @schneewoehner)
|
||||
- Ignored devices now persist across app restarts - remove them from ignored devices via the menu
|
||||
|
||||
**Fixes:**
|
||||
- resolve issues during app start
|
||||
|
||||
### 3.4.0 (08-11-2025)
|
||||
**New Features:**
|
||||
- Support for Shimano Di2
|
||||
- Support Keyboard shortcuts with modifier keys (Ctrl, Alt, Shift, ...)
|
||||
- Support cheap BLE HID remotes
|
||||
- add Keymap for Rouvy, supporting the new keyboard shortcuts for virtual shifting
|
||||
|
||||
**Fixes:**
|
||||
- fix detection of Elite Square Sterzo devices
|
||||
- recognize cheap Bluetooth device clicks also when BikeControl is in the background
|
||||
|
||||
### 3.3.0 (31-10-2025)
|
||||
|
||||
**New Feature:**
|
||||
**New Features:**
|
||||
- Support for Elite Sterzo (thanks @michidk)
|
||||
- Support for Gamepads
|
||||
- Support for cheap bluetooth remotes (such as [these](https://www.amazon.com/s?k=bluetooth+remote))
|
||||
@@ -18,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
|
||||
|
||||
@@ -28,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
|
||||
|
||||
@@ -57,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)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
**Instructions for using the MyWhoosh Link method**
|
||||
**Instructions for using the MyWhoosh Direct Connect method**
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) launch MyWhoosh Link, check if the "Link" connection works
|
||||
3) close MyWhoosh Link, open SwiftControl, follow on screen instructions
|
||||
Step 2 is not necessary after that once step 3 works
|
||||
3) close MyWhoosh Link
|
||||
4) open BikeControl, follow on screen instructions
|
||||
|
||||
Once you've confirmed the connection in BikeControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
|
||||
|
||||
And here's a video with a few explanations:
|
||||
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
|
||||
60
README.md
60
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
|
||||
- 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
|
||||
Check the compatibility matrix below!
|
||||
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,41 +34,33 @@ Check the compatibility matrix below!
|
||||
- Biketerra.com
|
||||
- Rouvy
|
||||
- Zwift
|
||||
- only Android and Windows support virtual shifting and in-app-navigation
|
||||
- iOS / macOS only support controlling Zwift via keyboard shortcuts or touch controls
|
||||
- running 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
|
||||
- You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
|
||||
|
||||
## Supported Devices
|
||||
- Zwift Click
|
||||
- Zwift Click v2 (mostly, see issue #68)
|
||||
- Zwift Ride
|
||||
- Zwift Play
|
||||
- Shimano Di2
|
||||
- Configure your levers to use D-Fly channels with Shimano E-Tube app
|
||||
- Wahoo Kickr Bike Shift
|
||||
- CYCPLUS BC2 Virtual Shifter
|
||||
- Elite Sterzo Smart (for steering support)
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Gamepads (beta)
|
||||
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
|
||||
- BLE HID devices and classic Bluetooth HID devices are supported
|
||||
- works on Android
|
||||
- on iOS it would require playing back an audio file - let me know if that is of interest to you
|
||||
- on iOS and macOS requires BikeControl to act as media player
|
||||
|
||||
Support for other devices can be added - check the issues tab here on GithUb.
|
||||
Support for other devices can be added; check the issues tab here on GitHub.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
Follow this compatibility matrix. It all depends on where you want to run your trainer app (e.g. MyWhoosh on):
|
||||
|
||||
| Run Trainer app (MyWhoosh, ...) on: | Possible | Link | Information |
|
||||
|-------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Android | ✅ | <a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a> | |
|
||||
| iPad | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically you would use an iPhone or an Android phone for that. |
|
||||
| Windows | ✅ | <a href="https://apps.microsoft.com/detail/9NP42GS03Z26"><img width="270" alt="Microsoft Store" src="https://github.com/user-attachments/assets/7a8a3cd6-ec26-4678-a850-732eedd27c48" /></a> | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
|
||||
| macOS | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a> | |
|
||||
| iPhone | (✅) | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you could use the Link method on another device to control MyWhoosh (and only MyWhoosh) on an iPhone. |
|
||||
| Apple TV | (✅*) | | *only MyWhoosh using the Link method is supported - but you cannot also use MyWhoosh Link at the same time |
|
||||
|
||||
|
||||
For testing purposes you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/) but this is just a tech demo - you won't be able to control other apps.
|
||||
Follow the "Get Started" button over at [swiftcontrol.app](https://swiftcontrol.app) to understand on which platform you want to run 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
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
@@ -76,16 +68,16 @@ 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 "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- if you want to use MyWhoosh you can use the Link method to directly connect to MyWhoosh
|
||||
- for other trainer apps you need to pair SwiftControl to your iPad / tablet via Bluetooth and your phone will send the button presses to your iPad / tablet
|
||||
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
|
||||
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
|
||||
- you can also create your own Keymaps for any other app
|
||||
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
|
||||
- **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 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
|
||||
- You can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
|
||||
|
||||
## Alternatives
|
||||
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app). This can be useful if your trainer app does not support virtual shifting.
|
||||
|
||||
@@ -1,48 +1,49 @@
|
||||
## 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.
|
||||
|
||||
## Link requirement for MyWhoosh stuck at "Waiting for MyWhoosh"
|
||||
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.
|
||||
## MyWhoosh Direct Connect never connects
|
||||
The same network restrictions apply for BikeControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
[INSTRUCTIONS_IOS.md](INSTRUCTIONS_IOS.md)
|
||||
|
||||
In essence:
|
||||
- your two devices (phone, tablet) need to be on the same WiFi network
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.2.0
|
||||
3.5.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Autogenerated from Pigeon (v25.2.0), do not edit directly.
|
||||
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
@@ -12,25 +12,57 @@ import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object AccessibilityApiPigeonUtils {
|
||||
|
||||
private fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
private fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
return a.size == b.size && a.all {
|
||||
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||
deepEquals(it.value, b[it.key])
|
||||
}
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +125,7 @@ data class WindowEvent (
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return packageName == other.packageName
|
||||
&& top == other.top
|
||||
&& bottom == other.bottom
|
||||
&& right == other.right
|
||||
&& left == other.left
|
||||
}
|
||||
return AccessibilityApiPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
@@ -141,6 +168,8 @@ 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 {
|
||||
/** The codec used by Accessibility. */
|
||||
@@ -158,7 +187,7 @@ interface Accessibility {
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.hasPermission())
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -174,7 +203,7 @@ interface Accessibility {
|
||||
api.openPermissions()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -195,7 +224,7 @@ interface Accessibility {
|
||||
api.performTouch(xArg, yArg, isKeyDownArg, isKeyUpArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -213,7 +242,38 @@ interface Accessibility {
|
||||
api.controlMedia(actionArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.ignoreHidDevices()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -274,3 +334,16 @@ abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWra
|
||||
}
|
||||
}
|
||||
|
||||
abstract class HidKeyPressedStreamHandler : AccessibilityApiPigeonEventChannelWrapper<String> {
|
||||
companion object {
|
||||
fun register(messenger: BinaryMessenger, streamHandler: HidKeyPressedStreamHandler, instanceName: String = "") {
|
||||
var channelName: String = "dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed"
|
||||
if (instanceName.isNotEmpty()) {
|
||||
channelName += ".$instanceName"
|
||||
}
|
||||
val internalStreamHandler = AccessibilityApiPigeonStreamHandler<String>(streamHandler)
|
||||
EventChannel(messenger, channelName, AccessibilityApiPigeonMethodCodec).setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jonasbark.accessibility
|
||||
|
||||
import Accessibility
|
||||
import HidKeyPressedStreamHandler
|
||||
import MediaAction
|
||||
import PigeonEventSink
|
||||
import StreamEventsStreamHandler
|
||||
@@ -10,6 +11,7 @@ import android.content.Intent
|
||||
import android.graphics.Rect
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.KeyEvent
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -23,17 +25,21 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
/// when the Flutter Engine is detached from the Activity
|
||||
private lateinit var channel : MethodChannel
|
||||
private lateinit var context: Context
|
||||
private lateinit var eventHandler: EventListener
|
||||
private lateinit var windowEventHandler: WindowEventListener
|
||||
private lateinit var hidEventHandler: HidEventListener
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "accessibility")
|
||||
|
||||
eventHandler = EventListener()
|
||||
windowEventHandler = WindowEventListener()
|
||||
hidEventHandler = HidEventListener()
|
||||
|
||||
context = flutterPluginBinding.applicationContext
|
||||
Accessibility.setUp(flutterPluginBinding.binaryMessenger, this)
|
||||
StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, eventHandler)
|
||||
Observable.fromService = eventHandler
|
||||
StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, windowEventHandler)
|
||||
HidKeyPressedStreamHandler.register(flutterPluginBinding.binaryMessenger, hidEventHandler)
|
||||
Observable.fromServiceWindow = windowEventHandler
|
||||
Observable.fromServiceKeys = hidEventHandler
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -45,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
|
||||
@@ -59,12 +69,12 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
val audioService = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
|
||||
when (action) {
|
||||
MediaAction.PLAY_PAUSE -> {
|
||||
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
|
||||
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
|
||||
audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
|
||||
audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
|
||||
}
|
||||
MediaAction.NEXT -> {
|
||||
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
|
||||
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
|
||||
audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT))
|
||||
audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_NEXT))
|
||||
}
|
||||
MediaAction.VOLUME_DOWN -> {
|
||||
audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI)
|
||||
@@ -75,16 +85,20 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
}
|
||||
}
|
||||
|
||||
override fun ignoreHidDevices() {
|
||||
Observable.ignoreHidDevices = true
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class EventListener : StreamEventsStreamHandler(), Receiver {
|
||||
class WindowEventListener : StreamEventsStreamHandler(), Receiver {
|
||||
private var eventSink: PigeonEventSink<WindowEvent>? = null
|
||||
|
||||
override fun onListen(p0: Any?, sink: PigeonEventSink<WindowEvent>) {
|
||||
eventSink = sink
|
||||
}
|
||||
|
||||
fun onEventsDone() {
|
||||
override fun onCancel(p0: Any?) {
|
||||
eventSink?.endOfStream()
|
||||
eventSink = null
|
||||
}
|
||||
@@ -93,4 +107,27 @@ class EventListener : StreamEventsStreamHandler(), Receiver {
|
||||
eventSink?.success(WindowEvent(packageName = packageName, right = window.right.toLong(), left = window.left.toLong(), bottom = window.bottom.toLong(), top = window.top.toLong()))
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class HidEventListener : HidKeyPressedStreamHandler(), Receiver {
|
||||
|
||||
private var keyEventSink: PigeonEventSink<String>? = null
|
||||
|
||||
override fun onListen(p0: Any?, sink: PigeonEventSink<String>) {
|
||||
keyEventSink = sink
|
||||
}
|
||||
|
||||
override fun onChange(packageName: String, window: Rect) {
|
||||
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent) {
|
||||
val keyString = KeyEvent.keyCodeToString(event.keyCode)
|
||||
keyEventSink?.success(keyString)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ package de.jonasbark.accessibility
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.accessibilityservice.GestureDescription.StrokeDescription
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.Context
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
|
||||
@@ -37,7 +42,7 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
val currentPackageName = event.packageName.toString()
|
||||
val windowSize = getWindowSize()
|
||||
Observable.fromService?.onChange(packageName = currentPackageName, window = windowSize)
|
||||
Observable.fromServiceWindow?.onChange(packageName = currentPackageName, window = windowSize)
|
||||
}
|
||||
|
||||
private fun getWindowSize(): Rect {
|
||||
@@ -51,6 +56,47 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
Log.d("AccessibilityService", "Service Interrupted")
|
||||
}
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
// Request key event filtering so we receive onKeyEvent for hardware/HID media keys
|
||||
try {
|
||||
val info = serviceInfo ?: AccessibilityServiceInfo()
|
||||
info.flags = info.flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
|
||||
// keep other capabilities as defined in XML
|
||||
setServiceInfo(info)
|
||||
} catch (e: Exception) {
|
||||
Log.w("AccessibilityService", "Failed to set service info for key events: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
|
||||
// Handle media and volume keys from HID devices here
|
||||
Log.d(
|
||||
"AccessibilityService",
|
||||
"onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}"
|
||||
)
|
||||
|
||||
// Forward key events to the plugin (Flutter) and swallow them so they don't propagate.
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
Observable.fromServiceKeys?.onKeyEvent(event)
|
||||
}
|
||||
// Return true to indicate we've handled the event and it should be swallowed.
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBleRemote(event: KeyEvent): Boolean {
|
||||
val dev = InputDevice.getDevice(event.deviceId) ?: return false
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
dev.isExternal
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
|
||||
val gestureBuilder = GestureDescription.Builder()
|
||||
val path = Path()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package de.jonasbark.accessibility
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.KeyEvent
|
||||
|
||||
object Observable {
|
||||
var toService: Listener? = null
|
||||
var fromService: Receiver? = null
|
||||
var fromServiceWindow: Receiver? = null
|
||||
var fromServiceKeys: Receiver? = null
|
||||
var ignoreHidDevices: Boolean = false
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
@@ -13,4 +16,5 @@ interface Listener {
|
||||
|
||||
interface Receiver {
|
||||
fun onChange(packageName: String, window: Rect)
|
||||
fun onKeyEvent(event: KeyEvent)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ abstract class Accessibility {
|
||||
void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
|
||||
void controlMedia(MediaAction action);
|
||||
|
||||
bool isRunning();
|
||||
|
||||
void ignoreHidDevices();
|
||||
}
|
||||
|
||||
enum MediaAction { playPause, next, volumeUp, volumeDown }
|
||||
@@ -32,4 +36,5 @@ class WindowEvent {
|
||||
@EventChannelApi()
|
||||
abstract class EventChannelMethods {
|
||||
WindowEvent streamEvents();
|
||||
String hidKeyPressed();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Autogenerated from Pigeon (v25.2.0), do not edit directly.
|
||||
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
@@ -14,6 +14,20 @@ PlatformException _createConnectionError(String channelName) {
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
|
||||
(b as Map<Object?, Object?>).containsKey(entry.key) &&
|
||||
_deepEquals(entry.value, b[entry.key]));
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
|
||||
enum MediaAction {
|
||||
playPause,
|
||||
@@ -74,12 +88,7 @@ class WindowEvent {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return
|
||||
packageName == other.packageName
|
||||
&& top == other.top
|
||||
&& bottom == other.bottom
|
||||
&& right == other.right
|
||||
&& left == other.left;
|
||||
return _deepEquals(encode(), other.encode());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -232,6 +241,57 @@ class Accessibility {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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?>(
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
|
||||
@@ -245,3 +305,14 @@ Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
|
||||
});
|
||||
}
|
||||
|
||||
Stream<String> hidKeyPressed( {String instanceName = ''}) {
|
||||
if (instanceName.isNotEmpty) {
|
||||
instanceName = '.$instanceName';
|
||||
}
|
||||
final EventChannel hidKeyPressedChannel =
|
||||
EventChannel('dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed$instanceName', pigeonMethodCodec);
|
||||
return hidKeyPressedChannel.receiveBroadcastStream().map((dynamic event) {
|
||||
return event as String;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeViewClicked"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagDefault"
|
||||
android:accessibilityFlags="flagDefault|flagRequestFilterKeyEvents"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:canRequestFilterKeyEvents="true"
|
||||
android:canPerformGestures="true"
|
||||
android:notificationTimeout="100"/>
|
||||
|
||||
@@ -11,6 +11,10 @@ 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):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -38,6 +42,8 @@ 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`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
@@ -60,6 +66,10 @@ 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:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -84,6 +94,8 @@ 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
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
@@ -14,13 +16,14 @@
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-16" y="-40"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
|
||||
@@ -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>
|
||||
@@ -24,21 +24,22 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app connects to your trainer app on your local network.</string>
|
||||
<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>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-peripheral</string>
|
||||
<string>bluetooth-central</string>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
@@ -88,6 +88,38 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to send modifier key events
|
||||
auto sendModifierKey = [](UINT vkCode, bool down) {
|
||||
WORD sc = (WORD)MapVirtualKey(vkCode, MAPVK_VK_TO_VSC);
|
||||
INPUT in = {0};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wVk = 0;
|
||||
in.ki.wScan = sc;
|
||||
in.ki.dwFlags = KEYEVENTF_SCANCODE | (down ? 0 : KEYEVENTF_KEYUP);
|
||||
SendInput(1, &in, sizeof(INPUT));
|
||||
};
|
||||
|
||||
// Helper function to process modifiers
|
||||
auto processModifiers = [&sendModifierKey](const std::vector<std::string>& mods, bool down) {
|
||||
for (const std::string& modifier : mods) {
|
||||
if (modifier == "shiftModifier") {
|
||||
sendModifierKey(VK_SHIFT, down);
|
||||
} else if (modifier == "controlModifier") {
|
||||
sendModifierKey(VK_CONTROL, down);
|
||||
} else if (modifier == "altModifier") {
|
||||
sendModifierKey(VK_MENU, down);
|
||||
} else if (modifier == "metaModifier") {
|
||||
sendModifierKey(VK_LWIN, down);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Press modifier keys first (if keyDown)
|
||||
if (keyDown) {
|
||||
processModifiers(modifiers, true);
|
||||
}
|
||||
|
||||
// Send the main key
|
||||
WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
|
||||
|
||||
INPUT in = {0};
|
||||
@@ -102,6 +134,11 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
}
|
||||
SendInput(1, &in, sizeof(INPUT));
|
||||
|
||||
// Release modifier keys (if keyUp)
|
||||
if (!keyDown) {
|
||||
processModifiers(modifiers, false);
|
||||
}
|
||||
|
||||
/*BYTE byteValue = static_cast<BYTE>(keyCode);
|
||||
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);*/
|
||||
|
||||
|
||||
@@ -6,17 +6,19 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:media_key_detector/media_key_detector.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/gamepad/gamepad_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/utils/requirements/android.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../utils/keymap/apps/my_whoosh.dart';
|
||||
import 'devices/base_device.dart';
|
||||
import 'devices/link/link_device.dart';
|
||||
import 'devices/zwift/constants.dart';
|
||||
import 'messages/notification.dart';
|
||||
|
||||
@@ -25,8 +27,7 @@ class Connection {
|
||||
|
||||
List<BluetoothDevice> get bluetoothDevices => devices.whereType<BluetoothDevice>().toList();
|
||||
List<GamepadDevice> get gamepadDevices => devices.whereType<GamepadDevice>().toList();
|
||||
List<BaseDevice> get controllerDevices => [...bluetoothDevices, ...gamepadDevices];
|
||||
List<BaseDevice> get remoteDevices => devices.whereNot((d) => d is BluetoothDevice || d is GamepadDevice).toList();
|
||||
List<BaseDevice> get controllerDevices => [...bluetoothDevices, ...gamepadDevices, ...devices.whereType<HidDevice>()];
|
||||
|
||||
var _androidNotificationsSetup = false;
|
||||
|
||||
@@ -36,6 +37,7 @@ class Connection {
|
||||
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
|
||||
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => _actionStreams.stream;
|
||||
List<({DateTime date, String entry})> lastLogEntries = [];
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
@@ -44,10 +46,26 @@ class Connection {
|
||||
final _lastScanResult = <BleDevice>[];
|
||||
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isScanning = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isMediaKeyDetectionEnabled = ValueNotifier(false);
|
||||
|
||||
Timer? _gamePadSearchTimer;
|
||||
|
||||
void initialize() {
|
||||
actionStream.listen((log) {
|
||||
lastLogEntries.add((date: DateTime.now(), entry: log.toString()));
|
||||
lastLogEntries = lastLogEntries.takeLast(20).toList();
|
||||
});
|
||||
|
||||
isMediaKeyDetectionEnabled.addListener(() {
|
||||
if (!isMediaKeyDetectionEnabled.value) {
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: false);
|
||||
mediaKeyDetector.removeListener(_onMediaKeyDetectedListener);
|
||||
} else {
|
||||
mediaKeyDetector.addListener(_onMediaKeyDetectedListener);
|
||||
mediaKeyDetector.setIsPlaying(isPlaying: true);
|
||||
}
|
||||
});
|
||||
|
||||
UniversalBle.onAvailabilityChange = (available) {
|
||||
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
|
||||
if (available == AvailabilityState.poweredOn && !kIsWeb) {
|
||||
@@ -66,7 +84,7 @@ class Connection {
|
||||
_connectionStreams.add(existingDevice); // Notify UI of update
|
||||
}
|
||||
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
|
||||
_lastScanResult.add(result);
|
||||
|
||||
if (kDebugMode) {
|
||||
@@ -77,7 +95,7 @@ class Connection {
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${scanResult.runtimeType}'));
|
||||
_addDevices([scanResult]);
|
||||
addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
final data = manufacturerData
|
||||
@@ -90,14 +108,26 @@ class Connection {
|
||||
}
|
||||
};
|
||||
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) async {
|
||||
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
if (device == null) {
|
||||
_actionStreams.add(LogNotification('Device not found: $deviceId'));
|
||||
UniversalBle.disconnect(deviceId);
|
||||
return;
|
||||
} else {
|
||||
device.processCharacteristic(characteristicUuid, value);
|
||||
try {
|
||||
await device.processCharacteristic(characteristicUuid, value);
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
|
||||
),
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,7 +154,7 @@ class Connection {
|
||||
).then((devices) async {
|
||||
final baseDevices = devices.mapNotNull(BluetoothDevice.fromScanResult).toList();
|
||||
if (baseDevices.isNotEmpty) {
|
||||
_addDevices(baseDevices);
|
||||
addDevices(baseDevices);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -135,74 +165,85 @@ class Connection {
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BluetoothDevice.servicesToScan)),
|
||||
);
|
||||
|
||||
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
|
||||
if (!kIsWeb) {
|
||||
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
addDevices(pads);
|
||||
|
||||
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
|
||||
for (var device in removedDevices) {
|
||||
devices.remove(device);
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
_addDevices(pads);
|
||||
|
||||
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
|
||||
for (var device in removedDevices) {
|
||||
devices.remove(device);
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
addDevices(pads);
|
||||
});
|
||||
});
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
_addDevices(pads);
|
||||
});
|
||||
|
||||
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
|
||||
startMyWhooshServer();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startMyWhooshServer() {
|
||||
return whooshLink.startServer(
|
||||
onConnected: (socket) {
|
||||
final existing = remoteDevices.firstOrNullWhere(
|
||||
(e) => e is LinkDevice && e.identifier == socket.remoteAddress.address,
|
||||
if (settings.getMyWhooshLinkEnabled() &&
|
||||
settings.getTrainerApp() is MyWhoosh &&
|
||||
!whooshLink.isStarted.value &&
|
||||
whooshLink.isCompatible(settings.getLastTarget()!)) {
|
||||
startMyWhooshServer().catchError((e) {
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
'Error starting MyWhoosh Direct Connect server. Please make sure the "MyWhoosh Link" app is not already running on this device.\n$e',
|
||||
),
|
||||
);
|
||||
if (existing != null) {
|
||||
existing.isConnected = true;
|
||||
signalChange(existing);
|
||||
}
|
||||
},
|
||||
onDisconnected: (socket) {
|
||||
final device = devices.firstOrNullWhere(
|
||||
(device) => device is LinkDevice && device.identifier == socket.remoteAddress.address,
|
||||
);
|
||||
if (device != null) {
|
||||
devices.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _addDevices(List<BaseDevice> dev) {
|
||||
final newDevices = dev.where((device) => !devices.contains(device)).toList();
|
||||
devices.addAll(newDevices);
|
||||
_connectionQueue.addAll(newDevices);
|
||||
|
||||
_handleConnectionQueue();
|
||||
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
// start foreground service only when app is in foreground
|
||||
NotificationRequirement.setup().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startMyWhooshServer() {
|
||||
return whooshLink.startServer(
|
||||
onConnected: (socket) {},
|
||||
onDisconnected: (socket) {},
|
||||
);
|
||||
}
|
||||
|
||||
void addDevices(List<BaseDevice> dev) {
|
||||
final ignoredDevices = settings.getIgnoredDevices();
|
||||
final ignoredDeviceIds = ignoredDevices.map((d) => d.id).toSet();
|
||||
final newDevices = dev.where((device) {
|
||||
if (devices.contains(device)) return false;
|
||||
|
||||
// Check if device is in the ignored list
|
||||
if (device is BluetoothDevice) {
|
||||
if (ignoredDeviceIds.contains(device.device.deviceId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
devices.addAll(newDevices);
|
||||
_connectionQueue.addAll(newDevices);
|
||||
|
||||
_handleConnectionQueue();
|
||||
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
}
|
||||
|
||||
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}'));
|
||||
@@ -236,7 +277,7 @@ class Connection {
|
||||
device.isConnected = state;
|
||||
_connectionStreams.add(device);
|
||||
if (!device.isConnected) {
|
||||
disconnect(device, forget: true);
|
||||
disconnect(device, forget: false);
|
||||
// try reconnect
|
||||
performScanning();
|
||||
}
|
||||
@@ -310,17 +351,41 @@ class Connection {
|
||||
if (device.isConnected) {
|
||||
await device.disconnect();
|
||||
}
|
||||
if (device is! LinkDevice) {
|
||||
// keep it in the list to allow reconnect
|
||||
devices.remove(device);
|
||||
}
|
||||
if (!forget && device is BluetoothDevice) {
|
||||
|
||||
if (device is BluetoothDevice) {
|
||||
if (forget) {
|
||||
// Add device to ignored list when forgetting
|
||||
await settings.addIgnoredDevice(device.device.deviceId, device.name);
|
||||
_actionStreams.add(LogNotification('Device ignored: ${device.name}'));
|
||||
}
|
||||
|
||||
// Clean up subscriptions and scan results for reconnection
|
||||
_lastScanResult.removeWhere((b) => b.deviceId == device.device.deviceId);
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
|
||||
// Remove device from the list
|
||||
devices.remove(device);
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
}
|
||||
|
||||
signalChange(device);
|
||||
}
|
||||
|
||||
void _onMediaKeyDetectedListener(MediaKey mediaKey) {
|
||||
final hidDevice = HidDevice('HID Device');
|
||||
final keyPressed = mediaKey.name;
|
||||
|
||||
final button = actionHandler.supportedApp!.keymap.getOrAddButton(keyPressed, () => ControllerButton(keyPressed));
|
||||
|
||||
var availableDevice = connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
|
||||
if (availableDevice == null) {
|
||||
connection.addDevices([hidDevice]);
|
||||
availableDevice = hidDevice;
|
||||
}
|
||||
availableDevice.handleButtonsClicked([button]);
|
||||
availableDevice.handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -29,7 +30,7 @@ abstract class BaseDevice {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return runtimeType.toString();
|
||||
return name;
|
||||
}
|
||||
|
||||
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
|
||||
@@ -39,6 +40,16 @@ abstract class BaseDevice {
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
try {
|
||||
await _handleButtonsClickedInternal(buttonsClicked);
|
||||
} catch (e, st) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification('Error handling button clicks: $e\n$st'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
@@ -93,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'dart:async';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
@@ -16,6 +18,7 @@ import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import 'cycplus/cycplus_bc2.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
import 'elite/elite_sterzo.dart';
|
||||
|
||||
@@ -37,6 +40,8 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
CycplusBc2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
|
||||
@@ -48,32 +53,31 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
'SQUARE' => EliteSquare(scanResult),
|
||||
null => null,
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
|
||||
device = WahooKickrBikeShift(scanResult);
|
||||
}
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
|
||||
device = EliteSterzo(scanResult);
|
||||
}
|
||||
} else {
|
||||
device = switch (scanResult.name) {
|
||||
null => null,
|
||||
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
|
||||
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
|
||||
// otherwise the service UUIDs will be used
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scanResult.name != null) {
|
||||
if (scanResult.name!.toUpperCase().startsWith('STERZO')) {
|
||||
device = EliteSterzo(scanResult);
|
||||
} else if (scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (device != null) {
|
||||
@@ -103,10 +107,6 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
} else if (scanResult.services.contains(SquareConstants.SERVICE_UUID)) {
|
||||
return EliteSquare(scanResult);
|
||||
} else if (scanResult.services.contains(SterzoConstants.SERVICE_UUID)) {
|
||||
return EliteSterzo(scanResult);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
@@ -115,14 +115,14 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
|
||||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult.deviceId == other.scanResult.deviceId;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.hashCode;
|
||||
int get hashCode => scanResult.deviceId.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return runtimeType.toString();
|
||||
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
|
||||
}
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
@@ -140,6 +140,41 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
}
|
||||
|
||||
final services = await UniversalBle.discoverServices(device.deviceId);
|
||||
final deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
}
|
||||
|
||||
final batteryService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_BATTERY_SERVICE_UUID.toLowerCase(),
|
||||
);
|
||||
|
||||
final batteryCharacteristic = batteryService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL.toLowerCase(),
|
||||
);
|
||||
if (batteryCharacteristic != null) {
|
||||
final batteryData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
batteryService!.uuid,
|
||||
batteryCharacteristic.uuid,
|
||||
);
|
||||
if (batteryData.isNotEmpty) {
|
||||
batteryLevel = batteryData.first;
|
||||
connection.signalChange(this);
|
||||
}
|
||||
}
|
||||
|
||||
await handleServices(services);
|
||||
}
|
||||
|
||||
@@ -157,7 +192,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
device.name?.screenshot ?? device.runtimeType.toString(),
|
||||
device.name?.screenshot ?? runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
@@ -172,7 +207,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
}),
|
||||
Text('$batteryLevel%'),
|
||||
],
|
||||
if (firmwareVersion != null) Text(' - Firmware: $firmwareVersion'),
|
||||
if (firmwareVersion != null) Text(' - v$firmwareVersion'),
|
||||
if (firmwareVersion != null &&
|
||||
this is ZwiftDevice &&
|
||||
firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion) ...[
|
||||
|
||||
128
lib/bluetooth/devices/cycplus/cycplus_bc2.dart
Normal file
128
lib/bluetooth/devices/cycplus/cycplus_bc2.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class CycplusBc2 extends BluetoothDevice {
|
||||
CycplusBc2(super.scanResult)
|
||||
: super(
|
||||
availableButtons: CycplusBc2Buttons.values,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${CycplusBc2Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${CycplusBc2Constants.TX_CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
// Track last state for index 6 and 7
|
||||
int _lastStateIndex6 = 0x00;
|
||||
int _lastStateIndex7 = 0x00;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
if (bytes.length > 7) {
|
||||
final buttonsToPress = <ControllerButton>[];
|
||||
|
||||
// Process index 6 (shift up)
|
||||
final currentByte6 = bytes[6];
|
||||
if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftUp);
|
||||
_lastStateIndex6 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte6, (val) => _lastStateIndex6 = val);
|
||||
}
|
||||
|
||||
// Process index 7 (shift down)
|
||||
final currentByte7 = bytes[7];
|
||||
if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftDown);
|
||||
_lastStateIndex7 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte7, (val) => _lastStateIndex7 = val);
|
||||
}
|
||||
|
||||
handleButtonsClicked(buttonsToPress);
|
||||
} else {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'CYCPLUS BC2 received unexpected packet: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join()}',
|
||||
),
|
||||
);
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
// Check if we should trigger a shift based on current and last state
|
||||
bool _shouldTriggerShift(int currentByte, int lastByte) {
|
||||
const pressedValues = {0x01, 0x02, 0x03};
|
||||
|
||||
// State change from one pressed value to another different pressed value
|
||||
// This is the ONLY time we trigger a shift
|
||||
if (pressedValues.contains(currentByte) && pressedValues.contains(lastByte) && currentByte != lastByte) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update state tracking
|
||||
void _updateState(int currentByte, void Function(int) setState) {
|
||||
const pressedValues = {0x01, 0x02, 0x03};
|
||||
const releaseValue = 0x00;
|
||||
|
||||
// Button released: current is 0x00 and last was pressed
|
||||
if (currentByte == releaseValue) {
|
||||
setState(releaseValue);
|
||||
}
|
||||
// Lock the new pressed state
|
||||
else if (pressedValues.contains(currentByte)) {
|
||||
setState(currentByte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CycplusBc2Constants {
|
||||
// Nordic UART Service (NUS) - commonly used by CYCPLUS BC2
|
||||
static const String SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
// TX Characteristic - device sends data to app
|
||||
static const String TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
// RX Characteristic - app sends data to device (not used for button reading)
|
||||
static const String RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
}
|
||||
|
||||
class CycplusBc2Buttons {
|
||||
static const ControllerButton shiftUp = ControllerButton(
|
||||
'shiftUp',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
);
|
||||
|
||||
static const ControllerButton shiftDown = ControllerButton(
|
||||
'shiftDown',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
shiftUp,
|
||||
shiftDown,
|
||||
];
|
||||
}
|
||||
@@ -40,11 +40,11 @@ class EliteSquare extends BluetoothDevice {
|
||||
actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)'));
|
||||
|
||||
if (_lastValue != null) {
|
||||
final currentRelevantPart = fullValue.length >= 19
|
||||
? fullValue.substring(6, fullValue.length - 13)
|
||||
final currentRelevantPart = fullValue.length >= 14
|
||||
? fullValue.substring(6, 14)
|
||||
: fullValue.substring(6);
|
||||
final lastRelevantPart = _lastValue!.length >= 19
|
||||
? _lastValue!.substring(6, _lastValue!.length - 13)
|
||||
final lastRelevantPart = _lastValue!.length >= 14
|
||||
? _lastValue!.substring(6, 14)
|
||||
: _lastValue!.substring(6);
|
||||
|
||||
if (currentRelevantPart != lastRelevantPart) {
|
||||
|
||||
@@ -66,7 +66,7 @@ class EliteSterzo extends BluetoothDevice {
|
||||
);
|
||||
|
||||
// Subscribe to measurement notifications
|
||||
await UniversalBle.subscribeIndications(
|
||||
await UniversalBle.subscribeNotifications(
|
||||
device.deviceId,
|
||||
service.uuid,
|
||||
measurementChar.uuid,
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
|
||||
import '../../../widgets/warning.dart';
|
||||
@@ -24,25 +23,12 @@ class GamepadDevice extends BaseDevice {
|
||||
Gamepads.eventsByGamepad(id).listen((event) {
|
||||
actionStreamInternal.add(LogNotification('Gamepad event: $event'));
|
||||
|
||||
ControllerButton? button = availableButtons.firstOrNullWhere((b) => b.name == event.key);
|
||||
ControllerButton? button = actionHandler.supportedApp?.keymap.getOrAddButton(
|
||||
event.key,
|
||||
() => ControllerButton(event.key),
|
||||
);
|
||||
|
||||
if (button == null) {
|
||||
button = ControllerButton(event.key);
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
availableButtons.add(button);
|
||||
actionHandler.supportedApp?.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final buttonsClicked = event.value == 0.0 ? [button] : <ControllerButton>[];
|
||||
final buttonsClicked = event.value == 0.0 && button != null ? [button] : <ControllerButton>[];
|
||||
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
|
||||
handleButtonsClicked(buttonsClicked);
|
||||
}
|
||||
|
||||
37
lib/bluetooth/devices/hid/hid_device.dart
Normal file
37
lib/bluetooth/devices/hid/hid_device.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
|
||||
class HidDevice extends BaseDevice {
|
||||
HidDevice(super.name, {super.availableButtons = const []});
|
||||
|
||||
@override
|
||||
Future<void> connect() {
|
||||
return Future.value(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: Text(name)),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Ignore'),
|
||||
onTap: () {
|
||||
connection.disconnect(this, forget: true);
|
||||
if (actionHandler is AndroidActions) {
|
||||
(actionHandler as AndroidActions).ignoreHidDevices();
|
||||
} else if (connection.isMediaKeyDetectionEnabled.value) {
|
||||
connection.isMediaKeyDetectionEnabled.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ 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';
|
||||
|
||||
class WhooshLink {
|
||||
Socket? _socket;
|
||||
@@ -37,48 +39,59 @@ class WhooshLink {
|
||||
required void Function(Socket socket) onConnected,
|
||||
required void Function(Socket socket) onDisconnected,
|
||||
}) async {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
21587,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
try {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
21587,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to start server: $e');
|
||||
}
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
rethrow;
|
||||
}
|
||||
isStarted.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_server!.port}');
|
||||
}
|
||||
|
||||
// Accept connection
|
||||
_server!.listen((Socket socket) {
|
||||
_socket = socket;
|
||||
onConnected(socket);
|
||||
isConnected.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
_server!.listen(
|
||||
(Socket socket) {
|
||||
_socket = socket;
|
||||
onConnected(socket);
|
||||
isConnected.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
// TODO we could check if virtual shifting is enabled
|
||||
final message = utf8.decode(data);
|
||||
print('Received message: $message');
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
onDisconnected(socket);
|
||||
isConnected.value = false;
|
||||
},
|
||||
);
|
||||
});
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
// TODO we could check if virtual shifting is enabled
|
||||
final message = utf8.decode(data);
|
||||
print('Received message: $message');
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
onDisconnected(socket);
|
||||
isConnected.value = false;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String sendAction(InGameAction action, int? value) {
|
||||
ActionResult sendAction(InGameAction action, int? value) {
|
||||
final jsonObject = switch (action) {
|
||||
InGameAction.shiftUp => {
|
||||
'MessageType': 'Controls',
|
||||
@@ -133,9 +146,18 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
bool isCompatible(Target target) {
|
||||
return kIsWeb
|
||||
? false
|
||||
: switch (target) {
|
||||
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class LinkDevice extends BaseDevice {
|
||||
String identifier;
|
||||
|
||||
LinkDevice(this.identifier) : super('MyWhoosh Link', availableButtons: []);
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
isConnected = true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
super.disconnect();
|
||||
whooshLink.stopServer();
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: settings.getMyWhooshLinkEnabled(),
|
||||
onChanged: (value) {
|
||||
settings.setMyWhooshLinkEnabled(value);
|
||||
if (!value) {
|
||||
disconnect();
|
||||
connection.disconnect(this, forget: true);
|
||||
} else if (value) {
|
||||
connection.startMyWhooshServer();
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
title: Text('Enable MyWhoosh Link'),
|
||||
subtitle: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (!settings.getMyWhooshLinkEnabled())
|
||||
Text('Disabled')
|
||||
else ...[
|
||||
Text(
|
||||
isConnected ? "Connected" : "Connecting to MyWhoosh...",
|
||||
),
|
||||
if (!isConnected) SmallProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
|
||||
},
|
||||
icon: Icon(Icons.help_outline),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
101
lib/bluetooth/devices/shimano/shimano_di2.dart
Normal file
101
lib/bluetooth/devices/shimano/shimano_di2.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class ShimanoDi2 extends BluetoothDevice {
|
||||
ShimanoDi2(super.scanResult) : super(availableButtons: []);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ShimanoDi2Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${ShimanoDi2Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${ShimanoDi2Constants.D_FLY_CHANNEL_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
final _lastButtons = <int, int>{};
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) {
|
||||
final channels = bytes.sublist(1);
|
||||
|
||||
// On first data reception, just initialize the state without triggering buttons
|
||||
if (!_isInitialized) {
|
||||
channels.forEachIndexed((int value, int index) {
|
||||
final readableIndex = index + 1;
|
||||
_lastButtons[index] = value;
|
||||
|
||||
actionHandler.supportedApp?.keymap.getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
);
|
||||
});
|
||||
_isInitialized = true;
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final clickedButtons = <ControllerButton>[];
|
||||
|
||||
channels.forEachIndexed((int value, int index) {
|
||||
final didChange = _lastButtons[index] != value;
|
||||
_lastButtons[index] = value;
|
||||
|
||||
final readableIndex = index + 1;
|
||||
|
||||
final button = actionHandler.supportedApp?.keymap.getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
);
|
||||
if (didChange && button != null) {
|
||||
clickedButtons.add(button);
|
||||
}
|
||||
});
|
||||
|
||||
if (clickedButtons.isNotEmpty) {
|
||||
handleButtonsClicked(clickedButtons);
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
'Make sure to set your Di2 buttons to D-Fly channels in the Shimano E-TUBE app.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
'Use a custom keymap to support ${scanResult.name}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShimanoDi2Constants {
|
||||
static const String SERVICE_UUID = "000018ef-5348-494d-414e-4f5f424c4500";
|
||||
|
||||
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
|
||||
}
|
||||
@@ -6,14 +6,14 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'constants.dart';
|
||||
|
||||
class ZwiftClick extends ZwiftDevice {
|
||||
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftDownLeft]);
|
||||
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftUpLeft]);
|
||||
|
||||
@override
|
||||
List<ControllerButton> processClickNotification(Uint8List message) {
|
||||
final status = ClickKeyPadStatus.fromBuffer(message);
|
||||
final buttonsClicked = [
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButtons.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftDownLeft,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftUpLeft,
|
||||
];
|
||||
return buttonsClicked;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
@@ -21,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 {
|
||||
@@ -32,29 +32,6 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
);
|
||||
}
|
||||
|
||||
final deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
if (firmwareVersion != latestFirmwareVersion) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
@@ -73,6 +50,14 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await setupHandshake();
|
||||
|
||||
if (firmwareVersion != latestFirmwareVersion) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setupHandshake() async {
|
||||
@@ -172,7 +157,8 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
@override
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
settings.getVibrationEnabled() &&
|
||||
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]),
|
||||
@@ -296,21 +297,21 @@ class ZwiftEmulator {
|
||||
),
|
||||
],
|
||||
);
|
||||
print('Starting advertising with HID service...');
|
||||
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);
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp_vendor.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
@@ -50,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]);
|
||||
@@ -61,19 +63,6 @@ class ZwiftRide extends ZwiftDevice {
|
||||
);
|
||||
}
|
||||
|
||||
if (this is ZwiftClickV2 &&
|
||||
(bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_1) ||
|
||||
bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_2))) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once per day. Resetting the device now.',
|
||||
),
|
||||
);
|
||||
if (!kDebugMode) {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
}
|
||||
}
|
||||
|
||||
switch (opcode) {
|
||||
case Opcode.RIDE_ON:
|
||||
//print("Empty RideOn response - unencrypted mode");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
@@ -18,16 +17,15 @@ import 'utils/actions/base_actions.dart';
|
||||
final connection = Connection();
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
late BaseActions actionHandler;
|
||||
final accessibilityHandler = Accessibility();
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
final settings = Settings();
|
||||
final whooshLink = WhooshLink();
|
||||
const screenshotMode = false;
|
||||
var screenshotMode = false;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
runApp(const SwiftPlayApp());
|
||||
final error = await settings.init();
|
||||
runApp(SwiftPlayApp(error: error));
|
||||
}
|
||||
|
||||
enum ConnectionType {
|
||||
@@ -62,18 +60,21 @@ Future<void> initializeActions(ConnectionType connectionType) async {
|
||||
}
|
||||
|
||||
class SwiftPlayApp extends StatelessWidget {
|
||||
const SwiftPlayApp({super.key});
|
||||
final String? error;
|
||||
const SwiftPlayApp({super.key, this.error});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'SwiftControl',
|
||||
title: 'BikeControl',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.light,
|
||||
home: const RequirementsPage(),
|
||||
themeMode: ThemeMode.system,
|
||||
home: error != null
|
||||
? Text('There was an error starting the App. Please contact support:\n$error')
|
||||
: const RequirementsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,35 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_auto_rotate_checker/device_auto_rotate_checker.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:swift_control/bluetooth/devices/link/link_device.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';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/manager.dart';
|
||||
import 'package:swift_control/utils/requirements/zwift.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/apps/mywhoosh_link_tile.dart';
|
||||
import 'package:swift_control/widgets/apps/zwift_tile.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
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';
|
||||
import '../utils/actions/android.dart';
|
||||
import '../utils/actions/remote.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/apps/supported_app.dart';
|
||||
@@ -43,6 +50,8 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
|
||||
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
bool _showAutoRotationWarning = false;
|
||||
bool _showMiuiWarning = false;
|
||||
bool _showNameChangeWarning = false;
|
||||
StreamSubscription<bool>? _autoRotateStream;
|
||||
|
||||
@override
|
||||
@@ -50,25 +59,30 @@ 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((_) {
|
||||
_checkAndShowChangelog();
|
||||
});
|
||||
|
||||
whooshLink.isStarted.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
zwiftEmulator.isConnected.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
if (settings.getZwiftEmulatorEnabled() && actionHandler.supportedApp?.supportsZwiftEmulation == true) {
|
||||
zwiftEmulator.startAdvertising(() {
|
||||
if (!kIsWeb) {
|
||||
whooshLink.isStarted.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
zwiftEmulator.isConnected.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
|
||||
if (settings.getZwiftEmulatorEnabled() && actionHandler.supportedApp?.supportsZwiftEmulation == true) {
|
||||
zwiftEmulator.startAdvertising(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS && (actionHandler as RemoteActions).isConnected) {
|
||||
@@ -99,6 +113,11 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
_showAutoRotationWarning = !isEnabled;
|
||||
});
|
||||
});
|
||||
|
||||
// Check if device is MIUI and using local accessibility service
|
||||
if (actionHandler is AndroidActions) {
|
||||
_checkMiuiDevice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +151,29 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkMiuiDevice() async {
|
||||
try {
|
||||
// Don't show if user has dismissed the warning
|
||||
if (settings.getMiuiWarningDismissed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final isMiui =
|
||||
deviceInfo.manufacturer.toLowerCase() == 'xiaomi' ||
|
||||
deviceInfo.brand.toLowerCase() == 'xiaomi' ||
|
||||
deviceInfo.brand.toLowerCase() == 'redmi' ||
|
||||
deviceInfo.brand.toLowerCase() == 'poco';
|
||||
if (isMiui && mounted) {
|
||||
setState(() {
|
||||
_showMiuiWarning = true;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently fail if device info is not available
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAndShowChangelog() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
@@ -152,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;
|
||||
@@ -169,7 +211,9 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
appBar: AppBar(
|
||||
title: AppTitle(),
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
backgroundColor: Theme.brightnessOf(context) == Brightness.light
|
||||
? Theme.of(context).colorScheme.inversePrimary
|
||||
: null,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -181,16 +225,97 @@ 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.'),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
if (_showMiuiWarning)
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Theme.of(context).colorScheme.error),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'MIUI Device Detected',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () async {
|
||||
await settings.setMiuiWarningDismissed(true);
|
||||
setState(() {
|
||||
_showMiuiWarning = false;
|
||||
});
|
||||
},
|
||||
tooltip: 'Dismiss',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Your device is running MIUI, which is known to aggressively kill background services and accessibility services.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'To ensure BikeControl works properly:',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'• Disable battery optimization for BikeControl',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'• Enable autostart for BikeControl',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'• Lock the app in recent apps',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://dontkillmyapp.com/xiaomi');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.open_in_new),
|
||||
label: Text('View Detailed Instructions'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -233,9 +358,8 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
(device) => device.showInformation(context),
|
||||
),
|
||||
|
||||
if (connection.remoteDevices.isNotEmpty ||
|
||||
actionHandler is RemoteActions ||
|
||||
settings.getTrainerApp() is MyWhoosh ||
|
||||
if (actionHandler is RemoteActions ||
|
||||
whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) ||
|
||||
actionHandler.supportedApp?.supportsZwiftEmulation == true)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
@@ -255,23 +379,23 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
...connection.remoteDevices.map(
|
||||
(device) => device.showInformation(context),
|
||||
),
|
||||
|
||||
if (settings.getTrainerApp() is MyWhoosh && !whooshLink.isConnected.value)
|
||||
LinkDevice('').showInformation(context),
|
||||
if (actionHandler.supportedApp?.supportsZwiftEmulation == true)
|
||||
ZwiftRequirement().build(context, () {
|
||||
setState(() {});
|
||||
})!,
|
||||
if (settings.getTrainerApp() is MyWhoosh &&
|
||||
whooshLink.isCompatible(settings.getLastTarget()!))
|
||||
MyWhooshLinkTile(),
|
||||
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
ZwiftTile(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
if (actionHandler is RemoteActions)
|
||||
if (actionHandler is RemoteActions && isAdvertisingPeripheral)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
|
||||
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected (optional)'}',
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (_) => [
|
||||
@@ -285,18 +409,18 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20),
|
||||
StatusWidget(),
|
||||
SizedBox(height: 20),
|
||||
if (!kIsWeb) ...[
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Customize', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -310,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,
|
||||
@@ -321,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: [
|
||||
@@ -413,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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -54,7 +54,9 @@ class _ChangelogPageState extends State<MarkdownPage> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.assetPath.replaceAll('.md', '').toLowerCase().capitalize),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
backgroundColor: Theme.brightnessOf(context) == Brightness.light
|
||||
? Theme.of(context).colorScheme.inversePrimary
|
||||
: null,
|
||||
),
|
||||
body: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
@@ -68,7 +70,10 @@ class _ChangelogPageState extends State<MarkdownPage> {
|
||||
child: MarkdownWidget(
|
||||
markdown: _markdown!,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(fontSize: 14.0, color: Colors.black87),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'dart:io';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/menu.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
import 'device.dart';
|
||||
@@ -30,16 +32,14 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
|
||||
// call after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
settings.init().then((_) {
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
// add more delay due to CBManagerStateUnknown
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
_reloadRequirements();
|
||||
});
|
||||
} else {
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
// add more delay due to CBManagerStateUnknown
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
_reloadRequirements();
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
_reloadRequirements();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,8 +61,10 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AppTitle(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.brightnessOf(context) == Brightness.light
|
||||
? Theme.of(context).colorScheme.inversePrimary
|
||||
: null,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
@@ -77,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),
|
||||
|
||||
@@ -101,10 +103,11 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
],
|
||||
),
|
||||
_requirements.isEmpty
|
||||
? Center(child: CircularProgressIndicator())
|
||||
? Center(child: SmallProgressIndicator())
|
||||
: Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Stepper(
|
||||
key: ObjectKey(_requirements.length),
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
currentStep: _currentStep,
|
||||
connectorColor: WidgetStateProperty.resolveWith<Color>(
|
||||
@@ -169,16 +172,28 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
}
|
||||
|
||||
void _callRequirement(PlatformRequirement req, BuildContext context, VoidCallback onUpdate) {
|
||||
req.call(context, onUpdate).then((_) {
|
||||
_reloadRequirements();
|
||||
});
|
||||
req
|
||||
.call(context, onUpdate)
|
||||
.then((_) {
|
||||
return _reloadRequirements();
|
||||
})
|
||||
.catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error handling requirement "${req.name}": $e'),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _reloadRequirements() {
|
||||
getRequirements(
|
||||
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
|
||||
).then((req) {
|
||||
void _reloadRequirements() async {
|
||||
try {
|
||||
final req = await getRequirements(
|
||||
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
|
||||
);
|
||||
_requirements = req;
|
||||
_currentStep = _currentStep >= _requirements.length ? 0 : _currentStep;
|
||||
setState(() {});
|
||||
final unresolvedIndex = req.indexWhere((req) => !req.status);
|
||||
if (unresolvedIndex != -1) {
|
||||
_currentStep = unresolvedIndex;
|
||||
@@ -198,9 +213,16 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
);
|
||||
}
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
connection.signalNotification(LogNotification('Error loading requirements: $e'));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error loading requirements: $e'),
|
||||
),
|
||||
);
|
||||
_currentStep = 0;
|
||||
_requirements = [ErrorRequirement('Error loading requirements: $e')];
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/button_widget.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
@@ -30,12 +30,12 @@ class TouchAreaSetupPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
File? _backgroundImage;
|
||||
Uint8List? _backgroundImage;
|
||||
final TransformationController _transformationController = TransformationController();
|
||||
|
||||
late Rect _imageRect;
|
||||
|
||||
bool _showAll = false;
|
||||
bool _showFaded = true;
|
||||
|
||||
Future<void> _pickScreenshot() async {
|
||||
final picker = ImagePicker();
|
||||
@@ -45,7 +45,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
final tempImage = File('${tempDir.path}/${actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png');
|
||||
await image.copy(tempImage.path);
|
||||
_backgroundImage = tempImage;
|
||||
_backgroundImage = tempImage.readAsBytesSync();
|
||||
await _calculateBounds();
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
if (_backgroundImage == null) return;
|
||||
|
||||
// need to decode image to get its size so we can have a percentage mapping
|
||||
final decodedImage = await decodeImageFromList(_backgroundImage!.readAsBytesSync());
|
||||
final decodedImage = await decodeImageFromList(_backgroundImage!);
|
||||
// calculate image rectangle in the current screen, given it's boxfit contain
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final imageAspectRatio = decodedImage.width / decodedImage.height;
|
||||
@@ -115,7 +115,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
getTemporaryDirectory().then((tempDir) async {
|
||||
final tempImage = File('${tempDir.path}/${actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png');
|
||||
if (tempImage.existsSync()) {
|
||||
_backgroundImage = tempImage;
|
||||
_backgroundImage = tempImage.readAsBytesSync();
|
||||
setState(() {});
|
||||
|
||||
// wait a bit until device rotation is done
|
||||
@@ -198,41 +198,50 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
top: position.dy,
|
||||
child: Tooltip(
|
||||
message: 'Drag to reposition',
|
||||
child: Draggable(
|
||||
dragAnchorStrategy: (widget, context, position) {
|
||||
final scale = _transformationController.value.getMaxScaleOnAxis();
|
||||
final RenderBox renderObject = context.findRenderObject() as RenderBox;
|
||||
return renderObject.globalToLocal(position).scale(scale, scale);
|
||||
},
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showFaded && widget.keyPair != keyPair ? 0.2 : 1.0,
|
||||
duration: Duration(milliseconds: 300),
|
||||
child: Draggable(
|
||||
dragAnchorStrategy: (widget, context, position) {
|
||||
final scale = _transformationController.value.getMaxScaleOnAxis();
|
||||
final RenderBox renderObject = context.findRenderObject() as RenderBox;
|
||||
return renderObject.globalToLocal(position).scale(scale, scale);
|
||||
},
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: icon,
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDragStarted: () {
|
||||
// Capture the starting position to calculate drag distance later
|
||||
dragStartPosition = position;
|
||||
if (keyPair != widget.keyPair && _showFaded) {
|
||||
setState(() {
|
||||
_showFaded = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
// Calculate drag distance to prevent accidental repositioning from clicks
|
||||
// while allowing legitimate drags even with low velocity (e.g., when overlapping buttons)
|
||||
final dragDistance = dragStartPosition != null
|
||||
? (details.offset - dragStartPosition!).distance
|
||||
: double.infinity;
|
||||
|
||||
// Only update position if dragged more than 5 pixels (prevents accidental clicks)
|
||||
if (dragDistance > 5) {
|
||||
final matrix = Matrix4.inverted(_transformationController.value);
|
||||
final height = 0;
|
||||
final sceneY = details.offset.dy - height;
|
||||
final viewportPoint = MatrixUtils.transformPoint(
|
||||
matrix,
|
||||
Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2),
|
||||
);
|
||||
setState(() => onPositionChanged(viewportPoint));
|
||||
}
|
||||
},
|
||||
child: icon,
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDragStarted: () {
|
||||
// Capture the starting position to calculate drag distance later
|
||||
dragStartPosition = position;
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
// Calculate drag distance to prevent accidental repositioning from clicks
|
||||
// while allowing legitimate drags even with low velocity (e.g., when overlapping buttons)
|
||||
final dragDistance = dragStartPosition != null
|
||||
? (details.offset - dragStartPosition!).distance
|
||||
: double.infinity;
|
||||
|
||||
// Only update position if dragged more than 5 pixels (prevents accidental clicks)
|
||||
if (dragDistance > 5) {
|
||||
final matrix = Matrix4.inverted(_transformationController.value);
|
||||
final height = 0;
|
||||
final sceneY = details.offset.dy - height;
|
||||
final viewportPoint = MatrixUtils.transformPoint(
|
||||
matrix,
|
||||
Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2),
|
||||
);
|
||||
setState(() => onPositionChanged(viewportPoint));
|
||||
}
|
||||
},
|
||||
child: icon,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -246,12 +255,11 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
if (_backgroundImage == null && constraints.biggest != _imageRect.size) {
|
||||
_imageRect = Rect.fromLTWH(0, 0, constraints.maxWidth, constraints.maxHeight);
|
||||
}
|
||||
final keyPairsToShow = _showAll
|
||||
? actionHandler.supportedApp?.keymap.keyPairs
|
||||
.where((kp) => kp.touchPosition != Offset.zero && !kp.isSpecialKey)
|
||||
.toList() ??
|
||||
[]
|
||||
: [widget.keyPair];
|
||||
final keyPairsToShow =
|
||||
actionHandler.supportedApp?.keymap.keyPairs
|
||||
.where((kp) => kp.touchPosition != Offset.zero && !kp.isSpecialKey)
|
||||
.toList() ??
|
||||
[];
|
||||
return InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
child: Stack(
|
||||
@@ -260,7 +268,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Image.file(
|
||||
child: Image.memory(
|
||||
_backgroundImage!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
@@ -339,23 +347,9 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _showAll,
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_showAll = !_showAll;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text('Show all touch areas'),
|
||||
],
|
||||
),
|
||||
child: Text('Choose another screenshot'),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showAll = !_showAll;
|
||||
});
|
||||
_pickScreenshot();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
@@ -401,7 +395,10 @@ class KeypairExplanation extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Icon(keyPair.icon),
|
||||
if (keyPair.inGameAction != null && (whooshLink.isConnected.value || zwiftEmulator.isConnected.value))
|
||||
if (keyPair.inGameAction != null &&
|
||||
((whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) &&
|
||||
settings.getMyWhooshLinkEnabled()) ||
|
||||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())))
|
||||
_KeyWidget(
|
||||
label: [
|
||||
keyPair.inGameAction.toString().split('.').last,
|
||||
@@ -422,7 +419,10 @@ class KeypairExplanation extends StatelessWidget {
|
||||
)
|
||||
else if (keyPair.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
|
||||
_KeyWidget(
|
||||
label: keyPair.logicalKey?.keyLabel ?? 'Unknown',
|
||||
label: [
|
||||
...keyPair.modifiers.map((e) => e.name.replaceAll('Modifier', '')),
|
||||
keyPair.logicalKey?.keyLabel ?? 'Unknown',
|
||||
].joinToString(separator: '+'),
|
||||
),
|
||||
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
|
||||
] else ...[
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -30,6 +30,9 @@ abstract final class AppTheme {
|
||||
FlexThemeData.dark(
|
||||
// Using FlexColorScheme built-in FlexScheme enum based colors.
|
||||
scheme: FlexScheme.redM3,
|
||||
primary: Color(0xFF0E74B7),
|
||||
primaryContainer: Color(0x7C0E9297),
|
||||
onPrimaryContainer: Colors.white,
|
||||
// Component theme configurations for dark mode.
|
||||
subThemesData: const FlexSubThemesData(
|
||||
interactionEffects: true,
|
||||
@@ -45,9 +48,11 @@ abstract final class AppTheme {
|
||||
visualDensity: FlexColorScheme.comfortablePlatformDensity,
|
||||
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
|
||||
).copyWith(
|
||||
scaffoldBackgroundColor: Color(0xff0b1623),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Color(0xFF0E74B7),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: Colors.white24,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/bluetooth/devices/hid/hid_device.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';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
|
||||
@@ -12,6 +14,8 @@ import '../single_line_exception.dart';
|
||||
class AndroidActions extends BaseActions {
|
||||
WindowEvent? windowInfo;
|
||||
|
||||
final accessibilityHandler = Accessibility();
|
||||
|
||||
AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]});
|
||||
|
||||
@override
|
||||
@@ -22,24 +26,41 @@ class AndroidActions extends BaseActions {
|
||||
windowInfo = windowEvent;
|
||||
}
|
||||
});
|
||||
|
||||
hidKeyPressed().listen((keyPressed) {
|
||||
if (supportedApp is CustomApp) {
|
||||
final button = supportedApp.keymap.getOrAddButton(keyPressed, () => ControllerButton(keyPressed));
|
||||
|
||||
final hidDevice = HidDevice('HID Device');
|
||||
var availableDevice = connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
|
||||
if (availableDevice == null) {
|
||||
connection.addDevices([hidDevice]);
|
||||
availableDevice = hidDevice;
|
||||
}
|
||||
availableDevice.handleButtonsClicked([button]);
|
||||
availableDevice.handleButtonsClicked([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -48,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);
|
||||
@@ -56,14 +77,20 @@ 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() {
|
||||
accessibilityHandler.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,14 +107,32 @@ 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 {
|
||||
StubActions({super.supportedModes = const []});
|
||||
|
||||
final List<ControllerButton> performedActions = [];
|
||||
|
||||
@override
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
return Future.value(action.name);
|
||||
Future<ActionResult> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
performedActions.add(action);
|
||||
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);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key clicked: $keyPair';
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
|
||||
return Success('Key clicked: $keyPair');
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
return 'Key pressed: $keyPair';
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
return Success('Key pressed: $keyPair');
|
||||
} else {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key released: $keyPair';
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
|
||||
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,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
@@ -14,15 +15,17 @@ class CustomApp extends SupportedApp {
|
||||
CustomApp({this.profileName = 'Other'})
|
||||
: super(
|
||||
name: profileName,
|
||||
compatibleTargets: [
|
||||
if (!Platform.isIOS) Target.thisDevice,
|
||||
Target.macOS,
|
||||
Target.windows,
|
||||
Target.iOS,
|
||||
Target.android,
|
||||
],
|
||||
compatibleTargets: kIsWeb
|
||||
? [Target.thisDevice]
|
||||
: [
|
||||
if (!Platform.isIOS) Target.thisDevice,
|
||||
Target.macOS,
|
||||
Target.windows,
|
||||
Target.iOS,
|
||||
Target.android,
|
||||
],
|
||||
packageName: "custom_$profileName",
|
||||
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
|
||||
supportsZwiftEmulation: !kIsWeb && !(Platform.isIOS || Platform.isMacOS),
|
||||
keymap: Keymap(keyPairs: []),
|
||||
);
|
||||
|
||||
@@ -49,24 +52,33 @@ class CustomApp extends SupportedApp {
|
||||
ControllerButton zwiftButton, {
|
||||
required PhysicalKeyboardKey? physicalKey,
|
||||
required LogicalKeyboardKey? logicalKey,
|
||||
List<ModifierKey> modifiers = const [],
|
||||
bool isLongPress = false,
|
||||
Offset? touchPosition,
|
||||
InGameAction? inGameAction,
|
||||
int? inGameActionValue,
|
||||
}) {
|
||||
// set the key for the zwift button
|
||||
final keyPair = keymap.getKeyPair(zwiftButton);
|
||||
if (keyPair != null) {
|
||||
keyPair.physicalKey = physicalKey;
|
||||
keyPair.logicalKey = logicalKey;
|
||||
keyPair.modifiers = modifiers;
|
||||
keyPair.isLongPress = isLongPress;
|
||||
keyPair.touchPosition = touchPosition ?? Offset.zero;
|
||||
keyPair.inGameAction = inGameAction;
|
||||
keyPair.inGameActionValue = inGameActionValue;
|
||||
} else {
|
||||
keymap.addKeyPair(
|
||||
KeyPair(
|
||||
buttons: [zwiftButton],
|
||||
physicalKey: physicalKey,
|
||||
logicalKey: logicalKey,
|
||||
modifiers: modifiers,
|
||||
isLongPress: isLongPress,
|
||||
touchPosition: touchPosition ?? Offset.zero,
|
||||
inGameAction: inGameAction,
|
||||
inGameActionValue: inGameActionValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
@@ -10,21 +15,33 @@ 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: null,
|
||||
logicalKey: null,
|
||||
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: null,
|
||||
logicalKey: null,
|
||||
physicalKey: PhysicalKeyboardKey.period,
|
||||
logicalKey: LogicalKeyboardKey.period,
|
||||
touchPosition: Offset(94, 72),
|
||||
),
|
||||
// like escape
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.b],
|
||||
physicalKey: PhysicalKeyboardKey.keyB,
|
||||
logicalKey: LogicalKeyboardKey.keyB,
|
||||
inGameAction: InGameAction.back,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:swift_control/bluetooth/devices/elite/elite_sterzo.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
@@ -75,5 +76,6 @@ class ControllerButton {
|
||||
...ZwiftButtons.values,
|
||||
...EliteSquareButtons.values,
|
||||
...WahooKickrShiftButtons.values,
|
||||
...CycplusBc2Buttons.values,
|
||||
].distinct().toList();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../actions/base_actions.dart';
|
||||
@@ -40,7 +41,14 @@ class Keymap {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
keyPairs = [];
|
||||
for (final keyPair in keyPairs) {
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
keyPair.touchPosition = Offset.zero;
|
||||
keyPair.isLongPress = false;
|
||||
keyPair.inGameAction = null;
|
||||
keyPair.inGameActionValue = null;
|
||||
}
|
||||
_updateStream.add(null);
|
||||
}
|
||||
|
||||
@@ -52,12 +60,32 @@ class Keymap {
|
||||
settings.setKeyMap(actionHandler.supportedApp!);
|
||||
}
|
||||
}
|
||||
|
||||
ControllerButton getOrAddButton(String name, ControllerButton Function() button) {
|
||||
final allButtons = keyPairs.expand((kp) => kp.buttons).toSet().toList();
|
||||
if (allButtons.none((b) => b.name == name)) {
|
||||
final newButton = button();
|
||||
addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [newButton],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
return newButton;
|
||||
} else {
|
||||
return allButtons.firstWhere((b) => b.name == name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
final List<ControllerButton> buttons;
|
||||
PhysicalKeyboardKey? physicalKey;
|
||||
LogicalKeyboardKey? logicalKey;
|
||||
List<ModifierKey> modifiers;
|
||||
Offset touchPosition;
|
||||
bool isLongPress;
|
||||
InGameAction? inGameAction;
|
||||
@@ -67,6 +95,7 @@ class KeyPair {
|
||||
required this.buttons,
|
||||
required this.physicalKey,
|
||||
required this.logicalKey,
|
||||
this.modifiers = const [],
|
||||
this.touchPosition = Offset.zero,
|
||||
this.isLongPress = false,
|
||||
this.inGameAction,
|
||||
@@ -90,14 +119,22 @@ class KeyPair {
|
||||
PhysicalKeyboardKey.audioVolumeUp ||
|
||||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
|
||||
_ when physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard) => Icons.keyboard,
|
||||
_ when inGameAction != null && whooshLink.isConnected.value => Icons.link,
|
||||
_
|
||||
when inGameAction != null &&
|
||||
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
|
||||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())) =>
|
||||
Icons.link,
|
||||
_ => Icons.touch_app,
|
||||
};
|
||||
}
|
||||
|
||||
bool get hasNoAction =>
|
||||
logicalKey == null && physicalKey == null && touchPosition == Offset.zero && inGameAction == null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return logicalKey?.keyLabel ??
|
||||
final baseKey =
|
||||
logicalKey?.keyLabel ??
|
||||
switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Next Track',
|
||||
@@ -107,6 +144,24 @@ 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) {
|
||||
ModifierKey.shiftModifier => 'Shift',
|
||||
ModifierKey.controlModifier => 'Ctrl',
|
||||
ModifierKey.altModifier => 'Alt',
|
||||
ModifierKey.metaModifier => 'Meta',
|
||||
ModifierKey.functionModifier => 'Fn',
|
||||
_ => m.name,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return '${modifierStrings.join('+')}+$baseKey';
|
||||
}
|
||||
|
||||
String encode() {
|
||||
@@ -116,6 +171,7 @@ class KeyPair {
|
||||
'actions': buttons.map((e) => e.name).toList(),
|
||||
if (logicalKey != null) 'logicalKey': logicalKey?.keyId.toString(),
|
||||
if (physicalKey != null) 'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
|
||||
if (modifiers.isNotEmpty) 'modifiers': modifiers.map((e) => e.name).toList(),
|
||||
if (touchPosition != Offset.zero) 'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
|
||||
'isLongPress': isLongPress,
|
||||
'inGameAction': inGameAction?.name,
|
||||
@@ -144,6 +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()
|
||||
: [];
|
||||
|
||||
return KeyPair(
|
||||
buttons: buttons,
|
||||
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
|
||||
@@ -152,6 +217,7 @@ class KeyPair {
|
||||
physicalKey: decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0
|
||||
? PhysicalKeyboardKey(int.parse(decoded['physicalKey']))
|
||||
: null,
|
||||
modifiers: modifiers,
|
||||
touchPosition: touchPosition,
|
||||
isLongPress: decoded['isLongPress'] ?? false,
|
||||
inGameAction: decoded.containsKey('inGameAction')
|
||||
|
||||
@@ -233,8 +233,8 @@ class KeymapManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> duplicate(BuildContext context, String currentProfile) async {
|
||||
final newName = await _showDuplicateProfileDialog(context, currentProfile);
|
||||
Future<String?> duplicate(BuildContext context, String currentProfile, {String? skipName}) async {
|
||||
final newName = skipName ?? await _showDuplicateProfileDialog(context, currentProfile);
|
||||
if (newName != null && newName.isNotEmpty) {
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
await settings.duplicateCustomAppProfile(currentProfile, newName);
|
||||
@@ -261,6 +261,8 @@ class KeymapManager {
|
||||
logicalKey: pair.logicalKey,
|
||||
isLongPress: pair.isLongPress,
|
||||
touchPosition: pair.touchPosition,
|
||||
inGameAction: pair.inGameAction,
|
||||
inGameActionValue: pair.inGameActionValue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class AccessibilityRequirement extends PlatformRequirement {
|
||||
AccessibilityRequirement()
|
||||
: 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
|
||||
@@ -21,7 +24,7 @@ class AccessibilityRequirement extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = await accessibilityHandler.hasPermission();
|
||||
status = await (actionHandler as AndroidActions).accessibilityHandler.hasPermission();
|
||||
}
|
||||
|
||||
Future<void> _showDisclosureDialog(BuildContext context, VoidCallback onUpdate) async {
|
||||
@@ -33,7 +36,7 @@ class AccessibilityRequirement extends PlatformRequirement {
|
||||
onAccept: () {
|
||||
Navigator.of(context).pop();
|
||||
// Open accessibility settings after user consents
|
||||
accessibilityHandler.openPermissions().then((_) {
|
||||
(actionHandler as AndroidActions).accessibilityHandler.openPermissions().then((_) {
|
||||
onUpdate();
|
||||
});
|
||||
},
|
||||
@@ -127,7 +130,7 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
},
|
||||
);
|
||||
|
||||
const String channelGroupId = 'SwiftControl';
|
||||
const String channelGroupId = 'BikeControl';
|
||||
// create the group first
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()!
|
||||
@@ -150,22 +153,39 @@ 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(
|
||||
channelGroupId,
|
||||
'Keep Alive',
|
||||
actions: [AndroidNotificationAction('Exit', 'Exit', cancelNotification: true, showsUserInterface: false)],
|
||||
actions: [
|
||||
AndroidNotificationAction(
|
||||
'Disconnect Devices',
|
||||
'Disconnect Devices',
|
||||
cancelNotification: true,
|
||||
showsUserInterface: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final receivePort = ReceivePort();
|
||||
IsolateNameServer.registerPortWithName(receivePort.sendPort, '_backgroundChannelKey');
|
||||
final backgroundMessagePort = receivePort.asBroadcastStream();
|
||||
backgroundMessagePort.listen((_) {
|
||||
UniversalBle.onAvailabilityChange = null;
|
||||
connection.reset();
|
||||
//exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void notificationTapBackground(NotificationResponse notificationResponse) {
|
||||
if (notificationResponse.actionId != null) {
|
||||
AndroidFlutterLocalNotificationsPlugin().stopForegroundService().then((_) {
|
||||
exit(0);
|
||||
});
|
||||
final sendPort = IsolateNameServer.lookupPortByName('_backgroundChannelKey');
|
||||
sendPort?.send('notificationResponse');
|
||||
//exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
@@ -21,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.',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -65,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;
|
||||
}
|
||||
}
|
||||
@@ -83,6 +86,20 @@ class UnsupportedPlatform extends PlatformRequirement {
|
||||
Future<void> getStatus() async {}
|
||||
}
|
||||
|
||||
class ErrorRequirement extends PlatformRequirement {
|
||||
ErrorRequirement(super.name) {
|
||||
status = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {}
|
||||
}
|
||||
|
||||
typedef BoolFunction = bool Function();
|
||||
|
||||
enum Target {
|
||||
@@ -90,6 +107,10 @@ enum Target {
|
||||
title: 'This Device',
|
||||
icon: Icons.devices,
|
||||
),
|
||||
otherDevice(
|
||||
title: 'Other Device',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
),
|
||||
iOS(
|
||||
title: 'iPhone / iPad / Apple TV',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
@@ -136,13 +157,15 @@ enum Target {
|
||||
'Due to platform restrictions only controlling ${app?.name ?? 'the Trainer app'} on other devices is supported.',
|
||||
Target.thisDevice => 'Run ${app?.name ?? 'the Trainer app'} on this device.',
|
||||
Target.iOS =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on your Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
|
||||
'Run ${app?.name ?? 'the Trainer app'} on an Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
Target.android =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on your Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
|
||||
'Run ${app?.name ?? 'the Trainer app'} on an Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
Target.macOS =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on your Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
|
||||
'Run ${app?.name ?? 'the Trainer app'} on a Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
Target.windows =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on your Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
|
||||
'Run ${app?.name ?? 'the Trainer app'} on a Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
|
||||
Target.otherDevice =>
|
||||
'Run ${app?.name ?? 'the Trainer app'} on another device and control it remotely from this device.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,9 +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 => "Download and use SwiftControl on that Android device.",
|
||||
Target.macOS => "Download and use SwiftControl on that macOS device.",
|
||||
Target.windows => "Download and use SwiftControl on that Windows device.",
|
||||
Target.android => "We highly recommended to download and use 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,
|
||||
};
|
||||
}
|
||||
@@ -199,106 +222,168 @@ class TargetRequirement extends PlatformRequirement {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
DropdownMenu<SupportedApp>(
|
||||
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
|
||||
return DropdownMenuEntry(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(app.name),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: DropdownMenu<SupportedApp>(
|
||||
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
|
||||
return DropdownMenuEntry(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(app.name),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'When running 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.warning_amber),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Trainer app',
|
||||
initialSelection: settings.getTrainerApp(),
|
||||
onSelected: (selectedApp) async {
|
||||
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
|
||||
whooshLink.stopServer();
|
||||
}
|
||||
settings.setTrainerApp(selectedApp!);
|
||||
if (actionHandler.supportedApp == null ||
|
||||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
|
||||
actionHandler.init(selectedApp);
|
||||
settings.setKeyMap(selectedApp);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
Icon(Icons.warning_amber),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Trainer app',
|
||||
initialSelection: settings.getTrainerApp(),
|
||||
onSelected: (selectedApp) async {
|
||||
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
|
||||
whooshLink.stopServer();
|
||||
}
|
||||
settings.setTrainerApp(selectedApp!);
|
||||
if (settings.getLastTarget() == null && Target.thisDevice.isCompatible) {
|
||||
await settings.setLastTarget(Target.thisDevice);
|
||||
}
|
||||
if (actionHandler.supportedApp == null ||
|
||||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
|
||||
actionHandler.init(selectedApp);
|
||||
settings.setKeyMap(selectedApp);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
DropdownMenu<Target>(
|
||||
dropdownMenuEntries: Target.values.map((target) {
|
||||
return DropdownMenuEntry(
|
||||
value: target,
|
||||
label: target.title,
|
||||
enabled: target.isCompatible,
|
||||
trailingIcon: Icon(target.icon),
|
||||
labelWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (target.isBeta) BetaPill(),
|
||||
],
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: DropdownMenu<Target>(
|
||||
dropdownMenuEntries: [Target.thisDevice, Target.otherDevice].map((target) {
|
||||
return DropdownMenuEntry(
|
||||
value: target,
|
||||
label: target.title,
|
||||
leadingIcon: Icon(target.icon),
|
||||
enabled: target.isCompatible,
|
||||
labelWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(target.title),
|
||||
Text(
|
||||
target.getDescription(settings.getTrainerApp()),
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Target device',
|
||||
initialSelection: settings.getLastTarget() != Target.thisDevice ? Target.otherDevice : Target.thisDevice,
|
||||
enabled: settings.getTrainerApp() != null,
|
||||
onSelected: (target) async {
|
||||
if (target != null) {
|
||||
await settings.setLastTarget(target);
|
||||
if (target.warning != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(target.warning!),
|
||||
duration: Duration(seconds: 10),
|
||||
),
|
||||
Text(
|
||||
target.getDescription(settings.getTrainerApp()),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (target == Target.thisDevice)
|
||||
Container(
|
||||
margin: EdgeInsets.only(top: 12),
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dividerColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
if (settings.getLastTarget() != null && settings.getLastTarget() != Target.thisDevice) ...[
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Select the other device where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: DropdownMenu<Target>(
|
||||
dropdownMenuEntries: Target.values
|
||||
.whereNot((e) => [Target.thisDevice, Target.otherDevice].contains(e))
|
||||
.map((target) {
|
||||
return DropdownMenuEntry(
|
||||
value: target,
|
||||
label: target.title,
|
||||
enabled: target.isCompatible,
|
||||
leadingIcon: Icon(target.icon),
|
||||
labelWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
target.title,
|
||||
style: TextStyle(
|
||||
fontWeight: target == Target.thisDevice && target.isCompatible
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (target.isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
target.getDescription(settings.getTrainerApp()),
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Target device',
|
||||
initialSelection: settings.getLastTarget(),
|
||||
onSelected: (target) async {
|
||||
if (target != null) {
|
||||
await settings.setLastTarget(target);
|
||||
initializeActions(target.connectionType);
|
||||
if (target.warning != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(target.warning!),
|
||||
duration: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(),
|
||||
hintText: 'Select Target device',
|
||||
initialSelection: settings.getLastTarget(),
|
||||
enabled: settings.getTrainerApp() != null,
|
||||
onSelected: (target) async {
|
||||
if (target != null) {
|
||||
await settings.setLastTarget(target);
|
||||
initializeActions(target.connectionType);
|
||||
if (target.warning != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(target.warning!),
|
||||
duration: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: settings.getTrainerApp() != null && settings.getLastTarget() != null
|
||||
? () {
|
||||
|
||||
@@ -10,12 +10,13 @@ import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
import '../../pages/markdown.dart';
|
||||
|
||||
final peripheralManager = PeripheralManager();
|
||||
bool _isAdvertising = false;
|
||||
bool isAdvertisingPeripheral = false;
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
@@ -31,23 +32,14 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Widget? buildDescription() {
|
||||
return settings.getLastTarget() == null
|
||||
? null
|
||||
: Text(
|
||||
switch (settings.getLastTarget()) {
|
||||
Target.iOS =>
|
||||
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
|
||||
_ =>
|
||||
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required if you want to use the remote control feature.',
|
||||
},
|
||||
);
|
||||
return Text('Choose your preferred connection method');
|
||||
}
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
isAdvertisingPeripheral = false;
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
startAdvertising(() {});
|
||||
}
|
||||
@@ -89,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;
|
||||
}
|
||||
@@ -259,7 +251,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name:
|
||||
'SwiftControl ${Platform.isIOS
|
||||
'BikeControl ${Platform.isIOS
|
||||
? 'iOS'
|
||||
: Platform.isAndroid
|
||||
? 'Android'
|
||||
@@ -272,7 +264,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
print('Starting advertising with HID service...');
|
||||
|
||||
await peripheralManager.startAdvertising(advertisement);
|
||||
_isAdvertising = true;
|
||||
isAdvertisingPeripheral = true;
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
@@ -297,21 +289,10 @@ 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: 10,
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
@@ -321,14 +302,85 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
onPressed: () async {
|
||||
await toggle();
|
||||
},
|
||||
child: Text(
|
||||
_isAdvertising ? 'Stop Pairing' : 'Start Pairing',
|
||||
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 (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
|
||||
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 {
|
||||
settings.setMyWhooshLinkEnabled(true);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => DevicePage(),
|
||||
settings: RouteSettings(name: '/device'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Connect via MyWhoosh Direct Connect'),
|
||||
Text(
|
||||
'Most reliable way to control MyWhoosh.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.push(
|
||||
@@ -344,49 +396,27 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Use MyWhoosh Link only'),
|
||||
Text('Connect to ${settings.getTrainerApp()?.name} as controller'),
|
||||
Text(
|
||||
'No pairing required, connect directly via MyWhoosh Link.',
|
||||
style: TextStyle(fontSize: 10, color: Colors.black87),
|
||||
'Most reliable way to control ${settings.getTrainerApp()?.name}.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (actionHandler.supportedApp?.supportsZwiftEmulation == true) ...[
|
||||
Text(
|
||||
'You can also skip pairing and directly connect to ${settings.getTrainerApp()?.name} by enabling the Zwift Controller.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => DevicePage(),
|
||||
settings: RouteSettings(name: '/device'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Connect to ${settings.getTrainerApp()?.name} directly as controller'),
|
||||
),
|
||||
],
|
||||
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;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider_windows/path_provider_windows.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:shared_preferences_windows/shared_preferences_windows.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
@@ -13,22 +17,40 @@ import '../keymap/apps/custom_app.dart';
|
||||
class Settings {
|
||||
late final SharedPreferences prefs;
|
||||
|
||||
Future<void> init() async {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
|
||||
|
||||
if (actionHandler is DesktopActions) {
|
||||
// Must add this line.
|
||||
await windowManager.ensureInitialized();
|
||||
}
|
||||
|
||||
Future<String?> init({bool retried = false}) async {
|
||||
try {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
|
||||
|
||||
if (actionHandler is DesktopActions) {
|
||||
// Must add this line.
|
||||
await windowManager.ensureInitialized();
|
||||
}
|
||||
|
||||
final app = getKeyMap();
|
||||
actionHandler.init(app);
|
||||
} catch (e) {
|
||||
// couldn't decode, reset
|
||||
await prefs.clear();
|
||||
rethrow;
|
||||
return null;
|
||||
} catch (e, s) {
|
||||
if (!retried) {
|
||||
if (Platform.isWindows) {
|
||||
// delete settings file
|
||||
final fs = SharedPreferencesWindows.instance.fs;
|
||||
|
||||
final pathProvider = PathProviderWindows();
|
||||
final String? directory = await pathProvider.getApplicationSupportPath();
|
||||
if (directory == null) {
|
||||
return null;
|
||||
}
|
||||
final String fileLocation = path.join(directory, 'shared_preferences.json');
|
||||
final file = fs.file(fileLocation);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
return init(retried: true);
|
||||
} else {
|
||||
return '$e\n$s';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,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());
|
||||
@@ -144,6 +172,7 @@ class Settings {
|
||||
|
||||
Future<void> setLastTarget(Target target) async {
|
||||
await prefs.setString('last_target', target.name);
|
||||
initializeActions(target.connectionType);
|
||||
}
|
||||
|
||||
Future<void> setLastSeenVersion(String version) async {
|
||||
@@ -173,4 +202,64 @@ class Settings {
|
||||
Future<void> setZwiftEmulatorEnabled(bool enabled) async {
|
||||
await prefs.setBool('zwift_emulator_enabled', enabled);
|
||||
}
|
||||
|
||||
bool getMiuiWarningDismissed() {
|
||||
return prefs.getBool('miui_warning_dismissed') ?? false;
|
||||
}
|
||||
|
||||
Future<void> setMiuiWarningDismissed(bool dismissed) async {
|
||||
await prefs.setBool('miui_warning_dismissed', dismissed);
|
||||
}
|
||||
|
||||
List<String> _getIgnoredDeviceIds() {
|
||||
return prefs.getStringList('ignored_device_ids') ?? [];
|
||||
}
|
||||
|
||||
List<String> _getIgnoredDeviceNames() {
|
||||
return prefs.getStringList('ignored_device_names') ?? [];
|
||||
}
|
||||
|
||||
Future<void> addIgnoredDevice(String deviceId, String deviceName) async {
|
||||
final ids = _getIgnoredDeviceIds();
|
||||
final names = _getIgnoredDeviceNames();
|
||||
|
||||
if (!ids.contains(deviceId)) {
|
||||
ids.add(deviceId);
|
||||
names.add(deviceName);
|
||||
await prefs.setStringList('ignored_device_ids', ids);
|
||||
await prefs.setStringList('ignored_device_names', names);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeIgnoredDevice(String deviceId) async {
|
||||
final ids = _getIgnoredDeviceIds();
|
||||
final names = _getIgnoredDeviceNames();
|
||||
|
||||
final index = ids.indexOf(deviceId);
|
||||
if (index != -1) {
|
||||
ids.removeAt(index);
|
||||
names.removeAt(index);
|
||||
await prefs.setStringList('ignored_device_ids', ids);
|
||||
await prefs.setStringList('ignored_device_names', names);
|
||||
}
|
||||
}
|
||||
|
||||
List<({String id, String name})> getIgnoredDevices() {
|
||||
final ids = _getIgnoredDeviceIds();
|
||||
final names = _getIgnoredDeviceNames();
|
||||
|
||||
final result = <({String id, String name})>[];
|
||||
for (int i = 0; i < ids.length && i < names.length; i++) {
|
||||
result.add((id: ids[i], name: names[i]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
93
lib/widgets/apps/mywhoosh_link_tile.dart
Normal file
93
lib/widgets/apps/mywhoosh_link_tile.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../small_progress_indicator.dart';
|
||||
|
||||
class MyWhooshLinkTile extends StatefulWidget {
|
||||
const MyWhooshLinkTile({super.key});
|
||||
|
||||
@override
|
||||
State<MyWhooshLinkTile> createState() => _MywhooshLinkTileState();
|
||||
}
|
||||
|
||||
class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isStarted,
|
||||
builder: (context, isStarted, _) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final myWhooshExplanation = actionHandler is RemoteActions
|
||||
? 'MyWhoosh Direct Connect allows you to do some additional features such as Emotes and turn directions.'
|
||||
: 'MyWhoosh Direct Connect is optional, but allows you to do some additional features such as Emotes and turn directions.';
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: isStarted,
|
||||
onChanged: (value) {
|
||||
settings.setMyWhooshLinkEnabled(value);
|
||||
if (!value) {
|
||||
whooshLink.stopServer();
|
||||
} else if (value) {
|
||||
connection.startMyWhooshServer().catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Error starting MyWhoosh Direct Connect server. Please make sure the "MyWhoosh Link" app is not already running on this device.',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
title: Text('Enable MyWhoosh Direct Connect'),
|
||||
subtitle: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
if (!isStarted)
|
||||
Expanded(
|
||||
child: Text(
|
||||
myWhooshExplanation,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnected ? "Connected" : "Connecting to MyWhoosh...\n$myWhooshExplanation",
|
||||
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (isStarted) SmallProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
|
||||
},
|
||||
icon: Icon(Icons.help_outline),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,21 @@
|
||||
import 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
class ZwiftRequirement extends PlatformRequirement {
|
||||
ZwiftRequirement()
|
||||
: super(
|
||||
'Pair SwiftControl with Zwift',
|
||||
);
|
||||
class ZwiftTile extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
|
||||
const ZwiftTile({super.key, required this.onUpdate});
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
State<ZwiftTile> createState() => _ZwiftTileState();
|
||||
}
|
||||
|
||||
class _ZwiftTileState extends State<ZwiftTile> {
|
||||
@override
|
||||
Widget? buildDescription() {
|
||||
return settings.getLastTarget() == null
|
||||
? null
|
||||
: Text(
|
||||
'In Zwift on your ${settings.getLastTarget()?.title} go into the Pairing settings and select SwiftControl from the list of available controllers.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: zwiftEmulator.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
@@ -39,7 +29,7 @@ class ZwiftRequirement extends PlatformRequirement {
|
||||
if (!value) {
|
||||
zwiftEmulator.stopAdvertising();
|
||||
} else if (value) {
|
||||
zwiftEmulator.startAdvertising(onUpdate);
|
||||
zwiftEmulator.startAdvertising(widget.onUpdate);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
@@ -50,11 +40,7 @@ class ZwiftRequirement extends PlatformRequirement {
|
||||
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 ...[
|
||||
@@ -62,7 +48,7 @@ class ZwiftRequirement extends PlatformRequirement {
|
||||
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(),
|
||||
@@ -75,9 +61,4 @@ class ZwiftRequirement extends PlatformRequirement {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = zwiftEmulator.isConnected.value || screenshotMode;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -30,13 +31,18 @@ class ChangelogDialog extends StatelessWidget {
|
||||
constraints: BoxConstraints(minWidth: 460),
|
||||
child: MarkdownWidget(markdown: latestVersion),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Got it!'))],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Got it!'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -25,6 +25,7 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
KeyDownEvent? _pressedKey;
|
||||
ControllerButton? _pressedButton;
|
||||
final Set<ModifierKey> _activeModifiers = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -52,20 +53,85 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
setState(() {
|
||||
// Track modifier keys
|
||||
if (event is KeyDownEvent) {
|
||||
_pressedKey = event;
|
||||
widget.customApp.setKey(
|
||||
_pressedButton!,
|
||||
physicalKey: _pressedKey!.physicalKey,
|
||||
logicalKey: _pressedKey!.logicalKey,
|
||||
touchPosition: widget.keyPair?.touchPosition,
|
||||
);
|
||||
final wasModifier = _updateModifierState(event.logicalKey, add: true);
|
||||
// Regular key pressed - record it along with active modifiers
|
||||
if (!wasModifier) {
|
||||
if (_pressedKey?.logicalKey != event.logicalKey) {}
|
||||
_pressedKey = event;
|
||||
widget.customApp.setKey(
|
||||
_pressedButton!,
|
||||
physicalKey: _pressedKey!.physicalKey,
|
||||
logicalKey: _pressedKey!.logicalKey,
|
||||
modifiers: _activeModifiers.toList(),
|
||||
touchPosition: widget.keyPair?.touchPosition,
|
||||
);
|
||||
}
|
||||
} else if (event is KeyUpEvent) {
|
||||
// Clear modifier when released
|
||||
_updateModifierState(event.logicalKey, add: false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _updateModifierState(LogicalKeyboardKey key, {required bool add}) {
|
||||
ModifierKey? modifier;
|
||||
|
||||
if (key == LogicalKeyboardKey.shift ||
|
||||
key == LogicalKeyboardKey.shiftLeft ||
|
||||
key == LogicalKeyboardKey.shiftRight) {
|
||||
modifier = ModifierKey.shiftModifier;
|
||||
} else if (key == LogicalKeyboardKey.control ||
|
||||
key == LogicalKeyboardKey.controlLeft ||
|
||||
key == LogicalKeyboardKey.controlRight) {
|
||||
modifier = ModifierKey.controlModifier;
|
||||
} else if (key == LogicalKeyboardKey.alt ||
|
||||
key == LogicalKeyboardKey.altLeft ||
|
||||
key == LogicalKeyboardKey.altRight) {
|
||||
modifier = ModifierKey.altModifier;
|
||||
} else if (key == LogicalKeyboardKey.meta ||
|
||||
key == LogicalKeyboardKey.metaLeft ||
|
||||
key == LogicalKeyboardKey.metaRight) {
|
||||
modifier = ModifierKey.metaModifier;
|
||||
} else if (key == LogicalKeyboardKey.fn) {
|
||||
modifier = ModifierKey.functionModifier;
|
||||
}
|
||||
|
||||
if (modifier != null) {
|
||||
if (add) {
|
||||
_activeModifiers.add(modifier);
|
||||
} else {
|
||||
_activeModifiers.remove(modifier);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _formatModifierName(ModifierKey m) {
|
||||
return switch (m) {
|
||||
ModifierKey.shiftModifier => 'Shift',
|
||||
ModifierKey.controlModifier => 'Ctrl',
|
||||
ModifierKey.altModifier => 'Alt',
|
||||
ModifierKey.metaModifier => 'Meta',
|
||||
ModifierKey.functionModifier => 'Fn',
|
||||
_ => m.name,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatKey(KeyDownEvent? key) {
|
||||
return key?.logicalKey.keyLabel ?? 'Waiting...';
|
||||
if (key == null) {
|
||||
return _activeModifiers.isEmpty ? 'Waiting...' : '${_activeModifiers.map(_formatModifierName).join('+')}+...';
|
||||
}
|
||||
|
||||
if (_activeModifiers.isEmpty) {
|
||||
return key.logicalKey.keyLabel;
|
||||
}
|
||||
|
||||
final modifierStrings = _activeModifiers.map(_formatModifierName);
|
||||
|
||||
return '${modifierStrings.join('+')}+${key.logicalKey.keyLabel}';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
75
lib/widgets/ignored_devices_dialog.dart
Normal file
75
lib/widgets/ignored_devices_dialog.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
|
||||
class IgnoredDevicesDialog extends StatefulWidget {
|
||||
const IgnoredDevicesDialog({super.key});
|
||||
|
||||
@override
|
||||
State<IgnoredDevicesDialog> createState() => _IgnoredDevicesDialogState();
|
||||
}
|
||||
|
||||
class _IgnoredDevicesDialogState extends State<IgnoredDevicesDialog> {
|
||||
List<({String id, String name})> _ignoredDevices = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadIgnoredDevices();
|
||||
}
|
||||
|
||||
void _loadIgnoredDevices() {
|
||||
setState(() {
|
||||
_ignoredDevices = settings.getIgnoredDevices();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _removeDevice(String deviceId) async {
|
||||
await settings.removeIgnoredDevice(deviceId);
|
||||
_loadIgnoredDevices();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Ignored Devices'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: _ignoredDevices.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'No ignored devices.',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _ignoredDevices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = _ignoredDevices[index];
|
||||
return ListTile(
|
||||
title: Text(device.name),
|
||||
subtitle: Text(
|
||||
device.id,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: Icon(Icons.delete_outline),
|
||||
tooltip: 'Remove from ignored list',
|
||||
onPressed: () => _removeDevice(device.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,9 @@ class _ButtonEditor extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final actions = [
|
||||
if (whooshLink.isConnected.value)
|
||||
final trainerApp = settings.getTrainerApp();
|
||||
final actions = <PopupMenuEntry>[
|
||||
if (settings.getMyWhooshLinkEnabled() && whooshLink.isCompatible(settings.getLastTarget()!))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton(
|
||||
itemBuilder: (_) => WhooshLink.supportedActions.map(
|
||||
@@ -182,15 +183,20 @@ class _ButtonEditor extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text('MyWhoosh Link Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.link),
|
||||
Expanded(child: Text('MyWhoosh Direct Connect Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (zwiftEmulator.isConnected.value)
|
||||
if (settings.getZwiftEmulatorEnabled() && settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton(
|
||||
itemBuilder: (_) => ZwiftEmulator.supportedActions.map(
|
||||
@@ -228,11 +234,74 @@ class _ButtonEditor extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text('Zwift Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.link),
|
||||
Expanded(child: Text('Zwift Controller Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -331,23 +400,28 @@ class _ButtonEditor extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
onTap: () {
|
||||
keyPair.isLongPress = !keyPair.isLongPress;
|
||||
onUpdate();
|
||||
},
|
||||
child: ListTile(
|
||||
title: const Text('Long Press Mode (vs. repeating)'),
|
||||
trailing: Checkbox(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
padding: EdgeInsets.zero,
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
|
||||
onUpdate();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
onUpdate();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const Text('Long Press Mode (vs. repeating)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
@@ -356,56 +430,99 @@ class _ButtonEditor extends StatelessWidget {
|
||||
keyPair.isLongPress = false;
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
keyPair.modifiers = [];
|
||||
keyPair.touchPosition = Offset.zero;
|
||||
keyPair.inGameAction = null;
|
||||
keyPair.inGameActionValue = null;
|
||||
onUpdate();
|
||||
},
|
||||
child: const Text('Unassign action'),
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.delete_outline),
|
||||
const Text('Unassign action'),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: kMinInteractiveDimension - 6),
|
||||
padding: EdgeInsets.only(right: actionHandler.supportedApp is CustomApp ? 4 : 0),
|
||||
child: PopupMenuButton(
|
||||
child: PopupMenuButton<dynamic>(
|
||||
itemBuilder: (c) => actions,
|
||||
enabled: actionHandler.supportedApp is CustomApp,
|
||||
child: Row(
|
||||
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;
|
||||
await KeymapManager().duplicate(context, currentProfile);
|
||||
final newName = await KeymapManager().duplicate(
|
||||
context,
|
||||
currentProfile,
|
||||
skipName: '$currentProfile (Copy)',
|
||||
);
|
||||
if (newName != null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).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 {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
class LinkWidget extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
const LinkWidget({super.key, required this.onUpdate});
|
||||
|
||||
@override
|
||||
State<LinkWidget> createState() => _LinkWidgetState();
|
||||
}
|
||||
|
||||
class _LinkWidgetState extends State<LinkWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isStarted,
|
||||
builder: (BuildContext context, value, Widget? child) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: value ? null : () async {},
|
||||
child: Text(value ? 'Waiting for MyWhoosh...' : 'Start Listening for MyWhoosh'),
|
||||
),
|
||||
if (value) SmallProgressIndicator(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
Text('Verify with the MyWhoosh Link app if connection is possible.'),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
);
|
||||
},
|
||||
child: const Text("Show Troubleshooting Guide"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import 'dart:io';
|
||||
|
||||
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() {
|
||||
return [
|
||||
@@ -82,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)
|
||||
@@ -90,8 +93,26 @@ List<Widget> buildMenuButtons() {
|
||||
child: Text('Get Support'),
|
||||
onTap: () {
|
||||
final isFromStore = (Platform.isAndroid ? isFromPlayStore == true : Platform.isIOS);
|
||||
final suffix = isFromStore ? '' : 'ler';
|
||||
launchUrlString('mailto:jonas.t.bark+swiftcontrol$suffix@gmail.com');
|
||||
final suffix = isFromStore ? '' : '-sw';
|
||||
|
||||
String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app');
|
||||
String subject = Uri.encodeComponent("Help requested for BikeControl v${packageInfoValue?.version}");
|
||||
String body = Uri.encodeComponent("""
|
||||
|
||||
|
||||
---
|
||||
App Version: ${packageInfoValue?.version}${shorebirdPatch?.number != null ? '+${shorebirdPatch!.number}' : ''}
|
||||
Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}
|
||||
Target: ${settings.getLastTarget()?.title ?? '-'}
|
||||
Trainer App: ${settings.getTrainerApp()?.name ?? '-'}
|
||||
Connected Controllers: ${connection.devices.map((e) => e.toString()).join(', ')}
|
||||
Logs:
|
||||
${connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')}
|
||||
|
||||
Please don't remove this information, it helps me to assist you better.""");
|
||||
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
|
||||
|
||||
launchUrl(mail);
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -122,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);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -137,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(
|
||||
@@ -154,7 +188,15 @@ class MenuButton extends StatelessWidget {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
|
||||
},
|
||||
),
|
||||
|
||||
PopupMenuItem(
|
||||
child: Text('Ignored Devices'),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => IgnoredDevicesDialog(),
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('License'),
|
||||
onTap: () {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
@@ -57,6 +59,28 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
Text(
|
||||
'Scanning for devices... Make sure they are powered on and in range and not connected to another device.',
|
||||
),
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isIOS || Platform.isWindows))
|
||||
ValueListenableBuilder(
|
||||
valueListenable: connection.isMediaKeyDetectionEnabled,
|
||||
builder: (context, value, child) {
|
||||
return SwitchListTile.adaptive(
|
||||
value: value,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
subtitle: Text(
|
||||
'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: [
|
||||
const Text("Enable Media Key Detection"),
|
||||
],
|
||||
),
|
||||
onChanged: (change) {
|
||||
connection.isMediaKeyDetectionEnabled.value = change;
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:version/version.dart';
|
||||
|
||||
PackageInfo? packageInfoValue;
|
||||
bool? isFromPlayStore;
|
||||
Patch? shorebirdPatch;
|
||||
|
||||
class AppTitle extends StatefulWidget {
|
||||
const AppTitle({super.key});
|
||||
@@ -25,7 +26,6 @@ class AppTitle extends StatefulWidget {
|
||||
|
||||
class _AppTitleState extends State<AppTitle> {
|
||||
final updater = ShorebirdUpdater();
|
||||
Patch? _shorebirdPatch;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -34,7 +34,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
if (updater.isAvailable) {
|
||||
updater.readCurrentPatch().then((patch) {
|
||||
setState(() {
|
||||
_shorebirdPatch = patch;
|
||||
shorebirdPatch = patch;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
|
||||
style: screenshotMode
|
||||
? TextStyle(fontSize: 12)
|
||||
: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
|
||||
)
|
||||
else
|
||||
SmallProgressIndicator(),
|
||||
@@ -160,9 +164,9 @@ class _AppTitleState extends State<AppTitle> {
|
||||
content: Text('Force-close the app to use the new version'),
|
||||
duration: Duration(seconds: 10),
|
||||
action: SnackBarAction(
|
||||
label: 'Attempt Restart',
|
||||
label: 'Restart',
|
||||
onPressed: () {
|
||||
if (Platform.isIOS || Platform.isAndroid) {
|
||||
if (Platform.isIOS) {
|
||||
connection.reset();
|
||||
Restart.restartApp(delayBeforeRestart: 1000);
|
||||
} else {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
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(8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <bluetooth_low_energy_linux/bluetooth_low_energy_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <gamepads_linux/gamepads_linux_plugin.h>
|
||||
#include <media_key_detector_linux/media_key_detector_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
@@ -23,6 +24,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) gamepads_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "GamepadsLinuxPlugin");
|
||||
gamepads_linux_plugin_register_with_registrar(gamepads_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) media_key_detector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKeyDetectorPlugin");
|
||||
media_key_detector_plugin_register_with_registrar(media_key_detector_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
bluetooth_low_energy_linux
|
||||
file_selector_linux
|
||||
gamepads_linux
|
||||
media_key_detector_linux
|
||||
screen_retriever_linux
|
||||
url_launcher_linux
|
||||
window_manager
|
||||
|
||||
BIN
logo.png
BIN
logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
@@ -11,6 +11,7 @@ import file_selector_macos
|
||||
import flutter_local_notifications
|
||||
import gamepads_darwin
|
||||
import keypress_simulator_macos
|
||||
import media_key_detector_macos
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import screen_retriever_macos
|
||||
@@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin"))
|
||||
KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin"))
|
||||
MediaKeyDetectorPlugin.register(with: registry.registrar(forPlugin: "MediaKeyDetectorPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||
|
||||
@@ -13,6 +13,8 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- keypress_simulator_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- media_key_detector_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -41,6 +43,7 @@ DEPENDENCIES:
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`)
|
||||
- keypress_simulator_macos (from `Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos`)
|
||||
- media_key_detector_macos (from `Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
|
||||
@@ -65,6 +68,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos
|
||||
keypress_simulator_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos
|
||||
media_key_detector_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos
|
||||
package_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
|
||||
path_provider_foundation:
|
||||
@@ -90,6 +95,7 @@ SPEC CHECKSUMS:
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
gamepads_darwin: 07af6c60c282902b66574c800e20b2b26e68fda8
|
||||
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
|
||||
media_key_detector_macos: a93757a483b4b47283ade432b1af9e427c47329f
|
||||
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
|
||||
|
||||
@@ -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>
|
||||
|
||||
48
media_key_detector/.gitignore
vendored
Normal file
48
media_key_detector/.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
.DS_Store
|
||||
.atom/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
.packages
|
||||
.pub/
|
||||
.dart_tool/
|
||||
pubspec.lock
|
||||
flutter_export_environment.sh
|
||||
coverage/
|
||||
|
||||
Podfile.lock
|
||||
Pods/
|
||||
.symlinks/
|
||||
**/Flutter/App.framework/
|
||||
**/Flutter/ephemeral/
|
||||
**/Flutter/Flutter.podspec
|
||||
**/Flutter/Flutter.framework/
|
||||
**/Flutter/Generated.xcconfig
|
||||
**/Flutter/flutter_assets/
|
||||
|
||||
ServiceDefinitions.json
|
||||
xcuserdata/
|
||||
**/DerivedData/
|
||||
|
||||
local.properties
|
||||
keystore.properties
|
||||
.gradle/
|
||||
gradlew
|
||||
gradlew.bat
|
||||
gradle-wrapper.jar
|
||||
.flutter-plugins-dependencies
|
||||
*.iml
|
||||
|
||||
generated_plugin_registrant.cc
|
||||
generated_plugin_registrant.h
|
||||
generated_plugin_registrant.dart
|
||||
GeneratedPluginRegistrant.java
|
||||
GeneratedPluginRegistrant.h
|
||||
GeneratedPluginRegistrant.m
|
||||
GeneratedPluginRegistrant.swift
|
||||
build/
|
||||
.flutter-plugins
|
||||
|
||||
.project
|
||||
.classpath
|
||||
.settings
|
||||
43
media_key_detector/README.md
Normal file
43
media_key_detector/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# media_key_detector
|
||||
|
||||
[![Very Good Ventures][logo_white]][very_good_ventures_link_dark]
|
||||
[![Very Good Ventures][logo_black]][very_good_ventures_link_light]
|
||||
|
||||
Developed with 💙 by [Very Good Ventures][very_good_ventures_link] 🦄
|
||||
|
||||
![coverage][coverage_badge]
|
||||
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
|
||||
[![License: MIT][license_badge]][license_link]
|
||||
|
||||
A Very Good Flutter Federated Plugin created by the [Very Good Ventures Team][very_good_ventures_link].
|
||||
|
||||
Generated by the [Very Good CLI][very_good_cli_link] 🤖
|
||||
|
||||
|
||||
### Integration tests 🧪
|
||||
|
||||
Very Good Flutter Plugin uses [fluttium][fluttium_link] for integration tests. Those tests are located
|
||||
in the front facing package `media_key_detector` example.
|
||||
|
||||
**❗ In order to run the integration tests, you need to have the `fluttium_cli` installed. [See how][fluttium_install].**
|
||||
|
||||
To run the integration tests, run the following command from the root of the project:
|
||||
|
||||
```sh
|
||||
cd media_key_detector/example
|
||||
fluttium test flows/test_platform_name.yaml
|
||||
```
|
||||
|
||||
[coverage_badge]: media_key_detector/coverage_badge.svg
|
||||
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
|
||||
[license_link]: https://opensource.org/licenses/MIT
|
||||
[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only
|
||||
[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only
|
||||
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
|
||||
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis
|
||||
[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli
|
||||
[very_good_ventures_link]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core
|
||||
[very_good_ventures_link_dark]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-dark-mode-only
|
||||
[very_good_ventures_link_light]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-light-mode-only
|
||||
[fluttium_link]: https://fluttium.dev/
|
||||
[fluttium_install]: https://fluttium.dev/docs/getting-started/installing-cli
|
||||
7
media_key_detector/media_key_detector/CHANGELOG.md
Normal file
7
media_key_detector/media_key_detector/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 0.0.2
|
||||
|
||||
- TBD
|
||||
|
||||
# 0.0.1
|
||||
|
||||
- Initial Release
|
||||
22
media_key_detector/media_key_detector/LICENSE
Normal file
22
media_key_detector/media_key_detector/LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Evan Kaiser
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
110
media_key_detector/media_key_detector/README.md
Normal file
110
media_key_detector/media_key_detector/README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
[![Pub][pub_badge]][pub_link]
|
||||
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
|
||||
[![License: MIT][license_badge]][license_link]
|
||||
|
||||
# media_key_detector
|
||||
|
||||
Triggers events when keyboard media keys are pressed. On MacOS, it registers
|
||||
your app as a [Now Playable App](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app),
|
||||
allowing it to respond to media events, regardless of whether the event came
|
||||
from a keyboard, headset, or media remote.
|
||||
|
||||
## Features
|
||||
|
||||
- Captures and triggeres events for the following keys:
|
||||
- Play / Pause
|
||||
- Previous / Rewind
|
||||
- Next / Fast-forward
|
||||
|
||||
## Rationale
|
||||
|
||||
In general media key capture works fine using normal keyboard approaches, such
|
||||
as RawKeyListener or FocusNode. However, it only works on Windows/Linux. In
|
||||
MacOS, the key events are not detected, and often will even send the events to
|
||||
an inactive window, like Music.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Add the latest version of this package:
|
||||
|
||||
- Run `flutter pub add media_key_detector` -or-
|
||||
- Edit `pubspec.yaml` and then run `flutter pub get`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
media_key_detector: ^latest_version
|
||||
```
|
||||
|
||||
2. Import the package
|
||||
|
||||
```
|
||||
import 'package:media_key_detector/media_key_detector.dart';
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```dart
|
||||
void _mediaKeyListener(MediaKey mediaKey) {
|
||||
debugPrint('$mediaKey pressed');
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
mediaKeyDetector.addListener(_mediaKeyListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
mediaKeyDetector.removeListener(_mediaKeyListener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The following two methods are only really needed if you're relying on
|
||||
/// the plugin to track the playing state. On MacOS/iOS, this is helpful to
|
||||
/// display the status in the "Command Center".
|
||||
|
||||
/// Get whether the media is playing. Note that it starts out "paused",
|
||||
/// so if your app plays media on open, you should call
|
||||
/// [mediaKeyDetector.setIsPlaying(true)]
|
||||
Future<bool> getIsMediaPlaying() async {
|
||||
return await mediaKeyDetector.getIsPlaying();
|
||||
}
|
||||
|
||||
/// The app tracks the playing state when the user presses the media key,
|
||||
/// but there are some cases, i.e. when a play button is pressed on the UI
|
||||
/// interface, where you may need to set it yourself
|
||||
Future play() async {
|
||||
return await mediaKeyDetector.setIsPlaying(isPlaying: true);
|
||||
}
|
||||
```
|
||||
|
||||
## Code Generation
|
||||
|
||||
[![Very Good Ventures][logo_white]][very_good_ventures_link_dark]
|
||||
[![Very Good Ventures][logo_black]][very_good_ventures_link_light]
|
||||
|
||||
Developed with 💙 by [Very Good Ventures][very_good_ventures_link] 🦄
|
||||
|
||||
A Very Good Flutter Federated Plugin created by the [Very Good Ventures Team][very_good_ventures_link].
|
||||
|
||||
Generated by the [Very Good CLI][very_good_cli_link] 🤖
|
||||
|
||||
## Support
|
||||
|
||||
You can support me by buying me a coffee <a href="https://www.buymeacoffee.com/honeydoodat"><img src="https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png" alt="Buy me a coffee" width="100" /></a>
|
||||
|
||||
And also don't forget to star this package on GitHub <a href="https://github.com/holotrek/media_key_detector"><img src="https://img.shields.io/github/stars/holotrek/media_key_detector?logo=github&style=flat-square"></a>
|
||||
|
||||
[pub_badge]: https://img.shields.io/pub/v/media_key_detector
|
||||
[pub_link]: https://pub.dev/packages/media_key_detector
|
||||
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
|
||||
[license_link]: https://opensource.org/licenses/MIT
|
||||
[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only
|
||||
[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only
|
||||
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
|
||||
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis
|
||||
[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli
|
||||
[very_good_ventures_link]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core
|
||||
[very_good_ventures_link_dark]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-dark-mode-only
|
||||
[very_good_ventures_link_light]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-light-mode-only
|
||||
@@ -0,0 +1 @@
|
||||
include: package:very_good_analysis/analysis_options.5.1.0.yaml
|
||||
20
media_key_detector/media_key_detector/coverage_badge.svg
Normal file
20
media_key_detector/media_key_detector/coverage_badge.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="102" height="20">
|
||||
<linearGradient id="b" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1" />
|
||||
<stop offset="1" stop-opacity=".1" />
|
||||
</linearGradient>
|
||||
<clipPath id="a">
|
||||
<rect width="102" height="20" rx="3" fill="#fff" />
|
||||
</clipPath>
|
||||
<g clip-path="url(#a)">
|
||||
<path fill="#555" d="M0 0h59v20H0z" />
|
||||
<path fill="#44cc11" d="M59 0h43v20H59z" />
|
||||
<path fill="url(#b)" d="M0 0h102v20H0z" />
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
|
||||
<text x="305" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="490">coverage</text>
|
||||
<text x="305" y="140" transform="scale(.1)" textLength="490">coverage</text>
|
||||
<text x="795" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text>
|
||||
<text x="795" y="140" transform="scale(.1)" textLength="330">100%</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
49
media_key_detector/media_key_detector/example/.gitignore
vendored
Normal file
49
media_key_detector/media_key_detector/example/.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
# Web related
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Fluttium related files
|
||||
.fluttium_*_launcher.dart
|
||||
45
media_key_detector/media_key_detector/example/.metadata
Normal file
45
media_key_detector/media_key_detector/example/.metadata
Normal file
@@ -0,0 +1,45 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled.
|
||||
|
||||
version:
|
||||
revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
channel: stable
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
- platform: android
|
||||
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
- platform: ios
|
||||
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
- platform: linux
|
||||
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
- platform: macos
|
||||
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
- platform: web
|
||||
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
- platform: windows
|
||||
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
3
media_key_detector/media_key_detector/example/README.md
Normal file
3
media_key_detector/media_key_detector/example/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# media_key_detector_example
|
||||
|
||||
Demonstrates how to use the media_key_detector plugin.
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:very_good_analysis/analysis_options.5.1.0.yaml
|
||||
linter:
|
||||
rules:
|
||||
public_member_api_docs: false
|
||||
require_trailing_commas: false
|
||||
lines_longer_than_80_chars: false
|
||||
144
media_key_detector/media_key_detector/example/lib/main.dart
Normal file
144
media_key_detector/media_key_detector/example/lib/main.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_key_detector/media_key_detector.dart';
|
||||
|
||||
void main() => runApp(const MyApp());
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const MaterialApp(home: HomePage());
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
String? _platformName;
|
||||
bool _isPlaying = false;
|
||||
Map<MediaKey, bool> keyPressed = {
|
||||
MediaKey.playPause: false,
|
||||
MediaKey.rewind: false,
|
||||
MediaKey.fastForward: false,
|
||||
};
|
||||
|
||||
void _mediaKeyListener(MediaKey mediaKey) {
|
||||
debugPrint('$mediaKey pressed');
|
||||
|
||||
mediaKeyDetector
|
||||
.getIsPlaying()
|
||||
.then((playing) => setState(() => _isPlaying = playing));
|
||||
|
||||
if (keyPressed[mediaKey] == false) {
|
||||
setState(() => keyPressed[mediaKey] = true);
|
||||
Timer(const Duration(seconds: 3), () {
|
||||
setState(() => keyPressed[mediaKey] = false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePlayPause() async {
|
||||
setState(() => _isPlaying = !_isPlaying);
|
||||
await mediaKeyDetector.setIsPlaying(isPlaying: _isPlaying);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
mediaKeyDetector.addListener(_mediaKeyListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
mediaKeyDetector.removeListener(_mediaKeyListener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('MediaKeyDetector Example')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Press a Media button on your IO device to highlight the corresponding icon.'),
|
||||
const Text(
|
||||
'Press the play/pause button to send now playing info to plugin.'),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.fast_rewind_rounded,
|
||||
size: 40,
|
||||
color: (keyPressed[MediaKey.rewind] ?? false)
|
||||
? colors.inversePrimary
|
||||
: colors.onBackground,
|
||||
),
|
||||
IconButton.filled(
|
||||
onPressed: _togglePlayPause,
|
||||
style:
|
||||
IconButton.styleFrom(backgroundColor: colors.secondary),
|
||||
icon: Icon(
|
||||
_isPlaying
|
||||
? Icons.pause_circle_rounded
|
||||
: Icons.play_circle_rounded,
|
||||
size: 40,
|
||||
color: (keyPressed[MediaKey.playPause] ?? false)
|
||||
? colors.inversePrimary
|
||||
: colors.onPrimary,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.fast_forward_rounded,
|
||||
size: 40,
|
||||
color: (keyPressed[MediaKey.fastForward] ?? false)
|
||||
? colors.inversePrimary
|
||||
: colors.onBackground,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text('Is currently playing: $_isPlaying'),
|
||||
if (_platformName == null)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
Text(
|
||||
'Platform Name: $_platformName',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
final result = await getPlatformName();
|
||||
setState(() => _platformName = result);
|
||||
} catch (error) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
content: Text('$error'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Get Platform Name'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
media_key_detector/media_key_detector/example/linux/.gitignore
vendored
Normal file
1
media_key_detector/media_key_detector/example/linux/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
flutter/ephemeral
|
||||
@@ -0,0 +1,106 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
set(BINARY_NAME "example")
|
||||
set(APPLICATION_ID "com.example.verygoodcore_example")
|
||||
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
|
||||
|
||||
# Configure build options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
|
||||
"Debug" "Profile" "Release")
|
||||
endif()
|
||||
|
||||
# Compilation settings that should be applied to most targets.
|
||||
function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_features(${TARGET} PUBLIC cxx_std_14)
|
||||
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
|
||||
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Application build
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
)
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
# the default top-level location.
|
||||
set_target_properties(${BINARY_NAME}
|
||||
PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
|
||||
)
|
||||
|
||||
# Generated plugin build rules, which manage building the plugins and adding
|
||||
# them to the application.
|
||||
include(flutter/generated_plugins.cmake)
|
||||
|
||||
|
||||
# === Installation ===
|
||||
# By default, "installing" just makes a relocatable bundle in the build
|
||||
# directory.
|
||||
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
|
||||
endif()
|
||||
|
||||
# Start with a clean build bundle directory every time.
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
|
||||
" COMPONENT Runtime)
|
||||
|
||||
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
|
||||
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
|
||||
|
||||
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
if(PLUGIN_BUNDLED_LIBRARIES)
|
||||
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
# Fully re-copy the assets directory on each build to avoid having stale files
|
||||
# from a previous install.
|
||||
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
|
||||
install(CODE "
|
||||
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
|
||||
" COMPONENT Runtime)
|
||||
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
|
||||
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
|
||||
|
||||
# Install the AOT library on non-Debug builds only.
|
||||
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
@@ -0,0 +1,88 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
|
||||
|
||||
# Configuration provided via flutter tool.
|
||||
include(${EPHEMERAL_DIR}/generated_config.cmake)
|
||||
|
||||
# TODO: Move the rest of this into files in ephemeral. See
|
||||
# https://github.com/flutter/flutter/issues/57146.
|
||||
|
||||
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
|
||||
# which isn't available in 3.10.
|
||||
function(list_prepend LIST_NAME PREFIX)
|
||||
set(NEW_LIST "")
|
||||
foreach(element ${${LIST_NAME}})
|
||||
list(APPEND NEW_LIST "${PREFIX}${element}")
|
||||
endforeach(element)
|
||||
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# === Flutter Library ===
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
|
||||
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
|
||||
pkg_check_modules(BLKID REQUIRED IMPORTED_TARGET blkid)
|
||||
|
||||
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
|
||||
|
||||
# Published to parent scope for install step.
|
||||
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
|
||||
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
|
||||
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
|
||||
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
|
||||
|
||||
list(APPEND FLUTTER_LIBRARY_HEADERS
|
||||
"fl_basic_message_channel.h"
|
||||
"fl_binary_codec.h"
|
||||
"fl_binary_messenger.h"
|
||||
"fl_dart_project.h"
|
||||
"fl_engine.h"
|
||||
"fl_json_message_codec.h"
|
||||
"fl_json_method_codec.h"
|
||||
"fl_message_codec.h"
|
||||
"fl_method_call.h"
|
||||
"fl_method_channel.h"
|
||||
"fl_method_codec.h"
|
||||
"fl_method_response.h"
|
||||
"fl_plugin_registrar.h"
|
||||
"fl_plugin_registry.h"
|
||||
"fl_standard_message_codec.h"
|
||||
"fl_standard_method_codec.h"
|
||||
"fl_string_codec.h"
|
||||
"fl_value.h"
|
||||
"fl_view.h"
|
||||
"flutter_linux.h"
|
||||
)
|
||||
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
|
||||
add_library(flutter INTERFACE)
|
||||
target_include_directories(flutter INTERFACE
|
||||
"${EPHEMERAL_DIR}"
|
||||
)
|
||||
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
|
||||
target_link_libraries(flutter INTERFACE
|
||||
PkgConfig::GTK
|
||||
PkgConfig::GLIB
|
||||
PkgConfig::GIO
|
||||
PkgConfig::BLKID
|
||||
)
|
||||
add_dependencies(flutter flutter_assemble)
|
||||
|
||||
# === Flutter tool backend ===
|
||||
# _phony_ is a non-existent file to force this command to run every time,
|
||||
# since currently there's no way to get a full input/output list from the
|
||||
# flutter tool.
|
||||
add_custom_command(
|
||||
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
|
||||
${CMAKE_CURRENT_BINARY_DIR}/_phony_
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
${FLUTTER_TOOL_ENVIRONMENT}
|
||||
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
|
||||
linux-x64 ${CMAKE_BUILD_TYPE}
|
||||
)
|
||||
add_custom_target(flutter_assemble DEPENDS
|
||||
"${FLUTTER_LIBRARY}"
|
||||
${FLUTTER_LIBRARY_HEADERS}
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
media_key_detector_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
)
|
||||
|
||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||
|
||||
foreach(plugin ${FLUTTER_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
|
||||
endforeach(plugin)
|
||||
|
||||
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
|
||||
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
|
||||
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
|
||||
endforeach(ffi_plugin)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user