Compare commits

...

181 Commits

Author SHA1 Message Date
Jonas Bark
6f5c6bf1d9 Merge remote-tracking branch 'origin/main' 2025-11-08 09:00:51 +01:00
Jonas Bark
8fc8f2dfda increase version 2025-11-08 09:00:42 +01:00
jonasbark
d36e031e87 Set release date for version 3.4.0
Updated the release date for version 3.4.0 in the changelog.
2025-11-08 08:55:56 +01:00
Jonas Bark
efac0af4b9 update changelog to include Rouvy keymap 2025-11-08 08:54:59 +01:00
Jonas Bark
d7e73524ad Merge branch 'feature/bluetoothmedia' 2025-11-08 08:49:23 +01:00
Jonas Bark
80998c955f media key detection on iOS and macOS 2025-11-08 08:49:06 +01:00
Jonas Bark
d824cb6207 integrate media_key_detector package, add iOS implementation #1 2025-11-07 21:55:07 +01:00
Jonas Bark
ab80d679e1 update Rouvy keymap 2025-11-07 20:53:44 +01:00
Jonas Bark
d4881faab1 code fix, update changelog and readme 2025-11-07 20:49:59 +01:00
Jonas Bark
7c74d61b43 Merge branch 'feature/mediabutton' 2025-11-07 20:45:30 +01:00
Jonas Bark
8ad2906a17 Merge branch 'feature/ble_hid' 2025-11-07 20:37:44 +01:00
Jonas Bark
0f4d19080a prefill mail text when support is used 2025-11-07 20:37:10 +01:00
Jonas Bark
a9a13be6ca fix modifier detection 2025-11-07 20:36:35 +01:00
Jonas Bark
c66badf39e Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-07 19:41:15 +01:00
jonasbark
6c2fc54612 Merge pull request #166 from jonasbark/copilot/add-keyboard-combination-support
Add modifier key support for keyboard mappings
2025-11-07 19:40:45 +01:00
jonasbark
807c0eaa98 Merge pull request #167 from jonasbark/copilot/support-elite-square-control
Fix Elite Square button detection substring mismatch
2025-11-07 19:29:28 +01:00
copilot-swe-agent[bot]
7d7b1e89e9 Final implementation of modifier key support
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:17:59 +00:00
copilot-swe-agent[bot]
cafb7408d9 Refactor: Consolidate modifier key detection logic
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:17:07 +00:00
copilot-swe-agent[bot]
723f741bca Improve test code quality and fix edge cases
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:15:27 +00:00
copilot-swe-agent[bot]
6a3cc0f8be Refactor: Extract helper methods to reduce code duplication
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:15:07 +00:00
copilot-swe-agent[bot]
66c548fa75 Refactor tests to reduce code duplication
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:13:21 +00:00
copilot-swe-agent[bot]
0b42f7e9c5 Fix Elite Square button detection logic and add tests
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:11:18 +00:00
copilot-swe-agent[bot]
35a995eddc Add support for modifier keys in keyboard mapping
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:10:14 +00:00
copilot-swe-agent[bot]
c3afb23625 Initial plan 2025-11-07 18:05:00 +00:00
copilot-swe-agent[bot]
f15d97585b Initial plan 2025-11-07 18:02:22 +00:00
Jonas Bark
5f03c072ff fix di2 initialization 2025-11-06 18:45:46 +01:00
jonasbark
ce94aea51a Merge pull request #165 from jonasbark/copilot/fix-di-fly-button-triggering
[WIP] Fix Shimano DI2 implementation for DI Fly buttons
2025-11-06 18:44:38 +01:00
copilot-swe-agent[bot]
a27ae070fc Fix DI2 button trigger on startup and add tests
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-06 17:43:29 +00:00
copilot-swe-agent[bot]
7bbdc6a4e2 Initial plan 2025-11-06 17:38:51 +00:00
Jonas Bark
3188002ecb fix a few bugs and use some more clearer texts 2025-11-05 20:11:36 +01:00
Jonas Bark
284d2ca70f resolve yet another Sterzo issue https://github.com/jonasbark/swiftcontrol/issues/111#issuecomment-3487265558 2025-11-04 20:09:44 +01:00
Jonas Bark
57961aec5d bump version 2025-11-04 17:36:01 +01:00
Jonas Bark
1675d7f2d0 bump version 2025-11-04 17:35:32 +01:00
Jonas Bark
baec8d24c3 fix detection of Sterzo devices https://github.com/jonasbark/swiftcontrol/issues/111#issuecomment-3486678629 2025-11-04 17:34:25 +01:00
Jonas Bark
820d0b37db allow to ignore HID device input 2025-11-04 10:05:52 +01:00
Jonas Bark
c18ac16208 support HID key events on Android #110 2025-11-04 09:53:52 +01:00
Jonas Bark
2bbc09bf13 Merge branch 'main' into feature/ble_hid 2025-11-04 08:59:02 +01:00
Jonas Bark
a968723277 update gitignore 2025-11-04 08:58:54 +01:00
Jonas Bark
8668957738 fix name 2025-11-03 20:30:05 +01:00
Jonas Bark
4498729e75 initial support for BLE HID device 2025-11-03 20:26:21 +01:00
Jonas Bark
ac550fad5b do not offer MyWhoosh Link when not compatible 2025-11-03 18:58:41 +01:00
Jonas Bark
c511ac32b6 fix 'Exit' behavior on Android notification tap 2025-11-03 10:02:37 +01:00
Jonas Bark
ee48ce0f4e Merge remote-tracking branch 'origin/main' 2025-11-03 09:30:45 +01:00
Jonas Bark
8a3d64491b version++ 2025-11-03 09:30:38 +01:00
jonasbark
b72cc803f0 Clarify iOS, macOS, and Windows requirements
Updated iOS, macOS, and Windows requirements for Bluetooth buttons.
2025-11-03 09:12:00 +01:00
Jonas Bark
69dd5c85ef detect media keys on macOS / iOS #1 2025-11-03 08:58:16 +01:00
Jonas Bark
ea17b2e142 Merge remote-tracking branch 'origin/main' 2025-11-03 08:45:50 +01:00
Jonas Bark
da62fc4dc6 fix resetting keymap, show all touches by default 2025-11-03 08:45:39 +01:00
jonasbark
239630f681 Fix formatting and grammar issues in README.md
Corrected formatting and grammar in the README file.
2025-11-02 17:54:51 +01:00
Jonas Bark
d95d0cf8cf exit app on Android to apply patch 2025-11-02 17:48:34 +01:00
Jonas Bark
2b25ba942c Merge remote-tracking branch 'origin/main' 2025-11-02 16:22:14 +01:00
Jonas Bark
c65369a746 clarify pairing vs controller vs MyWhoosh Link 2025-11-02 16:22:06 +01:00
Jonas Bark
fa7d5e7853 clarify pairing vs controller vs MyWhoosh Link 2025-11-02 16:20:32 +01:00
Jonas Bark
8ac47cbd4d make Link / emulation UI clearer 2025-11-02 16:02:07 +01:00
Jonas Bark
eb85844503 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-02 13:56:12 +01:00
Jonas Bark
010d0ed331 UI adjustments 2025-11-02 13:55:56 +01:00
Jonas Bark
1f8f7765a3 fix SwiftControl Web, more generic battery and firmware version reads 2025-11-02 13:24:19 +01:00
Jonas Bark
68f416dda3 fix SwiftControl Web, more generic battery and firmware version reads 2025-11-02 13:10:35 +01:00
Jonas Bark
49e45faec0 fix SwiftControl Web 2025-11-02 10:50:44 +01:00
Jonas Bark
c81516350a initial support for Shimano Di2 D-Fly channel buttons 2025-11-02 10:45:51 +01:00
jonasbark
890f393fd6 Merge pull request #151 from jonasbark/copilot/add-cycplus-bc2-support
Add CYCPLUS BC2 virtual shifter support via Nordic UART Service
2025-11-02 08:26:42 +01:00
copilot-swe-agent[bot]
e46969c5c4 Add CYCPLUS BC2 virtual shifter support
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-02 06:45:59 +00:00
copilot-swe-agent[bot]
1ec9b55645 Initial plan 2025-11-02 06:39:40 +00:00
Jonas Bark
b0caf7c13b UI adjustments 2025-11-01 19:43:30 +01:00
Jonas Bark
302fc15dd7 don't reset after a minute 2025-11-01 19:40:23 +01:00
jonasbark
6a2cf1a1c9 Merge pull request #147 from jonasbark/copilot/fix-miui-service-issue
Add MIUI device detection and battery optimization warning with dismissal
2025-11-01 19:36:16 +01:00
jonasbark
8ea73bc54a Update Windows Store version to 3.3.0 2025-11-01 19:27:07 +01:00
copilot-swe-agent[bot]
7cbab3925f Use AndroidActions type check instead of negated RemoteActions
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-01 10:15:16 +00:00
copilot-swe-agent[bot]
246a1bd2be Add dismiss button to MIUI warning with persistent state
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-01 09:54:20 +00:00
copilot-swe-agent[bot]
f7e2a89ed6 Move MIUI warning from requirements to DevicePage widget
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:48:58 +00:00
copilot-swe-agent[bot]
f94252edb9 Address code review feedback - improve comments and efficiency
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:37:58 +00:00
copilot-swe-agent[bot]
b7b6b9803f Add MIUI device detection and warning for accessibility service
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:31:30 +00:00
copilot-swe-agent[bot]
807d868b74 Initial plan 2025-10-31 18:25:43 +00:00
jonasbark
c3e8c4666c Merge pull request #145 from jonasbark/zwift
Zwift Support
2025-10-31 13:16:07 +01:00
Jonas Bark
926651ebb3 zwift emulation rouvy support 2025-10-31 13:15:19 +01:00
Jonas Bark
a7d5624582 update mywhoosh profile 2025-10-31 12:53:04 +01:00
Jonas Bark
03209740ec ux adjustments 2025-10-31 12:18:15 +01:00
jonasbark
af6ae3433e Update lib/bluetooth/devices/zwift/zwift_emulator.dart
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-31 11:28:11 +01:00
Jonas Bark
41f4dd1d57 update changelog 2025-10-31 11:24:08 +01:00
Jonas Bark
d5c1b67675 clarify bluetooth buttons 2025-10-31 11:20:54 +01:00
Jonas Bark
0b18d74ac9 fix already paired gamepad detection 2025-10-31 09:41:34 +01:00
Jonas Bark
fb3fe5f8c0 add rouvy keymap and enable zwift controller emulation 2025-10-31 09:41:19 +01:00
Jonas Bark
796c973fd4 clarify Zwift usage, make controller emulation optional 2025-10-30 09:17:35 +01:00
Jonas Bark
7c6335c4d1 implement remaining Zwift actions 2025-10-29 09:13:17 +01:00
Jonas Bark
af2267c486 allow action unassignment 2025-10-28 18:06:07 +01:00
Jonas Bark
56d9e62610 integration #1 2025-10-28 17:58:44 +01:00
Jonas Bark
7e18a169d4 cleanup 2025-10-28 16:08:59 +01:00
Jonas Bark
74280eda34 zwift click emulation #4 (command works) 2025-10-28 16:05:31 +01:00
Jonas Bark
e1309d4d95 zwift click emulation #3 (connection works) 2025-10-28 14:57:54 +01:00
Jonas Bark
14aa6f7454 zwift click emulation #2 (connection works) 2025-10-28 12:55:39 +01:00
Jonas Bark
1368d7d24e zwift click emulation #1 2025-10-28 12:25:51 +01:00
Jonas Bark
8c09b170c3 some bugfixes, UI adjustments 2025-10-28 09:19:22 +01:00
Jonas Bark
080409b984 add trainer app selection during app start 2025-10-27 21:45:50 +01:00
Jonas Bark
f0ec276547 improve UI for buttons clicked without a keypair 2025-10-27 17:32:08 +01:00
Jonas Bark
23aafcd7bc improve UI for MyWhoosh Link 2025-10-27 17:14:38 +01:00
Jonas Bark
3718a126ac fix keymap explanation not noticing when MyWhoosh Link is connected 2025-10-27 16:48:23 +01:00
Jonas Bark
846dd07bf4 fix UI, fix first button click not handled 2025-10-27 16:44:37 +01:00
Jonas Bark
ba60062a24 Bluetooth HID tests 2025-10-27 16:28:41 +01:00
Jonas Bark
ed4f928fde Click V2 adjustments 2025-10-27 14:40:04 +01:00
Jonas Bark
2a09d550e5 Click V2 adjustments 2025-10-27 14:05:09 +01:00
Jonas Bark
bb1ae4e616 cleanup, fixes 2025-10-27 13:43:18 +01:00
Jonas Bark
828aa70a56 implement MyWhoosh link in the editor 2025-10-27 12:14:41 +01:00
Jonas Bark
4021f3131d refactor connection with remote and controllers 2025-10-27 11:12:23 +01:00
Jonas Bark
80ef81ca64 allow disconnection of bluetooth device 2025-10-27 09:54:01 +01:00
Jonas Bark
fec13d012b show custom keymap hint for gamepads 2025-10-27 09:38:19 +01:00
Jonas Bark
e8ca3fc287 use clipboard again when sharing 2025-10-27 08:27:08 +01:00
Jonas Bark
d5260d801c fix issues in #135 2025-10-26 21:23:57 +01:00
Jonas Bark
916b1ec1fc cleanup 2025-10-26 20:58:52 +01:00
Jonas Bark
7380bb5001 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-10-26 20:55:20 +01:00
Jonas Bark
2e95fb556a don't reset in debug mode 2025-10-26 20:54:53 +01:00
jonasbark
90591cbfa2 Merge pull request #137 from michidk/rssi
Displaying the RSSI of connected devices
2025-10-26 20:54:05 +01:00
Michael Lohr
929409db71 implement rssi 2025-10-26 20:32:43 +01:00
Jonas Bark
4263375fb2 fix scanning issue on web https://github.com/jonasbark/swiftcontrol/issues/134#issuecomment-3448689314 2025-10-26 20:28:25 +01:00
jonasbark
bb5d149ba4 Merge pull request #136 from jonasbark/gamepads
Gamepads
2025-10-26 20:17:15 +01:00
Jonas Bark
1a322dc0d3 resolve issue #111 2025-10-26 20:13:54 +01:00
Jonas Bark
d10da94f20 adjust changelog and supported devices 2025-10-26 20:11:17 +01:00
Jonas Bark
7eb28881cb Merge branch 'main' into gamepads 2025-10-26 20:10:34 +01:00
Jonas Bark
823e04d189 resolve issue #133 partially 2025-10-26 19:55:51 +01:00
Jonas Bark
ca5d4aeadb resolve issue #134 2025-10-26 19:53:46 +01:00
Jonas Bark
a4d937c4f3 resolve issue #135 2025-10-26 19:48:17 +01:00
Jonas Bark
fa4add6797 store background image during touch editing, don't show auto rotation warning when not needed 2025-10-26 19:45:11 +01:00
Jonas Bark
ec2ed4e6c5 refactoring #5 2025-10-26 19:27:35 +01:00
Jonas Bark
6bd41d9a54 refactoring #4 2025-10-26 18:53:18 +01:00
Jonas Bark
1ff2a205bc refactoring #3 2025-10-26 17:21:00 +01:00
Jonas Bark
dd73c3249b refactoring #1 2025-10-26 16:26:56 +01:00
Jonas Bark
75eef49317 initial support for gamepads 2025-10-26 10:21:59 +01:00
Jonas Bark
e8858e0c7d fix macOS new version check 2025-10-26 10:06:32 +01:00
Jonas Bark
df9142a6bf update CI 2025-10-25 09:35:26 +02:00
Jonas Bark
36f312403b update changelog 2025-10-25 09:35:06 +02:00
Jonas Bark
d8983889ae fix latest firmware on Click v2 2025-10-25 09:29:35 +02:00
Jonas Bark
bfaf2f2d29 Merge remote-tracking branch 'origin/main' 2025-10-24 18:43:31 +02:00
Jonas Bark
2ba9c284ba scan for all devices, another attempt at #42 2025-10-24 18:43:25 +02:00
jonasbark
ef2b4af28a Update WINDOWS_STORE_VERSION.txt 2025-10-24 12:25:57 +02:00
Jonas Bark
ba042cd07d more work on issue #42 2025-10-24 10:37:28 +02:00
Jonas Bark
f8cb4cff4f update training peaks keymap according to #126 2025-10-24 09:53:55 +02:00
Jonas Bark
92010b787b cleanup 2025-10-24 09:47:37 +02:00
Jonas Bark
e142a8c587 resolve issue #42 2025-10-24 09:47:02 +02:00
Jonas Bark
759dcaa8b8 Merge branch 'fix-38' 2025-10-24 09:44:30 +02:00
Jonas Bark
05939dcf1e implement fix for issue #38 2025-10-24 09:44:06 +02:00
jonasbark
34494819f5 Add instructions for MyWhoosh Link method
Added detailed instructions for using the MyWhoosh Link method, including steps and a video link.
2025-10-23 15:29:11 +02:00
Jonas Bark
a9491b7fa5 Merge branch 'main' into fix-38 2025-10-23 10:49:13 +02:00
Jonas Bark
311a676aea implement fix for issue #38 2025-10-23 10:48:21 +02:00
Jonas Bark
2eab9c581c update readme 2025-10-22 09:43:28 +02:00
Jonas Bark
1284499c25 MyWhoosh Link implementation #2 2025-10-22 09:35:41 +02:00
Jonas Bark
a74471b9f8 MyWhoosh Link implementation #1 2025-10-21 22:43:02 +02:00
Jonas Bark
81f61a5b87 Merge remote-tracking branch 'origin/main' 2025-10-21 10:45:39 +02:00
Jonas Bark
7b2446b6e0 CI cleanup 2025-10-21 10:45:30 +02:00
jonasbark
60898f7536 Update Windows Store version to 3.1.1 2025-10-21 10:25:26 +02:00
Jonas Bark
b2fa7870b6 CI cleanup 2025-10-21 10:21:44 +02:00
Jonas Bark
6ef2ff711a misc fixes 2025-10-21 10:18:12 +02:00
Jonas Bark
9f58dca10e restructure UI to make target selection easier to understand as well as how to get help 2025-10-21 10:10:40 +02:00
Jonas Bark
35e499720b CI patch update 2025-10-20 19:26:22 +02:00
Jonas Bark
7820a80241 Merge remote-tracking branch 'origin/main' 2025-10-20 19:11:08 +02:00
Jonas Bark
ffc6409488 CI patch update 2025-10-20 19:10:57 +02:00
jonasbark
8eaa411a80 Update CHANGELOG for version 3.1.0 enhancements 2025-10-20 19:09:40 +02:00
copilot-swe-agent[bot]
f08714f25a Refine PWM keypress behavior to prevent overlaps
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-20 11:47:28 +00:00
copilot-swe-agent[bot]
1f3352ff80 Implement Elite Sterzo Smart improvements for issue #111
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-20 11:44:24 +00:00
copilot-swe-agent[bot]
2601844970 Initial plan 2025-10-20 11:37:42 +00:00
Jonas Bark
e4bbb8b279 windows store preparation 2025-10-20 12:19:25 +02:00
Jonas Bark
a13e2aa494 windows store preparation 2025-10-20 11:59:01 +02:00
Jonas Bark
b8383a2280 windows store preparation 2025-10-20 11:36:36 +02:00
Jonas Bark
2cb5ef03ce windows store preparation 2025-10-20 11:35:21 +02:00
Jonas Bark
5203c3a576 windows store preparation 2025-10-20 11:31:11 +02:00
Jonas Bark
36dfb2dc0b windows store preparation 2025-10-20 11:29:18 +02:00
jonasbark
3f6434b5a3 Revise download links and compatibility information
Updated download links for iPhone, macOS, and Windows in the README.
2025-10-20 11:28:15 +02:00
Jonas Bark
d9595a3485 windows store preparation 2025-10-20 11:25:20 +02:00
Jonas Bark
b3352d0c1c restart scanning when bluetooth turned on, cleanup when turned off 2025-10-19 12:55:13 +02:00
Jonas Bark
7e15df1f15 restart scanning when bluetooth turned on, cleanup when turned off
remove Sterzo from Readme until confirmed working
2025-10-19 12:44:44 +02:00
Jonas Bark
b7e086c326 resolve issue #123 2025-10-19 11:15:36 +02:00
Jonas Bark
659e7b0585 resolve #122 2025-10-19 11:09:11 +02:00
Jonas Bark
501ab48da5 Merge branch 'copilot/support-elite-sterzo-smart' 2025-10-19 09:56:50 +02:00
Jonas Bark
3b9ceea64b web CORS proxy workaround for fetching file 2025-10-19 09:56:39 +02:00
Jonas Bark
f3c7bbbcbf Revert "Use file storage instead of SharedPreferences for challenge codes"
This reverts commit a744242c70.
2025-10-19 09:48:51 +02:00
Jonas Bark
9b21a2775e Zwift devices: add firmware update available information 2025-10-19 09:11:51 +02:00
Jonas Bark
b669d4c5ea Android: fix touches for very old Android versions 2025-10-19 08:55:15 +02:00
copilot-swe-agent[bot]
a744242c70 Use file storage instead of SharedPreferences for challenge codes
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-19 06:36:11 +00:00
copilot-swe-agent[bot]
7f963f71f8 Load Elite Sterzo challenge codes from HTTP with caching
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-19 06:28:13 +00:00
Jonas Bark
9f0ab53e1f add troubleshooting entry for Redmi devices 2025-10-18 12:27:11 +02:00
copilot-swe-agent[bot]
9b020e09ae Fix Elite Sterzo Smart implementation with correct UUIDs and protocol
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-17 19:23:58 +00:00
copilot-swe-agent[bot]
ceb029afb0 Add Elite Sterzo Smart support for virtual steering
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-17 17:40:12 +00:00
copilot-swe-agent[bot]
dc769ce6a0 Initial plan 2025-10-17 17:33:56 +00:00
226 changed files with 11278 additions and 1993 deletions

