mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
195 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
69f47fa984 | ||
|
|
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 | ||
|
|
c3e8c4666c | ||
|
|
926651ebb3 | ||
|
|
a7d5624582 | ||
|
|
03209740ec | ||
|
|
af6ae3433e | ||
|
|
41f4dd1d57 | ||
|
|
d5c1b67675 | ||
|
|
0b18d74ac9 | ||
|
|
fb3fe5f8c0 | ||
|
|
796c973fd4 | ||
|
|
7c6335c4d1 | ||
|
|
af2267c486 | ||
|
|
56d9e62610 | ||
|
|
7e18a169d4 | ||
|
|
74280eda34 | ||
|
|
e1309d4d95 | ||
|
|
14aa6f7454 | ||
|
|
1368d7d24e | ||
|
|
8c09b170c3 | ||
|
|
080409b984 | ||
|
|
f0ec276547 | ||
|
|
23aafcd7bc | ||
|
|
3718a126ac | ||
|
|
846dd07bf4 | ||
|
|
ba60062a24 | ||
|
|
ed4f928fde | ||
|
|
2a09d550e5 | ||
|
|
bb1ae4e616 | ||
|
|
828aa70a56 | ||
|
|
4021f3131d | ||
|
|
80ef81ca64 | ||
|
|
fec13d012b | ||
|
|
e8ca3fc287 | ||
|
|
d5260d801c | ||
|
|
916b1ec1fc | ||
|
|
7380bb5001 | ||
|
|
2e95fb556a | ||
|
|
90591cbfa2 | ||
|
|
929409db71 | ||
|
|
4263375fb2 | ||
|
|
bb5d149ba4 | ||
|
|
1a322dc0d3 | ||
|
|
d10da94f20 | ||
|
|
7eb28881cb | ||
|
|
823e04d189 | ||
|
|
ca5d4aeadb | ||
|
|
a4d937c4f3 | ||
|
|
fa4add6797 | ||
|
|
ec2ed4e6c5 | ||
|
|
6bd41d9a54 | ||
|
|
1ff2a205bc | ||
|
|
dd73c3249b | ||
|
|
75eef49317 | ||
|
|
e8858e0c7d | ||
|
|
df9142a6bf | ||
|
|
36f312403b | ||
|
|
d8983889ae | ||
|
|
bfaf2f2d29 | ||
|
|
2ba9c284ba | ||
|
|
ef2b4af28a | ||
|
|
ba042cd07d | ||
|
|
f8cb4cff4f | ||
|
|
92010b787b | ||
|
|
e142a8c587 | ||
|
|
759dcaa8b8 | ||
|
|
05939dcf1e | ||
|
|
34494819f5 | ||
|
|
a9491b7fa5 | ||
|
|
311a676aea | ||
|
|
f08714f25a | ||
|
|
1f3352ff80 | ||
|
|
2601844970 |
18
.github/workflows/build.yml
vendored
18
.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,12 @@ jobs:
|
||||
mkdir -p whatsnew
|
||||
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
|
||||
|
||||
- name: Generate release body
|
||||
if: inputs.build_github
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
- name: 🚀 Shorebird Release iOS
|
||||
if: inputs.build_ios
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
@@ -248,7 +254,7 @@ jobs:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
allowUpdates: true
|
||||
prerelease: true
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
bodyFile: /tmp/release_body.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
@@ -340,13 +346,19 @@ jobs:
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Generate release body (Windows)
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
|
||||
- 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
|
||||
bodyFile: /tmp/release_body.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
17
.github/workflows/patch.yml
vendored
17
.github/workflows/patch.yml
vendored
@@ -9,6 +9,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: false
|
||||
name: Patch iOS, Android & macOS
|
||||
runs-on: macos-latest
|
||||
|
||||
@@ -75,6 +76,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
|
||||
@@ -124,13 +126,19 @@ jobs:
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
|
||||
- name: Generate release body
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
# add artifact to release
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
bodyFile: /tmp/release_body.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
@@ -143,13 +151,6 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
#2 Setup Java
|
||||
- name: Set Up Java
|
||||
uses: actions/setup-java@v3.12.0
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,7 @@
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
debug/
|
||||
migrate_working_dir/
|
||||
|
||||
android/keystore.properties
|
||||
@@ -47,3 +48,4 @@ app.*.map.json
|
||||
/android/app/release
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,3 +1,40 @@
|
||||
### 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 SwiftControl is in the background
|
||||
|
||||
### 3.3.0 (31-10-2025)
|
||||
|
||||
**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))
|
||||
- you can now customize the Keymap right from the Customize section
|
||||
- show signal strength of connected devices (thanks @michidk)
|
||||
- Android and Windows only: simulate bluetooth controllers
|
||||
- enables gamepad and bluetooth remotes support for Zwift, Rouvy and Biketerra
|
||||
|
||||
**Fixes:**
|
||||
- fix firmware version display for Zwift Click V2 devices
|
||||
- fix touch position on some Android devices
|
||||
- Wahoo Kickr Bike Shift can now be connected
|
||||
- update default keymap for TrainingPeaks
|
||||
|
||||
### 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
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
Instructions will be added soon
|
||||
**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
|
||||
4) open SwiftControl, follow on screen instructions
|
||||
|
||||
Once you've confirmed the connection in SwiftControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
|
||||
|
||||
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)
|
||||
|
||||
58
README.md
58
README.md
@@ -4,12 +4,12 @@
|
||||
|
||||
## Description
|
||||
|
||||
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
|
||||
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, or other similar devices. Here's what you can do with it, depending on your configuration:
|
||||
- Virtual Gear shifting
|
||||
- Steering / turning
|
||||
- Steering/turning
|
||||
- adjust workout intensity
|
||||
- control music on your device
|
||||
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
|
||||
- more? If you can do it via keyboard, mouse, or touch, you can do it with SwiftControl
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
@@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
|
||||
## Downloads
|
||||
Check the compatibility matrix below!
|
||||
Best follow our landing page and the "Get Started" button: [swiftcontrol.app](https://swiftcontrol.app/) to understand on which platform you want to run SwiftControl.
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
|
||||
|
||||
@@ -31,35 +31,36 @@ Check the compatibility matrix below!
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- TrainingPeaks Virtual / indieVelo
|
||||
- Biketerra.com (they do offer native integration already - check it out)
|
||||
- Rouvy (most Zwift devices are already supported by Rouvy)
|
||||
- any other! You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
|
||||
- Biketerra.com
|
||||
- Rouvy
|
||||
- Zwift
|
||||
- running SwiftControl on Android or Windows is required to act as a "Controllable" in Zwift - iOS and macOS are not able to do so
|
||||
- any other!
|
||||
- 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 and macOS requires SwiftControl 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 | (✅) | | Unconfirmed: only MyWhoosh using the Link method is supported |
|
||||
|
||||
|
||||
For testing purposes you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/) but this is just a tech demo - you won't be able to control other apps.
|
||||
Follow the "Get Started" button over at [swiftcontrol.app](https://swiftcontrol.app) to understand on which platform you want to run SwiftControl.
|
||||
You can even try it out in your [Browser](https://jonasbark.github.io/swiftcontrol/), if it supports Bluetooth connections. No controlling possible, though.
|
||||
|
||||
## Troubleshooting
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
@@ -68,16 +69,15 @@ Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
The app connects to your Controller devices (such as Zwift ones) automatically. It does not connect to your trainer itself.
|
||||
|
||||
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- **iOS**: use SwiftControl as a "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- if you want to use MyWhoosh you can use the Link method to directly connect to MyWhoosh
|
||||
- for other trainer apps you need to pair SwiftControl to your iPad / tablet via Bluetooth and your phone will send the button presses to your iPad / tablet
|
||||
- **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
|
||||
</details>
|
||||
- If you want to use MyWhoosh, you can use the Link method to connect to MyWhoosh directly
|
||||
- For other trainer apps, you need to pair SwiftControl to your iPad / tablet via Bluetooth, and your phone will send the button presses to your iPad / tablet
|
||||
- **macOS** / **Windows** A keyboard or mouse click is used to trigger the action.
|
||||
- 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.
|
||||
|
||||
@@ -16,11 +16,11 @@ If you don't do that SwiftControl will need to reconnect every minute.
|
||||
4. Close the Zwift app again and connect again in SwiftControl
|
||||
|
||||
## 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/:
|
||||
- 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 for more details
|
||||
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/jonasbark/swiftcontrol/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.
|
||||
@@ -37,11 +37,14 @@ 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.
|
||||
|
||||
## Link requirement for MyWhoosh stuck at "Waiting for MyWhoosh"
|
||||
## MyWhoosh Direct Connect never connects
|
||||
The same network restrictions apply for SwiftControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
https://mywhoosh.com/troubleshoot/
|
||||
https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
[INSTRUCTIONS_IOS.md](INSTRUCTIONS_IOS.md)
|
||||
|
||||
In essence:
|
||||
- your two devices (phone, tablet) need to be on the same WiFi network
|
||||
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.1.1
|
||||
3.4.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,7 @@ interface Accessibility {
|
||||
fun openPermissions()
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
fun controlMedia(action: MediaAction)
|
||||
fun ignoreHidDevices()
|
||||
|
||||
companion object {
|
||||
/** The codec used by Accessibility. */
|
||||
@@ -158,7 +186,7 @@ interface Accessibility {
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.hasPermission())
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -174,7 +202,7 @@ interface Accessibility {
|
||||
api.openPermissions()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -195,7 +223,7 @@ interface Accessibility {
|
||||
api.performTouch(xArg, yArg, isKeyDownArg, isKeyUpArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -213,7 +241,23 @@ 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.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 +318,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) {
|
||||
@@ -59,12 +65,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 +81,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 +103,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,8 @@ abstract class Accessibility {
|
||||
void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
|
||||
void controlMedia(MediaAction action);
|
||||
|
||||
void ignoreHidDevices();
|
||||
}
|
||||
|
||||
enum MediaAction { playPause, next, volumeUp, volumeDown }
|
||||
@@ -32,4 +34,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,29 @@ class Accessibility {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 +277,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>
|
||||
@@ -1,5 +1,46 @@
|
||||
package de.jonasbark.swiftcontrol
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import org.flame_engine.gamepads_android.GamepadsCompatibleActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity: FlutterActivity(), GamepadsCompatibleActivity {
|
||||
var keyListener: ((KeyEvent) -> Boolean)? = null
|
||||
var motionListener: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
override fun isGamepadsInputDevice(device: InputDevice): Boolean {
|
||||
return device.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|| device.sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
// Some bluetooth keyboards are identified as GamePad. Check if it is ALPHABETIC keyboard.
|
||||
// && device.keyboardType != InputDevice.KEYBOARD_TYPE_ALPHABETIC
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(motionEvent: MotionEvent): Boolean {
|
||||
return motionListener?.invoke(motionEvent) ?: false
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(keyEvent: KeyEvent): Boolean {
|
||||
if (keyListener?.invoke(keyEvent) == true) {
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(keyEvent)
|
||||
}
|
||||
|
||||
override fun registerInputDeviceListener(
|
||||
listener: InputManager.InputDeviceListener, handler: Handler?) {
|
||||
val inputManager = getSystemService(INPUT_SERVICE) as InputManager
|
||||
inputManager.registerInputDeviceListener(listener, null)
|
||||
}
|
||||
|
||||
override fun registerKeyEventHandler(handler: (KeyEvent) -> Boolean) {
|
||||
keyListener = handler
|
||||
}
|
||||
|
||||
override fun registerMotionEventHandler(handler: (MotionEvent) -> Boolean) {
|
||||
motionListener = handler
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -7,10 +7,17 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- gamepads_ios (0.1.1):
|
||||
- Flutter
|
||||
- image_picker_ios (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):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- restart_app (0.0.1):
|
||||
@@ -31,8 +38,11 @@ DEPENDENCIES:
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- 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`)
|
||||
- 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`)
|
||||
- restart_app (from `.symlinks/plugins/restart_app/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
@@ -49,10 +59,16 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
gamepads_ios:
|
||||
:path: ".symlinks/plugins/gamepads_ios/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/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:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
restart_app:
|
||||
@@ -71,8 +87,11 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<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);*/
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class BleUuid {
|
||||
static final DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb"
|
||||
.toLowerCase();
|
||||
static const DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb";
|
||||
static const DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb";
|
||||
|
||||
static final DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static const DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb";
|
||||
static const DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002A19-0000-1000-8000-00805F9B34FB";
|
||||
}
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package: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/zwift/constants.dart';
|
||||
import 'messages/notification.dart';
|
||||
|
||||
class Connection {
|
||||
final devices = <BaseDevice>[];
|
||||
|
||||
List<BluetoothDevice> get bluetoothDevices => devices.whereType<BluetoothDevice>().toList();
|
||||
List<GamepadDevice> get gamepadDevices => devices.whereType<GamepadDevice>().toList();
|
||||
List<BaseDevice> get controllerDevices => [...bluetoothDevices, ...gamepadDevices, ...devices.whereType<HidDevice>()];
|
||||
List<BaseDevice> get remoteDevices =>
|
||||
devices.whereNot((d) => d is BluetoothDevice || d is GamepadDevice || d is HidDevice).toList();
|
||||
|
||||
var _androidNotificationsSetup = false;
|
||||
|
||||
final _connectionQueue = <BaseDevice>[];
|
||||
@@ -23,6 +39,7 @@ class Connection {
|
||||
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
|
||||
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => _actionStreams.stream;
|
||||
List<({DateTime date, String entry})> lastLogEntries = [];
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
@@ -31,42 +48,96 @@ 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(LogNotification('Bluetooth availability changed: $available'));
|
||||
if (available == AvailabilityState.poweredOn) {
|
||||
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
|
||||
if (available == AvailabilityState.poweredOn && !kIsWeb) {
|
||||
performScanning();
|
||||
} else if (available == AvailabilityState.poweredOff) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
UniversalBle.onScanResult = (result) {
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
|
||||
// Update RSSI for already connected devices
|
||||
final existingDevice = bluetoothDevices.firstOrNullWhere(
|
||||
(e) => e.device.deviceId == result.deviceId,
|
||||
);
|
||||
if (existingDevice != null && existingDevice.rssi != result.rssi) {
|
||||
existingDevice.rssi = result.rssi;
|
||||
_connectionStreams.add(existingDevice); // Notify UI of update
|
||||
}
|
||||
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
|
||||
_lastScanResult.add(result);
|
||||
final scanResult = BaseDevice.fromScanResult(result);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Scan result: ${result.name} - ${result.deviceId}');
|
||||
}
|
||||
|
||||
final scanResult = BluetoothDevice.fromScanResult(result);
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${scanResult.runtimeType}'));
|
||||
_addDevices([scanResult]);
|
||||
addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
final data = manufacturerData
|
||||
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
|
||||
?.payload;
|
||||
_actionStreams.add(LogNotification('Found unknown device with identifier: ${data?.firstOrNull}'));
|
||||
if (data != null && kDebugMode) {
|
||||
_actionStreams.add(LogNotification('Found unknown device with identifier: ${data.firstOrNull}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
|
||||
final device = devices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
UniversalBle.onConnectionChange = (String deviceId, bool isConnected, String? error) {
|
||||
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
if (device != null && !isConnected) {
|
||||
// allow reconnection
|
||||
_lastScanResult.removeWhere((d) => d.deviceId == deviceId);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -81,54 +152,116 @@ class Connection {
|
||||
// does not work on web, may not work on Windows
|
||||
if (!kIsWeb && !Platform.isWindows) {
|
||||
UniversalBle.getSystemDevices(
|
||||
withServices: BaseDevice.servicesToScan,
|
||||
withServices: BluetoothDevice.servicesToScan,
|
||||
).then((devices) async {
|
||||
final baseDevices = devices.mapNotNull(BaseDevice.fromScanResult).toList();
|
||||
final baseDevices = devices.mapNotNull(BluetoothDevice.fromScanResult).toList();
|
||||
if (baseDevices.isNotEmpty) {
|
||||
_addDevices(baseDevices);
|
||||
addDevices(baseDevices);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await UniversalBle.startScan(
|
||||
scanFilter: ScanFilter(withServices: BaseDevice.servicesToScan),
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BaseDevice.servicesToScan)),
|
||||
// allow all to enable Wahoo Kickr Bike Shift detection
|
||||
//scanFilter: kIsWeb ? ScanFilter(withServices: BluetoothDevice.servicesToScan) : null,
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BluetoothDevice.servicesToScan)),
|
||||
);
|
||||
}
|
||||
|
||||
void _addDevices(List<BaseDevice> dev) {
|
||||
final newDevices = dev.where((device) => !devices.contains(device)).toList();
|
||||
devices.addAll(newDevices);
|
||||
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);
|
||||
|
||||
_connectionQueue.addAll(newDevices);
|
||||
_handleConnectionQueue();
|
||||
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
|
||||
for (var device in removedDevices) {
|
||||
devices.remove(device);
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
|
||||
addDevices(pads);
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.getMyWhooshLinkEnabled() &&
|
||||
settings.getTrainerApp() is MyWhoosh &&
|
||||
!whooshLink.isStarted.value &&
|
||||
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',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
_handlingConnectionQueue = true;
|
||||
final device = _connectionQueue.removeAt(0);
|
||||
_actionStreams.add(LogNotification('Connecting to: ${device.device.name ?? device.runtimeType}'));
|
||||
_actionStreams.add(LogNotification('Connecting to: ${device.name}'));
|
||||
_connect(device)
|
||||
.then((_) {
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(LogNotification('Connection finished: ${device.device.name ?? device.runtimeType}'));
|
||||
_actionStreams.add(LogNotification('Connection finished: ${device.name}'));
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
})
|
||||
.catchError((e) {
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(LogNotification('Connection failed: ${device.device.name ?? device.runtimeType} - $e'));
|
||||
_actionStreams.add(
|
||||
LogNotification('Connection failed: ${device.name} - $e'),
|
||||
);
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
@@ -136,30 +269,43 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect(BaseDevice bleDevice) async {
|
||||
Future<void> _connect(BaseDevice device) async {
|
||||
try {
|
||||
final actionSubscription = bleDevice.actionStream.listen((data) {
|
||||
final actionSubscription = device.actionStream.listen((data) {
|
||||
_actionStreams.add(data);
|
||||
});
|
||||
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((state) {
|
||||
bleDevice.isConnected = state;
|
||||
_connectionStreams.add(bleDevice);
|
||||
if (!bleDevice.isConnected) {
|
||||
devices.remove(bleDevice);
|
||||
_streamSubscriptions[bleDevice]?.cancel();
|
||||
_streamSubscriptions.remove(bleDevice);
|
||||
_connectionSubscriptions[bleDevice]?.cancel();
|
||||
_connectionSubscriptions.remove(bleDevice);
|
||||
_lastScanResult.clear();
|
||||
// try reconnect
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
|
||||
if (device is BluetoothDevice) {
|
||||
final connectionStateSubscription = UniversalBle.connectionStream(device.device.deviceId).listen((state) {
|
||||
device.isConnected = state;
|
||||
_connectionStreams.add(device);
|
||||
if (!device.isConnected) {
|
||||
disconnect(device, forget: false);
|
||||
// try reconnect
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
_connectionSubscriptions[device] = connectionStateSubscription;
|
||||
}
|
||||
|
||||
await bleDevice.connect();
|
||||
await device.connect();
|
||||
signalChange(device);
|
||||
|
||||
_streamSubscriptions[bleDevice] = actionSubscription;
|
||||
final newButtons = device.availableButtons.filter(
|
||||
(button) => actionHandler.supportedApp?.keymap.getKeyPair(button) == null,
|
||||
);
|
||||
for (final button in newButtons) {
|
||||
actionHandler.supportedApp?.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_streamSubscriptions[device] = actionSubscription;
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(LogNotification("$e\n$backtrace"));
|
||||
if (kDebugMode) {
|
||||
@@ -170,15 +316,18 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
Future<void> reset() async {
|
||||
_actionStreams.add(LogNotification('Disconnecting all devices'));
|
||||
if (actionHandler is AndroidActions) {
|
||||
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
|
||||
_androidNotificationsSetup = false;
|
||||
}
|
||||
UniversalBle.stopScan();
|
||||
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
|
||||
if (isBtEnabled) {
|
||||
UniversalBle.stopScan();
|
||||
}
|
||||
isScanning.value = false;
|
||||
for (var device in devices) {
|
||||
for (var device in bluetoothDevices) {
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
@@ -186,6 +335,7 @@ class Connection {
|
||||
UniversalBle.disconnect(device.device.deviceId);
|
||||
signalChange(device);
|
||||
}
|
||||
_gamePadSearchTimer?.cancel();
|
||||
_lastScanResult.clear();
|
||||
hasDevices.value = false;
|
||||
devices.clear();
|
||||
@@ -198,4 +348,46 @@ class Connection {
|
||||
void signalChange(BaseDevice baseDevice) {
|
||||
_connectionStreams.add(baseDevice);
|
||||
}
|
||||
|
||||
Future<void> disconnect(BaseDevice device, {required bool forget}) async {
|
||||
if (device.isConnected) {
|
||||
await device.disconnect();
|
||||
}
|
||||
|
||||
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([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +1,42 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
import 'elite/elite_sterzo.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
final String name;
|
||||
final bool isBeta;
|
||||
final List<ControllerButton> availableButtons;
|
||||
|
||||
BaseDevice(this.scanResult, {required this.availableButtons, this.isBeta = false});
|
||||
BaseDevice(this.name, {required this.availableButtons, this.isBeta = false});
|
||||
|
||||
bool isConnected = false;
|
||||
int? batteryLevel;
|
||||
String? firmwareVersion;
|
||||
|
||||
Timer? _longPressTimer;
|
||||
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
|
||||
|
||||
static List<String> servicesToScan = [
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static BaseDevice? fromScanResult(BleDevice scanResult) {
|
||||
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
|
||||
BaseDevice? device;
|
||||
if (kIsWeb) {
|
||||
device = switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
'SQUARE' => EliteSquare(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) {
|
||||
//'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
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
|
||||
device = EliteSterzo(scanResult);
|
||||
}
|
||||
}
|
||||
|
||||
if (device != null) {
|
||||
return device;
|
||||
} else if (scanResult.services.containsAny([
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
])) {
|
||||
// otherwise use the manufacturer data to identify the device
|
||||
final manufacturerData = scanResult.manufacturerDataList;
|
||||
final data = manufacturerData
|
||||
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
|
||||
?.payload;
|
||||
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final type = ZwiftDeviceType.fromManufacturerData(data.first);
|
||||
return switch (type) {
|
||||
ZwiftDeviceType.click => ZwiftClick(scanResult),
|
||||
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//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 if (scanResult.services.contains(WahooKickrBikeShiftConstants.SERVICE_UUID)) {
|
||||
if (scanResult.name != null && !scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT')) {
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
} else if (kIsWeb && scanResult.name == null) {
|
||||
// some devices don't broadcast the name, so we must rely on the service UUID
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BaseDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
|
||||
identical(this, other) || other is BaseDevice && runtimeType == other.runtimeType && name == other.name;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.hashCode;
|
||||
int get hashCode => name.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return runtimeType.toString();
|
||||
return name;
|
||||
}
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
|
||||
|
||||
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
|
||||
|
||||
Future<void> connect() async {
|
||||
actionStream.listen((message) {
|
||||
print("Received message: $message");
|
||||
});
|
||||
|
||||
await UniversalBle.connect(device.deviceId);
|
||||
|
||||
if (!kIsWeb) {
|
||||
await UniversalBle.requestMtu(device.deviceId, 517);
|
||||
}
|
||||
|
||||
final services = await UniversalBle.discoverServices(device.deviceId);
|
||||
await handleServices(services);
|
||||
}
|
||||
|
||||
Future<void> handleServices(List<BleService> services);
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes);
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
if (buttonsClicked == null) {
|
||||
@@ -186,8 +71,8 @@ abstract class BaseDevice {
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
|
||||
if (!isLongPress &&
|
||||
!(buttonsClicked.singleOrNull == ControllerButton.onOffLeft ||
|
||||
buttonsClicked.singleOrNull == ControllerButton.onOffRight)) {
|
||||
!(buttonsClicked.singleOrNull == ZwiftButtons.onOffLeft ||
|
||||
buttonsClicked.singleOrNull == ZwiftButtons.onOffRight)) {
|
||||
// we don't want to trigger the long press timer for the on/off buttons, also not when it's a long press key
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) async {
|
||||
@@ -237,7 +122,8 @@ abstract class BaseDevice {
|
||||
await (actionHandler as DesktopActions).releaseAllHeldKeys(_previouslyPressedButtons.toList());
|
||||
}
|
||||
_previouslyPressedButtons.clear();
|
||||
await UniversalBle.disconnect(device.deviceId);
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
Widget showInformation(BuildContext context);
|
||||
}
|
||||
|
||||
251
lib/bluetooth/devices/bluetooth_device.dart
Normal file
251
lib/bluetooth/devices/bluetooth_device.dart
Normal file
@@ -0,0 +1,251 @@
|
||||
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';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/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';
|
||||
|
||||
abstract class BluetoothDevice extends BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
|
||||
BluetoothDevice(this.scanResult, {required super.availableButtons, super.isBeta = false})
|
||||
: super(scanResult.name ?? 'Unknown Device') {
|
||||
rssi = scanResult.rssi;
|
||||
}
|
||||
|
||||
int? batteryLevel;
|
||||
String? firmwareVersion;
|
||||
int? rssi;
|
||||
|
||||
static List<String> servicesToScan = [
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
CycplusBc2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
|
||||
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
|
||||
BluetoothDevice? device;
|
||||
if (kIsWeb) {
|
||||
device = switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'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,
|
||||
};
|
||||
} 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 (device != null) {
|
||||
return device;
|
||||
} else if (scanResult.services.containsAny([
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(),
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(),
|
||||
])) {
|
||||
// otherwise use the manufacturer data to identify the device
|
||||
final manufacturerData = scanResult.manufacturerDataList;
|
||||
final data = manufacturerData
|
||||
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
|
||||
?.payload;
|
||||
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final type = ZwiftDeviceType.fromManufacturerData(data.first);
|
||||
return switch (type) {
|
||||
ZwiftDeviceType.click => ZwiftClick(scanResult),
|
||||
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult.deviceId == other.scanResult.deviceId;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.deviceId.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
|
||||
}
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
actionStream.listen((message) {
|
||||
print("Received message: $message");
|
||||
});
|
||||
|
||||
await UniversalBle.connect(device.deviceId);
|
||||
|
||||
if (!kIsWeb) {
|
||||
await UniversalBle.requestMtu(device.deviceId, 517);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
Future<void> handleServices(List<BleService> services);
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes);
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
await UniversalBle.disconnect(device.deviceId);
|
||||
super.disconnect();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
device.name?.screenshot ?? runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
if (batteryLevel != null) ...[
|
||||
Icon(switch (batteryLevel!) {
|
||||
>= 80 => Icons.battery_full,
|
||||
>= 60 => Icons.battery_6_bar,
|
||||
>= 50 => Icons.battery_5_bar,
|
||||
>= 25 => Icons.battery_4_bar,
|
||||
>= 10 => Icons.battery_2_bar,
|
||||
_ => Icons.battery_alert,
|
||||
}),
|
||||
Text('$batteryLevel%'),
|
||||
],
|
||||
if (firmwareVersion != null) Text(' - v$firmwareVersion'),
|
||||
if (firmwareVersion != null &&
|
||||
this is ZwiftDevice &&
|
||||
firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion) ...[
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
||||
Text(
|
||||
' (latest: ${(this as ZwiftDevice).latestFirmwareVersion})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
if (rssi != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Tooltip(
|
||||
message: 'Signal Strength: $rssi dBm',
|
||||
child: Icon(
|
||||
switch (rssi!) {
|
||||
>= -50 => Icons.signal_cellular_4_bar,
|
||||
>= -60 => Icons.signal_cellular_alt_2_bar,
|
||||
>= -70 => Icons.signal_cellular_alt_1_bar,
|
||||
_ => Icons.signal_cellular_alt,
|
||||
},
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: SizedBox()),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Disconnect and Forget'),
|
||||
onTap: () {
|
||||
connection.disconnect(this, forget: true);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
];
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../messages/notification.dart';
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class EliteSquare extends BaseDevice {
|
||||
class EliteSquare extends BluetoothDevice {
|
||||
EliteSquare(super.scanResult)
|
||||
: super(
|
||||
availableButtons: SquareConstants.BUTTON_MAPPING.values.toList(),
|
||||
availableButtons: EliteSquareButtons.values.toList(),
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
@@ -40,11 +40,11 @@ class EliteSquare extends BaseDevice {
|
||||
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) {
|
||||
@@ -83,25 +83,74 @@ class SquareConstants {
|
||||
|
||||
// Button mapping https://images.bike24.com/i/mb/c7/36/d9/elite-square-smart-frame-indoor-bike-3-1724305.jpg
|
||||
static const Map<String, ControllerButton> BUTTON_MAPPING = {
|
||||
"00000200": ControllerButton.navigationUp, //"Up",
|
||||
"00000100": ControllerButton.navigationLeft, //"Left",
|
||||
"00000800": ControllerButton.navigationDown, // "Down",
|
||||
"00000400": ControllerButton.navigationRight, //"Right",
|
||||
"00002000": ControllerButton.powerUpLeft, //"X",
|
||||
"00001000": ControllerButton.sideButtonLeft, // "Square",
|
||||
"00008000": ControllerButton.campagnoloLeft, // "Left Campagnolo",
|
||||
"00004000": ControllerButton.onOffLeft, //"Left brake",
|
||||
"00000002": ControllerButton.shiftDownLeft, //"Left shift 1",
|
||||
"00000001": ControllerButton.paddleLeft, // "Left shift 2",
|
||||
"02000000": ControllerButton.y, // "Y",
|
||||
"01000000": ControllerButton.a, //"A",
|
||||
"08000000": ControllerButton.b, // "B",
|
||||
"04000000": ControllerButton.z, // "Z",
|
||||
"20000000": ControllerButton.powerUpRight, // "Circle",
|
||||
"10000000": ControllerButton.sideButtonRight, //"Triangle",
|
||||
"80000000": ControllerButton.campagnoloRight, // "Right Campagnolo",
|
||||
"40000000": ControllerButton.onOffRight, //"Right brake",
|
||||
"00020000": ControllerButton.sideButtonRight, //"Right shift 1",
|
||||
"00010000": ControllerButton.paddleRight, //"Right shift 2",
|
||||
"00000200": EliteSquareButtons.up, //"Up",
|
||||
"00000100": EliteSquareButtons.left, //"Left",
|
||||
"00000800": EliteSquareButtons.down, // "Down",
|
||||
"00000400": EliteSquareButtons.right, //"Right",
|
||||
"00002000": EliteSquareButtons.x, //"X",
|
||||
"00001000": EliteSquareButtons.square, // "Square",
|
||||
"00008000": EliteSquareButtons.campagnoloLeft, // "Left Campagnolo",
|
||||
"00004000": EliteSquareButtons.leftBrake, //"Left brake",
|
||||
"00000002": EliteSquareButtons.leftShift1, //"Left shift 1",
|
||||
"00000001": EliteSquareButtons.leftShift2, // "Left shift 2",
|
||||
"02000000": EliteSquareButtons.y, // "Y",
|
||||
"01000000": EliteSquareButtons.a, //"A",
|
||||
"08000000": EliteSquareButtons.b, // "B",
|
||||
"04000000": EliteSquareButtons.z, // "Z",
|
||||
"20000000": EliteSquareButtons.circle, // "Circle",
|
||||
"10000000": EliteSquareButtons.triangle, //"Triangle",
|
||||
"80000000": EliteSquareButtons.campagnoloRight, // "Right Campagnolo",
|
||||
"40000000": EliteSquareButtons.rightBrake, //"Right brake",
|
||||
"00020000": EliteSquareButtons.rightShift1, //"Right shift 1",
|
||||
"00010000": EliteSquareButtons.rightShift2, //"Right shift 2",
|
||||
};
|
||||
}
|
||||
|
||||
class EliteSquareButtons {
|
||||
static const ControllerButton up = ControllerButton('eliteSquareUp', action: null);
|
||||
static const ControllerButton left = ControllerButton('eliteSquareLeft', action: InGameAction.navigateLeft);
|
||||
static const ControllerButton down = ControllerButton('eliteSquareDown', action: null);
|
||||
static const ControllerButton right = ControllerButton('eliteSquareRight', action: InGameAction.navigateRight);
|
||||
static const ControllerButton x = ControllerButton('eliteSquareX', action: null);
|
||||
static const ControllerButton square = ControllerButton('eliteSquareSquare', action: null);
|
||||
static const ControllerButton campagnoloLeft = ControllerButton('eliteSquareCampagnoloLeft', action: null);
|
||||
static const ControllerButton leftBrake = ControllerButton('eliteSquareLeftBrake', action: null);
|
||||
static const ControllerButton leftShift1 = ControllerButton('eliteSquareLeftShift1', action: InGameAction.shiftUp);
|
||||
static const ControllerButton leftShift2 = ControllerButton('eliteSquareLeftShift2', action: InGameAction.shiftDown);
|
||||
static const ControllerButton y = ControllerButton('y', action: null);
|
||||
static const ControllerButton a = ControllerButton('a', action: null);
|
||||
static const ControllerButton b = ControllerButton('b', action: null);
|
||||
static const ControllerButton z = ControllerButton('z', action: null);
|
||||
static const ControllerButton circle = ControllerButton('eliteSquareCircle', action: null);
|
||||
static const ControllerButton triangle = ControllerButton('eliteSquareTriangle', action: null);
|
||||
static const ControllerButton campagnoloRight = ControllerButton('eliteSquareCampagnoloRight', action: null);
|
||||
static const ControllerButton rightBrake = ControllerButton('eliteSquareRightBrake', action: null);
|
||||
static const ControllerButton rightShift1 = ControllerButton('eliteSquareRightShift1', action: InGameAction.shiftUp);
|
||||
static const ControllerButton rightShift2 = ControllerButton(
|
||||
'eliteSquareRightShift2',
|
||||
action: InGameAction.shiftDown,
|
||||
);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
up,
|
||||
left,
|
||||
down,
|
||||
right,
|
||||
x,
|
||||
square,
|
||||
campagnoloLeft,
|
||||
leftBrake,
|
||||
leftShift1,
|
||||
leftShift2,
|
||||
y,
|
||||
a,
|
||||
b,
|
||||
z,
|
||||
circle,
|
||||
triangle,
|
||||
campagnoloRight,
|
||||
rightBrake,
|
||||
rightShift1,
|
||||
rightShift2,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../messages/notification.dart';
|
||||
|
||||
class EliteSterzo extends BaseDevice {
|
||||
EliteSterzo(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
ControllerButton.navigationLeft,
|
||||
ControllerButton.navigationRight,
|
||||
],
|
||||
isBeta: true,
|
||||
);
|
||||
class EliteSterzo extends BluetoothDevice {
|
||||
EliteSterzo(super.scanResult) : super(availableButtons: SterzoButtons.values);
|
||||
|
||||
double _lastAngle = 0.0;
|
||||
int? _latestChallenge;
|
||||
@@ -26,6 +20,18 @@ class EliteSterzo extends BaseDevice {
|
||||
static Uint8List? _challengeCodesData;
|
||||
static bool _isLoadingChallenges = false;
|
||||
|
||||
// Calibration state
|
||||
final List<double> _calibrationSamples = [];
|
||||
double _calibrationOffset = 0.0;
|
||||
bool _isCalibrated = false;
|
||||
|
||||
// Last rounded angle for logging optimization
|
||||
int? _lastRoundedAngle;
|
||||
|
||||
// Debounce timer for PWM-like keypress behavior
|
||||
Timer? _keypressTimer;
|
||||
bool _isProcessingKeypresses = false;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstOrNullWhere(
|
||||
@@ -53,7 +59,7 @@ class EliteSterzo extends BaseDevice {
|
||||
}
|
||||
|
||||
// Subscribe to challenge code indications
|
||||
await UniversalBle.subscribeNotifications(
|
||||
await UniversalBle.subscribeIndications(
|
||||
device.deviceId,
|
||||
service.uuid,
|
||||
challengeChar.uuid,
|
||||
@@ -222,32 +228,91 @@ class EliteSterzo extends BaseDevice {
|
||||
void _handleSteeringMeasurement(Uint8List bytes) {
|
||||
if (bytes.length >= 4) {
|
||||
// Steering angle is a 32-bit float (little-endian)
|
||||
final angle = ByteData.sublistView(bytes).getFloat32(0, Endian.little);
|
||||
final rawAngle = ByteData.sublistView(bytes).getFloat32(0, Endian.little);
|
||||
|
||||
actionStreamInternal.add(LogNotification('Steering angle: ${angle.toStringAsFixed(1)}°'));
|
||||
|
||||
// Determine steering direction based on angle threshold
|
||||
final button = _getSteeringButton(angle);
|
||||
|
||||
if (button != null) {
|
||||
handleButtonsClicked([button]);
|
||||
} else if (_getSteeringButton(_lastAngle) != null) {
|
||||
// Release button if we were steering but now we're centered
|
||||
handleButtonsClicked([]);
|
||||
// Ignore NaN readings during initial connection
|
||||
if (rawAngle.isNaN) {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastAngle = angle;
|
||||
// Handle calibration: collect initial samples to compute offset
|
||||
if (!_isCalibrated) {
|
||||
_calibrationSamples.add(rawAngle);
|
||||
if (_calibrationSamples.length >= SterzoConstants.CALIBRATION_SAMPLE_COUNT) {
|
||||
// Compute average offset from collected samples
|
||||
_calibrationOffset = _calibrationSamples.reduce((a, b) => a + b) / _calibrationSamples.length;
|
||||
_isCalibrated = true;
|
||||
actionStreamInternal.add(
|
||||
LogNotification('Elite Sterzo: Calibration complete, offset: ${_calibrationOffset.toStringAsFixed(2)}°'),
|
||||
);
|
||||
}
|
||||
return; // Don't process steering during calibration
|
||||
}
|
||||
|
||||
// Apply calibration offset
|
||||
final calibratedAngle = rawAngle - _calibrationOffset;
|
||||
|
||||
// Round to whole degrees to reduce noise
|
||||
final roundedAngle = calibratedAngle.round();
|
||||
|
||||
// Only log and process steering when rounded value changes to reduce verbosity
|
||||
if (_lastRoundedAngle != roundedAngle) {
|
||||
actionStreamInternal.add(LogNotification('Steering angle: $roundedAngle°'));
|
||||
_lastRoundedAngle = roundedAngle;
|
||||
|
||||
// Apply PWM-like steering behavior only when angle changes
|
||||
_applyPWMSteering(roundedAngle);
|
||||
}
|
||||
|
||||
_lastAngle = calibratedAngle;
|
||||
}
|
||||
}
|
||||
|
||||
ControllerButton? _getSteeringButton(double angle) {
|
||||
// Use a threshold to avoid jitter around center
|
||||
if (angle < -SterzoConstants.STEERING_THRESHOLD) {
|
||||
return ControllerButton.navigationLeft;
|
||||
} else if (angle > SterzoConstants.STEERING_THRESHOLD) {
|
||||
return ControllerButton.navigationRight;
|
||||
/// Applies PWM-like steering behavior with repeated keypresses proportional to angle magnitude
|
||||
void _applyPWMSteering(int roundedAngle) {
|
||||
// Cancel any pending keypress timer
|
||||
_keypressTimer?.cancel();
|
||||
|
||||
// Determine if we're steering
|
||||
if (roundedAngle.abs() > SterzoConstants.STEERING_THRESHOLD) {
|
||||
// Determine direction
|
||||
final button = roundedAngle > 0 ? SterzoButtons.rightSteer : SterzoButtons.leftSteer;
|
||||
|
||||
// Calculate number of keypress levels based on angle magnitude
|
||||
final levels = _calculateKeypressLevels(roundedAngle.abs());
|
||||
|
||||
// Only trigger new keypresses when rounded angle changes to avoid overlap
|
||||
// The check for _lastRoundedAngle change is already done in _handleSteeringMeasurement
|
||||
// so we know this is a new angle value
|
||||
_scheduleRepeatedKeypresses(button, levels);
|
||||
} else {
|
||||
// Center position - release any held buttons
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Calculates the number of keypress levels based on angle magnitude
|
||||
int _calculateKeypressLevels(int absAngle) {
|
||||
final levels = (absAngle / SterzoConstants.LEVEL_DEGREE_STEP).floor();
|
||||
return levels.clamp(1, SterzoConstants.MAX_LEVELS);
|
||||
}
|
||||
|
||||
/// Schedules repeated keypresses to simulate PWM behavior
|
||||
Future<void> _scheduleRepeatedKeypresses(ControllerButton button, int levels) async {
|
||||
// Don't overlap keypress sequences
|
||||
if (_isProcessingKeypresses) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isProcessingKeypresses = true;
|
||||
|
||||
// Send keypresses in sequence with delays between them
|
||||
for (int i = 0; i < levels; i++) {
|
||||
await Future.delayed(Duration(milliseconds: SterzoConstants.KEY_REPEAT_INTERVAL_MS));
|
||||
handleButtonsClicked([button]);
|
||||
}
|
||||
|
||||
_isProcessingKeypresses = false;
|
||||
}
|
||||
|
||||
List<int> _getChallengeResponse(int challenge) {
|
||||
@@ -264,6 +329,12 @@ class EliteSterzo extends BaseDevice {
|
||||
// Fallback for out of range challenges
|
||||
return [0x96, 0x96];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_keypressTimer?.cancel();
|
||||
await super.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
class SterzoConstants {
|
||||
@@ -278,7 +349,7 @@ class SterzoConstants {
|
||||
static const String SERVICE_UUID = "347b0001-7635-408b-8918-8ff3949ce592";
|
||||
|
||||
// Steering angle threshold in degrees to trigger steering action
|
||||
static const double STEERING_THRESHOLD = 5.0;
|
||||
static const double STEERING_THRESHOLD = 10.0;
|
||||
|
||||
static const int RECONNECT_DELAY = 5; // seconds between reconnection attempts
|
||||
|
||||
@@ -288,4 +359,32 @@ class SterzoConstants {
|
||||
|
||||
// Cache key for SharedPreferences
|
||||
static const String CACHE_KEY = 'elite_sterzo_challenge_codes';
|
||||
|
||||
// Calibration settings
|
||||
// Number of initial valid samples to collect for calibration offset
|
||||
static const int CALIBRATION_SAMPLE_COUNT = 10;
|
||||
|
||||
// PWM-like steering behavior constants
|
||||
// Degrees per level for repeated keypress behavior
|
||||
static const double LEVEL_DEGREE_STEP = 10.0;
|
||||
// Maximum number of keypress levels
|
||||
static const int MAX_LEVELS = 5;
|
||||
// Interval between repeated keypresses in milliseconds
|
||||
static const int KEY_REPEAT_INTERVAL_MS = 40;
|
||||
}
|
||||
|
||||
class SterzoButtons {
|
||||
static final ControllerButton leftSteer = ControllerButton(
|
||||
'leftSteer',
|
||||
action: InGameAction.steerLeft,
|
||||
);
|
||||
static final ControllerButton rightSteer = ControllerButton(
|
||||
'rightSteer',
|
||||
action: InGameAction.steerRight,
|
||||
);
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
leftSteer,
|
||||
rightSteer,
|
||||
];
|
||||
}
|
||||
|
||||
66
lib/bluetooth/devices/gamepad/gamepad_device.dart
Normal file
66
lib/bluetooth/devices/gamepad/gamepad_device.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/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/widgets/beta_pill.dart';
|
||||
|
||||
import '../../../widgets/warning.dart';
|
||||
|
||||
class GamepadDevice extends BaseDevice {
|
||||
final String id;
|
||||
|
||||
GamepadDevice(super.name, {required this.id}) : super(availableButtons: [], isBeta: true);
|
||||
|
||||
List<ControllerButton> _lastButtonsClicked = [];
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
Gamepads.eventsByGamepad(id).listen((event) {
|
||||
actionStreamInternal.add(LogNotification('Gamepad event: $event'));
|
||||
|
||||
ControllerButton? button = actionHandler.supportedApp?.keymap.getOrAddButton(
|
||||
event.key,
|
||||
() => ControllerButton(event.key),
|
||||
);
|
||||
|
||||
final buttonsClicked = event.value == 0.0 && button != null ? [button] : <ControllerButton>[];
|
||||
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
|
||||
handleButtonsClicked(buttonsClicked);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
Warning(
|
||||
children: [
|
||||
Text('Use a custom keymap to use the buttons on $name.'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
class WhooshLink {
|
||||
Socket? _socket;
|
||||
@@ -18,8 +19,8 @@ class WhooshLink {
|
||||
InGameAction.steerRight,
|
||||
];
|
||||
|
||||
final ValueNotifier<bool> isConnected = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isStarted = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isConnected = ValueNotifier(false);
|
||||
|
||||
void stopServer() async {
|
||||
if (isStarted.value) {
|
||||
@@ -33,50 +34,63 @@ class WhooshLink {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startServer() async {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
21587,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
Future<void> startServer({
|
||||
required void Function(Socket socket) onConnected,
|
||||
required void Function(Socket socket) onDisconnected,
|
||||
}) async {
|
||||
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;
|
||||
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');
|
||||
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) {
|
||||
if (!isConnected.value) {
|
||||
return 'Not connected to MyWhoosh.';
|
||||
}
|
||||
final jsonObject = switch (action) {
|
||||
InGameAction.shiftUp => {
|
||||
'MessageType': 'Controls',
|
||||
@@ -125,14 +139,24 @@ class WhooshLink {
|
||||
InGameAction.navigateLeft => null,
|
||||
InGameAction.navigateRight => null,
|
||||
InGameAction.toggleUi => null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (jsonObject != null) {
|
||||
final jsonString = jsonEncode(jsonObject);
|
||||
_socket?.writeln(jsonString);
|
||||
return 'Sent action to MyWhoosh: $action';
|
||||
return 'Sent action to MyWhoosh: $action ${value ?? ''}';
|
||||
} else {
|
||||
return 'No action available for button: $action';
|
||||
}
|
||||
}
|
||||
|
||||
bool isCompatible(Target target) {
|
||||
return kIsWeb
|
||||
? false
|
||||
: switch (target) {
|
||||
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
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";
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class WahooKickrBikeShift extends BaseDevice {
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class WahooKickrBikeShift extends BluetoothDevice {
|
||||
WahooKickrBikeShift(super.scanResult)
|
||||
: super(
|
||||
availableButtons: WahooKickrBikeShiftConstants.prefixToButton.values.toList(),
|
||||
@@ -111,17 +113,75 @@ class WahooKickrBikeShiftConstants {
|
||||
|
||||
// https://support.wahoofitness.com/hc/en-us/articles/22259367275410-Shifter-and-button-configuration-for-KICKR-BIKE-1-2
|
||||
static const Map<String, ControllerButton> prefixToButton = {
|
||||
'0001': ControllerButton.powerUpRight, //'Right Up',
|
||||
'8000': ControllerButton.sideButtonRight, //'Right Down',
|
||||
'0008': ControllerButton.navigationRight, //'Right Steer',
|
||||
'0200': ControllerButton.powerUpLeft, // 'Left Up',
|
||||
'0400': ControllerButton.sideButtonLeft, //'Left Down',
|
||||
'2000': ControllerButton.navigationLeft, //'Left Steer',
|
||||
'0004': ControllerButton.shiftUpRight, // 'Right Shift Up',
|
||||
'0002': ControllerButton.shiftDownRight, // 'Right Shift Down',
|
||||
'1000': ControllerButton.shiftUpLeft, //'Left Shift Up',
|
||||
'0800': ControllerButton.shiftDownLeft, //'Left Shift Down',
|
||||
'4000': ControllerButton.paddleRight, //'Right Brake',
|
||||
'0100': ControllerButton.paddleLeft, //'Left Brake',
|
||||
'0001': WahooKickrShiftButtons.rightUp, //'Right Up',
|
||||
'8000': WahooKickrShiftButtons.rightDown, //'Right Down',
|
||||
'0008': WahooKickrShiftButtons.rightSteer, //'Right Steer',
|
||||
'0200': WahooKickrShiftButtons.leftUp, // 'Left Up',
|
||||
'0400': WahooKickrShiftButtons.leftDown, //'Left Down',
|
||||
'2000': WahooKickrShiftButtons.leftSteer, //'Left Steer',
|
||||
'0004': WahooKickrShiftButtons.shiftUpRight, // 'Right Shift Up',
|
||||
'0002': WahooKickrShiftButtons.shiftDownRight, // 'Right Shift Down',
|
||||
'1000': WahooKickrShiftButtons.shiftUpLeft, //'Left Shift Up',
|
||||
'0800': WahooKickrShiftButtons.shiftDownLeft, //'Left Shift Down',
|
||||
'4000': WahooKickrShiftButtons.rightBrake, //'Right Brake',
|
||||
'0100': WahooKickrShiftButtons.leftBrake, //'Left Brake',
|
||||
};
|
||||
}
|
||||
|
||||
class WahooKickrShiftButtons {
|
||||
static const ControllerButton leftSteer = ControllerButton(
|
||||
'leftSteer',
|
||||
action: InGameAction.navigateLeft,
|
||||
icon: Icons.keyboard_arrow_left,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton rightSteer = ControllerButton(
|
||||
'rightSteer',
|
||||
action: InGameAction.navigateRight,
|
||||
icon: Icons.keyboard_arrow_right,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton leftDown = ControllerButton('leftDown', action: InGameAction.shiftDown);
|
||||
static const ControllerButton leftBrake = ControllerButton('leftBrake', action: InGameAction.shiftDown);
|
||||
|
||||
static const ControllerButton shiftUpLeft = ControllerButton(
|
||||
'shiftUpLeft',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton shiftDownLeft = ControllerButton(
|
||||
'shiftDownLeft',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton leftUp = ControllerButton('leftUp', action: InGameAction.shiftDown);
|
||||
|
||||
static const ControllerButton rightDown = ControllerButton('rightDown', action: InGameAction.shiftUp);
|
||||
static const ControllerButton rightBrake = ControllerButton('rightBrake', action: InGameAction.shiftUp);
|
||||
|
||||
static const ControllerButton shiftUpRight = ControllerButton(
|
||||
'shiftUpRight',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton shiftDownRight = ControllerButton('shiftDownRight', action: InGameAction.shiftUp);
|
||||
static const ControllerButton rightUp = ControllerButton('rightUp', action: InGameAction.shiftUp);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
leftSteer,
|
||||
rightSteer,
|
||||
leftDown,
|
||||
leftBrake,
|
||||
shiftUpLeft,
|
||||
shiftDownLeft,
|
||||
leftUp,
|
||||
rightDown,
|
||||
rightBrake,
|
||||
shiftUpRight,
|
||||
shiftDownRight,
|
||||
rightUp,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class ZwiftConstants {
|
||||
static final ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
|
||||
static final ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
|
||||
static final ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
|
||||
static final ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
|
||||
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
|
||||
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb";
|
||||
static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT = "fc82";
|
||||
static const ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1";
|
||||
static const ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1";
|
||||
static const ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1";
|
||||
|
||||
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A
|
||||
|
||||
@@ -29,51 +33,21 @@ class ZwiftConstants {
|
||||
static final VIBRATE_PATTERN = Uint8List.fromList([0x12, 0x12, 0x08, 0x0A, 0x06, 0x08, 0x02, 0x10, 0x00, 0x18]);
|
||||
|
||||
// these don't actually seem to matter, its just the header has to be 7 bytes RIDEON + 2
|
||||
static final REQUEST_START = Uint8List.fromList([0, 9]); //byteArrayOf(1, 2)
|
||||
static final RESPONSE_START_CLICK = Uint8List.fromList([1, 3]); // from device
|
||||
static final RESPONSE_START_PLAY = Uint8List.fromList([1, 4]); // from device
|
||||
static final REQUEST_START = Uint8List.fromList([0x00, 0x09]); //byteArrayOf(1, 2)
|
||||
static final RESPONSE_START_CLICK = Uint8List.fromList([0x01, 0x03]); // from device
|
||||
static final RESPONSE_START_PLAY = Uint8List.fromList([0x01, 0x04]); // from device
|
||||
static final RESPONSE_START_CLICK_V2 = Uint8List.fromList([0x02, 0x03]); // from device
|
||||
static final RESPONSE_STOPPED_CLICK_V2 = Uint8List.fromList([
|
||||
0xff,
|
||||
0x05,
|
||||
0x00,
|
||||
0xea,
|
||||
0x05,
|
||||
0x19,
|
||||
0x0a,
|
||||
0x0c,
|
||||
0x35,
|
||||
0x38,
|
||||
0x44,
|
||||
0x31,
|
||||
0x35,
|
||||
0x41,
|
||||
0x42,
|
||||
0x42,
|
||||
0x34,
|
||||
0x33,
|
||||
0x36,
|
||||
0x33,
|
||||
0x10,
|
||||
0x01,
|
||||
0x18,
|
||||
0x84,
|
||||
0x07,
|
||||
0x20,
|
||||
0x08,
|
||||
0x28,
|
||||
0x09,
|
||||
0x30,
|
||||
]); // from device
|
||||
static final RESPONSE_STOPPED_CLICK_V2_VARIANT_1 = Uint8List.fromList([0xff, 0x05, 0x00, 0xea, 0x05]); // from device
|
||||
static final RESPONSE_STOPPED_CLICK_V2_VARIANT_2 = Uint8List.fromList([0xff, 0x05, 0x00, 0xfa, 0x05]); // from device
|
||||
|
||||
// Message types received from device
|
||||
static const CONTROLLER_NOTIFICATION_MESSAGE_TYPE = 07;
|
||||
static const EMPTY_MESSAGE_TYPE = 21;
|
||||
static const EMPTY_MESSAGE_TYPE = 21; // 0x15
|
||||
static const BATTERY_LEVEL_TYPE = 25;
|
||||
static const UNKNOWN_CLICKV2_TYPE = 0x3C;
|
||||
|
||||
// not figured out the protobuf type this really is, the content is just two varints.
|
||||
static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55;
|
||||
static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55; // 0x37
|
||||
static const int PLAY_NOTIFICATION_MESSAGE_TYPE = 7;
|
||||
static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35; // 0x23
|
||||
|
||||
@@ -81,6 +55,94 @@ class ZwiftConstants {
|
||||
static const DISCONNECT_MESSAGE_TYPE = 0xFE;
|
||||
}
|
||||
|
||||
class ZwiftButtons {
|
||||
// left controller
|
||||
static const ControllerButton navigationUp = ControllerButton(
|
||||
'navigationUp',
|
||||
action: InGameAction.toggleUi,
|
||||
icon: Icons.keyboard_arrow_up,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton navigationDown = ControllerButton(
|
||||
'navigationDown',
|
||||
action: InGameAction.uturn,
|
||||
icon: Icons.keyboard_arrow_down,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton navigationLeft = ControllerButton(
|
||||
'navigationLeft',
|
||||
action: InGameAction.navigateLeft,
|
||||
icon: Icons.keyboard_arrow_left,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton navigationRight = ControllerButton(
|
||||
'navigationRight',
|
||||
action: InGameAction.navigateRight,
|
||||
icon: Icons.keyboard_arrow_right,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton onOffLeft = ControllerButton('onOffLeft', action: InGameAction.toggleUi);
|
||||
static const ControllerButton sideButtonLeft = ControllerButton('sideButtonLeft', action: InGameAction.shiftDown);
|
||||
static const ControllerButton paddleLeft = ControllerButton('paddleLeft', action: InGameAction.shiftDown);
|
||||
|
||||
// zwift ride only
|
||||
static const ControllerButton shiftUpLeft = ControllerButton(
|
||||
'shiftUpLeft',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton shiftDownLeft = ControllerButton(
|
||||
'shiftDownLeft',
|
||||
action: InGameAction.shiftDown,
|
||||
);
|
||||
static const ControllerButton powerUpLeft = ControllerButton('powerUpLeft', action: InGameAction.shiftDown);
|
||||
|
||||
// right controller
|
||||
static const ControllerButton a = ControllerButton('a', action: null, color: Colors.lightGreen);
|
||||
static const ControllerButton b = ControllerButton('b', action: null, color: Colors.pinkAccent);
|
||||
static const ControllerButton z = ControllerButton('z', action: null, color: Colors.deepOrangeAccent);
|
||||
static const ControllerButton y = ControllerButton('y', action: null, color: Colors.lightBlue);
|
||||
static const ControllerButton onOffRight = ControllerButton('onOffRight', action: InGameAction.toggleUi);
|
||||
static const ControllerButton sideButtonRight = ControllerButton('sideButtonRight', action: InGameAction.shiftUp);
|
||||
static const ControllerButton paddleRight = ControllerButton('paddleRight', action: InGameAction.shiftUp);
|
||||
|
||||
// zwift ride only
|
||||
static const ControllerButton shiftUpRight = ControllerButton(
|
||||
'shiftUpRight',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton shiftDownRight = ControllerButton('shiftDownRight', action: InGameAction.shiftUp);
|
||||
static const ControllerButton powerUpRight = ControllerButton('powerUpRight', action: InGameAction.shiftUp);
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
// left
|
||||
navigationUp,
|
||||
navigationDown,
|
||||
navigationLeft,
|
||||
navigationRight,
|
||||
onOffLeft,
|
||||
sideButtonLeft,
|
||||
paddleLeft,
|
||||
shiftUpLeft,
|
||||
shiftDownLeft,
|
||||
powerUpLeft,
|
||||
// right
|
||||
a,
|
||||
b,
|
||||
z,
|
||||
y,
|
||||
onOffRight,
|
||||
sideButtonRight,
|
||||
paddleRight,
|
||||
shiftUpRight,
|
||||
shiftDownRight,
|
||||
powerUpRight,
|
||||
];
|
||||
}
|
||||
|
||||
enum ZwiftDeviceType {
|
||||
click,
|
||||
clickV2Right,
|
||||
|
||||
@@ -3,16 +3,17 @@ import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
|
||||
class ZwiftClick extends ZwiftDevice {
|
||||
ZwiftClick(super.scanResult)
|
||||
: super(availableButtons: [ControllerButton.shiftUpRight, ControllerButton.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) ControllerButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ControllerButton.shiftDownLeft,
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButtons.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftUpLeft,
|
||||
];
|
||||
return buttonsClicked;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,93 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/pages/markdown.dart';
|
||||
import 'package:swift_control/widgets/warning.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult) : super(isBeta: true);
|
||||
ZwiftClickV2(super.scanResult)
|
||||
: super(
|
||||
isBeta: true,
|
||||
availableButtons: [
|
||||
ZwiftButtons.navigationLeft,
|
||||
ZwiftButtons.navigationRight,
|
||||
ZwiftButtons.navigationUp,
|
||||
ZwiftButtons.navigationDown,
|
||||
ZwiftButtons.a,
|
||||
ZwiftButtons.b,
|
||||
ZwiftButtons.y,
|
||||
ZwiftButtons.z,
|
||||
ZwiftButtons.shiftUpLeft,
|
||||
ZwiftButtons.shiftUpRight,
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK_V2;
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.1.0';
|
||||
|
||||
@override
|
||||
Future<void> setupHandshake() async {
|
||||
super.setupHandshake();
|
||||
await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00]));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (isConnected)
|
||||
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.
|
||||
|
||||
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''',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Troubleshooting'),
|
||||
),
|
||||
if (kDebugMode)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
test();
|
||||
},
|
||||
child: Text('Test'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> test() async {
|
||||
await sendCommand(Opcode.RESET, null);
|
||||
//await sendCommand(Opcode.GET, Get(dataObjectId: VendorDO.PAGE_DEVICE_PAIRING.value)); // 0008 82E0 03
|
||||
|
||||
@@ -2,8 +2,7 @@ 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/base_device.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';
|
||||
import 'package:swift_control/main.dart';
|
||||
@@ -11,7 +10,7 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
abstract class ZwiftDevice extends BaseDevice {
|
||||
abstract class ZwiftDevice extends BluetoothDevice {
|
||||
ZwiftDevice(super.scanResult, {required super.availableButtons, super.isBeta});
|
||||
|
||||
BleCharacteristic? syncRxCharacteristic;
|
||||
@@ -24,7 +23,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId.toLowerCase());
|
||||
|
||||
if (customService == null) {
|
||||
throw Exception(
|
||||
@@ -32,37 +31,14 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
);
|
||||
}
|
||||
|
||||
final deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
|
||||
);
|
||||
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,
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
|
||||
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
|
||||
@@ -73,6 +49,14 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
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 {
|
||||
@@ -87,7 +71,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode && false) {
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
);
|
||||
@@ -98,7 +82,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
|
||||
try {
|
||||
if (bytes.startsWith(startCommand)) {
|
||||
_processDevicePublicKeyResponse(bytes);
|
||||
processDevicePublicKeyResponse(bytes);
|
||||
} else {
|
||||
processData(bytes);
|
||||
}
|
||||
@@ -113,7 +97,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
void _processDevicePublicKeyResponse(Uint8List bytes) {
|
||||
void processDevicePublicKeyResponse(Uint8List bytes) {
|
||||
final devicePublicKeyBytes = bytes.sublist(
|
||||
ZwiftConstants.RIDE_ON.length + ZwiftConstants.RESPONSE_START_CLICK.length,
|
||||
);
|
||||
@@ -152,7 +136,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
@override
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
// the same messages are sent multiple times, so ignore
|
||||
if (_lastButtonsClicked?.contentEquals(buttonsClicked ?? []) == false) {
|
||||
if (_lastButtonsClicked == null || _lastButtonsClicked?.contentEquals(buttonsClicked ?? []) == false) {
|
||||
super.handleButtonsClicked(buttonsClicked);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
|
||||
367
lib/bluetooth/devices/zwift/zwift_emulator.dart
Normal file
367
lib/bluetooth/devices/zwift/zwift_emulator.dart
Normal file
@@ -0,0 +1,367 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/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/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
import 'protocol/zwift.pb.dart' show RideKeyPadStatus;
|
||||
|
||||
final zwiftEmulator = ZwiftEmulator();
|
||||
|
||||
class ZwiftEmulator {
|
||||
static final List<InGameAction> supportedActions = [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
];
|
||||
|
||||
ValueNotifier<bool> isConnected = ValueNotifier<bool>(false);
|
||||
bool get isAdvertising => _isAdvertising;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
final peripheralManager = PeripheralManager();
|
||||
bool _isAdvertising = false;
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
Central? _central;
|
||||
GATTCharacteristic? _asyncCharacteristic;
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
startAdvertising(() {});
|
||||
}
|
||||
|
||||
Future<void> startAdvertising(VoidCallback onUpdate) async {
|
||||
_isLoading = true;
|
||||
onUpdate();
|
||||
|
||||
peripheralManager.stateChanged.forEach((state) {
|
||||
print('Peripheral manager state: ${state.state}');
|
||||
});
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
if (Platform.isAndroid) {
|
||||
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) {
|
||||
_central = null;
|
||||
isConnected.value = false;
|
||||
onUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final status = await Permission.bluetoothAdvertise.request();
|
||||
if (!status.isGranted) {
|
||||
print('Bluetooth advertise permission not granted');
|
||||
_isAdvertising = false;
|
||||
onUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
if (settings.getLastTarget() == Target.thisDevice) {
|
||||
return;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
final syncTxCharacteristic = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.read,
|
||||
GATTCharacteristicProperty.indicate,
|
||||
],
|
||||
permissions: [
|
||||
GATTCharacteristicPermission.read,
|
||||
],
|
||||
);
|
||||
|
||||
_asyncCharacteristic = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.notify,
|
||||
],
|
||||
permissions: [],
|
||||
);
|
||||
|
||||
if (!_isServiceAdded) {
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
|
||||
if (!_isSubscribedToEvents) {
|
||||
_isSubscribedToEvents = true;
|
||||
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
|
||||
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
|
||||
case ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID:
|
||||
print('Handling read request for SYNC TX characteristic');
|
||||
break;
|
||||
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
|
||||
await peripheralManager.respondReadRequestWithValue(
|
||||
eventArgs.request,
|
||||
value: Uint8List.fromList([100]),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
}
|
||||
|
||||
final request = eventArgs.request;
|
||||
final trimmedValue = Uint8List.fromList([]);
|
||||
await peripheralManager.respondReadRequestWithValue(
|
||||
request,
|
||||
value: trimmedValue,
|
||||
);
|
||||
// You can respond to read requests here if needed
|
||||
});
|
||||
|
||||
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
print(
|
||||
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
|
||||
);
|
||||
});
|
||||
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
|
||||
_central = eventArgs.central;
|
||||
isConnected.value = true;
|
||||
|
||||
final characteristic = eventArgs.characteristic;
|
||||
final request = eventArgs.request;
|
||||
final value = request.value;
|
||||
print(
|
||||
'Write request for characteristic: ${characteristic.uuid}',
|
||||
);
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
|
||||
case ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID:
|
||||
print(
|
||||
'Handling write request for SYNC RX characteristic, value: ${value.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}\n${String.fromCharCodes(value)}',
|
||||
);
|
||||
|
||||
final handshake = [...ZwiftConstants.RIDE_ON, ...ZwiftConstants.RESPONSE_START_CLICK_V2];
|
||||
final handshakeAlternative = ZwiftConstants.RIDE_ON; // e.g. Rouvy
|
||||
|
||||
if (value.contentEquals(handshake) || value.contentEquals(handshakeAlternative)) {
|
||||
print('Sending handshake');
|
||||
await peripheralManager.notifyCharacteristic(
|
||||
_central!,
|
||||
syncTxCharacteristic,
|
||||
value: ZwiftConstants.RIDE_ON,
|
||||
);
|
||||
onUpdate();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
}
|
||||
|
||||
await peripheralManager.respondWriteRequest(request);
|
||||
});
|
||||
}
|
||||
|
||||
// Device Information
|
||||
await peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('SwiftControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('09-B48123283828F1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('A.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
|
||||
// Battery Service
|
||||
await peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180F'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('2A19'),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.read,
|
||||
GATTCharacteristicProperty.notify,
|
||||
],
|
||||
permissions: [
|
||||
GATTCharacteristicPermission.read,
|
||||
],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
|
||||
// Unknown Service
|
||||
await peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
_asyncCharacteristic!,
|
||||
GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.writeWithoutResponse,
|
||||
],
|
||||
permissions: [],
|
||||
),
|
||||
syncTxCharacteristic,
|
||||
GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('00000005-19CA-4651-86E5-FA29DCDD09D1'),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.notify,
|
||||
],
|
||||
permissions: [],
|
||||
),
|
||||
GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('00000006-19CA-4651-86E5-FA29DCDD09D1'),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.indicate,
|
||||
GATTCharacteristicProperty.read,
|
||||
GATTCharacteristicProperty.writeWithoutResponse,
|
||||
GATTCharacteristicProperty.write,
|
||||
],
|
||||
permissions: [
|
||||
GATTCharacteristicPermission.read,
|
||||
GATTCharacteristicPermission.write,
|
||||
],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
_isServiceAdded = true;
|
||||
}
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name: 'SwiftControl',
|
||||
serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT)],
|
||||
serviceData: {
|
||||
UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT): Uint8List.fromList([0x02]),
|
||||
},
|
||||
manufacturerSpecificData: [
|
||||
ManufacturerSpecificData(
|
||||
id: 0x094A,
|
||||
data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x13, 0x37]),
|
||||
),
|
||||
],
|
||||
);
|
||||
print('Starting advertising with Zwift service...');
|
||||
|
||||
await peripheralManager.startAdvertising(advertisement);
|
||||
_isAdvertising = true;
|
||||
_isLoading = false;
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
Future<void> stopAdvertising() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
_isAdvertising = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
Future<String> sendAction(InGameAction inGameAction, int? inGameActionValue) async {
|
||||
final button = switch (inGameAction) {
|
||||
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
|
||||
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
|
||||
InGameAction.uturn => RideButtonMask.DOWN_BTN,
|
||||
InGameAction.steerLeft => RideButtonMask.LEFT_BTN,
|
||||
InGameAction.steerRight => RideButtonMask.RIGHT_BTN,
|
||||
InGameAction.openActionBar => RideButtonMask.UP_BTN,
|
||||
InGameAction.usePowerUp => RideButtonMask.Y_BTN,
|
||||
InGameAction.select => RideButtonMask.A_BTN,
|
||||
InGameAction.back => RideButtonMask.B_BTN,
|
||||
InGameAction.rideOnBomb => RideButtonMask.Z_BTN,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (button == null) {
|
||||
return 'Action ${inGameAction.name} not supported by Zwift Emulator';
|
||||
}
|
||||
|
||||
final status = RideKeyPadStatus()
|
||||
..buttonMap = (~button.mask) & 0xFFFFFFFF
|
||||
..analogPaddles.clear();
|
||||
|
||||
final bytes = status.writeToBuffer();
|
||||
|
||||
final commandProto = Uint8List.fromList([
|
||||
Opcode.CONTROLLER_NOTIFICATION.value,
|
||||
...bytes,
|
||||
]);
|
||||
|
||||
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}';
|
||||
}
|
||||
}
|
||||
|
||||
class ZwiftEmulatorInformation extends StatelessWidget {
|
||||
const ZwiftEmulatorInformation({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: zwiftEmulator.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Text('Zwift is ${isConnected ? 'connected' : 'not connected'}');
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,20 +8,20 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
ControllerButton.y,
|
||||
ControllerButton.z,
|
||||
ControllerButton.a,
|
||||
ControllerButton.b,
|
||||
ControllerButton.onOffRight,
|
||||
ControllerButton.sideButtonRight,
|
||||
ControllerButton.paddleRight,
|
||||
ControllerButton.navigationUp,
|
||||
ControllerButton.navigationLeft,
|
||||
ControllerButton.navigationRight,
|
||||
ControllerButton.navigationDown,
|
||||
ControllerButton.onOffLeft,
|
||||
ControllerButton.sideButtonLeft,
|
||||
ControllerButton.paddleLeft,
|
||||
ZwiftButtons.y,
|
||||
ZwiftButtons.z,
|
||||
ZwiftButtons.a,
|
||||
ZwiftButtons.b,
|
||||
ZwiftButtons.onOffRight,
|
||||
ZwiftButtons.sideButtonRight,
|
||||
ZwiftButtons.paddleRight,
|
||||
ZwiftButtons.navigationUp,
|
||||
ZwiftButtons.navigationLeft,
|
||||
ZwiftButtons.navigationRight,
|
||||
ZwiftButtons.navigationDown,
|
||||
ZwiftButtons.onOffLeft,
|
||||
ZwiftButtons.sideButtonLeft,
|
||||
ZwiftButtons.paddleLeft,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -34,22 +34,22 @@ class ZwiftPlay extends ZwiftDevice {
|
||||
|
||||
return [
|
||||
if (status.rightPad == PlayButtonStatus.ON) ...[
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ControllerButton.y,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ControllerButton.z,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ControllerButton.a,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ControllerButton.b,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ControllerButton.onOffRight,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ControllerButton.sideButtonRight,
|
||||
if (status.analogLR.abs() == 100) ControllerButton.paddleRight,
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButtons.y,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButtons.z,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButtons.a,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButtons.b,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButtons.onOffRight,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButtons.sideButtonRight,
|
||||
if (status.analogLR.abs() == 100) ZwiftButtons.paddleRight,
|
||||
],
|
||||
if (status.rightPad == PlayButtonStatus.OFF) ...[
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ControllerButton.navigationUp,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ControllerButton.navigationLeft,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ControllerButton.navigationRight,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ControllerButton.navigationDown,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ControllerButton.onOffLeft,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ControllerButton.sideButtonLeft,
|
||||
if (status.analogLR.abs() == 100) ControllerButton.paddleLeft,
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButtons.navigationUp,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButtons.navigationLeft,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButtons.navigationRight,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButtons.navigationDown,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButtons.onOffLeft,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButtons.sideButtonLeft,
|
||||
if (status.analogLR.abs() == 100) ZwiftButtons.paddleLeft,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -18,28 +17,30 @@ class ZwiftRide extends ZwiftDevice {
|
||||
/// analog drift or light touches.
|
||||
static const int analogPaddleThreshold = 25;
|
||||
|
||||
ZwiftRide(super.scanResult, {super.isBeta})
|
||||
ZwiftRide(super.scanResult, {super.isBeta, List<ControllerButton>? availableButtons})
|
||||
: super(
|
||||
availableButtons: [
|
||||
ControllerButton.navigationLeft,
|
||||
ControllerButton.navigationRight,
|
||||
ControllerButton.navigationUp,
|
||||
ControllerButton.navigationDown,
|
||||
ControllerButton.a,
|
||||
ControllerButton.b,
|
||||
ControllerButton.y,
|
||||
ControllerButton.z,
|
||||
ControllerButton.shiftUpLeft,
|
||||
ControllerButton.shiftDownLeft,
|
||||
ControllerButton.shiftUpRight,
|
||||
ControllerButton.shiftDownRight,
|
||||
ControllerButton.powerUpLeft,
|
||||
ControllerButton.powerUpRight,
|
||||
ControllerButton.onOffLeft,
|
||||
ControllerButton.onOffRight,
|
||||
ControllerButton.paddleLeft,
|
||||
ControllerButton.paddleRight,
|
||||
],
|
||||
availableButtons:
|
||||
availableButtons ??
|
||||
[
|
||||
ZwiftButtons.navigationLeft,
|
||||
ZwiftButtons.navigationRight,
|
||||
ZwiftButtons.navigationUp,
|
||||
ZwiftButtons.navigationDown,
|
||||
ZwiftButtons.a,
|
||||
ZwiftButtons.b,
|
||||
ZwiftButtons.y,
|
||||
ZwiftButtons.z,
|
||||
ZwiftButtons.shiftUpLeft,
|
||||
ZwiftButtons.shiftDownLeft,
|
||||
ZwiftButtons.shiftUpRight,
|
||||
ZwiftButtons.shiftDownRight,
|
||||
ZwiftButtons.powerUpLeft,
|
||||
ZwiftButtons.powerUpRight,
|
||||
ZwiftButtons.onOffLeft,
|
||||
ZwiftButtons.onOffRight,
|
||||
ZwiftButtons.paddleLeft,
|
||||
ZwiftButtons.paddleRight,
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -59,15 +60,6 @@ class ZwiftRide extends ZwiftDevice {
|
||||
);
|
||||
}
|
||||
|
||||
if (bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2) && this is ZwiftClickV2) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once per day. Resetting the device now.',
|
||||
),
|
||||
);
|
||||
sendCommand(Opcode.RESET, null);
|
||||
}
|
||||
|
||||
switch (opcode) {
|
||||
case Opcode.RIDE_ON:
|
||||
//print("Empty RideOn response - unencrypted mode");
|
||||
@@ -184,31 +176,23 @@ class ZwiftRide extends ZwiftDevice {
|
||||
|
||||
// Process DIGITAL buttons separately
|
||||
final buttonsClicked = [
|
||||
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationLeft,
|
||||
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationRight,
|
||||
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.navigationUp,
|
||||
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationDown,
|
||||
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.a,
|
||||
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.b,
|
||||
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.y,
|
||||
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.z,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftDownLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftDownRight,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.powerUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.powerUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.onOffLeft,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.onOffRight,
|
||||
if (status.buttonMap & RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationLeft,
|
||||
if (status.buttonMap & RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationRight,
|
||||
if (status.buttonMap & RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationUp,
|
||||
if (status.buttonMap & RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationDown,
|
||||
if (status.buttonMap & RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.a,
|
||||
if (status.buttonMap & RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.b,
|
||||
if (status.buttonMap & RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.y,
|
||||
if (status.buttonMap & RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.z,
|
||||
if (status.buttonMap & RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpLeft,
|
||||
if (status.buttonMap & RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftDownLeft,
|
||||
if (status.buttonMap & RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpRight,
|
||||
if (status.buttonMap & RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ZwiftButtons.shiftDownRight,
|
||||
if (status.buttonMap & RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpLeft,
|
||||
if (status.buttonMap & RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpRight,
|
||||
if (status.buttonMap & RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffLeft,
|
||||
if (status.buttonMap & RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffRight,
|
||||
];
|
||||
|
||||
// Process ANALOG inputs separately - now properly separated from digital
|
||||
@@ -219,8 +203,8 @@ class ZwiftRide extends ZwiftDevice {
|
||||
if (paddle.hasLocation() && paddle.hasAnalogValue()) {
|
||||
if (paddle.analogValue.abs() >= analogPaddleThreshold) {
|
||||
final button = switch (paddle.location.value) {
|
||||
0 => ControllerButton.paddleLeft, // L0 = left paddle
|
||||
1 => ControllerButton.paddleRight, // L1 = right paddle
|
||||
0 => ZwiftButtons.paddleLeft, // L0 = left paddle
|
||||
1 => ZwiftButtons.paddleRight, // L1 = right paddle
|
||||
_ => null, // L2, L3 unused
|
||||
};
|
||||
|
||||
@@ -268,7 +252,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
}
|
||||
}
|
||||
|
||||
enum _RideButtonMask {
|
||||
enum RideButtonMask {
|
||||
LEFT_BTN(0x00001),
|
||||
UP_BTN(0x00002),
|
||||
RIGHT_BTN(0x00004),
|
||||
@@ -291,5 +275,5 @@ enum _RideButtonMask {
|
||||
|
||||
final int mask;
|
||||
|
||||
const _RideButtonMask(this.mask);
|
||||
const RideButtonMask(this.mask);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ class LogNotification extends BaseNotification {
|
||||
}
|
||||
}
|
||||
|
||||
class BluetoothAvailabilityNotification extends BaseNotification {
|
||||
final bool isAvailable;
|
||||
|
||||
BluetoothAvailabilityNotification(this.isAvailable);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Bluetooth is ${isAvailable ? "available" : "unavailable"}';
|
||||
}
|
||||
}
|
||||
|
||||
class ButtonNotification extends BaseNotification {
|
||||
List<ControllerButton> buttonsClicked;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -8,17 +7,16 @@ import 'package:swift_control/pages/requirements.dart';
|
||||
import 'package:swift_control/theme.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/actions/link.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/utils/settings/settings.dart';
|
||||
|
||||
import 'bluetooth/connection.dart';
|
||||
import 'link/link.dart';
|
||||
import 'bluetooth/devices/link/link.dart';
|
||||
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();
|
||||
@@ -26,59 +24,57 @@ const screenshotMode = false;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
runApp(const SwiftPlayApp());
|
||||
final error = await settings.init();
|
||||
runApp(SwiftPlayApp(error: error));
|
||||
}
|
||||
|
||||
enum ConnectionType {
|
||||
unknown,
|
||||
local,
|
||||
remote,
|
||||
link,
|
||||
}
|
||||
|
||||
Future<void> initializeActions(ConnectionType connectionType) async {
|
||||
if (connectionType != ConnectionType.link) {
|
||||
whooshLink.stopServer();
|
||||
}
|
||||
if (kIsWeb) {
|
||||
actionHandler = StubActions();
|
||||
} else if (Platform.isAndroid) {
|
||||
actionHandler = switch (connectionType) {
|
||||
ConnectionType.local => AndroidActions(),
|
||||
ConnectionType.remote => RemoteActions(),
|
||||
ConnectionType.link => LinkActions(),
|
||||
ConnectionType.unknown => StubActions(),
|
||||
};
|
||||
} else if (Platform.isIOS) {
|
||||
actionHandler = switch (connectionType) {
|
||||
ConnectionType.local => StubActions(),
|
||||
ConnectionType.remote => RemoteActions(),
|
||||
ConnectionType.link => LinkActions(),
|
||||
ConnectionType.unknown => StubActions(),
|
||||
};
|
||||
} else {
|
||||
actionHandler = switch (connectionType) {
|
||||
ConnectionType.local => DesktopActions(),
|
||||
ConnectionType.remote => RemoteActions(),
|
||||
ConnectionType.link => LinkActions(),
|
||||
ConnectionType.unknown => StubActions(),
|
||||
};
|
||||
}
|
||||
actionHandler.init(settings.getKeyMap());
|
||||
}
|
||||
|
||||
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',
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
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:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/pages/touch_area.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/actions/link.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/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/ingameactions_customizer.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/loading_widget.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/testbed.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:swift_control/widgets/warning.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
import '../utils/actions/android.dart';
|
||||
import '../utils/actions/remote.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/apps/supported_app.dart';
|
||||
import '../utils/requirements/remote.dart';
|
||||
import '../widgets/changelog_dialog.dart';
|
||||
import '../widgets/menu.dart';
|
||||
|
||||
class DevicePage extends StatefulWidget {
|
||||
@@ -41,27 +45,10 @@ class DevicePage extends StatefulWidget {
|
||||
class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
late StreamSubscription<BaseDevice> _connectionStateSubscription;
|
||||
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
|
||||
|
||||
List<SupportedApp> _getAllApps() {
|
||||
final baseApps = SupportedApp.supportedApps.where((app) => app is! CustomApp).toList();
|
||||
final customProfiles = settings.getCustomAppProfiles();
|
||||
|
||||
final customApps = customProfiles.map((profile) {
|
||||
final customApp = CustomApp(profileName: profile);
|
||||
final savedKeymap = settings.getCustomAppKeymap(profile);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
return customApp;
|
||||
}).toList();
|
||||
|
||||
// If no custom profiles exist, add the default "Custom" one
|
||||
if (customApps.isEmpty) {
|
||||
customApps.add(CustomApp());
|
||||
}
|
||||
|
||||
return [...baseApps, ...customApps];
|
||||
}
|
||||
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
bool _showAutoRotationWarning = false;
|
||||
bool _showMiuiWarning = false;
|
||||
StreamSubscription<bool>? _autoRotateStream;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -71,12 +58,32 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
WakelockPlus.enable();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkAndShowChangelog();
|
||||
});
|
||||
|
||||
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) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// show snackbar to inform user that the app needs to stay in foreground
|
||||
_snackBarMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('To keep working properly the app needs to stay in the foreground.'),
|
||||
content: Text('To simulate touches the app needs to stay in the foreground.'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
@@ -85,12 +92,33 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
_connectionStateSubscription = connection.connectionStream.listen((state) async {
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
DeviceAutoRotateChecker.checkAutoRotate().then((isEnabled) {
|
||||
if (!isEnabled) {
|
||||
setState(() {
|
||||
_showAutoRotationWarning = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
_autoRotateStream = DeviceAutoRotateChecker.autoRotateStream.listen((isEnabled) {
|
||||
setState(() {
|
||||
_showAutoRotationWarning = !isEnabled;
|
||||
});
|
||||
});
|
||||
|
||||
// Check if device is MIUI and using local accessibility service
|
||||
if (actionHandler is AndroidActions) {
|
||||
_checkMiuiDevice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
_autoRotateStream?.cancel();
|
||||
_connectionStateSubscription.cancel();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
@@ -98,27 +126,67 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && actionHandler is RemoteActions && Platform.isIOS) {
|
||||
UniversalBle.getBluetoothAvailabilityState().then((state) {
|
||||
if (state == AvailabilityState.poweredOn) {
|
||||
final requirement = RemoteRequirement();
|
||||
requirement.reconnect();
|
||||
_snackBarMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('To keep working properly the app needs to stay in the foreground.'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
if (actionHandler is RemoteActions && Platform.isIOS && (actionHandler as RemoteActions).isConnected) {
|
||||
UniversalBle.getBluetoothAvailabilityState().then((state) {
|
||||
if (state == AvailabilityState.poweredOn) {
|
||||
final requirement = RemoteRequirement();
|
||||
requirement.reconnect();
|
||||
_snackBarMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('To simulate touches the app needs to stay in the foreground.'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
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();
|
||||
final currentVersion = packageInfo.version;
|
||||
final lastSeenVersion = settings.getLastSeenVersion();
|
||||
|
||||
if (mounted) {
|
||||
await ChangelogDialog.showIfNeeded(context, currentVersion, lastSeenVersion);
|
||||
}
|
||||
|
||||
// Update last seen version
|
||||
await settings.setLastSeenVersion(currentVersion);
|
||||
} catch (e) {
|
||||
print('Failed to check changelog: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canVibrate = connection.devices.any(
|
||||
final canVibrate = connection.bluetoothDevices.any(
|
||||
(device) => (device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') && device.isConnected,
|
||||
);
|
||||
|
||||
@@ -136,7 +204,9 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
appBar: AppBar(
|
||||
title: AppTitle(),
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
backgroundColor: Theme.brightnessOf(context) == Brightness.light
|
||||
? Theme.of(context).colorScheme.inversePrimary
|
||||
: null,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
@@ -148,6 +218,77 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_showAutoRotationWarning)
|
||||
Warning(
|
||||
children: [
|
||||
Text('Enable auto-rotation on your device to make sure the app works correctly.'),
|
||||
],
|
||||
),
|
||||
if (_showMiuiWarning)
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Theme.of(context).colorScheme.error),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'MIUI Device Detected',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () async {
|
||||
await settings.setMiuiWarningDismissed(true);
|
||||
setState(() {
|
||||
_showMiuiWarning = false;
|
||||
});
|
||||
},
|
||||
tooltip: 'Dismiss',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Your device is running MIUI, which is known to aggressively kill background services and accessibility services.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'To ensure SwiftControl works properly:',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'• Disable battery optimization for SwiftControl',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'• Enable autostart for SwiftControl',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
Text(
|
||||
'• Lock the app in recent apps',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final url = Uri.parse('https://dontkillmyapp.com/xiaomi');
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.open_in_new),
|
||||
label: Text('View Detailed Instructions'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
|
||||
@@ -163,124 +304,106 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (connection.devices.isEmpty) Text('No devices connected. Searching...'),
|
||||
...connection.devices.map(
|
||||
(device) => Row(
|
||||
children: [
|
||||
Text(
|
||||
device.device.name?.screenshot ?? device.runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
if (device.isBeta) BetaPill(),
|
||||
if (device.batteryLevel != null) ...[
|
||||
Icon(switch (device.batteryLevel!) {
|
||||
>= 80 => Icons.battery_full,
|
||||
>= 60 => Icons.battery_6_bar,
|
||||
>= 50 => Icons.battery_5_bar,
|
||||
>= 25 => Icons.battery_4_bar,
|
||||
>= 10 => Icons.battery_2_bar,
|
||||
_ => Icons.battery_alert,
|
||||
}),
|
||||
Text('${device.batteryLevel}%'),
|
||||
if (device.firmwareVersion != null) Text(' - Firmware: ${device.firmwareVersion}'),
|
||||
if (device.firmwareVersion != null &&
|
||||
device is ZwiftDevice &&
|
||||
device.firmwareVersion != device.latestFirmwareVersion) ...[
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
||||
Text(
|
||||
' (latest: ${device.latestFirmwareVersion})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Connected Controllers',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (connection.controllerDevices.isEmpty) SmallProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (actionHandler is RemoteActions)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
|
||||
),
|
||||
LoadingWidget(
|
||||
futureCallback: () async {
|
||||
final requirement = RemoteRequirement();
|
||||
await requirement.reconnect();
|
||||
},
|
||||
renderChild: (isLoading, tap) => TextButton(
|
||||
onPressed: tap,
|
||||
child: isLoading ? SmallProgressIndicator() : Text('Reconnect'),
|
||||
if (connection.controllerDevices.isEmpty)
|
||||
ScanWidget()
|
||||
else
|
||||
...connection.controllerDevices.map(
|
||||
(device) => device.showInformation(context),
|
||||
),
|
||||
|
||||
if (connection.remoteDevices.isNotEmpty ||
|
||||
actionHandler is RemoteActions ||
|
||||
whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) ||
|
||||
actionHandler.supportedApp?.supportsZwiftEmulation == true)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (actionHandler is LinkActions)
|
||||
ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isConnected,
|
||||
builder: (BuildContext context, value, Widget? child) {
|
||||
return Text(
|
||||
'Link connected: ${value ? 'Connected' : 'Not connected'}',
|
||||
);
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(
|
||||
'Remote Connections',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
...connection.remoteDevices.map(
|
||||
(device) => device.showInformation(context),
|
||||
),
|
||||
|
||||
if (settings.getTrainerApp() is MyWhoosh &&
|
||||
whooshLink.isCompatible(settings.getLastTarget()!))
|
||||
MyWhooshLinkTile(),
|
||||
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
ZwiftTile(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
|
||||
Container(
|
||||
margin: EdgeInsets.only(bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
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.
|
||||
|
||||
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''',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
connection.devices.whereType<ZwiftClickV2>().forEach(
|
||||
(device) => device.sendCommand(Opcode.RESET, null),
|
||||
);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Troubleshooting'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actionHandler is RemoteActions)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected (optional)'}',
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Reconnect'),
|
||||
onTap: () async {
|
||||
final requirement = RemoteRequirement();
|
||||
await requirement.reconnect();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 20),
|
||||
if (!kIsWeb) ...[
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Customize', style: Theme.of(context).textTheme.titleMedium),
|
||||
child: Text(
|
||||
'Customize ${settings.getTrainerApp()?.name} on ${settings.getLastTarget()?.title}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
@@ -295,195 +418,90 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (actionHandler is! LinkActions)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<SupportedApp?>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries: [
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<SupportedApp?>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries: [
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
label: Text('Select Keymap / app'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await KeypadManager().showNewProfileDialog(context);
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
controller.text = profileName;
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setApp(app);
|
||||
setState(() {});
|
||||
if (app is! CustomApp &&
|
||||
!kIsWeb &&
|
||||
(Platform.isMacOS || Platform.isWindows)) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Customize the keymap if you experience any issues (e.g. wrong keyboard output)',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (actionHandler.supportedApp != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
if (actionHandler.supportedApp is! CustomApp) {
|
||||
final result = await KeypadManager().duplicate(
|
||||
context,
|
||||
actionHandler.supportedApp!.name,
|
||||
);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
final result = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
|
||||
|
||||
if (result == true && actionHandler.supportedApp is CustomApp) {
|
||||
await settings.setApp(actionHandler.supportedApp!);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
icon: Icon(Icons.edit),
|
||||
label: Text('Edit'),
|
||||
),
|
||||
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final currentProfile = actionHandler.supportedApp?.name;
|
||||
final action = await KeypadManager().showManageProfileDialog(
|
||||
context,
|
||||
currentProfile,
|
||||
);
|
||||
if (action != null) {
|
||||
if (action == 'rename') {
|
||||
final newName = await KeypadManager().showRenameProfileDialog(
|
||||
context,
|
||||
currentProfile!,
|
||||
);
|
||||
if (newName != null &&
|
||||
newName.isNotEmpty &&
|
||||
newName != currentProfile) {
|
||||
await settings.duplicateCustomAppProfile(currentProfile, newName);
|
||||
await settings.deleteCustomAppProfile(currentProfile);
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
final savedKeymap = settings.getCustomAppKeymap(newName);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
controller.text = newName;
|
||||
setState(() {});
|
||||
}
|
||||
} else if (action == 'duplicate') {
|
||||
final newName = await KeypadManager().duplicate(
|
||||
context,
|
||||
currentProfile!,
|
||||
);
|
||||
|
||||
if (newName != null) {
|
||||
controller.text = newName;
|
||||
setState(() {});
|
||||
}
|
||||
} else if (action == 'delete') {
|
||||
final confirmed = await KeypadManager().showDeleteConfirmDialog(
|
||||
context,
|
||||
currentProfile!,
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await settings.deleteCustomAppProfile(currentProfile);
|
||||
controller.text = '';
|
||||
setState(() {});
|
||||
}
|
||||
} else if (action == 'import') {
|
||||
final jsonData = await KeypadManager().showImportDialog(context);
|
||||
if (jsonData != null && jsonData.isNotEmpty) {
|
||||
final success = await settings.importCustomAppProfile(jsonData);
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Profile imported successfully'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
} else {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to import profile. Invalid format.'),
|
||||
duration: Duration(seconds: 5),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (action == 'export') {
|
||||
final currentProfile =
|
||||
(actionHandler.supportedApp as CustomApp).profileName;
|
||||
final jsonData = settings.exportCustomAppProfile(currentProfile);
|
||||
if (jsonData != null) {
|
||||
await Clipboard.setData(ClipboardData(text: jsonData));
|
||||
if (mounted) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Profile "$currentProfile" exported to clipboard',
|
||||
),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.more_vert),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
label: Text('Select Keymap'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await KeymapManager().showNewProfileDialog(context);
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
actionHandler.init(customApp);
|
||||
await settings.setKeyMap(customApp);
|
||||
controller.text = profileName;
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
KeymapManager().getManageProfileDialog(
|
||||
context,
|
||||
actionHandler.supportedApp is CustomApp
|
||||
? actionHandler.supportedApp?.name
|
||||
: null,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
'Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
if (actionHandler is LinkActions)
|
||||
InGameActionsCustomizer()
|
||||
else if (actionHandler.supportedApp != null)
|
||||
if (actionHandler.supportedApp != null && connection.controllerDevices.isNotEmpty)
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
settings.setKeyMap(actionHandler.supportedApp!);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (canVibrate) ...[
|
||||
@@ -518,6 +536,27 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<SupportedApp> _getAllApps() {
|
||||
final baseApp = settings.getTrainerApp();
|
||||
final customProfiles = settings.getCustomAppProfiles();
|
||||
|
||||
final customApps = customProfiles.map((profile) {
|
||||
final customApp = CustomApp(profileName: profile);
|
||||
final savedKeymap = settings.getCustomAppKeymap(profile);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
return customApp;
|
||||
}).toList();
|
||||
|
||||
// If no custom profiles exist, add the default "Custom" one
|
||||
if (customApps.isEmpty) {
|
||||
customApps.add(CustomApp());
|
||||
}
|
||||
|
||||
return [if (baseApp != null) baseApp, ...customApps];
|
||||
}
|
||||
}
|
||||
|
||||
extension Screenshot on String {
|
||||
|
||||
@@ -54,31 +54,35 @@ 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!))
|
||||
: _markdown == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MarkdownWidget(
|
||||
markdown: _markdown!,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(fontSize: 14.0, color: Colors.black87),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
body: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _markdown == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MarkdownWidget(
|
||||
markdown: _markdown!,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import 'dart:io';
|
||||
import 'package:dartx/dartx.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/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/changelog_dialog.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';
|
||||
@@ -32,43 +32,17 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
|
||||
// call after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
settings.init().then((_) {
|
||||
_checkAndShowChangelog();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
connection.hasDevices.addListener(() {
|
||||
if (connection.hasDevices.value) {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
|
||||
});
|
||||
} else {
|
||||
_reloadRequirements();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkAndShowChangelog() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final currentVersion = packageInfo.version;
|
||||
final lastSeenVersion = settings.getLastSeenVersion();
|
||||
|
||||
if (mounted) {
|
||||
await ChangelogDialog.showIfNeeded(context, currentVersion, lastSeenVersion);
|
||||
}
|
||||
|
||||
// Update last seen version
|
||||
await settings.setLastSeenVersion(currentVersion);
|
||||
} catch (e) {
|
||||
print('Failed to check changelog: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -87,83 +61,168 @@ 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: _requirements.isEmpty
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: Stepper(
|
||||
currentStep: _currentStep,
|
||||
connectorColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onStepContinue: _currentStep < _requirements.length
|
||||
? () {
|
||||
setState(() {
|
||||
_currentStep += 1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onStepTapped: (step) {
|
||||
if (_requirements[step].status && _requirements[step] is! TargetRequirement) {
|
||||
return;
|
||||
}
|
||||
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
|
||||
if (hasEarlierIncomplete) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_currentStep = step;
|
||||
});
|
||||
},
|
||||
controlsBuilder: (context, details) => Container(),
|
||||
steps: _requirements
|
||||
.mapIndexed(
|
||||
(index, req) => Step(
|
||||
title: Text(req.name, style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: req.buildDescription() ?? (req.description != null ? Text(req.description!) : null),
|
||||
content: Container(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child:
|
||||
(index == _currentStep
|
||||
? req.build(context, () {
|
||||
_reloadRequirements();
|
||||
})
|
||||
: null) ??
|
||||
ElevatedButton(
|
||||
onPressed: req.status
|
||||
? null
|
||||
: () => _callRequirement(req, context, () {
|
||||
_reloadRequirements();
|
||||
}),
|
||||
child: Text(req.name),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Image.asset('icon.png', width: 64, height: 64),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Welcome to SwiftControl!', style: Theme.of(context).textTheme.titleMedium),
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width - 140),
|
||||
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: 'Need help? Click on the '),
|
||||
WidgetSpan(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Icon(Icons.help_outline),
|
||||
),
|
||||
),
|
||||
TextSpan(text: ' button on top and don\'t hesitate to contact us.'),
|
||||
],
|
||||
),
|
||||
state: req.status ? StepState.complete : StepState.indexed,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
_requirements.isEmpty
|
||||
? 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>(
|
||||
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onStepContinue: _currentStep < _requirements.length
|
||||
? () {
|
||||
setState(() {
|
||||
_currentStep += 1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onStepTapped: (step) {
|
||||
if (_requirements[step].status && _requirements[step] is! TargetRequirement) {
|
||||
return;
|
||||
}
|
||||
final hasEarlierIncomplete =
|
||||
_requirements.indexWhere((req) => !req.status) != -1 &&
|
||||
_requirements.indexWhere((req) => !req.status) < step;
|
||||
if (hasEarlierIncomplete) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_currentStep = step;
|
||||
});
|
||||
},
|
||||
controlsBuilder: (context, details) => Container(),
|
||||
steps: _requirements
|
||||
.mapIndexed(
|
||||
(index, req) => Step(
|
||||
title: Text(req.name, style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle:
|
||||
req.buildDescription() ?? (req.description != null ? Text(req.description!) : null),
|
||||
content: Container(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child:
|
||||
(index == _currentStep
|
||||
? req.build(context, () {
|
||||
_reloadRequirements();
|
||||
})
|
||||
: null) ??
|
||||
ElevatedButton(
|
||||
onPressed: req.status
|
||||
? null
|
||||
: () => _callRequirement(req, context, () {
|
||||
_reloadRequirements();
|
||||
}),
|
||||
child: Text(req.name),
|
||||
),
|
||||
),
|
||||
state: req.status ? StepState.complete : StepState.indexed,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 = req.indexWhere((req) => !req.status);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_currentStep = _currentStep >= _requirements.length ? 0 : _currentStep;
|
||||
setState(() {});
|
||||
final unresolvedIndex = req.indexWhere((req) => !req.status);
|
||||
if (unresolvedIndex != -1) {
|
||||
_currentStep = unresolvedIndex;
|
||||
} else if (mounted) {
|
||||
String? currentPath;
|
||||
navigatorKey.currentState?.popUntil((route) {
|
||||
currentPath = route.settings.name;
|
||||
return true;
|
||||
});
|
||||
if (currentPath == '/') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (c) => DevicePage(),
|
||||
settings: RouteSettings(name: '/device'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,71 +2,79 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/widgets/button_widget.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/menu.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
import '../utils/actions/base_actions.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/buttons.dart';
|
||||
import '../utils/keymap/keymap.dart';
|
||||
import '../widgets/custom_keymap_selector.dart';
|
||||
|
||||
final touchAreaSize = 42.0;
|
||||
|
||||
class TouchAreaSetupPage extends StatefulWidget {
|
||||
const TouchAreaSetupPage({super.key});
|
||||
final KeyPair keyPair;
|
||||
const TouchAreaSetupPage({super.key, required this.keyPair});
|
||||
|
||||
@override
|
||||
State<TouchAreaSetupPage> createState() => _TouchAreaSetupPageState();
|
||||
}
|
||||
|
||||
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
File? _backgroundImage;
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
ControllerButton? _pressedButton;
|
||||
Uint8List? _backgroundImage;
|
||||
final TransformationController _transformationController = TransformationController();
|
||||
|
||||
late Rect _imageRect;
|
||||
|
||||
bool _showFaded = true;
|
||||
|
||||
Future<void> _pickScreenshot() async {
|
||||
final picker = ImagePicker();
|
||||
final result = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (result != null) {
|
||||
final image = File(result.path);
|
||||
|
||||
// need to decode image to get its size so we can have a percentage mapping
|
||||
final decodedImage = await decodeImageFromList(image.readAsBytesSync());
|
||||
// calculate image rectangle in the current screen, given it's boxfit contain
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final imageAspectRatio = decodedImage.width / decodedImage.height;
|
||||
final screenAspectRatio = screenSize.width / screenSize.height;
|
||||
if (imageAspectRatio > screenAspectRatio) {
|
||||
// image is wider than screen
|
||||
final width = screenSize.width;
|
||||
final height = width / imageAspectRatio;
|
||||
final top = (screenSize.height - height) / 2;
|
||||
_imageRect = Rect.fromLTWH(0, top, width, height);
|
||||
} else {
|
||||
// image is taller than screen
|
||||
final height = screenSize.height;
|
||||
final width = height * imageAspectRatio;
|
||||
final left = (screenSize.width - width) / 2;
|
||||
_imageRect = Rect.fromLTWH(left, 0, width, height);
|
||||
}
|
||||
_backgroundImage = image;
|
||||
setState(() {});
|
||||
final Directory tempDir = await getTemporaryDirectory();
|
||||
final tempImage = File('${tempDir.path}/${actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png');
|
||||
await image.copy(tempImage.path);
|
||||
_backgroundImage = tempImage.readAsBytesSync();
|
||||
await _calculateBounds();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _calculateBounds() async {
|
||||
if (_backgroundImage == null) return;
|
||||
|
||||
// need to decode image to get its size so we can have a percentage mapping
|
||||
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;
|
||||
final screenAspectRatio = screenSize.width / screenSize.height;
|
||||
if (imageAspectRatio > screenAspectRatio) {
|
||||
// image is wider than screen
|
||||
final width = screenSize.width;
|
||||
final height = width / imageAspectRatio;
|
||||
final top = (screenSize.height - height) / 2;
|
||||
_imageRect = Rect.fromLTWH(0, top, width, height);
|
||||
} else {
|
||||
// image is taller than screen
|
||||
final height = screenSize.height;
|
||||
final width = height * imageAspectRatio;
|
||||
final left = (screenSize.width - width) / 2;
|
||||
_imageRect = Rect.fromLTWH(left, 0, width, height);
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _saveAndClose() {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
@@ -74,7 +82,6 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_actionSubscription.cancel();
|
||||
// Exit full screen
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
|
||||
// Reset orientation preferences to allow all orientations
|
||||
@@ -105,35 +112,16 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
windowManager.setFullScreen(true);
|
||||
}
|
||||
_actionSubscription = connection.actionStream.listen((data) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (data is ButtonNotification) {
|
||||
_pressedButton = data.buttonsClicked.singleOrNull;
|
||||
}
|
||||
getTemporaryDirectory().then((tempDir) async {
|
||||
final tempImage = File('${tempDir.path}/${actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png');
|
||||
if (tempImage.existsSync()) {
|
||||
_backgroundImage = tempImage.readAsBytesSync();
|
||||
setState(() {});
|
||||
|
||||
if (_pressedButton != null) {
|
||||
if (actionHandler.supportedApp!.keymap.getKeyPair(_pressedButton!) == null) {
|
||||
final KeyPair keyPair;
|
||||
actionHandler.supportedApp!.keymap.keyPairs.add(
|
||||
keyPair = KeyPair(
|
||||
touchPosition: Offset((actionHandler.supportedApp!.keymap.keyPairs.length + 1) * 10, 10),
|
||||
buttons: [_pressedButton!],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
|
||||
// open menu
|
||||
if (Platform.isMacOS || Platform.isWindows) {
|
||||
await Future.delayed(Duration(milliseconds: 300));
|
||||
await keyPressSimulator.simulateMouseClickDown(keyPair.touchPosition);
|
||||
await keyPressSimulator.simulateMouseClickUp(keyPair.touchPosition);
|
||||
}
|
||||
}
|
||||
// wait a bit until device rotation is done
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_calculateBounds();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -173,93 +161,6 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
_imageRect.top + relativeY * _imageRect.height - differenceInHeight - iconSize / 2,
|
||||
);
|
||||
|
||||
final actions = [
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.keyboard_alt_outlined),
|
||||
title: const Text('Simulate Keyboard shortcut'),
|
||||
trailing: keyPair.physicalKey != null ? Checkbox(value: true, onChanged: null) : null,
|
||||
),
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder: (c) =>
|
||||
HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.touch))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
title: const Text('Simulate Touch'),
|
||||
leading: Icon(Icons.touch_app_outlined),
|
||||
trailing: keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero
|
||||
? Checkbox(value: true, onChanged: null)
|
||||
: null,
|
||||
),
|
||||
onTap: () {
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton<PhysicalKeyboardKey>(
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaPlayPause,
|
||||
child: const Text('Media: Play/Pause'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaStop,
|
||||
child: const Text('Media: Stop'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackPrevious,
|
||||
child: const Text('Media: Previous'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackNext,
|
||||
child: const Text('Media: Next'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeUp,
|
||||
child: const Text('Media: Volume Up'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeDown,
|
||||
child: const Text('Media: Volume Down'),
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
keyPair.physicalKey = key;
|
||||
keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.music_note_outlined),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (keyPair.isSpecialKey) Checkbox(value: true, onChanged: null),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
title: Text('Simulate Media key'),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
final icon = Container(
|
||||
constraints: BoxConstraints(minHeight: iconSize, minWidth: iconSize),
|
||||
child: Column(
|
||||
@@ -287,51 +188,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<PhysicalKeyboardKey>(
|
||||
enabled: enableTouch,
|
||||
itemBuilder: (context) => [
|
||||
if (actions.length > 1) ...actions,
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
onTap: () {
|
||||
keyPair.isLongPress = !keyPair.isLongPress;
|
||||
setState(() {});
|
||||
},
|
||||
child: CheckboxListTile(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
setState(() {});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
title: const Text('Long Press Mode (vs. repeating)'),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
title: const Text('Delete Keymap'),
|
||||
leading: Icon(Icons.delete, color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
keyPair.physicalKey = key;
|
||||
keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
KeypairExplanation(withKey: true, keyPair: keyPair),
|
||||
Icon(Icons.more_vert),
|
||||
],
|
||||
),
|
||||
),
|
||||
KeypairExplanation(withKey: true, keyPair: keyPair),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -341,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -389,6 +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 =
|
||||
actionHandler.supportedApp?.keymap.keyPairs
|
||||
.where((kp) => kp.touchPosition != Offset.zero && !kp.isSpecialKey)
|
||||
.toList() ??
|
||||
[];
|
||||
return InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
child: Stack(
|
||||
@@ -397,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,
|
||||
),
|
||||
@@ -417,8 +288,8 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
),
|
||||
),
|
||||
|
||||
...?actionHandler.supportedApp?.keymap.keyPairs.map((keyPair) {
|
||||
return _buildDraggableArea(
|
||||
for (final keyPair in keyPairsToShow)
|
||||
_buildDraggableArea(
|
||||
enableTouch: true,
|
||||
keyPair: keyPair,
|
||||
onPositionChanged: (newPos) {
|
||||
@@ -429,8 +300,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
setState(() {});
|
||||
},
|
||||
color: Colors.red,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
Positioned.fill(child: Testbed()),
|
||||
|
||||
@@ -476,10 +346,17 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Choose another screenshot'),
|
||||
onTap: () {
|
||||
_pickScreenshot();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Reset'),
|
||||
onTap: () {
|
||||
_backgroundImage = null;
|
||||
|
||||
actionHandler.supportedApp?.keymap.reset();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -487,7 +364,6 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
],
|
||||
icon: Icon(Icons.more_vert),
|
||||
),
|
||||
if (kDebugMode) MenuButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -510,6 +386,7 @@ class KeypairExplanation extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (withKey)
|
||||
@@ -518,8 +395,17 @@ class KeypairExplanation extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Icon(keyPair.icon),
|
||||
if (keyPair.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
|
||||
KeyWidget(
|
||||
if (keyPair.inGameAction != null &&
|
||||
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
|
||||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())))
|
||||
_KeyWidget(
|
||||
label: [
|
||||
keyPair.inGameAction.toString().split('.').last,
|
||||
if (keyPair.inGameActionValue != null) ': ${keyPair.inGameActionValue}',
|
||||
].joinToString(separator: ''),
|
||||
)
|
||||
else if (keyPair.isSpecialKey && actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
_KeyWidget(
|
||||
label: switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaStop => 'Stop',
|
||||
@@ -527,16 +413,53 @@ class KeypairExplanation extends StatelessWidget {
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Next',
|
||||
PhysicalKeyboardKey.audioVolumeUp => 'Volume Up',
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
|
||||
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
|
||||
_ => 'Unknown',
|
||||
},
|
||||
)
|
||||
else if (keyPair.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
|
||||
_KeyWidget(
|
||||
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 ...[
|
||||
if (!withKey)
|
||||
KeyWidget(label: 'X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}'),
|
||||
if (!withKey && keyPair.touchPosition != Offset.zero)
|
||||
_KeyWidget(label: 'X:${keyPair.touchPosition.dx.toInt()}, Y:${keyPair.touchPosition.dy.toInt()}'),
|
||||
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyWidget extends StatelessWidget {
|
||||
final String label;
|
||||
const _KeyWidget({super.key, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicWidth(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
constraints: BoxConstraints(minWidth: 30),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label.splitByUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: '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,5 +1,8 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
@@ -12,6 +15,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,6 +27,21 @@ 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
|
||||
@@ -30,25 +50,33 @@ class AndroidActions extends BaseActions {
|
||||
return ("Could not perform ${button.name.splitByUpperCase()}: No keymap set");
|
||||
}
|
||||
|
||||
if (supportedApp is CustomApp) {
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(button);
|
||||
if (keyPair != null && keyPair.isSpecialKey) {
|
||||
await accessibilityHandler.controlMedia(switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaTrackNext => MediaAction.next,
|
||||
PhysicalKeyboardKey.mediaPlayPause => MediaAction.playPause,
|
||||
PhysicalKeyboardKey.audioVolumeUp => MediaAction.volumeUp,
|
||||
PhysicalKeyboardKey.audioVolumeDown => MediaAction.volumeDown,
|
||||
_ => throw SingleLineException("No action for key: ${keyPair.physicalKey}"),
|
||||
});
|
||||
return "Key pressed: ${keyPair.toString()}";
|
||||
}
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(button);
|
||||
|
||||
if (keyPair == null) {
|
||||
return ("Could not perform ${button.name.splitByUpperCase()}: No action assigned");
|
||||
}
|
||||
final point = await resolveTouchPosition(action: button, windowInfo: windowInfo);
|
||||
|
||||
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);
|
||||
} else if (keyPair.isSpecialKey) {
|
||||
await accessibilityHandler.controlMedia(switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaTrackNext => MediaAction.next,
|
||||
PhysicalKeyboardKey.mediaPlayPause => MediaAction.playPause,
|
||||
PhysicalKeyboardKey.audioVolumeUp => MediaAction.volumeUp,
|
||||
PhysicalKeyboardKey.audioVolumeDown => MediaAction.volumeDown,
|
||||
_ => throw SingleLineException("No action for key: ${keyPair.physicalKey}"),
|
||||
});
|
||||
return "Key pressed: ${keyPair.toString()}";
|
||||
}
|
||||
|
||||
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: windowInfo);
|
||||
if (point != Offset.zero) {
|
||||
try {
|
||||
await accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
|
||||
} on PlatformException catch (e) {
|
||||
return "Failed to perform touch action. Please get in contact with Jonas.\n${e.message}";
|
||||
return "Accessibility Service not working. Follow instructions at https://dontkillmyapp.com/";
|
||||
}
|
||||
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
|
||||
? "click"
|
||||
@@ -56,6 +84,10 @@ class AndroidActions extends BaseActions {
|
||||
? "down"
|
||||
: "up"}";
|
||||
}
|
||||
return "No touch performed";
|
||||
return "No action assigned";
|
||||
}
|
||||
|
||||
void ignoreHidDevices() {
|
||||
accessibilityHandler.ignoreHidDevices();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
import '../keymap/apps/supported_app.dart';
|
||||
|
||||
@@ -21,56 +25,70 @@ abstract class BaseActions {
|
||||
|
||||
void init(SupportedApp? supportedApp) {
|
||||
this.supportedApp = supportedApp;
|
||||
print('Supported app: ${supportedApp?.name ?? "None"}');
|
||||
|
||||
if (supportedApp != null) {
|
||||
final allButtons = connection.devices.map((e) => e.availableButtons).flatten().distinct();
|
||||
|
||||
final newButtons = allButtons.filter(
|
||||
(button) => supportedApp.keymap.getKeyPair(button) == null,
|
||||
);
|
||||
for (final button in newButtons) {
|
||||
supportedApp.keymap.addKeyPair(
|
||||
KeyPair(
|
||||
touchPosition: Offset.zero,
|
||||
buttons: [button],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Offset> resolveTouchPosition({required ControllerButton action, required WindowEvent? windowInfo}) async {
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
|
||||
Future<Offset> resolveTouchPosition({required KeyPair keyPair, required WindowEvent? windowInfo}) async {
|
||||
if (keyPair.touchPosition != Offset.zero) {
|
||||
// convert relative position to absolute position based on window info
|
||||
|
||||
if (windowInfo != null && windowInfo.top > 0) {
|
||||
final x = windowInfo.left + (keyPair.touchPosition.dx / 100) * (windowInfo.right - windowInfo.left);
|
||||
final y = windowInfo.top + (keyPair.touchPosition.dy / 100) * (windowInfo.bottom - windowInfo.top);
|
||||
|
||||
if (kDebugMode) {
|
||||
print("Window info: ${windowInfo.encode()} => Touch at: $x, $y");
|
||||
}
|
||||
return Offset(x, y);
|
||||
// TODO support multiple screens
|
||||
final Size displaySize;
|
||||
final double devicePixelRatio;
|
||||
if (Platform.isWindows) {
|
||||
// TODO remove once https://github.com/flutter/flutter/pull/164460 is available in stable
|
||||
final display = await screenRetriever.getPrimaryDisplay();
|
||||
displaySize = display.size;
|
||||
devicePixelRatio = 1.0;
|
||||
} else {
|
||||
// TODO support multiple screens
|
||||
final Size displaySize;
|
||||
final double devicePixelRatio;
|
||||
if (Platform.isWindows) {
|
||||
// TODO remove once https://github.com/flutter/flutter/pull/164460 is available in stable
|
||||
final display = await screenRetriever.getPrimaryDisplay();
|
||||
displaySize = display.size;
|
||||
devicePixelRatio = 1.0;
|
||||
} else {
|
||||
final display = WidgetsBinding.instance.platformDispatcher.views.first.display;
|
||||
displaySize = display.size;
|
||||
devicePixelRatio = display.devicePixelRatio;
|
||||
}
|
||||
final display = WidgetsBinding.instance.platformDispatcher.views.first.display;
|
||||
displaySize = display.size;
|
||||
devicePixelRatio = display.devicePixelRatio;
|
||||
}
|
||||
|
||||
late final Size physicalSize;
|
||||
if (this is AndroidActions) {
|
||||
late final Size physicalSize;
|
||||
if (this is AndroidActions) {
|
||||
if (windowInfo != null && windowInfo.packageName != 'de.jonasbark.swiftcontrol') {
|
||||
// a trainer app is in foreground, so use the always assume landscape
|
||||
physicalSize = Size(max(displaySize.width, displaySize.height), min(displaySize.width, displaySize.height));
|
||||
} else {
|
||||
// display size is already in physical pixels
|
||||
physicalSize = displaySize;
|
||||
} else if (this is DesktopActions) {
|
||||
// display size is in logical pixels, convert to physical pixels
|
||||
// TODO on macOS the notch is included here, but it's not part of the usable screen area, so we should exclude it
|
||||
physicalSize = displaySize / devicePixelRatio;
|
||||
} else {
|
||||
physicalSize = displaySize;
|
||||
}
|
||||
|
||||
final x = (keyPair.touchPosition.dx / 100.0) * physicalSize.width;
|
||||
final y = (keyPair.touchPosition.dy / 100.0) * physicalSize.height;
|
||||
|
||||
if (kDebugMode) {
|
||||
print("Screen size: $physicalSize => Touch at: $x, $y");
|
||||
}
|
||||
return Offset(x, y);
|
||||
} else if (this is DesktopActions) {
|
||||
// display size is in logical pixels, convert to physical pixels
|
||||
// TODO on macOS the notch is included here, but it's not part of the usable screen area, so we should exclude it
|
||||
physicalSize = displaySize / devicePixelRatio;
|
||||
} else {
|
||||
physicalSize = displaySize;
|
||||
}
|
||||
|
||||
final x = (keyPair.touchPosition.dx / 100.0) * physicalSize.width;
|
||||
final y = (keyPair.touchPosition.dy / 100.0) * physicalSize.height;
|
||||
|
||||
if (kDebugMode) {
|
||||
print("Screen size: $physicalSize vs $displaySize => Touch at: $x, $y");
|
||||
}
|
||||
return Offset(x, y);
|
||||
}
|
||||
return Offset.zero;
|
||||
}
|
||||
@@ -81,8 +99,11 @@ abstract class BaseActions {
|
||||
class StubActions extends BaseActions {
|
||||
StubActions({super.supportedModes = const []});
|
||||
|
||||
final List<ControllerButton> performedActions = [];
|
||||
|
||||
@override
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
performedActions.add(action);
|
||||
return Future.value(action.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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';
|
||||
@@ -20,31 +24,39 @@ class DesktopActions extends BaseActions {
|
||||
}
|
||||
|
||||
// Handle regular key press mode (existing behavior)
|
||||
if (keyPair.physicalKey != null) {
|
||||
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);
|
||||
} else if (keyPair.physicalKey != null) {
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
|
||||
return 'Key clicked: $keyPair';
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
|
||||
return 'Key pressed: $keyPair';
|
||||
} else {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
|
||||
return 'Key released: $keyPair';
|
||||
}
|
||||
} else {
|
||||
final point = await resolveTouchPosition(action: action, windowInfo: null);
|
||||
if (isKeyDown && isKeyUp) {
|
||||
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} ${point.dy}';
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
return 'Mouse down at: ${point.dx} ${point.dy}';
|
||||
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: null);
|
||||
if (point != Offset.zero) {
|
||||
if (isKeyDown && isKeyUp) {
|
||||
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()}';
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
return 'Mouse down at: ${point.dx.toInt()} ${point.dy.toInt()}';
|
||||
} else {
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse up at: ${point.dx.toInt()} ${point.dy.toInt()}';
|
||||
}
|
||||
} else {
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse up at: ${point.dx} ${point.dy}';
|
||||
return 'No action assigned';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class LinkActions extends BaseActions {
|
||||
LinkActions() : super(supportedModes: [SupportedMode.keyboard]);
|
||||
|
||||
@override
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
final inGameAction = settings.getInGameActionForButton(action);
|
||||
if (inGameAction == null) {
|
||||
return 'No action defined for button: $action';
|
||||
}
|
||||
final value = settings.getInGameActionForButtonValue(action);
|
||||
return whooshLink.sendAction(inGameAction, value);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@ 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';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
@@ -26,14 +28,18 @@ class RemoteActions extends BaseActions {
|
||||
return 'Keymap entry not found for action: ${action.toString().splitByUpperCase()}';
|
||||
}
|
||||
|
||||
if (!(actionHandler as RemoteActions).isConnected) {
|
||||
return 'Not connected to a device';
|
||||
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);
|
||||
} else if (!(actionHandler as RemoteActions).isConnected) {
|
||||
return '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');
|
||||
} else {
|
||||
final point = await resolveTouchPosition(action: action, windowInfo: null);
|
||||
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: null);
|
||||
final point2 = point; //Offset(100, 99.0);
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
|
||||
@@ -44,13 +50,9 @@ class RemoteActions extends BaseActions {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Offset> resolveTouchPosition({required ControllerButton action, required WindowEvent? windowInfo}) async {
|
||||
Future<Offset> resolveTouchPosition({required KeyPair keyPair, required WindowEvent? windowInfo}) async {
|
||||
// for remote actions we use the relative position only
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
|
||||
return keyPair.touchPosition;
|
||||
}
|
||||
return Offset.zero;
|
||||
return keyPair.touchPosition;
|
||||
}
|
||||
|
||||
Uint8List absMouseReport(int buttons3bit, int x, int y) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
@@ -10,6 +13,8 @@ class Biketerra extends SupportedApp {
|
||||
: super(
|
||||
name: 'Biketerra',
|
||||
packageName: "biketerra",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
@@ -8,10 +12,20 @@ import '../keymap.dart';
|
||||
class CustomApp extends SupportedApp {
|
||||
final String profileName;
|
||||
|
||||
CustomApp({this.profileName = 'Custom'})
|
||||
CustomApp({this.profileName = 'Other'})
|
||||
: super(
|
||||
name: profileName,
|
||||
compatibleTargets: kIsWeb
|
||||
? [Target.thisDevice]
|
||||
: [
|
||||
if (!Platform.isIOS) Target.thisDevice,
|
||||
Target.macOS,
|
||||
Target.windows,
|
||||
Target.iOS,
|
||||
Target.android,
|
||||
],
|
||||
packageName: "custom_$profileName",
|
||||
supportsZwiftEmulation: !kIsWeb && !(Platform.isIOS || Platform.isMacOS),
|
||||
keymap: Keymap(keyPairs: []),
|
||||
);
|
||||
|
||||
@@ -38,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.keyPairs.add(
|
||||
keymap.addKeyPair(
|
||||
KeyPair(
|
||||
buttons: [zwiftButton],
|
||||
physicalKey: physicalKey,
|
||||
logicalKey: logicalKey,
|
||||
modifiers: modifiers,
|
||||
isLongPress: isLongPress,
|
||||
touchPosition: touchPosition ?? Offset.zero,
|
||||
inGameAction: inGameAction,
|
||||
inGameActionValue: inGameActionValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
@@ -10,6 +11,8 @@ class MyWhoosh extends SupportedApp {
|
||||
: super(
|
||||
name: 'MyWhoosh',
|
||||
packageName: "com.mywhoosh.whooshgame",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
@@ -17,19 +20,22 @@ class MyWhoosh extends SupportedApp {
|
||||
physicalKey: PhysicalKeyboardKey.keyI,
|
||||
logicalKey: LogicalKeyboardKey.keyI,
|
||||
touchPosition: Offset(80, 94),
|
||||
inGameAction: InGameAction.shiftDown,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyK,
|
||||
logicalKey: LogicalKeyboardKey.keyK,
|
||||
touchPosition: Offset(98, 94),
|
||||
touchPosition: Offset(97, 94),
|
||||
inGameAction: InGameAction.shiftUp,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
touchPosition: Offset(98, 80),
|
||||
touchPosition: Offset(60, 80),
|
||||
isLongPress: true,
|
||||
inGameAction: InGameAction.navigateRight,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
@@ -37,11 +43,13 @@ class MyWhoosh extends SupportedApp {
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
touchPosition: Offset(32, 80),
|
||||
isLongPress: true,
|
||||
inGameAction: InGameAction.navigateLeft,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyH,
|
||||
logicalKey: LogicalKeyboardKey.keyH,
|
||||
inGameAction: InGameAction.toggleUi,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
42
lib/utils/keymap/apps/rouvy.dart
Normal file
42
lib/utils/keymap/apps/rouvy.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
class Rouvy extends SupportedApp {
|
||||
Rouvy()
|
||||
: super(
|
||||
name: 'Rouvy',
|
||||
packageName: "eu.virtualtraining.rouvy.android",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: true,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
// https://support.rouvy.com/hc/de/articles/32452137189393-Virtuelles-Schalten#h_01K5GMVG4KVYZ0Y6W7RBRZC9MA
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
inGameAction: InGameAction.shiftDown,
|
||||
physicalKey: PhysicalKeyboardKey.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
inGameAction: InGameAction.shiftUp,
|
||||
physicalKey: PhysicalKeyboardKey.numpadAdd,
|
||||
logicalKey: LogicalKeyboardKey.numpadAdd,
|
||||
),
|
||||
// like escape
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.b],
|
||||
physicalKey: PhysicalKeyboardKey.keyB,
|
||||
logicalKey: LogicalKeyboardKey.keyB,
|
||||
inGameAction: InGameAction.back,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,36 @@
|
||||
import 'package:swift_control/utils/keymap/apps/biketerra.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/training_peaks.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
import 'custom_app.dart';
|
||||
import 'my_whoosh.dart';
|
||||
|
||||
abstract class SupportedApp {
|
||||
final List<Target> compatibleTargets;
|
||||
final String packageName;
|
||||
final String name;
|
||||
final Keymap keymap;
|
||||
final bool supportsZwiftEmulation;
|
||||
|
||||
const SupportedApp({required this.name, required this.packageName, required this.keymap});
|
||||
const SupportedApp({
|
||||
required this.name,
|
||||
required this.packageName,
|
||||
required this.keymap,
|
||||
required this.compatibleTargets,
|
||||
required this.supportsZwiftEmulation,
|
||||
});
|
||||
|
||||
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];
|
||||
static final List<SupportedApp> supportedApps = [
|
||||
MyWhoosh(),
|
||||
Zwift(),
|
||||
TrainingPeaks(),
|
||||
Biketerra(),
|
||||
Rouvy(),
|
||||
CustomApp(),
|
||||
];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/elite/elite_square.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
@@ -10,31 +13,83 @@ class TrainingPeaks extends SupportedApp {
|
||||
: super(
|
||||
name: 'TrainingPeaks Virtual / IndieVelo',
|
||||
packageName: "com.indieVelo.client",
|
||||
compatibleTargets: Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
// https://help.trainingpeaks.com/hc/en-us/articles/31340399556877-TrainingPeaks-Virtual-Controls-and-Keyboard-Shortcuts
|
||||
// Explicit controller-button mappings with updated touch coordinates
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
touchPosition: Offset(50 * 1.32, 74),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
buttons: [ZwiftButtons.shiftUpRight],
|
||||
physicalKey: PhysicalKeyboardKey.numpadAdd,
|
||||
logicalKey: LogicalKeyboardKey.numpadAdd,
|
||||
touchPosition: Offset(50 * 1.15, 74),
|
||||
touchPosition: Offset(22.65384615384622, 7.0769230769229665),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
buttons: [ZwiftButtons.shiftUpLeft],
|
||||
physicalKey: PhysicalKeyboardKey.numpadAdd,
|
||||
logicalKey: LogicalKeyboardKey.numpadAdd,
|
||||
touchPosition: Offset(18.14448747554958, 6.772862761010401),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.shiftDownLeft],
|
||||
physicalKey: PhysicalKeyboardKey.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
touchPosition: Offset(18.128205128205135, 6.75213675213675),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.shiftDownRight],
|
||||
physicalKey: PhysicalKeyboardKey.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
touchPosition: Offset(22.61769250748708, 8.13909075507417),
|
||||
),
|
||||
|
||||
// Navigation buttons (keep arrow key mappings and add touch positions)
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.steerRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
touchPosition: Offset(56.75858807279006, 92.42753954973301),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.steerLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
touchPosition: Offset(41.11538461538456, 92.64957264957286),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.navigationUp],
|
||||
physicalKey: PhysicalKeyboardKey.arrowUp,
|
||||
logicalKey: LogicalKeyboardKey.arrowUp,
|
||||
touchPosition: Offset(42.28406293368177, 92.61854987939971),
|
||||
),
|
||||
|
||||
// Face buttons with touch positions and keyboard fallbacks where sensible
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.z, EliteSquareButtons.z],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(33.993890038715456, 92.43667306401531),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.a, EliteSquareButtons.a],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(47.37191097597044, 92.86963594239016),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.b, EliteSquareButtons.b],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(41.12364102683652, 83.72743323236598),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.y, EliteSquareButtons.y],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(58.52936866684111, 84.31131200977018),
|
||||
),
|
||||
|
||||
// Keep other existing mappings (toggle UI, increase/decrease resistance)
|
||||
KeyPair(
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyH,
|
||||
|
||||
113
lib/utils/keymap/apps/zwift.dart
Normal file
113
lib/utils/keymap/apps/zwift.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
class Zwift extends SupportedApp {
|
||||
Zwift()
|
||||
: super(
|
||||
name: 'Zwift',
|
||||
packageName: "com.zwift.zwiftgame",
|
||||
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
|
||||
compatibleTargets: [
|
||||
if (!Platform.isIOS) Target.thisDevice,
|
||||
Target.macOS,
|
||||
Target.windows,
|
||||
Target.iOS,
|
||||
Target.android,
|
||||
],
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.navigationUp],
|
||||
physicalKey: PhysicalKeyboardKey.arrowUp,
|
||||
logicalKey: LogicalKeyboardKey.arrowUp,
|
||||
inGameAction: InGameAction.openActionBar,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.navigationDown],
|
||||
physicalKey: PhysicalKeyboardKey.arrowDown,
|
||||
logicalKey: LogicalKeyboardKey.arrowDown,
|
||||
inGameAction: InGameAction.uturn,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.navigationLeft],
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
inGameAction: InGameAction.steerLeft,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.navigationRight],
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
inGameAction: InGameAction.steerRight,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.shiftUpLeft],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: InGameAction.shiftDown,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.shiftUpRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: InGameAction.shiftUp,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.shiftDownLeft],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: InGameAction.shiftDown,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.shiftDownRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: InGameAction.shiftUp,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.paddleLeft],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: InGameAction.shiftDown,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: InGameAction.shiftUp,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.y],
|
||||
physicalKey: PhysicalKeyboardKey.space,
|
||||
logicalKey: LogicalKeyboardKey.space,
|
||||
inGameAction: InGameAction.usePowerUp,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.a],
|
||||
physicalKey: PhysicalKeyboardKey.enter,
|
||||
logicalKey: LogicalKeyboardKey.enter,
|
||||
inGameAction: InGameAction.select,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.b],
|
||||
physicalKey: PhysicalKeyboardKey.escape,
|
||||
logicalKey: LogicalKeyboardKey.escape,
|
||||
inGameAction: InGameAction.back,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: [ZwiftButtons.z],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: InGameAction.rideOnBomb,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,33 @@
|
||||
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';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
|
||||
enum InGameAction {
|
||||
shiftUp('Shift Up'),
|
||||
shiftDown('Shift Down'),
|
||||
uturn('U-Turn'),
|
||||
steerLeft('Steer Left'),
|
||||
steerRight('Steer Right'),
|
||||
|
||||
// mywhoosh
|
||||
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
||||
emote('Emote', possibleValues: [1, 2, 3, 4, 5, 6]),
|
||||
toggleUi('Toggle UI'),
|
||||
navigateLeft('Navigate Left'),
|
||||
navigateRight('Navigate Right'),
|
||||
increaseResistance('Increase Resistance'),
|
||||
decreaseResistance('Decrease Resistance'),
|
||||
toggleUi('Toggle UI'),
|
||||
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
||||
emote('Emote', possibleValues: [1, 2, 3, 4, 5, 6]),
|
||||
uturn('U-Turn'),
|
||||
steerLeft('Steer Left'),
|
||||
steerRight('Steer Right');
|
||||
|
||||
// zwift
|
||||
openActionBar('Open Action Bar'),
|
||||
usePowerUp('Use Power-Up'),
|
||||
select('Select'),
|
||||
back('Back'),
|
||||
rideOnBomb('Ride On Bomb');
|
||||
|
||||
final String title;
|
||||
final List<int>? possibleValues;
|
||||
@@ -25,46 +40,42 @@ enum InGameAction {
|
||||
}
|
||||
}
|
||||
|
||||
enum ControllerButton {
|
||||
// left controller
|
||||
navigationUp._(null, icon: Icons.keyboard_arrow_up, color: Colors.black),
|
||||
navigationDown._(InGameAction.uturn, icon: Icons.keyboard_arrow_down, color: Colors.black),
|
||||
navigationLeft._(InGameAction.navigateLeft, icon: Icons.keyboard_arrow_left, color: Colors.black),
|
||||
navigationRight._(InGameAction.navigateRight, icon: Icons.keyboard_arrow_right, color: Colors.black),
|
||||
onOffLeft._(InGameAction.toggleUi),
|
||||
sideButtonLeft._(InGameAction.shiftDown),
|
||||
paddleLeft._(InGameAction.shiftDown),
|
||||
|
||||
// zwift ride only
|
||||
shiftUpLeft._(InGameAction.shiftDown, icon: Icons.remove, color: Colors.black),
|
||||
shiftDownLeft._(InGameAction.shiftDown, icon: Icons.remove, color: Colors.black),
|
||||
powerUpLeft._(InGameAction.shiftDown),
|
||||
|
||||
// right controller
|
||||
a._(null, color: Colors.lightGreen),
|
||||
b._(null, color: Colors.pinkAccent),
|
||||
z._(null, color: Colors.deepOrangeAccent),
|
||||
y._(null, color: Colors.lightBlue),
|
||||
onOffRight._(InGameAction.toggleUi),
|
||||
sideButtonRight._(InGameAction.shiftUp),
|
||||
paddleRight._(InGameAction.shiftUp),
|
||||
|
||||
// zwift ride only
|
||||
shiftUpRight._(InGameAction.shiftUp, icon: Icons.add, color: Colors.black),
|
||||
shiftDownRight._(InGameAction.shiftUp),
|
||||
powerUpRight._(InGameAction.shiftUp),
|
||||
|
||||
// elite square only
|
||||
campagnoloLeft._(InGameAction.shiftDown),
|
||||
campagnoloRight._(InGameAction.shiftUp);
|
||||
|
||||
class ControllerButton {
|
||||
final String name;
|
||||
final InGameAction? action;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
const ControllerButton._(this.action, {this.color, this.icon});
|
||||
|
||||
const ControllerButton(
|
||||
this.name, {
|
||||
this.color,
|
||||
this.icon,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ControllerButton &&
|
||||
runtimeType == other.runtimeType &&
|
||||
name == other.name &&
|
||||
action == other.action &&
|
||||
color == other.color &&
|
||||
icon == other.icon;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(name, action, color, icon);
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
...SterzoButtons.values,
|
||||
...ZwiftButtons.values,
|
||||
...EliteSquareButtons.values,
|
||||
...WahooKickrShiftButtons.values,
|
||||
...CycplusBc2Buttons.values,
|
||||
].distinct().toList();
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
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';
|
||||
import 'apps/custom_app.dart';
|
||||
|
||||
class Keymap {
|
||||
static Keymap custom = Keymap(keyPairs: []);
|
||||
@@ -15,6 +18,9 @@ class Keymap {
|
||||
|
||||
Keymap({required this.keyPairs});
|
||||
|
||||
final StreamController<void> _updateStream = StreamController<void>.broadcast();
|
||||
Stream<void> get updateStream => _updateStream.stream;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return keyPairs.joinToString(
|
||||
@@ -35,7 +41,43 @@ 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);
|
||||
}
|
||||
|
||||
void addKeyPair(KeyPair keyPair) {
|
||||
keyPairs.add(keyPair);
|
||||
_updateStream.add(null);
|
||||
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,15 +85,21 @@ class KeyPair {
|
||||
final List<ControllerButton> buttons;
|
||||
PhysicalKeyboardKey? physicalKey;
|
||||
LogicalKeyboardKey? logicalKey;
|
||||
List<ModifierKey> modifiers;
|
||||
Offset touchPosition;
|
||||
bool isLongPress;
|
||||
InGameAction? inGameAction;
|
||||
int? inGameActionValue;
|
||||
|
||||
KeyPair({
|
||||
required this.buttons,
|
||||
required this.physicalKey,
|
||||
required this.logicalKey,
|
||||
this.modifiers = const [],
|
||||
this.touchPosition = Offset.zero,
|
||||
this.isLongPress = false,
|
||||
this.inGameAction,
|
||||
this.inGameActionValue,
|
||||
});
|
||||
|
||||
bool get isSpecialKey =>
|
||||
@@ -70,16 +118,19 @@ class KeyPair {
|
||||
PhysicalKeyboardKey.mediaTrackNext ||
|
||||
PhysicalKeyboardKey.audioVolumeUp ||
|
||||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
|
||||
_ =>
|
||||
physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)
|
||||
? Icons.keyboard
|
||||
: Icons.touch_app,
|
||||
_ when physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard) => Icons.keyboard,
|
||||
_
|
||||
when inGameAction != null &&
|
||||
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
|
||||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())) =>
|
||||
Icons.link,
|
||||
_ => Icons.touch_app,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return logicalKey?.keyLabel ??
|
||||
final baseKey = logicalKey?.keyLabel ??
|
||||
switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Next Track',
|
||||
@@ -89,6 +140,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() {
|
||||
@@ -98,8 +167,11 @@ 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,
|
||||
'inGameActionValue': inGameActionValue,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,13 +188,23 @@ class KeyPair {
|
||||
: Offset.zero;
|
||||
|
||||
final buttons = decoded['actions']
|
||||
.map<ControllerButton?>((e) => ControllerButton.values.firstOrNullWhere((element) => element.name == e))
|
||||
.where((e) => e != null)
|
||||
.map<ControllerButton>(
|
||||
(e) => ControllerButton.values.firstOrNullWhere((element) => element.name == e) ?? ControllerButton(e),
|
||||
)
|
||||
.cast<ControllerButton>()
|
||||
.toList();
|
||||
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
|
||||
@@ -131,8 +213,13 @@ 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')
|
||||
? InGameAction.values.firstOrNullWhere((element) => element.name == decoded['inGameAction'])
|
||||
: null,
|
||||
inGameActionValue: decoded['inGameActionValue'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ import 'package:swift_control/main.dart';
|
||||
|
||||
import 'apps/custom_app.dart';
|
||||
|
||||
class KeypadManager {
|
||||
class KeymapManager {
|
||||
// Singleton instance
|
||||
static final KeypadManager _instance = KeypadManager._internal();
|
||||
static final KeymapManager _instance = KeymapManager._internal();
|
||||
|
||||
// Private constructor
|
||||
KeypadManager._internal();
|
||||
KeymapManager._internal();
|
||||
|
||||
// Factory constructor to return the singleton instance
|
||||
factory KeypadManager() {
|
||||
factory KeymapManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
@@ -36,51 +36,110 @@ class KeypadManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showManageProfileDialog(BuildContext context, String? currentProfile) async {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Manage Profile: ${currentProfile ?? ''}'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (currentProfile != null && actionHandler.supportedApp is CustomApp)
|
||||
ListTile(
|
||||
leading: Icon(Icons.edit),
|
||||
title: Text('Rename'),
|
||||
onTap: () => Navigator.pop(context, 'rename'),
|
||||
),
|
||||
if (currentProfile != null)
|
||||
ListTile(
|
||||
leading: Icon(Icons.copy),
|
||||
title: Text('Duplicate'),
|
||||
onTap: () => Navigator.pop(context, 'duplicate'),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.file_upload),
|
||||
title: Text('Import'),
|
||||
onTap: () => Navigator.pop(context, 'import'),
|
||||
),
|
||||
if (currentProfile != null)
|
||||
ListTile(
|
||||
leading: Icon(Icons.share),
|
||||
title: Text('Export'),
|
||||
onTap: () => Navigator.pop(context, 'export'),
|
||||
),
|
||||
if (currentProfile != null)
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: Theme.of(context).colorScheme.error),
|
||||
title: Text('Delete', style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
onTap: () => Navigator.pop(context, 'delete'),
|
||||
),
|
||||
],
|
||||
PopupMenuButton<String> getManageProfileDialog(
|
||||
BuildContext context,
|
||||
String? currentProfile, {
|
||||
required VoidCallback onDone,
|
||||
}) {
|
||||
return PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
if (currentProfile != null && actionHandler.supportedApp is CustomApp)
|
||||
PopupMenuItem(
|
||||
child: Text('Rename'),
|
||||
onTap: () async {
|
||||
final newName = await _showRenameProfileDialog(
|
||||
context,
|
||||
currentProfile,
|
||||
);
|
||||
if (newName != null && newName.isNotEmpty && newName != currentProfile) {
|
||||
await settings.duplicateCustomAppProfile(currentProfile, newName);
|
||||
await settings.deleteCustomAppProfile(currentProfile);
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
final savedKeymap = settings.getCustomAppKeymap(newName);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setKeyMap(customApp);
|
||||
}
|
||||
onDone();
|
||||
},
|
||||
),
|
||||
if (currentProfile != null)
|
||||
PopupMenuItem(
|
||||
child: Text('Duplicate'),
|
||||
onTap: () async {
|
||||
final newName = await duplicate(
|
||||
context,
|
||||
currentProfile,
|
||||
);
|
||||
onDone();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Import'),
|
||||
onTap: () async {
|
||||
final jsonData = await _showImportDialog(context);
|
||||
if (jsonData != null && jsonData.isNotEmpty) {
|
||||
final success = await settings.importCustomAppProfile(jsonData);
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Profile imported successfully'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to import profile. Invalid format.'),
|
||||
duration: Duration(seconds: 5),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel'))],
|
||||
),
|
||||
if (currentProfile != null)
|
||||
PopupMenuItem(
|
||||
child: Text('Export'),
|
||||
onTap: () {
|
||||
final currentProfile = (actionHandler.supportedApp as CustomApp).profileName;
|
||||
final jsonData = settings.exportCustomAppProfile(currentProfile);
|
||||
if (jsonData != null) {
|
||||
Clipboard.setData(ClipboardData(text: jsonData));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Profile "$currentProfile" exported to clipboard',
|
||||
),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (currentProfile != null)
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
onTap: () async {
|
||||
final confirmed = await _showDeleteConfirmDialog(
|
||||
context,
|
||||
currentProfile,
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await settings.deleteCustomAppProfile(currentProfile);
|
||||
}
|
||||
onDone();
|
||||
},
|
||||
child: Text('Delete', style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showRenameProfileDialog(BuildContext context, String currentName) async {
|
||||
Future<String?> _showRenameProfileDialog(BuildContext context, String currentName) async {
|
||||
final controller = TextEditingController(text: currentName);
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
@@ -99,12 +158,12 @@ class KeypadManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showDuplicateProfileDialog(BuildContext context, String currentName) async {
|
||||
Future<String?> _showDuplicateProfileDialog(BuildContext context, String currentName) async {
|
||||
final controller = TextEditingController(text: '$currentName (Copy)');
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Duplicate Profile'),
|
||||
title: Text('Create new custom profile by duplicating "$currentName"'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'New Profile Name'),
|
||||
@@ -118,7 +177,7 @@ class KeypadManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> showDeleteConfirmDialog(BuildContext context, String profileName) async {
|
||||
Future<bool?> _showDeleteConfirmDialog(BuildContext context, String profileName) async {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -136,7 +195,7 @@ class KeypadManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> showImportDialog(BuildContext context) async {
|
||||
Future<String?> _showImportDialog(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
|
||||
// Try to get data from clipboard
|
||||
@@ -174,8 +233,8 @@ class KeypadManager {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -185,7 +244,7 @@ class KeypadManager {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
await settings.setKeyMap(customApp);
|
||||
return newName;
|
||||
} else {
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
@@ -201,15 +260,15 @@ class KeypadManager {
|
||||
physicalKey: pair.physicalKey,
|
||||
logicalKey: pair.logicalKey,
|
||||
isLongPress: pair.isLongPress,
|
||||
touchPosition: pair.touchPosition != Offset.zero
|
||||
? pair.touchPosition
|
||||
: Offset(((indexB + 1)) * 10, 20 + (index * 10)),
|
||||
touchPosition: pair.touchPosition,
|
||||
inGameAction: pair.inGameAction,
|
||||
inGameActionValue: pair.inGameActionValue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
await settings.setKeyMap(customApp);
|
||||
return newName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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()
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
@@ -152,20 +155,37 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
channelGroupId,
|
||||
'Allows SwiftControl to keep running in background',
|
||||
foregroundServiceTypes: {AndroidServiceForegroundType.foregroundServiceTypeConnectedDevice},
|
||||
startType: AndroidServiceStartType.startRedeliverIntent,
|
||||
notificationDetails: AndroidNotificationDetails(
|
||||
channelGroupId,
|
||||
'Keep Alive',
|
||||
actions: [AndroidNotificationAction('Exit', 'Exit', cancelNotification: true, showsUserInterface: false)],
|
||||
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,14 +1,17 @@
|
||||
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';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/utils/requirements/remote.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/link.dart';
|
||||
import 'package:swift_control/widgets/scan.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class KeyboardRequirement extends PlatformRequirement {
|
||||
@@ -37,14 +40,30 @@ class BluetoothTurnedOn extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
final currentState = await UniversalBle.getBluetoothAvailabilityState();
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
// on iOS we cannot programmatically enable Bluetooth, just open settings
|
||||
await peripheralManager.showAppSettings();
|
||||
} else {
|
||||
} else if (currentState == AvailabilityState.poweredOff) {
|
||||
await UniversalBle.enableBluetooth();
|
||||
} else {
|
||||
// I guess bluetooth is on now
|
||||
// TODO move UniversalBle.onAvailabilityChange
|
||||
getStatus();
|
||||
onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
call(context, onUpdate);
|
||||
},
|
||||
child: Text('Enable Bluetooth'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
final currentState = await UniversalBle.getBluetoothAvailabilityState();
|
||||
@@ -65,86 +84,104 @@ class UnsupportedPlatform extends PlatformRequirement {
|
||||
Future<void> getStatus() async {}
|
||||
}
|
||||
|
||||
class BluetoothScanning extends PlatformRequirement {
|
||||
BluetoothScanning() : super('Finding your Controller...') {
|
||||
class ErrorRequirement extends PlatformRequirement {
|
||||
ErrorRequirement(super.name) {
|
||||
status = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
return ScanWidget();
|
||||
}
|
||||
}
|
||||
|
||||
typedef BoolFunction = bool Function();
|
||||
|
||||
enum Target {
|
||||
thisDevice(
|
||||
title: 'This device',
|
||||
description: 'Trainer app runs on this device',
|
||||
title: 'This Device',
|
||||
icon: Icons.devices,
|
||||
),
|
||||
myWhooshLink(
|
||||
title: 'MyWhoosh Link',
|
||||
description: 'Control MyWhoosh directly on another device, such as a tablet or a TV',
|
||||
icon: Icons.link,
|
||||
),
|
||||
iPad(
|
||||
title: 'iPad',
|
||||
description: 'Remotely control any trainer app on an iPad by acting as a Mouse',
|
||||
otherDevice(
|
||||
title: 'Other Device',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
),
|
||||
iOS(
|
||||
title: 'iPhone / iPad / Apple TV',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
isBeta: true,
|
||||
),
|
||||
android(
|
||||
title: 'Android Device',
|
||||
description: 'Remotely control any trainer app on an Android device',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
isBeta: true,
|
||||
),
|
||||
macOS(
|
||||
title: 'Mac',
|
||||
description: 'Remotely control any trainer app on a Mac',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
isBeta: true,
|
||||
),
|
||||
windows(
|
||||
title: 'Windows PC',
|
||||
description: 'Remotely control any trainer app on a Windows PC',
|
||||
icon: Icons.settings_remote_outlined,
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final bool isBeta;
|
||||
|
||||
const Target({required this.title, required this.description, required this.icon, this.isBeta = false});
|
||||
const Target({required this.title, required this.icon});
|
||||
|
||||
bool get isCompatible {
|
||||
return settings.getTrainerApp()?.compatibleTargets.contains(this) == true;
|
||||
}
|
||||
|
||||
bool get isBeta {
|
||||
final supportedApp = settings.getTrainerApp();
|
||||
|
||||
if (supportedApp is Zwift && !(Platform.isIOS || Platform.isMacOS)) {
|
||||
// everything is supported, this device is not compatible anyway
|
||||
return false;
|
||||
}
|
||||
|
||||
return switch (this) {
|
||||
Target.thisDevice => !Platform.isIOS,
|
||||
Target.thisDevice => false,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
String getDescription(SupportedApp? app) {
|
||||
return switch (this) {
|
||||
Target.thisDevice when !isCompatible =>
|
||||
'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 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 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 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 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.',
|
||||
};
|
||||
}
|
||||
|
||||
String? get warning {
|
||||
if (settings.getTrainerApp()?.supportsZwiftEmulation == true) {
|
||||
// no warnings for zwift emulation
|
||||
return null;
|
||||
}
|
||||
return switch (this) {
|
||||
Target.android when Platform.isAndroid =>
|
||||
"Download and use SwiftControl on that Android device or select 'This device'.",
|
||||
"Select 'This device' unless you want to control another Android device. Are you sure?",
|
||||
Target.macOS when Platform.isMacOS =>
|
||||
"Download and use SwiftControl on that macOS device or select 'This device'.",
|
||||
"Select 'This device' unless you want to control another macOS device. Are you sure?",
|
||||
Target.windows when Platform.isWindows =>
|
||||
"Download and use SwiftControl on that Windows device or select 'This device'.",
|
||||
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.",
|
||||
"Select 'This device' unless you want to control another Windows device. Are you sure?",
|
||||
Target.android => "We highly recommended to download and use SwiftControl on that Android device.",
|
||||
Target.macOS => "We highly recommended to download and use SwiftControl on that macOS device.",
|
||||
Target.windows => "We highly recommended to download and use SwiftControl on that Windows device.",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -152,7 +189,6 @@ enum Target {
|
||||
ConnectionType get connectionType {
|
||||
return switch (this) {
|
||||
Target.thisDevice => ConnectionType.local,
|
||||
Target.myWhooshLink => ConnectionType.link,
|
||||
_ => ConnectionType.remote,
|
||||
};
|
||||
}
|
||||
@@ -161,7 +197,7 @@ enum Target {
|
||||
class TargetRequirement extends PlatformRequirement {
|
||||
TargetRequirement()
|
||||
: super(
|
||||
'Select Target Device',
|
||||
'Select Trainer App & Target Device',
|
||||
description: 'Select your Target Device where you want to run your trainer app on',
|
||||
) {
|
||||
status = false;
|
||||
@@ -172,72 +208,198 @@ class TargetRequirement extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = settings.getLastTarget() != null;
|
||||
status = settings.getLastTarget() != null && settings.getTrainerApp() != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
return 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(),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
target == Target.myWhooshLink && Platform.isAndroid
|
||||
? 'Control MyWhoosh directly on this or another device'
|
||||
: target.isCompatible
|
||||
? target.description
|
||||
: 'Due to iOS restrictions only controlling trainer apps on other devices is supported.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (target == Target.myWhooshLink)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Divider(),
|
||||
),
|
||||
],
|
||||
return StatefulBuilder(
|
||||
builder: (c, setState) => Column(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: DropdownMenu<SupportedApp>(
|
||||
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
|
||||
return DropdownMenuEntry(
|
||||
value: app,
|
||||
label: app.name,
|
||||
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(app.name),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
Icon(Icons.warning_amber),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Trainer app',
|
||||
initialSelection: settings.getTrainerApp(),
|
||||
onSelected: (selectedApp) async {
|
||||
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
|
||||
whooshLink.stopServer();
|
||||
}
|
||||
settings.setTrainerApp(selectedApp!);
|
||||
if (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(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
hintText: name,
|
||||
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),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 400),
|
||||
child: DropdownMenu<Target>(
|
||||
dropdownMenuEntries: [Target.thisDevice, Target.otherDevice].map((target) {
|
||||
return DropdownMenuEntry(
|
||||
value: target,
|
||||
label: target.title,
|
||||
leadingIcon: Icon(target.icon),
|
||||
labelWidget: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(target.title),
|
||||
Text(
|
||||
target.getDescription(settings.getTrainerApp()),
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
hintText: 'Select Target device',
|
||||
initialSelection: settings.getLastTarget() != Target.thisDevice ? Target.otherDevice : Target.thisDevice,
|
||||
enabled: settings.getTrainerApp() != null,
|
||||
onSelected: (target) async {
|
||||
if (target != null) {
|
||||
await settings.setLastTarget(target);
|
||||
if (target.warning != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(target.warning!),
|
||||
duration: Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
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(),
|
||||
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(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
onUpdate();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: settings.getTrainerApp() != null && settings.getLastTarget() != null
|
||||
? () {
|
||||
onUpdate();
|
||||
}
|
||||
: null,
|
||||
child: Text('Continue'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? buildDescription() {
|
||||
final trainer = settings.getTrainerApp();
|
||||
final target = settings.getLastTarget();
|
||||
|
||||
if (target != null) {
|
||||
if (target != null && trainer != null) {
|
||||
if (target.warning != null) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
@@ -252,7 +414,7 @@ class TargetRequirement extends PlatformRequirement {
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Text(target.title);
|
||||
return Text('${trainer.name} on ${target.title}');
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
@@ -260,27 +422,6 @@ class TargetRequirement extends PlatformRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
class LinkRequirement extends PlatformRequirement {
|
||||
LinkRequirement()
|
||||
: super(
|
||||
'Link Requirement',
|
||||
description: 'Start MyWhoosh on another device, open the connection screen and you\'re good to go!',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
return LinkWidget(onUpdate: onUpdate);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = whooshLink.isConnected.value;
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceholderRequirement extends PlatformRequirement {
|
||||
PlaceholderRequirement() : super('Requirement');
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
if (availablity == AvailabilityState.unsupported) {
|
||||
list = [UnsupportedPlatform()];
|
||||
} else {
|
||||
list = [BluetoothTurnedOn(), BluetoothScanning()];
|
||||
list = [BluetoothTurnedOn()];
|
||||
}
|
||||
} else if (Platform.isMacOS) {
|
||||
list = [
|
||||
@@ -45,10 +45,8 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
switch (connectionType) {
|
||||
ConnectionType.local => KeyboardRequirement(),
|
||||
ConnectionType.remote => RemoteRequirement(),
|
||||
ConnectionType.link => LinkRequirement(),
|
||||
ConnectionType.unknown => PlaceholderRequirement(),
|
||||
},
|
||||
BluetoothScanning(),
|
||||
];
|
||||
} else if (Platform.isIOS) {
|
||||
list = [
|
||||
@@ -57,10 +55,8 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
switch (connectionType) {
|
||||
ConnectionType.local => RemoteRequirement(),
|
||||
ConnectionType.remote => RemoteRequirement(),
|
||||
ConnectionType.link => LinkRequirement(),
|
||||
ConnectionType.unknown => PlaceholderRequirement(),
|
||||
},
|
||||
BluetoothScanning(),
|
||||
];
|
||||
} else if (Platform.isWindows) {
|
||||
list = [
|
||||
@@ -69,10 +65,8 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
switch (connectionType) {
|
||||
ConnectionType.local => KeyboardRequirement(),
|
||||
ConnectionType.remote => RemoteRequirement(),
|
||||
ConnectionType.link => LinkRequirement(),
|
||||
ConnectionType.unknown => PlaceholderRequirement(),
|
||||
},
|
||||
BluetoothScanning(),
|
||||
];
|
||||
} else if (Platform.isAndroid) {
|
||||
final deviceInfoPlugin = DeviceInfoPlugin();
|
||||
@@ -90,10 +84,8 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
|
||||
switch (connectionType) {
|
||||
ConnectionType.local => AccessibilityRequirement(),
|
||||
ConnectionType.remote => RemoteRequirement(),
|
||||
ConnectionType.link => LinkRequirement(),
|
||||
ConnectionType.unknown => PlaceholderRequirement(),
|
||||
},
|
||||
BluetoothScanning(),
|
||||
];
|
||||
} else {
|
||||
list = [UnsupportedPlatform()];
|
||||
|
||||
@@ -5,9 +5,12 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
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';
|
||||
@@ -29,16 +32,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Widget? buildDescription() {
|
||||
return settings.getLastTarget() == null
|
||||
? null
|
||||
: Text(
|
||||
switch (settings.getLastTarget()) {
|
||||
Target.iPad =>
|
||||
'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 to use the remote feature.',
|
||||
},
|
||||
);
|
||||
return Text('Choose your preferred connection method');
|
||||
}
|
||||
|
||||
Future<void> reconnect() async {
|
||||
@@ -92,14 +86,18 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kDebugMode && false) {
|
||||
print('Continuing');
|
||||
return;
|
||||
}
|
||||
|
||||
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
print('Waiting for peripheral manager to be powered on... ${peripheralManager.state}');
|
||||
if (settings.getLastTarget() == Target.thisDevice) {
|
||||
return;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
if (!_isServiceAdded) {
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
final reportMapDataAbsolute = Uint8List.fromList([
|
||||
@@ -277,7 +275,7 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = (actionHandler as RemoteActions).isConnected || screenshotMode;
|
||||
status = (actionHandler is RemoteActions && (actionHandler as RemoteActions).isConnected) || screenshotMode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,16 +294,79 @@ class _PairWidgetState extends State<_PairWidget> {
|
||||
super.initState();
|
||||
// after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
toggle();
|
||||
if (actionHandler.supportedApp?.supportsZwiftEmulation == false) {
|
||||
toggle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 10,
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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(
|
||||
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 to ${settings.getTrainerApp()?.name} as controller'),
|
||||
Text(
|
||||
'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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
spacing: 10,
|
||||
children: [
|
||||
@@ -313,11 +374,43 @@ 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(
|
||||
_isAdvertising ? 'Stop Pairing process' : 'Start Pairing',
|
||||
),
|
||||
Text(
|
||||
'Pairing allows full customizability,\nbut may not work on all devices.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
BetaPill(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
|
||||
],
|
||||
),
|
||||
if (_isAdvertising)
|
||||
Text(
|
||||
switch (settings.getLastTarget()) {
|
||||
Target.iOS =>
|
||||
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
|
||||
_ =>
|
||||
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required if you want to use the remote control feature.',
|
||||
},
|
||||
),
|
||||
if (_isAdvertising) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/widgets.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/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
@@ -15,61 +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 {
|
||||
// Get screen size for migrations
|
||||
Size? screenSize;
|
||||
try {
|
||||
final view = WidgetsBinding.instance.platformDispatcher.views.first;
|
||||
screenSize = view.physicalSize / view.devicePixelRatio;
|
||||
} catch (e) {
|
||||
screenSize = null;
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
|
||||
|
||||
if (actionHandler is DesktopActions) {
|
||||
// Must add this line.
|
||||
await windowManager.ensureInitialized();
|
||||
}
|
||||
|
||||
// Handle migration from old "customapp" key to new "customapp_Custom" key
|
||||
if (prefs.containsKey('customapp') && !prefs.containsKey('customapp_Custom')) {
|
||||
final oldCustomApp = prefs.getStringList('customapp');
|
||||
if (oldCustomApp != null) {
|
||||
// Migrate pixel-based to percentage-based if screen size available
|
||||
if (screenSize != null) {
|
||||
final migratedData = await _migrateToPercentageBased(oldCustomApp, screenSize);
|
||||
await prefs.setStringList('customapp_Custom', migratedData);
|
||||
} else {
|
||||
await prefs.setStringList('customapp_Custom', oldCustomApp);
|
||||
final app = getKeyMap();
|
||||
actionHandler.init(app);
|
||||
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();
|
||||
}
|
||||
await prefs.remove('customapp');
|
||||
}
|
||||
}
|
||||
|
||||
final appName = prefs.getString('app');
|
||||
if (appName == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a custom app with a profile name
|
||||
if (appName.startsWith('Custom') || prefs.containsKey('customapp_$appName')) {
|
||||
final customApp = CustomApp(profileName: appName);
|
||||
final appSetting = prefs.getStringList('customapp_$appName');
|
||||
if (appSetting != null) {
|
||||
customApp.decodeKeymap(appSetting);
|
||||
}
|
||||
actionHandler.init(customApp);
|
||||
return init(retried: true);
|
||||
} else {
|
||||
final app = SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
|
||||
actionHandler.init(app);
|
||||
return '$e\n$s';
|
||||
}
|
||||
} catch (e) {
|
||||
// couldn't decode, reset
|
||||
await prefs.clear();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +59,44 @@ class Settings {
|
||||
actionHandler.init(null);
|
||||
}
|
||||
|
||||
Future<void> setApp(SupportedApp app) async {
|
||||
void setTrainerApp(SupportedApp app) {
|
||||
prefs.setString('trainer_app', app.name);
|
||||
}
|
||||
|
||||
SupportedApp? getTrainerApp() {
|
||||
final appName = prefs.getString('trainer_app');
|
||||
if (appName == null) {
|
||||
return null;
|
||||
}
|
||||
return SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
|
||||
}
|
||||
|
||||
Future<void> setKeyMap(SupportedApp app) async {
|
||||
if (app is CustomApp) {
|
||||
await prefs.setStringList('customapp_${app.profileName}', app.encodeKeymap());
|
||||
}
|
||||
await prefs.setString('app', app.name);
|
||||
}
|
||||
|
||||
SupportedApp? getKeyMap() {
|
||||
final appName = prefs.getString('app');
|
||||
if (appName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if it's a custom app with a profile name
|
||||
if (appName.startsWith('Custom') || prefs.containsKey('customapp_$appName')) {
|
||||
final customApp = CustomApp(profileName: appName);
|
||||
final appSetting = prefs.getStringList('customapp_$appName');
|
||||
if (appSetting != null) {
|
||||
customApp.decodeKeymap(appSetting);
|
||||
}
|
||||
return customApp;
|
||||
} else {
|
||||
return SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> getCustomAppProfiles() {
|
||||
// Get all keys starting with 'customapp_'
|
||||
final keys = prefs.getKeys().where((key) => key.startsWith('customapp_')).toList();
|
||||
@@ -154,6 +166,7 @@ class Settings {
|
||||
|
||||
Future<void> setLastTarget(Target target) async {
|
||||
await prefs.setString('last_target', target.name);
|
||||
initializeActions(target.connectionType);
|
||||
}
|
||||
|
||||
Future<void> setLastSeenVersion(String version) async {
|
||||
@@ -168,61 +181,71 @@ class Settings {
|
||||
await prefs.setBool('vibration_enabled', enabled);
|
||||
}
|
||||
|
||||
Future<List<String>> _migrateToPercentageBased(List<String> keymapData, Size screenSize) async {
|
||||
final migratedData = <String>[];
|
||||
bool getMyWhooshLinkEnabled() {
|
||||
return prefs.getBool('mywhoosh_link_enabled') ?? true;
|
||||
}
|
||||
|
||||
final needMigrations = keymapData.associateWith((encodedKeyPair) {
|
||||
final decoded = jsonDecode(encodedKeyPair);
|
||||
final touchPosData = decoded['touchPosition'];
|
||||
Future<void> setMyWhooshLinkEnabled(bool enabled) async {
|
||||
await prefs.setBool('mywhoosh_link_enabled', enabled);
|
||||
}
|
||||
|
||||
// Convert pixel-based to percentage-based
|
||||
final x = (touchPosData['x'] as num).toDouble();
|
||||
final y = (touchPosData['y'] as num).toDouble();
|
||||
return x > 100.0 || y > 100.0;
|
||||
});
|
||||
bool getZwiftEmulatorEnabled() {
|
||||
return prefs.getBool('zwift_emulator_enabled') ?? true;
|
||||
}
|
||||
|
||||
for (final entry in needMigrations.entries) {
|
||||
if (entry.value) {
|
||||
final decoded = jsonDecode(entry.key);
|
||||
final touchPosData = decoded['touchPosition'];
|
||||
Future<void> setZwiftEmulatorEnabled(bool enabled) async {
|
||||
await prefs.setBool('zwift_emulator_enabled', enabled);
|
||||
}
|
||||
|
||||
// Convert pixel-based to percentage-based
|
||||
final x = (touchPosData['x'] as num).toDouble();
|
||||
final y = (touchPosData['y'] as num).toDouble();
|
||||
final newX = (x / screenSize.width).clamp(0.0, 1.0) * 100.0;
|
||||
final newY = (y / screenSize.height).clamp(0.0, 1.0) * 100.0;
|
||||
bool getMiuiWarningDismissed() {
|
||||
return prefs.getBool('miui_warning_dismissed') ?? false;
|
||||
}
|
||||
|
||||
// Update the JSON structure
|
||||
decoded['touchPosition'] = {'x': newX, 'y': newY};
|
||||
Future<void> setMiuiWarningDismissed(bool dismissed) async {
|
||||
await prefs.setBool('miui_warning_dismissed', dismissed);
|
||||
}
|
||||
|
||||
migratedData.add(jsonEncode(decoded));
|
||||
} else {
|
||||
migratedData.add(entry.key);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
return migratedData;
|
||||
}
|
||||
|
||||
void setInGameActionForButton(ControllerButton button, InGameAction inGameAction) {
|
||||
final key = 'ingameaction_${button.name}';
|
||||
prefs.setString(key, inGameAction.name);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
InGameAction? getInGameActionForButton(ControllerButton button) {
|
||||
final key = 'ingameaction_${button.name}';
|
||||
final actionName = prefs.getString(key);
|
||||
if (actionName == null) return button.action;
|
||||
return InGameAction.values.firstOrNullWhere((e) => e.name == actionName) ?? button.action;
|
||||
}
|
||||
List<({String id, String name})> getIgnoredDevices() {
|
||||
final ids = _getIgnoredDeviceIds();
|
||||
final names = _getIgnoredDeviceNames();
|
||||
|
||||
void setInGameActionForButtonValue(ControllerButton button, InGameAction inGameAction, int value) {
|
||||
final key = 'ingameaction_${button.name}_value';
|
||||
prefs.setInt(key, value);
|
||||
}
|
||||
|
||||
int? getInGameActionForButtonValue(ControllerButton button) {
|
||||
final key = 'ingameaction_${button.name}_value';
|
||||
return prefs.getInt(key);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/widgets/apps/zwift_tile.dart
Normal file
69
lib/widgets/apps/zwift_tile.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/zwift.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
class ZwiftTile extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
|
||||
const ZwiftTile({super.key, required this.onUpdate});
|
||||
|
||||
@override
|
||||
State<ZwiftTile> createState() => _ZwiftTileState();
|
||||
}
|
||||
|
||||
class _ZwiftTileState extends State<ZwiftTile> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: zwiftEmulator.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return SwitchListTile.adaptive(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: settings.getZwiftEmulatorEnabled(),
|
||||
onChanged: (value) {
|
||||
settings.setZwiftEmulatorEnabled(value);
|
||||
if (!value) {
|
||||
zwiftEmulator.stopAdvertising();
|
||||
} else if (value) {
|
||||
zwiftEmulator.startAdvertising(widget.onUpdate);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
title: Text('Enable Zwift Controller'),
|
||||
subtitle: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
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.'
|
||||
: ''}',
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnected
|
||||
? "Connected"
|
||||
: "Waiting for connection. Choose SwiftControl in ${settings.getTrainerApp()?.name}'s controller pairing menu.",
|
||||
),
|
||||
),
|
||||
if (!isConnected) SmallProgressIndicator(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BetaPill extends StatelessWidget {
|
||||
const BetaPill({super.key});
|
||||
final String text;
|
||||
const BetaPill({super.key, this.text = 'BETA'});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -14,7 +15,7 @@ class BetaPill extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'BETA',
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
|
||||
45
lib/widgets/button_widget.dart
Normal file
45
lib/widgets/button_widget.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
|
||||
class ButtonWidget extends StatelessWidget {
|
||||
final ControllerButton button;
|
||||
final bool big;
|
||||
const ButtonWidget({super.key, required this.button, this.big = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicWidth(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
constraints: BoxConstraints(
|
||||
minWidth: big && button.color != null ? 40 : 30,
|
||||
minHeight: big && button.color != null ? 40 : 0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: button.color != null ? Colors.black : Theme.of(context).colorScheme.primary),
|
||||
shape: button.color != null || button.icon != null ? BoxShape.circle : BoxShape.rectangle,
|
||||
borderRadius: button.color != null || button.icon != null ? null : BorderRadius.circular(4),
|
||||
color: button.color ?? Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
child: Center(
|
||||
child: button.icon != null
|
||||
? Icon(
|
||||
button.icon,
|
||||
color: Colors.white,
|
||||
size: big && button.color != null ? null : 14,
|
||||
)
|
||||
: Text(
|
||||
button.name.splitByUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: '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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@ class ChangelogDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latestVersion = Markdown(blocks: entry.blocks.skip(1).take(2).toList(), markdown: entry.markdown);
|
||||
final latestVersion = Markdown(
|
||||
blocks: entry.blocks.skip(1).takeWhile((b) => b.type != 'heading').toList(),
|
||||
markdown: entry.markdown,
|
||||
);
|
||||
return AlertDialog(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -27,7 +30,12 @@ class ChangelogDialog extends StatelessWidget {
|
||||
constraints: BoxConstraints(minWidth: 460),
|
||||
child: MarkdownWidget(markdown: latestVersion),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Got it!'))],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Got it!'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +49,7 @@ class ChangelogDialog extends StatelessWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
routeSettings: RouteSettings(name: '/changelog'),
|
||||
builder: (context) => ChangelogDialog(entry: markdown),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/link/link.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
|
||||
class InGameActionsCustomizer extends StatefulWidget {
|
||||
const InGameActionsCustomizer({super.key});
|
||||
|
||||
@override
|
||||
State<InGameActionsCustomizer> createState() => _InGameActionsCustomizerState();
|
||||
}
|
||||
|
||||
class _InGameActionsCustomizerState extends State<InGameActionsCustomizer> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectedDevice = connection.devices.firstOrNull;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Table(
|
||||
border: TableBorder.symmetric(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
inside: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
outside: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
'Button on your ${connectedDevice?.device.name?.screenshot ?? connectedDevice?.runtimeType ?? 'device'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
'Action on MyWhoosh',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final button in connectedDevice?.availableButtons ?? <ControllerButton>[]) ...[
|
||||
TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
children: [
|
||||
IntrinsicWidth(
|
||||
child: ButtonWidget(button: button),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
children: [
|
||||
if (MediaQuery.sizeOf(context).width < 1800)
|
||||
Expanded(child: _buildDropdownButton(button, true))
|
||||
else
|
||||
_buildDropdownButton(button, false),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownButton(ControllerButton button, bool expand) {
|
||||
final value = WhooshLink.supportedActions.contains(settings.getInGameActionForButton(button))
|
||||
? settings.getInGameActionForButton(button)
|
||||
: null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButton<InGameAction>(
|
||||
isExpanded: expand,
|
||||
items: WhooshLink.supportedActions
|
||||
.map(
|
||||
(ingame) => DropdownMenuItem(
|
||||
value: ingame,
|
||||
child: Text(ingame.toString()),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
padding: EdgeInsets.zero,
|
||||
menuWidth: 250,
|
||||
value: value,
|
||||
onChanged: (action) {
|
||||
settings.setInGameActionForButton(
|
||||
button,
|
||||
action!,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (value?.possibleValues != null)
|
||||
DropdownButton<int>(
|
||||
items: value!.possibleValues!
|
||||
.map((val) => DropdownMenuItem<int>(value: val, child: Text(val.toString())))
|
||||
.toList(),
|
||||
value: settings.getInGameActionForButtonValue(button),
|
||||
onChanged: (val) {
|
||||
settings.setInGameActionForButtonValue(
|
||||
button,
|
||||
value,
|
||||
val!,
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
hint: Text('Value'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +1,70 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.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/keymap.dart';
|
||||
import 'package:swift_control/utils/keymap/manager.dart';
|
||||
import 'package:swift_control/widgets/button_widget.dart';
|
||||
import 'package:swift_control/widgets/custom_keymap_selector.dart';
|
||||
|
||||
import '../bluetooth/devices/link/link.dart';
|
||||
import '../pages/touch_area.dart';
|
||||
import '../utils/actions/base_actions.dart';
|
||||
|
||||
class KeymapExplanation extends StatelessWidget {
|
||||
class KeymapExplanation extends StatefulWidget {
|
||||
final Keymap keymap;
|
||||
final VoidCallback onUpdate;
|
||||
const KeymapExplanation({super.key, required this.keymap, required this.onUpdate});
|
||||
|
||||
@override
|
||||
State<KeymapExplanation> createState() => _KeymapExplanationState();
|
||||
}
|
||||
|
||||
class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
late StreamSubscription<void> _updateStreamListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateStreamListener = widget.keymap.updateStream.listen((_) {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(KeymapExplanation oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.keymap != widget.keymap) {
|
||||
_updateStreamListener.cancel();
|
||||
_updateStreamListener = widget.keymap.updateStream.listen((_) {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_updateStreamListener.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectedDevice = connection.devices.firstOrNull;
|
||||
final availableKeypairs = widget.keymap.keyPairs;
|
||||
final allAvailableButtons = connection.devices.flatMap((d) => d.availableButtons);
|
||||
|
||||
final availableKeypairs = keymap.keyPairs.filter(
|
||||
(e) => connectedDevice?.availableButtons.containsAny(e.buttons) ?? true,
|
||||
);
|
||||
|
||||
final keyboardGroups = availableKeypairs
|
||||
.filter((e) => e.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
.groupBy((element) => '${element.physicalKey?.usbHidUsage}-${element.isLongPress}');
|
||||
final touchGroups = availableKeypairs
|
||||
.filter(
|
||||
(e) =>
|
||||
(e.physicalKey == null || !actionHandler.supportedModes.contains(SupportedMode.keyboard)) &&
|
||||
e.touchPosition != Offset.zero,
|
||||
)
|
||||
.groupBy((element) => '${element.touchPosition.dx}-${element.touchPosition.dy}-${element.isLongPress}');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (keymap.keyPairs.isEmpty)
|
||||
Text('No key mappings found. Please customize the keymap.')
|
||||
else
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isConnected,
|
||||
builder: (c, _, _) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Table(
|
||||
border: TableBorder.symmetric(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
@@ -56,7 +81,7 @@ class KeymapExplanation extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
'Button on your ${connectedDevice?.device.name?.screenshot ?? connectedDevice?.runtimeType ?? 'device'}',
|
||||
'Button on your ${connection.devices.isEmpty ? 'Device' : connection.devices.joinToString(transform: (d) => d.name.screenshot)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@@ -69,125 +94,345 @@ class KeymapExplanation extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final pair in keyboardGroups.entries) ...[
|
||||
for (final keyPair in availableKeypairs) ...[
|
||||
TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final keyPair in pair.value)
|
||||
for (final button in keyPair.buttons)
|
||||
if (connectedDevice?.availableButtons.contains(button) ?? true)
|
||||
IntrinsicWidth(child: ButtonWidget(button: button)),
|
||||
],
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (actionHandler.supportedApp is! CustomApp)
|
||||
if (keyPair.buttons.filter((b) => allAvailableButtons.contains(b)).isEmpty)
|
||||
Text('No button assigned for your connected device')
|
||||
else
|
||||
for (final button in keyPair.buttons.filter((b) => allAvailableButtons.contains(b)))
|
||||
IntrinsicWidth(child: ButtonWidget(button: button))
|
||||
else
|
||||
for (final button in keyPair.buttons) IntrinsicWidth(child: ButtonWidget(button: button)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: KeypairExplanation(keyPair: pair.value.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
for (final pair in touchGroups.entries) ...[
|
||||
TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final keyPair in pair.value)
|
||||
for (final button in keyPair.buttons)
|
||||
if (connectedDevice?.availableButtons.contains(button) ?? true)
|
||||
ButtonWidget(button: button),
|
||||
],
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: _ButtonEditor(keyPair: keyPair, onUpdate: widget.onUpdate),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: KeypairExplanation(keyPair: pair.value.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class KeyWidget extends StatelessWidget {
|
||||
final String label;
|
||||
const KeyWidget({super.key, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicWidth(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
constraints: BoxConstraints(minWidth: 30),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label.splitByUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ButtonWidget extends StatelessWidget {
|
||||
final ControllerButton button;
|
||||
final bool big;
|
||||
const ButtonWidget({super.key, required this.button, this.big = false});
|
||||
class _ButtonEditor extends StatelessWidget {
|
||||
final KeyPair keyPair;
|
||||
final VoidCallback onUpdate;
|
||||
const _ButtonEditor({required this.onUpdate, super.key, required this.keyPair});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicWidth(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
constraints: BoxConstraints(
|
||||
minWidth: big && button.color != null ? 40 : 30,
|
||||
minHeight: big && button.color != null ? 40 : 0,
|
||||
final actions = <PopupMenuEntry>[
|
||||
if (settings.getMyWhooshLinkEnabled() && whooshLink.isCompatible(settings.getLastTarget()!))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton(
|
||||
itemBuilder: (_) => WhooshLink.supportedActions.map(
|
||||
(ingame) {
|
||||
return PopupMenuItem(
|
||||
value: ingame,
|
||||
child: ingame.possibleValues != null
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (c) => ingame.possibleValues!
|
||||
.map(
|
||||
(value) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Text(value.toString()),
|
||||
onTap: () {
|
||||
keyPair.inGameAction = ingame;
|
||||
keyPair.inGameActionValue = value;
|
||||
onUpdate();
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(ingame.toString())),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(ingame.toString()),
|
||||
onTap: () {
|
||||
keyPair.inGameAction = ingame;
|
||||
keyPair.inGameActionValue = null;
|
||||
onUpdate();
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.link),
|
||||
Expanded(child: Text('MyWhoosh Direct Connect Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: button.color != null ? Colors.black : Theme.of(context).colorScheme.primary),
|
||||
shape: button.color != null || button.icon != null ? BoxShape.circle : BoxShape.rectangle,
|
||||
borderRadius: button.color != null || button.icon != null ? null : BorderRadius.circular(4),
|
||||
color: button.color ?? Theme.of(context).colorScheme.primaryContainer,
|
||||
if (settings.getZwiftEmulatorEnabled() && settings.getTrainerApp()?.supportsZwiftEmulation == true)
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton(
|
||||
itemBuilder: (_) => ZwiftEmulator.supportedActions.map(
|
||||
(ingame) {
|
||||
return PopupMenuItem(
|
||||
value: ingame,
|
||||
child: ingame.possibleValues != null
|
||||
? PopupMenuButton(
|
||||
itemBuilder: (c) => ingame.possibleValues!
|
||||
.map(
|
||||
(value) => PopupMenuItem(
|
||||
value: value,
|
||||
child: Text(value.toString()),
|
||||
onTap: () {
|
||||
keyPair.inGameAction = ingame;
|
||||
keyPair.inGameActionValue = value;
|
||||
onUpdate();
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(ingame.toString())),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text(ingame.toString()),
|
||||
onTap: () {
|
||||
keyPair.inGameAction = ingame;
|
||||
keyPair.inGameActionValue = null;
|
||||
onUpdate();
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
spacing: 14,
|
||||
children: [
|
||||
Icon(Icons.link),
|
||||
Expanded(child: Text('Zwift Controller Action')),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: button.icon != null
|
||||
? Icon(
|
||||
button.icon,
|
||||
color: Colors.white,
|
||||
size: big && button.color != null ? null : 14,
|
||||
)
|
||||
: Text(
|
||||
button.name.splitByUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: '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,
|
||||
),
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.keyboard_alt_outlined),
|
||||
title: const Text('Simulate Keyboard shortcut'),
|
||||
trailing: keyPair.physicalKey != null ? Checkbox(value: true, onChanged: null) : null,
|
||||
),
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder: (c) =>
|
||||
HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair),
|
||||
);
|
||||
onUpdate();
|
||||
},
|
||||
),
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.touch))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
title: const Text('Simulate Touch'),
|
||||
leading: Icon(Icons.touch_app_outlined),
|
||||
trailing: keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero
|
||||
? Checkbox(value: true, onChanged: null)
|
||||
: null,
|
||||
),
|
||||
onTap: () async {
|
||||
if (keyPair.touchPosition == Offset.zero) {
|
||||
keyPair.touchPosition = Offset(50, 50);
|
||||
}
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
await Navigator.of(context).push<bool?>(
|
||||
MaterialPageRoute(
|
||||
builder: (c) => TouchAreaSetupPage(
|
||||
keyPair: keyPair,
|
||||
),
|
||||
),
|
||||
);
|
||||
onUpdate();
|
||||
},
|
||||
),
|
||||
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton<PhysicalKeyboardKey>(
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaPlayPause,
|
||||
child: const Text('Media: Play/Pause'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaStop,
|
||||
child: const Text('Media: Stop'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackPrevious,
|
||||
child: const Text('Media: Previous'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackNext,
|
||||
child: const Text('Media: Next'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeUp,
|
||||
child: const Text('Media: Volume Up'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeDown,
|
||||
child: const Text('Media: Volume Down'),
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
keyPair.physicalKey = key;
|
||||
keyPair.logicalKey = null;
|
||||
|
||||
onUpdate();
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.music_note_outlined),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (keyPair.isSpecialKey) Checkbox(value: true, onChanged: null),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
title: Text('Simulate Media key'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
onTap: () {
|
||||
keyPair.isLongPress = !keyPair.isLongPress;
|
||||
onUpdate();
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
|
||||
onUpdate();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const Text('Long Press Mode (vs. repeating)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
onTap: () {
|
||||
keyPair.isLongPress = false;
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
keyPair.modifiers = [];
|
||||
keyPair.touchPosition = Offset.zero;
|
||||
keyPair.inGameAction = null;
|
||||
keyPair.inGameActionValue = null;
|
||||
onUpdate();
|
||||
},
|
||||
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<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 {
|
||||
final currentProfile = actionHandler.supportedApp!.name;
|
||||
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
|
||||
Icon(Icons.edit, size: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,62 +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();
|
||||
whooshLink.startServer();
|
||||
whooshLink.isConnected.addListener(() {
|
||||
widget.onUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
@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 {
|
||||
await whooshLink.startServer();
|
||||
},
|
||||
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"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ class _LogviewerState extends State<LogViewer> {
|
||||
|
||||
_actionSubscription = connection.actionStream.listen((data) {
|
||||
if (mounted) {
|
||||
if (data is BluetoothAvailabilityNotification) {
|
||||
if (!data.isAvailable && Navigator.canPop(context)) {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_actions.add((date: DateTime.now(), entry: data.toString()));
|
||||
_actions = _actions.takeLast(kIsWeb ? 1000 : 60).toList();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
@@ -8,9 +9,11 @@ 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: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 [
|
||||
@@ -80,11 +83,38 @@ List<Widget> buildMenuButtons() {
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Get Support'),
|
||||
child: Text('Provide Feedback'),
|
||||
onTap: () {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
),
|
||||
if (!kIsWeb)
|
||||
PopupMenuItem(
|
||||
child: Text('Get Support'),
|
||||
onTap: () {
|
||||
final isFromStore = (Platform.isAndroid ? isFromPlayStore == true : Platform.isIOS);
|
||||
final suffix = isFromStore ? '' : '-sw';
|
||||
|
||||
String email = Uri.encodeComponent('jonas$suffix@swiftcontrol.app');
|
||||
String subject = Uri.encodeComponent("Help requested for SwiftControl v${packageInfoValue?.version}");
|
||||
String 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);
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
icon: Icon(Icons.help_outline),
|
||||
@@ -145,7 +175,15 @@ class MenuButton extends StatelessWidget {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
|
||||
},
|
||||
),
|
||||
|
||||
PopupMenuItem(
|
||||
child: Text('Ignored Devices'),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => IgnoredDevicesDialog(),
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('License'),
|
||||
onTap: () {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
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';
|
||||
|
||||
import 'logviewer.dart';
|
||||
|
||||
class ScanWidget extends StatefulWidget {
|
||||
const ScanWidget({super.key});
|
||||
@@ -44,53 +43,71 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: 200),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: connection.isScanning,
|
||||
builder: (context, isScanning, widget) {
|
||||
if (isScanning) {
|
||||
return Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Scanning for devices... Make sure they are powered on and in range and not connected to another device.',
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: connection.isScanning,
|
||||
builder: (context, isScanning, widget) {
|
||||
if (isScanning) {
|
||||
return Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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 Swift Control to detect bluetooth remotes. In order to do so SwiftControl needs to act as a media player.',
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
const Text("Enable Media Key Detection"),
|
||||
],
|
||||
),
|
||||
onChanged: (change) {
|
||||
connection.isMediaKeyDetectionEnabled.value = change;
|
||||
},
|
||||
);
|
||||
},
|
||||
child: const Text("Show Troubleshooting Guide"),
|
||||
),
|
||||
SmallProgressIndicator(),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
connection.performScanning();
|
||||
},
|
||||
child: const Text("SCAN"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (kDebugMode && false) SizedBox(height: 500, child: LogViewer()),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
);
|
||||
},
|
||||
child: const Text("Show Troubleshooting Guide"),
|
||||
),
|
||||
SizedBox(),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
connection.performScanning();
|
||||
},
|
||||
child: const Text("SCAN"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
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/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/button_widget.dart';
|
||||
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
|
||||
/// A developer overlay that visualizes touches and keyboard events.
|
||||
/// - Touch dots appear where you touch and fade out over [touchRevealDuration].
|
||||
@@ -14,8 +21,8 @@ class Testbed extends StatefulWidget {
|
||||
this.enabled = true,
|
||||
this.showTouches = true,
|
||||
this.showKeyboard = true,
|
||||
this.touchRevealDuration = const Duration(seconds: 2),
|
||||
this.keyboardRevealDuration = const Duration(seconds: 2),
|
||||
this.touchRevealDuration = const Duration(seconds: 3),
|
||||
this.keyboardRevealDuration = const Duration(seconds: 3),
|
||||
this.maxKeyboardEvents = 6,
|
||||
this.touchColor = const Color(0xFF00BCD4), // cyan-ish
|
||||
this.keyboardBadgeColor = const Color(0xCC000000), // translucent black
|
||||
@@ -40,6 +47,7 @@ class Testbed extends StatefulWidget {
|
||||
|
||||
class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
late final Ticker _ticker;
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
|
||||
// ----- Touch tracking -----
|
||||
final Map<int, _TouchSample> _active = <int, _TouchSample>{};
|
||||
@@ -55,6 +63,49 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode(debugLabel: 'TestbedFocus', canRequestFocus: true, skipTraversal: true);
|
||||
_actionSubscription = connection.actionStream.listen((data) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (data is ButtonNotification) {
|
||||
for (final button in data.buttonsClicked) {
|
||||
final sample = _KeySample(
|
||||
button: button,
|
||||
text: '🔘 ${button.name}',
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
_keys.insert(0, sample);
|
||||
if (_keys.length > widget.maxKeyboardEvents) {
|
||||
_keys.removeLast();
|
||||
}
|
||||
|
||||
if (actionHandler.supportedApp is! CustomApp &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(button) == null) {
|
||||
ScaffoldMessenger.maybeOf(
|
||||
context,
|
||||
)?.showSnackBar(
|
||||
SnackBar(
|
||||
padding: EdgeInsets.only(left: 70, top: 12, bottom: 12, right: 12),
|
||||
content: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const TextSpan(text: 'Use a custom keymap to support the '),
|
||||
WidgetSpan(
|
||||
child: ButtonWidget(button: button),
|
||||
),
|
||||
const TextSpan(
|
||||
text: ' button.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
|
||||
_ticker = createTicker((_) {
|
||||
// Cull expired touch and key samples.
|
||||
@@ -215,10 +266,9 @@ class _TouchesPainter extends CustomPainter {
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint =
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
|
||||
for (final s in samples) {
|
||||
final age = now.difference(s.timestamp);
|
||||
@@ -242,17 +292,15 @@ class _TouchesPainter extends CustomPainter {
|
||||
canvas.drawCircle(s.position, rOuter, paint);
|
||||
|
||||
// Inner fill (stronger)
|
||||
final fill =
|
||||
Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = color.withOpacity(0.35 + 0.35 * fade);
|
||||
final fill = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = color.withOpacity(0.35 + 0.35 * fade);
|
||||
canvas.drawCircle(s.position, rInner, fill);
|
||||
|
||||
// Tiny center dot for precision
|
||||
final center =
|
||||
Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = color.withOpacity(0.9 * fade);
|
||||
final center = Paint()
|
||||
..style = PaintingStyle.fill
|
||||
..color = color.withOpacity(0.9 * fade);
|
||||
canvas.drawCircle(s.position, 2.5, center);
|
||||
}
|
||||
}
|
||||
@@ -269,7 +317,8 @@ class _TouchesPainter extends CustomPainter {
|
||||
// ===== Keyboard overlay =====
|
||||
|
||||
class _KeySample {
|
||||
_KeySample({required this.text, required this.timestamp});
|
||||
_KeySample({required this.text, required this.timestamp, this.button});
|
||||
final ControllerButton? button;
|
||||
final String text;
|
||||
final DateTime timestamp;
|
||||
}
|
||||
@@ -297,7 +346,7 @@ class _KeyboardOverlay extends StatelessWidget {
|
||||
children: [
|
||||
for (final item in items)
|
||||
_KeyboardToast(
|
||||
text: item.text,
|
||||
item: item,
|
||||
age: now.difference(item.timestamp),
|
||||
duration: duration,
|
||||
badgeColor: badgeColor,
|
||||
@@ -310,14 +359,14 @@ class _KeyboardOverlay extends StatelessWidget {
|
||||
|
||||
class _KeyboardToast extends StatelessWidget {
|
||||
const _KeyboardToast({
|
||||
required this.text,
|
||||
required this.item,
|
||||
required this.age,
|
||||
required this.duration,
|
||||
required this.badgeColor,
|
||||
required this.textStyle,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final _KeySample item;
|
||||
final Duration age;
|
||||
final Duration duration;
|
||||
final Color badgeColor;
|
||||
@@ -329,13 +378,14 @@ class _KeyboardToast extends StatelessWidget {
|
||||
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: badgeColor, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text(text, style: textStyle),
|
||||
child: item.button != null ? ButtonWidget(button: item.button!) : Text(item.text, style: textStyle),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -13,8 +13,9 @@ import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
PackageInfo? _packageInfoValue;
|
||||
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,15 +34,15 @@ class _AppTitleState extends State<AppTitle> {
|
||||
if (updater.isAvailable) {
|
||||
updater.readCurrentPatch().then((patch) {
|
||||
setState(() {
|
||||
_shorebirdPatch = patch;
|
||||
shorebirdPatch = patch;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (_packageInfoValue == null) {
|
||||
if (packageInfoValue == null) {
|
||||
PackageInfo.fromPlatform().then((value) {
|
||||
setState(() {
|
||||
_packageInfoValue = value;
|
||||
packageInfoValue = value;
|
||||
});
|
||||
_checkForUpdate();
|
||||
});
|
||||
@@ -109,7 +109,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
}
|
||||
}
|
||||
} else if (Platform.isMacOS) {
|
||||
final url = Uri.parse('https://apps.microsoft.com/detail/9NP42GS03Z26');
|
||||
final url = Uri.parse('https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac');
|
||||
final res = await http.get(url, headers: {'User-Agent': 'Mozilla/5.0'});
|
||||
if (res.statusCode != 200) return null;
|
||||
|
||||
@@ -143,9 +143,9 @@ class _AppTitleState extends State<AppTitle> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('SwiftControl', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (_packageInfoValue != null)
|
||||
if (packageInfoValue != null)
|
||||
Text(
|
||||
'v${_packageInfoValue!.version}${_shorebirdPatch != null ? '+${_shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
|
||||
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
|
||||
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
|
||||
)
|
||||
else
|
||||
@@ -157,12 +157,12 @@ class _AppTitleState extends State<AppTitle> {
|
||||
void _showShorebirdRestartSnackbar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Restart the app to use the new version'),
|
||||
content: Text('Force-close the app to use the new version'),
|
||||
duration: Duration(seconds: 10),
|
||||
action: SnackBarAction(
|
||||
label: 'Restart',
|
||||
onPressed: () {
|
||||
if (Platform.isIOS || Platform.isAndroid) {
|
||||
if (Platform.isIOS) {
|
||||
connection.reset();
|
||||
Restart.restartApp(delayBeforeRestart: 1000);
|
||||
} else {
|
||||
@@ -177,7 +177,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
|
||||
void _compareVersion(String versionString) {
|
||||
final parsed = Version.parse(versionString);
|
||||
final current = Version.parse(_packageInfoValue!.version);
|
||||
final current = Version.parse(packageInfoValue!.version);
|
||||
if (parsed > current && mounted && !kDebugMode) {
|
||||
if (Platform.isAndroid) {
|
||||
_showUpdateSnackbar(parsed, 'https://play.google.com/store/apps/details?id=org.jonasbark.swiftcontrol');
|
||||
|
||||
24
lib/widgets/warning.dart
Normal file
24
lib/widgets/warning.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Warning extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const Warning({super.key, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(bottom: 6),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
#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>
|
||||
@@ -19,6 +21,12 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
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
|
||||
|
||||
@@ -9,8 +9,11 @@ import bluetooth_low_energy_darwin
|
||||
import device_info_plus
|
||||
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
|
||||
import shared_preferences_foundation
|
||||
import universal_ble
|
||||
@@ -23,8 +26,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
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"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
|
||||
|
||||
@@ -9,10 +9,17 @@ PODS:
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- gamepads_darwin (0.1.1):
|
||||
- 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):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- screen_retriever_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -34,8 +41,11 @@ DEPENDENCIES:
|
||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||
- 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`)
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
|
||||
@@ -54,10 +64,16 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
gamepads_darwin:
|
||||
: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:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||
screen_retriever_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
|
||||
shared_preferences_foundation:
|
||||
@@ -77,8 +93,11 @@ SPEC CHECKSUMS:
|
||||
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
|
||||
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
|
||||
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
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user