View File

@@ -31,7 +31,7 @@ on:
build_web:
description: 'Build for Web'
required: false
default: true
default: false
type: boolean
env:
@@ -97,6 +97,7 @@ jobs:
cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles
- name: 🐦 Setup Shorebird
if: inputs.build_mac || inputs.build_android || inputs.build_ios || inputs.build_web
uses: shorebirdtech/setup-shorebird@v1
with:
cache: true
@@ -239,17 +240,6 @@ jobs:
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
echo "VERSION=$version" >> $GITHUB_ENV
#11 Check if Tag Exists
- name: Check if Tag Exists
if: inputs.build_github
id: check_tag
run: |
if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then
echo "TAG_EXISTS=true" >> $GITHUB_ENV
else
echo "TAG_EXISTS=false" >> $GITHUB_ENV
fi
#13 Create Release
- name: Create Release
if: inputs.build_github
@@ -264,7 +254,7 @@ jobs:
windows:
needs: build
if: inputs.build_windows && inputs.build_github
if: inputs.build_windows
name: Build & Release on Windows
runs-on: windows-latest
@@ -273,13 +263,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:
@@ -315,7 +298,31 @@ jobs:
}
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/SwiftControl.windows.zip"
#9 Upload Artifacts
- uses: microsoft/setup-msstore-cli@v1
if: false
- name: Configure the Microsoft Store CLI
if: false
run: msstore reconfigure --tenantId $ --clientId $ --clientSecret $ --sellerId $
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Create MSIX package
run: dart run msix:create
- name: Publish MSIX to the Microsoft Store
if: false
run: msstore publish -v "build/windows/x64/runner/Release/"
- name: Rename swift_control.msix to SwiftControl.windows.msix
shell: pwsh
run: |
Rename-Item -Path "build/windows/x64/runner/Release/swift_control.msix" -NewName "SwiftControl.windows.msix"
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
@@ -323,8 +330,8 @@ jobs:
name: Releases
path: |
build/windows/x64/runner/Release/SwiftControl.windows.zip
build/windows/x64/runner/Release/SwiftControl.windows.msix
#10 Extract Version
- name: Extract version from pubspec.yaml (Windows)
shell: pwsh
run: |
@@ -333,13 +340,12 @@ jobs:
}
echo "VERSION=$version" >> $env:GITHUB_ENV
# add artifact to release
- name: Create Release
- name: Update Release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip"
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip,build/windows/x64/runner/Release/SwiftControl.windows.msix"
bodyFile: scripts/RELEASE_NOTES.md
prerelease: true
tag: v${{ env.VERSION }}

View File

@@ -23,7 +23,6 @@ jobs:
uses: actions/checkout@v3
- name: 🐦 Setup Shorebird
if: false
uses: shorebirdtech/setup-shorebird@v1
with:
cache: true
@@ -71,34 +70,31 @@ jobs:
cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles
- name: Decode Keystore
if: false
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
- name: 🚀 Shorebird Patch macOS
if: false
if: false # patch doesn't work: https://github.com/jonasbark/swiftcontrol/issues/143
uses: shorebirdtech/shorebird-patch@v1
with:
platform: macos
release-version: latest
args: '--allow-asset-diffs'
args: '--allow-asset-diffs --allow-native-diffs'
- name: 🚀 Shorebird Patch Android
if: false
uses: shorebirdtech/shorebird-patch@v1
with:
platform: android
release-version: latest
args: '--allow-asset-diffs'
args: '--allow-asset-diffs --allow-native-diffs'
- name: 🚀 Shorebird Patch iOS
if: false
uses: shorebirdtech/shorebird-patch@v1
with:
platform: ios
release-version: latest
args: '--allow-asset-diffs'
args: '--allow-asset-diffs --allow-native-diffs'
- name: Set Up Flutter
uses: subosito/flutter-action@v2
@@ -136,12 +132,12 @@ jobs:
allowUpdates: true
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
bodyFile: scripts/RELEASE_NOTES.md
prerelease: true
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
windows:
name: Patch Windows
if: false
runs-on: windows-latest
steps:
@@ -166,4 +162,4 @@ jobs:
with:
platform: windows
release-version: latest
args: '--allow-asset-diffs'
args: '--allow-asset-diffs --allow-native-diffs'

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@
.history
.svn/
.swiftpm/
debug/
migrate_working_dir/
android/keystore.properties

View File

@@ -1,8 +1,46 @@
### 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
- SwiftControl can now stay in the background
- more devices can be controlled
- do more, such as define Emotes, Camera angles and steering
### 3.1.0 (2025-10-17)
- new app icon
- adjusted MyWhoosh keyboard navigation mapping (thanks @bin101)
- support for Wahook Kickr Bike Shift (thanks @MattW2)
- initial support for Elite Square Smart Frame
- initial support for Elite Square Smart Frame
- reconnects to your device automatically when connection is lost
- SwiftControl now warns you if your device firmware is outdated
- SwiftControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
### 3.0.3 (2025-10-12)
- SwiftControl now supports iOS!

1
INSTRUCTIONS_ANDROID.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

12
INSTRUCTIONS_IOS.md Normal file
View File

@@ -0,0 +1,12 @@
**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:
[![SwiftControl Instruction for iOS](https://img.youtube.com/vi/p8sgQhuufeI/0.jpg)](https://www.youtube.com/watch?v=p8sgQhuufeI)
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)

1
INSTRUCTIONS_MACOS.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

1
INSTRUCTIONS_WINDOWS.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

View File

@@ -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
@@ -21,26 +21,41 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
Check the compatibility matrix below!
<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>
<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>
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a>
<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>
Get the latest version for Windows here: https://github.com/jonasbark/swiftcontrol/releases
<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>
## 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)
- Wahoo Kickr Bike Shift (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.
## Supported Platforms
@@ -49,31 +64,31 @@ Follow this compatibility matrix. It all depends on where you want to run your t
| 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 | ✅ | [Get it here](https://github.com/jonasbark/swiftcontrol/releases) | - 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). |
| 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 can use it to remotely control MyWhoosh and similar on e.g. an iPad. |
| Apple TV | ❌ | | Apple TV does not support touch inputs. Instead you can use e.g. SwiftControl with MyWhoosh Link to control your session |
| iPhone | (✅) | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you could use the Link method on another device to control MyWhoosh (and only MyWhoosh) on an iPhone. |
| Apple TV | (✅*) | | *only MyWhoosh using the Link method is supported - but you cannot also use MyWhoosh Link at the same time |
For testing purposes you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/) but this is just a tech demo - you won't be able to control other apps.
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.
## Troubleshooting
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
## How does it work?
The app connects to your Zwift devices automatically. It does not connect to your trainer itself.
The app connects to your Controller devices (such as Zwift ones) automatically. It does not connect to your trainer itself.
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
- your phone (Android/iOS) runs SwiftControl and connects to your Zwift devices
- **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)
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
- 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>
- 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.

View File

@@ -15,6 +15,13 @@ If you don't do that SwiftControl will need to reconnect every minute.
3. Connect your Trainer, then connect the Click V2
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/](https://dontkillmyapp.com/):
- disable battery optimization for SwiftControl
- enable auto start of SwiftControl
- grant accessibility permission for SwiftControl
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/jonasbark/swiftcontrol/issues/38) for more details
## Remote control is not working - nothing happens
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
- Try restarting the pairing process in SwiftControl
@@ -29,3 +36,18 @@ 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.
## 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://mywhoosh.com/troubleshoot/)
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
[INSTRUCTIONS_IOS.md](INSTRUCTIONS_IOS.md)
In essence:
- your two devices (phone, tablet) need to be on the same WiFi network
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
- Limit IP Address Tracking may need to be disabled
- mesh networks may not work

View File

@@ -0,0 +1 @@
3.3.0

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -3,9 +3,14 @@ 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.KeyEvent
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
@@ -36,7 +41,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 {
@@ -50,13 +55,50 @@ 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) {
// 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
}
}
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
val gestureBuilder = GestureDescription.Builder()
val path = Path()
path.moveTo(x.toFloat(), y.toFloat())
path.lineTo(x.toFloat()+1, y.toFloat())
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown && !isKeyUp)
val stroke = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown && !isKeyUp)
} else {
// API 2425: no “willContinue” support
StrokeDescription(path, 0L, ViewConfiguration.getTapTimeout().toLong())
}
gestureBuilder.addStroke(stroke)
dispatchGesture(gestureBuilder.build(), null, null)

View File

@@ -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)
}

View File

@@ -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();
}

View File

@@ -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;
});
}

View File

@@ -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
}
}

View File

@@ -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"/>

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -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

View File

@@ -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>

View File

@@ -24,19 +24,22 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<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>

View File

@@ -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);*/

View File

@@ -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";
}

View File

@@ -1,20 +1,37 @@
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/link/link_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>[];
@@ -31,28 +48,67 @@ 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;
final _dontAllowReconnectDevices = <String>{};
void initialize() {
isMediaKeyDetectionEnabled.addListener(() {
if (!isMediaKeyDetectionEnabled.value) {
mediaKeyDetector.setIsPlaying(isPlaying: false);
mediaKeyDetector.removeListener(_onMediaKeyDetectedListener);
} else {
mediaKeyDetector.addListener(_onMediaKeyDetectedListener);
mediaKeyDetector.setIsPlaying(isPlaying: true);
}
});
UniversalBle.onAvailabilityChange = (available) {
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
if (available == AvailabilityState.poweredOn && !kIsWeb) {
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);
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
if (device == null) {
_actionStreams.add(LogNotification('Device not found: $deviceId'));
UniversalBle.disconnect(deviceId);
@@ -61,41 +117,100 @@ class Connection {
device.processCharacteristic(characteristicUuid, value);
}
};
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);
}
};
}
Future<void> performScanning() async {
if (isScanning.value) {
return;
}
isScanning.value = true;
_actionStreams.add(LogNotification('Scanning for devices...'));
// 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)),
);
Future.delayed(Duration(seconds: 30)).then((_) {
if (isScanning.value) {
UniversalBle.stopScan();
isScanning.value = false;
}
});
if (!kIsWeb) {
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
Gamepads.list().then((list) {
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
addDevices(pads);
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
for (var device in removedDevices) {
devices.remove(device);
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
signalChange(device);
}
});
});
Gamepads.list().then((list) {
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
addDevices(pads);
});
}
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
startMyWhooshServer();
}
}
void _addDevices(List<BaseDevice> dev) {
final newDevices = dev.where((device) => !devices.contains(device)).toList();
devices.addAll(newDevices);
Future<void> startMyWhooshServer() {
return whooshLink.startServer(
onConnected: (socket) {
final existing = remoteDevices.firstOrNullWhere(
(e) => e is LinkDevice && e.identifier == socket.remoteAddress.address,
);
if (existing != null) {
existing.isConnected = true;
signalChange(existing);
}
},
onDisconnected: (socket) {
final device = devices.firstOrNullWhere(
(device) => device is LinkDevice && device.identifier == socket.remoteAddress.address,
);
if (device != null) {
devices.remove(device);
signalChange(device);
}
},
);
}
void addDevices(List<BaseDevice> dev) {
final newDevices = dev
.where((device) => !devices.contains(device) && !_dontAllowReconnectDevices.contains(device.name))
.toList();
devices.addAll(newDevices);
_connectionQueue.addAll(newDevices);
_handleConnectionQueue();
hasDevices.value = devices.isNotEmpty;
@@ -112,18 +227,20 @@ class Connection {
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();
}
@@ -131,32 +248,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
if (!isScanning.value) {
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: true);
// try reconnect
performScanning();
}
}
});
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
});
_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) {
@@ -167,21 +295,26 @@ 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();
_connectionSubscriptions.remove(device);
UniversalBle.disconnect(device.device.deviceId);
signalChange(device);
}
_gamePadSearchTimer?.cancel();
_lastScanResult.clear();
hasDevices.value = false;
devices.clear();
@@ -194,4 +327,40 @@ 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! LinkDevice) {
// keep it in the list to allow reconnect
devices.remove(device);
if (forget) {
_dontAllowReconnectDevices.add(device.name);
}
}
if (!forget && device is BluetoothDevice) {
_lastScanResult.removeWhere((b) => b.deviceId == device.device.deviceId);
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
}
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([]);
}
}

View File

@@ -1,145 +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';
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,
];
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);
}
} 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 (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(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) {
@@ -174,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 {
@@ -225,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);
}

View 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 == other.scanResult;
@override
int get hashCode => scanResult.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 ?? device.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);
},
),
],
),
],
);
}
}

View File

@@ -0,0 +1,97 @@
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);
}
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
// Process CYCPLUS BC2 data
// The BC2 typically sends button press data as simple byte values
// Common patterns for virtual shifters:
// - 0x01 or similar for shift up
// - 0x02 or similar for shift down
// - 0x00 for button release
if (bytes.isNotEmpty) {
final buttonCode = bytes[0];
switch (buttonCode) {
case 0x01:
// Shift up button pressed
handleButtonsClicked([CycplusBc2Buttons.shiftUp]);
break;
case 0x02:
// Shift down button pressed
handleButtonsClicked([CycplusBc2Buttons.shiftDown]);
break;
case 0x00:
// Button released
handleButtonsClicked([]);
break;
default:
// Unknown button code - log for debugging
actionStreamInternal.add(
LogNotification('CYCPLUS BC2: Unknown button code: 0x${buttonCode.toRadixString(16)}'),
);
break;
}
}
}
return Future.value();
}
}
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,
];
}

View File

@@ -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,
];
}

View File

@@ -0,0 +1,390 @@
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/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 BluetoothDevice {
EliteSterzo(super.scanResult) : super(availableButtons: SterzoButtons.values);
double _lastAngle = 0.0;
int? _latestChallenge;
String? _serviceUuid;
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(
(e) => e.uuid.toLowerCase().startsWith('347b0'),
);
if (service == null) {
throw Exception('Elite Sterzo service not found');
}
_serviceUuid = service.uuid;
// Find characteristics
final challengeChar = service.characteristics.firstOrNullWhere(
(e) => e.uuid == SterzoConstants.CHALLENGE_CODE_CHARACTERISTIC_UUID,
);
final measurementChar = service.characteristics.firstOrNullWhere(
(e) => e.uuid == SterzoConstants.MEASUREMENT_CHARACTERISTIC_UUID,
);
final controlChar = service.characteristics.firstOrNullWhere(
(e) => e.uuid == SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID,
);
if (challengeChar == null || measurementChar == null || controlChar == null) {
throw Exception('Required Sterzo characteristics not found');
}
// Subscribe to challenge code indications
await UniversalBle.subscribeIndications(
device.deviceId,
service.uuid,
challengeChar.uuid,
);
// Subscribe to measurement notifications
await UniversalBle.subscribeNotifications(
device.deviceId,
service.uuid,
measurementChar.uuid,
);
// Request to start challenge
await UniversalBle.write(
device.deviceId,
service.uuid,
controlChar.uuid,
Uint8List.fromList([0x03, 0x10]),
withoutResponse: false,
);
actionStreamInternal.add(LogNotification('Elite Sterzo: Initialization started'));
}
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
if (characteristic == SterzoConstants.CHALLENGE_CODE_CHARACTERISTIC_UUID) {
_handleChallengeCode(bytes);
} else if (characteristic == SterzoConstants.MEASUREMENT_CHARACTERISTIC_UUID) {
_handleSteeringMeasurement(bytes);
}
}
Future<void> _handleChallengeCode(Uint8List bytes) async {
if (bytes.length >= 4) {
// Challenge is in bytes 2-3 (big-endian)
final challenge = (bytes[2] << 8) | bytes[3];
_latestChallenge = challenge;
actionStreamInternal.add(LogNotification('Elite Sterzo: Received challenge code: $challenge'));
// Respond to challenge
await _activateSteeringMeasurements();
}
}
Future<void> _activateSteeringMeasurements() async {
if (_latestChallenge == null || _serviceUuid == null) {
return;
}
// Ensure challenge codes are loaded
await _ensureChallengeCodesLoaded();
// Get response codes for the challenge
final challengeCodes = _getChallengeResponse(_latestChallenge!);
// Send challenge response
await UniversalBle.write(
device.deviceId,
_serviceUuid!,
SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID,
Uint8List.fromList([0x03, 0x11, challengeCodes[0], challengeCodes[1]]),
withoutResponse: false,
);
await Future.delayed(const Duration(seconds: 1));
// Activate measurements
await UniversalBle.write(
device.deviceId,
_serviceUuid!,
SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID,
Uint8List.fromList([0x02, 0x02]),
withoutResponse: false,
);
actionStreamInternal.add(LogNotification('Elite Sterzo: Steering measurements activated'));
}
static Future<void> _ensureChallengeCodesLoaded() async {
if (_challengeCodesData != null) {
return; // Already loaded
}
// Wait if already loading
while (_isLoadingChallenges) {
await Future.delayed(const Duration(milliseconds: 100));
}
// Check again after waiting
if (_challengeCodesData != null) {
return;
}
_isLoadingChallenges = true;
try {
if (kIsWeb) {
// On web, always fetch from HTTP
_challengeCodesData = await _fetchChallengeCodes();
} else {
// On native platforms, try to load from cache first
_challengeCodesData = await _loadCachedChallengeCodes();
if (_challengeCodesData == null) {
// Cache miss - fetch from HTTP and cache it
_challengeCodesData = await _fetchChallengeCodes();
if (_challengeCodesData != null) {
await _cacheChallengeCodes(_challengeCodesData!);
}
}
}
} finally {
_isLoadingChallenges = false;
}
}
static Future<Uint8List?> _fetchChallengeCodes() async {
final url = kIsWeb
? 'https://corsproxy.io/${SterzoConstants.CHALLENGE_CODES_URL}'
: SterzoConstants.CHALLENGE_CODES_URL;
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
return response.bodyBytes;
}
} catch (e) {
if (kDebugMode) {
print('Failed to fetch challenge codes for URL $url: $e');
}
}
return null;
}
static Future<Uint8List?> _loadCachedChallengeCodes() async {
try {
final prefs = await SharedPreferences.getInstance();
final cached = prefs.getString(SterzoConstants.CACHE_KEY);
if (cached != null) {
// Decode from base64
return base64Decode(cached);
}
} catch (e) {
if (kDebugMode) {
print('Failed to load cached challenge codes: $e');
}
}
return null;
}
static Future<void> _cacheChallengeCodes(Uint8List data) async {
try {
final prefs = await SharedPreferences.getInstance();
// Encode to base64 for storage
await prefs.setString(SterzoConstants.CACHE_KEY, base64Encode(data));
} catch (e) {
if (kDebugMode) {
print('Failed to cache challenge codes: $e');
}
}
}
void _handleSteeringMeasurement(Uint8List bytes) {
if (bytes.length >= 4) {
// Steering angle is a 32-bit float (little-endian)
final rawAngle = ByteData.sublistView(bytes).getFloat32(0, Endian.little);
// Ignore NaN readings during initial connection
if (rawAngle.isNaN) {
return;
}
// 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;
}
}
/// 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([]);
}
}
/// 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) {
if (_challengeCodesData == null) {
// Fallback if data not loaded
return [0x96, 0x96];
}
final index = challenge * 2;
if (index >= 0 && index < _challengeCodesData!.length - 1) {
return [_challengeCodesData![index], _challengeCodesData![index + 1]];
}
// Fallback for out of range challenges
return [0x96, 0x96];
}
@override
Future<void> disconnect() async {
_keypressTimer?.cancel();
await super.disconnect();
}
}
class SterzoConstants {
static const String DEVICE_NAME = "STERZO";
// Elite Sterzo Smart characteristic UUIDs
static const String MEASUREMENT_CHARACTERISTIC_UUID = "347b0030-7635-408b-8918-8ff3949ce592";
static const String CONTROL_POINT_CHARACTERISTIC_UUID = "347b0031-7635-408b-8918-8ff3949ce592";
static const String CHALLENGE_CODE_CHARACTERISTIC_UUID = "347b0032-7635-408b-8918-8ff3949ce592";
// Service UUID pattern (matches Elite devices)
static const String SERVICE_UUID = "347b0001-7635-408b-8918-8ff3949ce592";
// Steering angle threshold in degrees to trigger steering action
static const double STEERING_THRESHOLD = 10.0;
static const int RECONNECT_DELAY = 5; // seconds between reconnection attempts
// URL to fetch challenge codes
static const String CHALLENGE_CODES_URL =
'https://github.com/zacharyedwardbull/pycycling/raw/refs/heads/master/pycycling/data/sterzo-challenge-codes.dat';
// 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,
];
}

View 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.'),
],
),
],
),
);
}
}

View 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;
}
},
),
],
),
],
);
}
}

View File

@@ -0,0 +1,151 @@
import 'dart:convert';
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;
ServerSocket? _server;
static final List<InGameAction> supportedActions = [
InGameAction.shiftUp,
InGameAction.shiftDown,
InGameAction.cameraAngle,
InGameAction.emote,
InGameAction.uturn,
InGameAction.steerLeft,
InGameAction.steerRight,
];
final ValueNotifier<bool> isStarted = ValueNotifier(false);
final ValueNotifier<bool> isConnected = ValueNotifier(false);
void stopServer() async {
if (isStarted.value) {
await _socket?.close();
await _server?.close();
isConnected.value = false;
isStarted.value = false;
if (kDebugMode) {
print('Server stopped.');
}
}
}
Future<void> startServer({
required void Function(Socket socket) onConnected,
required void Function(Socket socket) onDisconnected,
}) async {
// Create and bind server socket
_server = await ServerSocket.bind(
InternetAddress.anyIPv6,
21587,
shared: true,
v6Only: false,
);
isStarted.value = true;
if (kDebugMode) {
print('Server started on port ${_server!.port}');
}
// Accept connection
_server!.listen(
(Socket socket) {
_socket = socket;
onConnected(socket);
isConnected.value = true;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
}
// 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) {
final jsonObject = switch (action) {
InGameAction.shiftUp => {
'MessageType': 'Controls',
'InGameControls': {
'GearShifting': '1',
},
},
InGameAction.shiftDown => {
'MessageType': 'Controls',
'InGameControls': {
'GearShifting': '-1',
},
},
InGameAction.cameraAngle => {
'MessageType': 'Controls',
'InGameControls': {
'CameraAngle': '$value',
},
},
InGameAction.emote => {
'MessageType': 'Controls',
'InGameControls': {
'Emote': '$value',
},
},
InGameAction.uturn => {
'MessageType': 'Controls',
'InGameControls': {
'UTurn': 'true',
},
},
InGameAction.steerLeft => {
'MessageType': 'Controls',
'InGameControls': {
'Steering': '-1',
},
},
InGameAction.steerRight => {
'MessageType': 'Controls',
'InGameControls': {
'Steering': '1',
},
},
InGameAction.increaseResistance => null,
InGameAction.decreaseResistance => null,
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 ${value ?? ''}';
} else {
return 'No action available for button: $action';
}
}
bool isCompatible(Target target) {
return switch (target) {
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
_ => true,
};
}
}

View File

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

View File

@@ -0,0 +1,100 @@
import 'dart:typed_data';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
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);
if (actionHandler.supportedApp is! CustomApp) {
actionStreamInternal.add(LogNotification('Use a custom keymap to support ${scanResult.name}'));
}
}
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(
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),
),
],
);
}
}
class ShimanoDi2Constants {
static const String SERVICE_UUID = "000018ef-5348-494d-414e-4f5f424c4500";
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
}

View File

@@ -1,15 +1,16 @@
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(),
isBeta: true,
);
@override
@@ -112,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,
];
}

View File

@@ -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,

View File

@@ -3,17 +3,21 @@ 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;
}
@override
String get latestFirmwareVersion => '1.1.0';
}

View File

@@ -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

View File

@@ -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,19 +10,20 @@ 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;
List<ControllerButton>? _lastButtonsClicked;
String get latestFirmwareVersion;
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK;
String get customServiceId => ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID;
@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(
@@ -31,30 +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);
}
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) {
@@ -65,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 {
@@ -79,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(' ')}",
);
@@ -90,7 +82,7 @@ abstract class ZwiftDevice extends BaseDevice {
try {
if (bytes.startsWith(startCommand)) {
_processDevicePublicKeyResponse(bytes);
processDevicePublicKeyResponse(bytes);
} else {
processData(bytes);
}
@@ -105,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,
);
@@ -144,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;

View 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'}');
},
);
},
);
}
}

View File

@@ -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,23 +34,26 @@ 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,
],
];
}
@override
String get latestFirmwareVersion => '1.3.1';
}

View File

@@ -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,33 +17,38 @@ 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
String get customServiceId => ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID;
@override
String get latestFirmwareVersion => '1.2.0';
@override
Future<void> processData(Uint8List bytes) async {
Opcode? opcode = Opcode.valueOf(bytes[0]);
@@ -56,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");
@@ -181,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
@@ -216,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
};
@@ -265,7 +252,7 @@ class ZwiftRide extends ZwiftDevice {
}
}
enum _RideButtonMask {
enum RideButtonMask {
LEFT_BTN(0x00001),
UP_BTN(0x00002),
RIGHT_BTN(0x00004),
@@ -288,5 +275,5 @@ enum _RideButtonMask {
final int mask;
const _RideButtonMask(this.mask);
const RideButtonMask(this.mask);
}

View File

@@ -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;

View File

@@ -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';
@@ -10,49 +9,54 @@ import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/settings/settings.dart';
import 'package:window_manager/window_manager.dart';
import 'bluetooth/connection.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();
const screenshotMode = false;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
initializeActions(true);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
windowManager.setSize(Size(1280, 800));
}
runApp(const SwiftPlayApp());
}
Future<void> initializeActions(bool local) async {
enum ConnectionType {
unknown,
local,
remote,
}
Future<void> initializeActions(ConnectionType connectionType) async {
if (kIsWeb) {
actionHandler = StubActions();
} else if (Platform.isAndroid) {
if (local) {
actionHandler = AndroidActions();
} else {
actionHandler = RemoteActions();
}
actionHandler = switch (connectionType) {
ConnectionType.local => AndroidActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.unknown => StubActions(),
};
} else if (Platform.isIOS) {
actionHandler = RemoteActions();
actionHandler = switch (connectionType) {
ConnectionType.local => StubActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.unknown => StubActions(),
};
} else {
if (local) {
actionHandler = DesktopActions();
} else {
actionHandler = RemoteActions();
}
actionHandler = switch (connectionType) {
ConnectionType.local => DesktopActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.unknown => StubActions(),
};
}
actionHandler.init(settings.getKeyMap());
}
class SwiftPlayApp extends StatelessWidget {
@@ -61,6 +65,7 @@ class SwiftPlayApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
debugShowCheckedModeBanner: false,
title: 'SwiftControl',
theme: AppTheme.light,

File diff suppressed because it is too large Load Diff

View File

@@ -56,29 +56,28 @@ class _ChangelogPageState extends State<MarkdownPage> {
title: Text(widget.assetPath.replaceAll('.md', '').toLowerCase().capitalize),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
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: Colors.black87),
onLinkTap: (title, url) {
launchUrlString(url);
},
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -3,11 +3,11 @@ 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/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';
@@ -21,7 +21,6 @@ class RequirementsPage extends StatefulWidget {
class _RequirementsPageState extends State<RequirementsPage> with WidgetsBindingObserver {
int _currentStep = 0;
var _local = true;
List<PlatformRequirement> _requirements = [];
@@ -30,12 +29,9 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
super.initState();
WidgetsBinding.instance.addObserver(this);
_local = kIsWeb || !Platform.isIOS;
// 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), () {
@@ -46,29 +42,6 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
});
});
connection.hasDevices.addListener(() {
if (connection.hasDevices.value) {
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
}
});
}
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
@@ -92,36 +65,48 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: buildMenuButtons(),
),
body: _requirements.isEmpty
? Center(child: CircularProgressIndicator())
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
body: SingleChildScrollView(
child: Column(
spacing: 12,
children: [
SizedBox(height: 12),
Row(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
SwitchListTile.adaptive(
value: _local,
title: Text('Trainer app is running on this device'),
subtitle: Text('Turn off if you want to control another device, e.g. your tablet'),
onChanged: (local) {
if (kIsWeb || Platform.isIOS) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('This platform only supports controlling trainer apps on other devices'),
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.'),
],
),
);
} else {
initializeActions(local);
setState(() {
_local = local;
_reloadRequirements();
});
}
},
),
),
],
),
Expanded(
child: Card(
margin: EdgeInsets.symmetric(horizontal: 16),
],
),
_requirements.isEmpty
? Center(child: SmallProgressIndicator())
: Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Stepper(
physics: NeverScrollableScrollPhysics(),
currentStep: _currentStep,
connectorColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
@@ -134,11 +119,13 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
: null,
onStepTapped: (step) {
if (_requirements[step].status) {
if (_requirements[step].status && _requirements[step] is! TargetRequirement) {
return;
}
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
if (hasEarlierIncomplete && !kDebugMode) {
final hasEarlierIncomplete =
_requirements.indexWhere((req) => !req.status) != -1 &&
_requirements.indexWhere((req) => !req.status) < step;
if (hasEarlierIncomplete) {
return;
}
setState(() {
@@ -150,7 +137,8 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
.mapIndexed(
(index, req) => Step(
title: Text(req.name, style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: req.description != null ? Text(req.description!) : null,
subtitle:
req.buildDescription() ?? (req.description != null ? Text(req.description!) : null),
content: Container(
padding: const EdgeInsets.only(top: 16.0),
alignment: Alignment.centerLeft,
@@ -175,9 +163,9 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
.toList(),
),
),
),
],
),
],
),
),
);
}
@@ -188,9 +176,29 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
void _reloadRequirements() {
getRequirements(_local).then((req) {
getRequirements(
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
).then((req) {
_requirements = req;
_currentStep = req.indexWhere((req) => !req.status);
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'),
),
);
}
}
if (mounted) {
setState(() {});
}

View File

@@ -1,96 +0,0 @@
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 '../widgets/logviewer.dart';
class ScanWidget extends StatefulWidget {
const ScanWidget({super.key});
@override
State<ScanWidget> createState() => _ScanWidgetState();
}
class _ScanWidgetState extends State<ScanWidget> {
@override
void initState() {
super.initState();
connection.initialize();
/*_isScanningSubscription = FlutterBluePlus.isScanning.listen((state) {
_isScanning = state;
if (mounted) {
setState(() {});
}
});*/
// after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
// must be called from a button
if (!kIsWeb) {
Future.delayed(Duration(seconds: 1))
.then((_) {
return connection.performScanning();
})
.catchError((e) {
print(e);
});
}
});
}
@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')),
);
},
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()),
],
),
);
}
}

View File

@@ -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,
),
),
),
),
);
}
}

View File

@@ -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,28 +50,44 @@ 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) {
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
try {
await accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
} on PlatformException catch (e) {
return "Accessibility Service not working. Follow instructions at https://dontkillmyapp.com/";
}
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
? "click"
: isKeyDown
? "down"
: "up"}";
}
return "No touch performed";
return "No action assigned";
}
void ignoreHidDevices() {
accessibilityHandler.ignoreHidDevices();
}
}

View File

@@ -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;
}

View File

@@ -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';
}
}
}

View File

@@ -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,30 +28,31 @@ 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());
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
return 'Mouse clicked at: ${point2.dx} ${point2.dy}';
return 'Mouse clicked at: ${point2.dx.toInt()} ${point2.dy.toInt()}';
}
}
@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) {

View File

@@ -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(

View File

@@ -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,
),
);
}

View File

@@ -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,
),
],
),

View 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,
),
],
),
);
}

View File

@@ -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() {

View File

@@ -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,

View 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,
),
],
),
);
}

View File

@@ -1,60 +1,81 @@
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,
shiftDown,
navigateLeft,
navigateRight,
toggleUi,
increaseResistance,
decreaseResistance;
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'),
// zwift
openActionBar('Open Action Bar'),
usePowerUp('Use Power-Up'),
select('Select'),
back('Back'),
rideOnBomb('Ride On Bomb');
final String title;
final List<int>? possibleValues;
const InGameAction(this.title, {this.possibleValues});
@override
String toString() {
return name;
return title;
}
}
enum ControllerButton {
// left controller
navigationUp._(InGameAction.increaseResistance, icon: Icons.keyboard_arrow_up, color: Colors.black),
navigationDown._(InGameAction.decreaseResistance, 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();
}

View File

@@ -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'],
);
}
}

View File

@@ -0,0 +1,277 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/main.dart';
import 'apps/custom_app.dart';
class KeymapManager {
// Singleton instance
static final KeymapManager _instance = KeymapManager._internal();
// Private constructor
KeymapManager._internal();
// Factory constructor to return the singleton instance
factory KeymapManager() {
return _instance;
}
Future<String?> showNewProfileDialog(BuildContext context) async {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('New Custom Profile'),
content: TextField(
controller: controller,
decoration: InputDecoration(labelText: 'Profile Name', hintText: 'e.g., Workout, Race, Event'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Create')),
],
),
);
}
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,
),
);
}
}
},
),
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 {
final controller = TextEditingController(text: currentName);
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Rename Profile'),
content: TextField(
controller: controller,
decoration: InputDecoration(labelText: 'Profile Name'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Rename')),
],
),
);
}
Future<String?> _showDuplicateProfileDialog(BuildContext context, String currentName) async {
final controller = TextEditingController(text: '$currentName (Copy)');
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Create new custom profile by duplicating "$currentName"'),
content: TextField(
controller: controller,
decoration: InputDecoration(labelText: 'New Profile Name'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Duplicate')),
],
),
);
}
Future<bool?> _showDeleteConfirmDialog(BuildContext context, String profileName) async {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete Profile'),
content: Text('Are you sure you want to delete "$profileName"? This action cannot be undone.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
Future<String?> _showImportDialog(BuildContext context) async {
final controller = TextEditingController();
// Try to get data from clipboard
try {
final clipboardData = await Clipboard.getData('text/plain');
if (clipboardData?.text != null) {
controller.text = clipboardData!.text!;
}
} catch (e) {
// Ignore clipboard errors
}
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Import Profile'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Paste the exported JSON data below:'),
SizedBox(height: 16),
TextField(
controller: controller,
decoration: InputDecoration(labelText: 'JSON Data', border: OutlineInputBorder()),
maxLines: 5,
autofocus: true,
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Import')),
],
),
);
}
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);
final customApp = CustomApp(profileName: newName);
final savedKeymap = settings.getCustomAppKeymap(newName);
if (savedKeymap != null) {
customApp.decodeKeymap(savedKeymap);
}
actionHandler.supportedApp = customApp;
await settings.setKeyMap(customApp);
return newName;
} else {
final customApp = CustomApp(profileName: newName);
final connectedDevice = connection.devices.firstOrNull;
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
pair.buttons.filter((button) => connectedDevice?.availableButtons.contains(button) == true).forEachIndexed((
button,
indexB,
) {
customApp.setKey(
button,
physicalKey: pair.physicalKey,
logicalKey: pair.logicalKey,
isLongPress: pair.isLongPress,
touchPosition: pair.touchPosition,
inGameAction: pair.inGameAction,
inGameActionValue: pair.inGameActionValue,
);
});
});
actionHandler.supportedApp = customApp;
await settings.setKeyMap(customApp);
return newName;
}
}
return null;
}
}

View File

@@ -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,30 @@ 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)],
),
);
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);
}
}

View File

@@ -4,9 +4,13 @@ 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/pages/scan.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:universal_ble/universal_ble.dart';
class KeyboardRequirement extends PlatformRequirement {
@@ -35,14 +39,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();
@@ -51,7 +71,8 @@ class BluetoothTurnedOn extends PlatformRequirement {
}
class UnsupportedPlatform extends PlatformRequirement {
UnsupportedPlatform() : super('Unsupported platform :(') {
UnsupportedPlatform()
: super('This ${kIsWeb ? 'Browser does not support Web Bluetooth and ' : 'platform'} is not supported :(') {
status = false;
}
@@ -62,8 +83,102 @@ class UnsupportedPlatform extends PlatformRequirement {
Future<void> getStatus() async {}
}
class BluetoothScanning extends PlatformRequirement {
BluetoothScanning() : super('Finding your Controller...') {
typedef BoolFunction = bool Function();
enum Target {
thisDevice(
title: 'This Device',
icon: Icons.devices,
),
iOS(
title: 'iPhone / iPad / Apple TV',
icon: Icons.settings_remote_outlined,
),
android(
title: 'Android Device',
icon: Icons.settings_remote_outlined,
),
macOS(
title: 'Mac',
icon: Icons.settings_remote_outlined,
),
windows(
title: 'Windows PC',
icon: Icons.settings_remote_outlined,
);
final String title;
final IconData icon;
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 => 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 your Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.android =>
'Run ${app?.name ?? 'the Trainer app'} on your Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.macOS =>
'Run ${app?.name ?? 'the Trainer app'} on your Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.windows =>
'Run ${app?.name ?? 'the Trainer app'} on your Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
};
}
String? get warning {
if (settings.getTrainerApp()?.supportsZwiftEmulation == true) {
// no warnings for zwift emulation
return null;
}
return switch (this) {
Target.android when Platform.isAndroid =>
"Select 'This device' unless you want to control another Android device. Are you sure?",
Target.macOS when Platform.isMacOS =>
"Select 'This device' unless you want to control another macOS device. Are you sure?",
Target.windows when Platform.isWindows =>
"Select 'This device' unless you want to control another Windows device. Are you sure?",
Target.android => "Download and use SwiftControl on that Android device.",
Target.macOS => "Download and use SwiftControl on that macOS device.",
Target.windows => "Download and use SwiftControl on that Windows device.",
_ => null,
};
}
ConnectionType get connectionType {
return switch (this) {
Target.thisDevice => ConnectionType.local,
_ => ConnectionType.remote,
};
}
}
class TargetRequirement extends PlatformRequirement {
TargetRequirement()
: super(
'Select Trainer App & Target Device',
description: 'Select your Target Device where you want to run your trainer app on',
) {
status = false;
}
@@ -71,10 +186,168 @@ class BluetoothScanning extends PlatformRequirement {
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Future<void> getStatus() async {}
Future<void> getStatus() async {
status = settings.getLastTarget() != null && settings.getTrainerApp() != null;
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return ScanWidget();
return StatefulBuilder(
builder: (c, setState) => Column(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)),
DropdownMenu<SupportedApp>(
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
return DropdownMenuEntry(
value: app,
label: app.name,
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(app.name),
Row(
children: [
Expanded(
child: Text(
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Icon(Icons.warning_amber),
],
),
],
)
: null,
);
}).toList(),
hintText: 'Select Trainer app',
initialSelection: settings.getTrainerApp(),
onSelected: (selectedApp) async {
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
whooshLink.stopServer();
}
settings.setTrainerApp(selectedApp!);
if (actionHandler.supportedApp == null ||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
actionHandler.init(selectedApp);
settings.setKeyMap(selectedApp);
}
setState(() {});
},
),
SizedBox(height: 8),
Text(
'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
style: TextStyle(fontWeight: FontWeight.bold),
),
DropdownMenu<Target>(
dropdownMenuEntries: Target.values.map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
trailingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
if (target.isBeta) BetaPill(),
],
),
Text(
target.getDescription(settings.getTrainerApp()),
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (target == Target.thisDevice)
Container(
margin: EdgeInsets.only(top: 12),
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
);
}).toList(),
hintText: 'Select Target device',
initialSelection: settings.getLastTarget(),
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
initializeActions(target.connectionType);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
);
}
setState(() {});
}
},
),
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 && trainer != null) {
if (target.warning != null) {
return Row(
spacing: 8,
children: [
Icon(Icons.warning, color: Colors.red, size: 16),
Expanded(
child: Text(
settings.getLastTarget()!.warning!,
style: TextStyle(color: Colors.red),
),
),
],
);
} else {
return Text('${trainer.name} on ${target.title}');
}
} else {
return null;
}
}
}
class PlaceholderRequirement extends PlatformRequirement {
PlaceholderRequirement() : super('Requirement');
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Future<void> getStatus() async {
status = false;
}
}

View File

@@ -3,9 +3,11 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/android.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/remote.dart';
import 'package:universal_ble/universal_ble.dart';
abstract class PlatformRequirement {
String name;
@@ -21,24 +23,57 @@ abstract class PlatformRequirement {
Widget? build(BuildContext context, VoidCallback onUpdate) {
return null;
}
Widget? buildDescription() {
return null;
}
}
Future<List<PlatformRequirement>> getRequirements(bool local) async {
Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType) async {
List<PlatformRequirement> list;
if (kIsWeb) {
list = [BluetoothTurnedOn(), BluetoothScanning()];
final availablity = await UniversalBle.getBluetoothAvailabilityState();
if (availablity == AvailabilityState.unsupported) {
list = [UnsupportedPlatform()];
} else {
list = [BluetoothTurnedOn()];
}
} else if (Platform.isMacOS) {
list = [BluetoothTurnedOn(), local ? KeyboardRequirement() : RemoteRequirement(), BluetoothScanning()];
list = [
TargetRequirement(),
BluetoothTurnedOn(),
switch (connectionType) {
ConnectionType.local => KeyboardRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
];
} else if (Platform.isIOS) {
list = [BluetoothTurnedOn(), RemoteRequirement(), BluetoothScanning()];
list = [
TargetRequirement(),
BluetoothTurnedOn(),
switch (connectionType) {
ConnectionType.local => RemoteRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
];
} else if (Platform.isWindows) {
list = [BluetoothTurnedOn(), local ? KeyboardRequirement() : RemoteRequirement(), BluetoothScanning()];
list = [
TargetRequirement(),
BluetoothTurnedOn(),
switch (connectionType) {
ConnectionType.local => KeyboardRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
];
} else if (Platform.isAndroid) {
final deviceInfoPlugin = DeviceInfoPlugin();
final deviceInfo = await deviceInfoPlugin.androidInfo;
list = [
TargetRequirement(),
BluetoothTurnedOn(),
local ? AccessibilityRequirement() : RemoteRequirement(),
NotificationRequirement(),
if (deviceInfo.version.sdkInt <= 30)
LocationRequirement()
@@ -46,7 +81,11 @@ Future<List<PlatformRequirement>> getRequirements(bool local) async {
BluetoothScanRequirement(),
BluetoothConnectRequirement(),
],
BluetoothScanning(),
switch (connectionType) {
ConnectionType.local => AccessibilityRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
];
} else {
list = [UnsupportedPlatform()];

View File

@@ -5,8 +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';
@@ -18,11 +22,19 @@ bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
class RemoteRequirement extends PlatformRequirement {
RemoteRequirement() : super('Connect to your other device');
RemoteRequirement()
: super(
'Connect to your target device',
);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Widget? buildDescription() {
return Text('Choose your preferred connection method');
}
Future<void> reconnect() async {
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
@@ -74,12 +86,18 @@ class RemoteRequirement extends PlatformRequirement {
return;
}
}
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn &&
peripheralManager.state != BluetoothLowEnergyState.unknown) {
print('Waiting for peripheral manager to be powered on...');
await Future.delayed(Duration(seconds: 1));
if (kDebugMode && false) {
print('Continuing');
return;
}
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn) {
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([
@@ -257,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;
}
}
@@ -276,15 +294,71 @@ 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: 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: Colors.black87, fontWeight: FontWeight.normal),
),
],
),
),
),
Row(
spacing: 10,
children: [
@@ -292,24 +366,40 @@ 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: Colors.black87, fontWeight: FontWeight.normal),
),
],
),
BetaPill(),
],
),
),
),
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
if (kDebugMode && !screenshotMode)
ElevatedButton(
onPressed: () {
(actionHandler as RemoteActions).sendAbsMouseReport(0, 90, 90);
(actionHandler as RemoteActions).sendAbsMouseReport(1, 90, 90);
(actionHandler as RemoteActions).sendAbsMouseReport(0, 90, 90);
},
child: Text('Test'),
),
],
),
if (_isAdvertising) ...[
if (_isAdvertising)
Text(
'If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
switch (settings.getLastTarget()) {
Target.iOS =>
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
_ =>
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required if you want to use the remote control feature.',
},
),
if (_isAdvertising) ...[
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));
@@ -334,7 +424,7 @@ class _PairWidgetState extends State<_PairWidget> {
setState(() {});
await widget.requirement.startAdvertising(widget.onUpdate);
_isLoading = false;
setState(() {});
if (mounted) setState(() {});
}
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
import 'package:swift_control/utils/keymap/apps/zwift.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
class ZwiftRequirement extends PlatformRequirement {
ZwiftRequirement()
: super(
'Pair SwiftControl with Zwift',
);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Widget? buildDescription() {
return settings.getLastTarget() == null
? null
: Text(
'In Zwift on your ${settings.getLastTarget()?.title} go into the Pairing settings and select SwiftControl from the list of available controllers.',
);
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
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(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(),
],
],
),
);
},
);
},
);
}
@override
Future<void> getStatus() async {
status = zwiftEmulator.isConnected.value || screenshotMode;
}
}

View File

@@ -1,11 +1,13 @@
import 'dart:convert';
import 'package:dartx/dartx.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:window_manager/window_manager.dart';
import '../../main.dart';
import '../actions/desktop.dart';
import '../keymap/apps/custom_app.dart';
class Settings {
@@ -13,49 +15,16 @@ class Settings {
Future<void> init() async {
prefs = await SharedPreferences.getInstance();
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
}
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;
}
// 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);
}
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);
} else {
final app = SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
actionHandler.init(app);
}
final app = getKeyMap();
actionHandler.init(app);
} catch (e) {
// couldn't decode, reset
await prefs.clear();
@@ -68,13 +37,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();
@@ -136,6 +136,16 @@ class Settings {
return prefs.getString('last_seen_version');
}
Target? getLastTarget() {
final targetString = prefs.getString('last_target');
if (targetString == null) return null;
return Target.values.firstOrNullWhere((e) => e.name == targetString);
}
Future<void> setLastTarget(Target target) async {
await prefs.setString('last_target', target.name);
}
Future<void> setLastSeenVersion(String version) async {
await prefs.setString('last_seen_version', version);
}
@@ -148,39 +158,27 @@ 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};
migratedData.add(jsonEncode(decoded));
} else {
migratedData.add(entry.key);
}
}
return migratedData;
Future<void> setMiuiWarningDismissed(bool dismissed) async {
await prefs.setBool('miui_warning_dismissed', dismissed);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
class BetaPill extends StatelessWidget {
final String text;
const BetaPill({super.key, this.text = 'BETA'});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: Text(
text,
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
);
}
}

View 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,
),
),
),
),
);
}
}

View File

@@ -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,
@@ -41,6 +44,7 @@ class ChangelogDialog extends StatelessWidget {
showDialog(
context: context,
useRootNavigator: true,
routeSettings: RouteSettings(name: '/changelog'),
builder: (context) => ChangelogDialog(entry: markdown),
);
}

View File

@@ -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

View File

@@ -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),
],
),
),
);

View File

@@ -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();

View File

@@ -8,6 +8,7 @@ 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';
@@ -51,6 +52,70 @@ List<Widget> buildMenuButtons() {
),
SizedBox(width: 8),
],
PopupMenuButton(
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Text('Instructions'),
onTap: () {
final instructions = Platform.isAndroid
? 'INSTRUCTIONS_ANDROID.md'
: Platform.isIOS
? 'INSTRUCTIONS_IOS.md'
: Platform.isMacOS
? 'INSTRUCTIONS_MACOS.md'
: 'INSTRUCTIONS_WINDOWS.md';
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: instructions)),
);
},
),
PopupMenuItem(
child: Text('Troubleshooting Guide'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
),
PopupMenuItem(
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 ? '' : 'ler';
String email = Uri.encodeComponent('jonas.t.bark+swiftcontrol$suffix@gmail.com');
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(', ')}
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),
),
SizedBox(width: 8),
const MenuButton(),
SizedBox(width: 8),
];
@@ -106,21 +171,7 @@ class MenuButton extends StatelessWidget {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
},
),
PopupMenuItem(
child: Text('Troubleshooting Guide'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
),
PopupMenuItem(
child: Text('Feedback'),
onTap: () {
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
),
PopupMenuItem(
child: Text('License'),
onTap: () {

113
lib/widgets/scan.dart Normal file
View File

@@ -0,0 +1,113 @@
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';
class ScanWidget extends StatefulWidget {
const ScanWidget({super.key});
@override
State<ScanWidget> createState() => _ScanWidgetState();
}
class _ScanWidgetState extends State<ScanWidget> {
@override
void initState() {
super.initState();
connection.initialize();
/*_isScanningSubscription = FlutterBluePlus.isScanning.listen((state) {
_isScanning = state;
if (mounted) {
setState(() {});
}
});*/
// after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
// must be called from a button
if (!kIsWeb) {
Future.delayed(Duration(seconds: 1))
.then((_) {
return connection.performScanning();
})
.catchError((e) {
print(e);
});
}
});
}
@override
Widget build(BuildContext context) {
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;
},
);
},
),
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"),
),
],
);
}
},
),
],
);
}
}

View File

@@ -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),
),
),
);

View File

@@ -1,7 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
@@ -14,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});
@@ -26,38 +26,6 @@ class AppTitle extends StatefulWidget {
class _AppTitleState extends State<AppTitle> {
final updater = ShorebirdUpdater();
Patch? _shorebirdPatch;
Future<Pair<Version, String>?> _getLatestVersionUrlIfNewer() async {
final response = await http.get(Uri.parse('https://api.github.com/repos/jonasbark/swiftcontrol/releases/latest'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final tagName = data['tag_name'] as String;
final prerelase = data['prerelease'] as bool;
final latestVersion = Version.parse(tagName.split('+').first.replaceAll('v', ''));
final currentVersion = Version.parse(_packageInfoValue!.version);
// +1337 releases are considered beta
if (latestVersion > currentVersion && !prerelase) {
final assets = data['assets'] as List;
if (Platform.isAndroid) {
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
return Pair(latestVersion, apkUrl);
} else if (Platform.isMacOS) {
final dmgUrl = assets.firstOrNullWhere(
(asset) => asset['name'].endsWith('.macos.zip'),
)['browser_download_url'];
return Pair(latestVersion, dmgUrl);
} else if (Platform.isWindows) {
final appImageUrl = assets.firstOrNullWhere(
(asset) => asset['name'].endsWith('.windows.zip'),
)['browser_download_url'];
return Pair(latestVersion, appImageUrl);
}
}
}
return null;
}
@override
void initState() {
@@ -66,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();
});
@@ -85,30 +53,19 @@ class _AppTitleState extends State<AppTitle> {
if (updater.isAvailable) {
final updateStatus = await updater.checkForUpdate();
if (updateStatus == UpdateStatus.outdated) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('New version available'),
duration: Duration(seconds: 1337),
action: SnackBarAction(
label: 'Update',
onPressed: () {
updater
.update()
.then((value) {
_showShorebirdRestartSnackbar();
})
.catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update: $e'),
duration: Duration(seconds: 5),
),
);
});
},
),
),
);
updater
.update()
.then((value) {
_showShorebirdRestartSnackbar();
})
.catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to update: $e'),
duration: Duration(seconds: 5),
),
);
});
} else if (updateStatus == UpdateStatus.restartRequired) {
_showShorebirdRestartSnackbar();
}
@@ -169,10 +126,14 @@ class _AppTitleState extends State<AppTitle> {
_compareVersion(versionString);
}
} else if (Platform.isWindows) {
final updatePair = await _getLatestVersionUrlIfNewer();
if (updatePair != null && mounted && !kDebugMode) {
_showUpdateSnackbar(updatePair.first, updatePair.second);
}
final url = Uri.parse(
'https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/WINDOWS_STORE_VERSION.txt',
);
final res = await http.get(url, headers: {'User-Agent': 'Mozilla/5.0'});
if (res.statusCode != 200) return null;
final body = res.body.trim();
_compareVersion(body);
}
}
@@ -182,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
@@ -196,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 {
@@ -216,12 +177,14 @@ 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');
} else if (Platform.isIOS || Platform.isMacOS) {
_showUpdateSnackbar(parsed, 'https://apps.apple.com/app/id6753721284');
} else if (Platform.isWindows) {
_showUpdateSnackbar(parsed, 'ms-windows-store://pdp/?productid=9NP42GS03Z26');
}
}
}

24
lib/widgets/warning.dart Normal file
View 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,
),
);
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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"))

View File

@@ -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

View File

@@ -33,6 +33,8 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>SwiftControl requires Bluetooth to connect to your devices.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>This app connects to your trainer app on your local network.</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>

48
media_key_detector/.gitignore vendored Normal file
View 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

View 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

View File

@@ -0,0 +1,7 @@
# 0.0.2
- TBD
# 0.0.1
- Initial Release

View 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.

View 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

View File

@@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.5.1.0.yaml

View 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

View 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

View 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'

View File

@@ -0,0 +1,3 @@
# media_key_detector_example
Demonstrates how to use the media_key_detector plugin.

View File

@@ -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

View 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'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1 @@
flutter/ephemeral

Some files were not shown because too many files have changed in this diff Show More