mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
526 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea4fa82a7 | ||
|
|
0d78ca6352 | ||
|
|
b82ad80d1c | ||
|
|
eece2ce7dc | ||
|
|
3bfd7dd7ab | ||
|
|
b0f3dffc3c | ||
|
|
f5234d0c11 | ||
|
|
8a12ccb01e | ||
|
|
fac2e86240 | ||
|
|
39b49bb9de | ||
|
|
406ccfa2ce | ||
|
|
16e6b96cc7 | ||
|
|
1e37c8a742 | ||
|
|
5f0389b36f | ||
|
|
8762d85d57 | ||
|
|
018bbd43f1 | ||
|
|
756e3fc556 | ||
|
|
7ac5f5dbcb | ||
|
|
65c613fd24 | ||
|
|
e0ad9007a4 | ||
|
|
c3cda5f547 | ||
|
|
12f379c03d | ||
|
|
e760c34ede | ||
|
|
187a15a55d | ||
|
|
3e4751a6b5 | ||
|
|
b05d87196a | ||
|
|
b8297848f6 | ||
|
|
de0f004a48 | ||
|
|
0bc9c1d4d2 | ||
|
|
3fccb59544 | ||
|
|
ea76aabf91 | ||
|
|
dc1c900bd2 | ||
|
|
b1e59d8f2a | ||
|
|
006148dbd0 | ||
|
|
974ba258f5 | ||
|
|
29d833e9d1 | ||
|
|
eea72405bb | ||
|
|
e500f1ed0b | ||
|
|
6c3bd2e6a1 | ||
|
|
e54d8968e8 | ||
|
|
9dca2e989a | ||
|
|
610c5d6ef5 | ||
|
|
7797b34852 | ||
|
|
99e9f326f7 | ||
|
|
0f2d73239b | ||
|
|
497b489ea9 | ||
|
|
51581f106a | ||
|
|
43e9aa02e0 | ||
|
|
089a41cc2b | ||
|
|
279ab101cc | ||
|
|
c0f278652e | ||
|
|
ac02cc78bc | ||
|
|
d42ac3af6b | ||
|
|
20cfe76091 | ||
|
|
b68170b489 | ||
|
|
21273fa9ca | ||
|
|
700afb1050 | ||
|
|
d943544e56 | ||
|
|
e994eb01dd | ||
|
|
59d953bbc4 | ||
|
|
12ecbf80e1 | ||
|
|
a5b76b43bf | ||
|
|
383055bfe2 | ||
|
|
24212e8e4c | ||
|
|
1513a53dd4 | ||
|
|
fe9dd29964 | ||
|
|
eece2bcc0f | ||
|
|
3ca63c0523 | ||
|
|
b46982918d | ||
|
|
13b075a1f3 | ||
|
|
f71a417ac5 | ||
|
|
27065f2906 | ||
|
|
b5d938fd47 | ||
|
|
46300fc0d4 | ||
|
|
93882b8b36 | ||
|
|
f19c6b8dd0 | ||
|
|
968e2c5928 | ||
|
|
44599b2d33 | ||
|
|
613f75fd25 | ||
|
|
c09ab5482e | ||
|
|
6f68e6cb62 | ||
|
|
43ac412efd | ||
|
|
7149c98564 | ||
|
|
0f4e46a758 | ||
|
|
23fb927cd6 | ||
|
|
d055a260ab | ||
|
|
d55fa5f7c0 | ||
|
|
5cc49bd246 | ||
|
|
c3c49decd1 | ||
|
|
127c997ea1 | ||
|
|
724d52ba10 | ||
|
|
5cee0fbf55 | ||
|
|
e44760d0e3 | ||
|
|
29be3c4411 | ||
|
|
0eba068910 | ||
|
|
04dee3f14c | ||
|
|
f4fd658c36 | ||
|
|
0e80d5612c | ||
|
|
9302ebc667 | ||
|
|
2265866f58 | ||
|
|
8ec6ee5ef0 | ||
|
|
a03d250bdb | ||
|
|
a0ebac41ea | ||
|
|
b4b3f5db67 | ||
|
|
b291f59e10 | ||
|
|
02de453952 | ||
|
|
4c53f6e408 | ||
|
|
4f4d67cccc | ||
|
|
ef056f0503 | ||
|
|
a6f5755b42 | ||
|
|
5ce6e37973 | ||
|
|
ebebd7ad8b | ||
|
|
f6c47e3dab | ||
|
|
de711e12dc | ||
|
|
b94fed2f21 | ||
|
|
9316881048 | ||
|
|
c60a990938 | ||
|
|
e9aaa96185 | ||
|
|
cb497daee4 | ||
|
|
4881fe4778 | ||
|
|
5d5d8ffb18 | ||
|
|
b707812a7e | ||
|
|
9de50dc6fc | ||
|
|
11d308b53b | ||
|
|
0e03ec4a03 | ||
|
|
68a04fad96 | ||
|
|
ce75fd0f34 | ||
|
|
d46b71b2d0 | ||
|
|
6492afc46f | ||
|
|
c9b068e1b3 | ||
|
|
5cdf15a419 | ||
|
|
24db720927 | ||
|
|
94754d3d9b | ||
|
|
bffdae1a9b | ||
|
|
a8b68c2d89 | ||
|
|
84f70f13d8 | ||
|
|
ef1048ec08 | ||
|
|
37bc2110f5 | ||
|
|
84fd828d36 | ||
|
|
a51b4d7958 | ||
|
|
117467d708 | ||
|
|
789509f9cf | ||
|
|
a323dc213d | ||
|
|
0ec998a618 | ||
|
|
0f5e9d59a8 | ||
|
|
2280fda916 | ||
|
|
8d4db788a3 | ||
|
|
c2bfc472fe | ||
|
|
09ffd258b7 | ||
|
|
4ae92ca557 | ||
|
|
e066054681 | ||
|
|
a0f4aadd37 | ||
|
|
3566dbc37c | ||
|
|
73a23e06ba | ||
|
|
a03576d415 | ||
|
|
079db14127 | ||
|
|
2f4764a01f | ||
|
|
2671a9807b | ||
|
|
da46deb495 | ||
|
|
0700bd331f | ||
|
|
14676d9277 | ||
|
|
3f9c3611ec | ||
|
|
26263f4d6b | ||
|
|
105a644599 | ||
|
|
ba54234734 | ||
|
|
d39af2c8ff | ||
|
|
eb07b78cce | ||
|
|
f9a13a90a8 | ||
|
|
6302091c54 | ||
|
|
1647fe9818 | ||
|
|
3ece6cb4db | ||
|
|
b04732cc24 | ||
|
|
828819907b | ||
|
|
c8c449d2ef | ||
|
|
6b5c202e93 | ||
|
|
56c67ae9a5 | ||
|
|
94d9467bc3 | ||
|
|
fb65616cee | ||
|
|
7e488b3cb1 | ||
|
|
9c6084397e | ||
|
|
73a0fe6203 | ||
|
|
cac1872459 | ||
|
|
65f4ca6356 | ||
|
|
d771b3da57 | ||
|
|
d52a062e0c | ||
|
|
ef350398e1 | ||
|
|
69fa8834ee | ||
|
|
ac9bbd3986 | ||
|
|
6f99f0762a | ||
|
|
999fc3faba | ||
|
|
ebd33666fc | ||
|
|
8537c5b8c3 | ||
|
|
3ff59eff6e | ||
|
|
46752de0e8 | ||
|
|
27629db924 | ||
|
|
1f00fd9d6c | ||
|
|
637bf87dad | ||
|
|
41415432db | ||
|
|
745d26dade | ||
|
|
134de0fcd9 | ||
|
|
ee0c4c083f | ||
|
|
5873ce34e4 | ||
|
|
9904999dd2 | ||
|
|
f5a35ad04b | ||
|
|
7301734b82 | ||
|
|
19ada28cf3 | ||
|
|
7655b35b77 | ||
|
|
0c200eae20 | ||
|
|
18d6c9ec1f | ||
|
|
cc16a2c8de | ||
|
|
91a6502310 | ||
|
|
bb8720247e | ||
|
|
b01470339b | ||
|
|
855fe00346 | ||
|
|
f957258a2c | ||
|
|
945a247274 | ||
|
|
59ecfe5186 | ||
|
|
dc5e9939c7 | ||
|
|
79821783e2 | ||
|
|
531e53eaec | ||
|
|
4f01f23458 | ||
|
|
3760b84fb7 | ||
|
|
afdb442597 | ||
|
|
eadcd17bbb | ||
|
|
67ca63b047 | ||
|
|
17d3450bd2 | ||
|
|
d7c700a4e5 | ||
|
|
fd659e40fe | ||
|
|
bdd7c75ebd | ||
|
|
ca5648fec4 | ||
|
|
978325ceff | ||
|
|
87ad9c630d | ||
|
|
6b0ca1b6dd | ||
|
|
3c9a505f4f | ||
|
|
7bb4ace875 | ||
|
|
499cd86955 | ||
|
|
79bad50ce3 | ||
|
|
2dff9c92fd | ||
|
|
d05557819e | ||
|
|
22a39406ff | ||
|
|
91d0fd656e | ||
|
|
4440a04ab8 | ||
|
|
5bf617d1db | ||
|
|
3cf209edd9 | ||
|
|
ad95ce23ec | ||
|
|
9e29d5dd04 | ||
|
|
4678ac0255 | ||
|
|
8660f979b9 | ||
|
|
c20265ddf8 | ||
|
|
2b90606d9c | ||
|
|
c75fd8ab80 | ||
|
|
fc13d71960 | ||
|
|
354179de30 | ||
|
|
e4ea924b1a | ||
|
|
f9b5dda123 | ||
|
|
4cd38d4502 | ||
|
|
70c9a5b6a3 | ||
|
|
4fb46de074 | ||
|
|
b0487f534c | ||
|
|
719eb9b50f | ||
|
|
f3148405d7 | ||
|
|
6da13f18af | ||
|
|
b3b6f510d8 | ||
|
|
01400ab955 | ||
|
|
8348848ef8 | ||
|
|
04920e7eee | ||
|
|
464c5208dc | ||
|
|
981254875b | ||
|
|
d941135fff | ||
|
|
28663f5cdf | ||
|
|
bfc12711ad | ||
|
|
076e729e39 | ||
|
|
570f5ca82d | ||
|
|
9749a9018d | ||
|
|
4724f55e6b | ||
|
|
8f68636bfc | ||
|
|
fefcb1db53 | ||
|
|
98fd5c5d7c | ||
|
|
e97a76e488 | ||
|
|
02f25899e9 | ||
|
|
3a0ef5110d | ||
|
|
36c5df7d03 | ||
|
|
4252ed05ae | ||
|
|
2adf3026fd | ||
|
|
2cc705c556 | ||
|
|
faff8ab219 | ||
|
|
e63e647ebc | ||
|
|
e2339321bb | ||
|
|
f7c57b1f15 | ||
|
|
a1fad509cf | ||
|
|
daec33f827 | ||
|
|
cf66845a38 | ||
|
|
7199f5b545 | ||
|
|
0490956551 | ||
|
|
8be2108cdc | ||
|
|
4595e509c9 | ||
|
|
17d7953d11 | ||
|
|
4adfe0812d | ||
|
|
7ca9c8752b | ||
|
|
ab53d23404 | ||
|
|
9894271145 | ||
|
|
5d9960156c | ||
|
|
aa6782d29b | ||
|
|
98d683a6a5 | ||
|
|
55a2e4db79 | ||
|
|
4db985e2e5 | ||
|
|
bbd95beb36 | ||
|
|
a9ee0dc9a1 | ||
|
|
46d3770a28 | ||
|
|
99ee63ce1f | ||
|
|
cb10ad685e | ||
|
|
748a21fb54 | ||
|
|
b3ffe867c6 | ||
|
|
fb1ffec37d | ||
|
|
9ea4f7157a | ||
|
|
a9b43bd347 | ||
|
|
b9ac193e77 | ||
|
|
1d4947b3ae | ||
|
|
74e098e9b1 | ||
|
|
e5dae225f1 | ||
|
|
0339089972 | ||
|
|
1e8bd61264 | ||
|
|
79613bc8de | ||
|
|
d0ec785e32 | ||
|
|
020b91fd21 | ||
|
|
f2406152fd | ||
|
|
ab3ef7be53 | ||
|
|
bb7484ff2e | ||
|
|
80061fd076 | ||
|
|
124e005fb1 | ||
|
|
8e760ef202 | ||
|
|
de740f6453 | ||
|
|
3bde90ae62 | ||
|
|
aee8dc2e07 | ||
|
|
b3952542f8 | ||
|
|
910f23a3f6 | ||
|
|
5cc9ac85af | ||
|
|
e10e22d038 | ||
|
|
aaeaec36a2 | ||
|
|
c16a593f3c | ||
|
|
50d9f47576 | ||
|
|
a53fb578ef | ||
|
|
1f89859a03 | ||
|
|
7c1cee6748 | ||
|
|
a4949ad615 | ||
|
|
a57f4654b0 | ||
|
|
8192d3addf | ||
|
|
308f461ad4 | ||
|
|
513b2ba367 | ||
|
|
69f47fa984 | ||
|
|
4daf553514 | ||
|
|
aeae148e0b | ||
|
|
1a9a265671 | ||
|
|
b06e9ad4b3 | ||
|
|
7ecce43a3d | ||
|
|
82cb41c207 | ||
|
|
c356242a8f | ||
|
|
61ad7c2eef | ||
|
|
311104ddf0 | ||
|
|
2b106fd1c9 | ||
|
|
1784e008ee | ||
|
|
33ccdbd7af | ||
|
|
d15f1ddc13 | ||
|
|
e7ea01cd60 | ||
|
|
f7ed426441 | ||
|
|
d30485b82e | ||
|
|
4e646ab922 | ||
|
|
4183ede58d | ||
|
|
dd85e99e4b | ||
|
|
2334a88452 | ||
|
|
ee64b18f75 | ||
|
|
647dac9e7c | ||
|
|
df2496eb67 | ||
|
|
859424b895 | ||
|
|
dde3f38bde | ||
|
|
01744c258e | ||
|
|
231aadbc27 | ||
|
|
a806a628bd | ||
|
|
c529fee1fa | ||
|
|
c36a0252e6 | ||
|
|
66486ec38e | ||
|
|
6f5c6bf1d9 | ||
|
|
8fc8f2dfda | ||
|
|
d36e031e87 | ||
|
|
efac0af4b9 | ||
|
|
d7e73524ad | ||
|
|
80998c955f | ||
|
|
d824cb6207 | ||
|
|
ab80d679e1 | ||
|
|
d4881faab1 | ||
|
|
7c74d61b43 | ||
|
|
8ad2906a17 | ||
|
|
0f4d19080a | ||
|
|
a9a13be6ca | ||
|
|
c66badf39e | ||
|
|
6c2fc54612 | ||
|
|
807c0eaa98 | ||
|
|
7d7b1e89e9 | ||
|
|
cafb7408d9 | ||
|
|
723f741bca | ||
|
|
6a3cc0f8be | ||
|
|
66c548fa75 | ||
|
|
0b42f7e9c5 | ||
|
|
35a995eddc | ||
|
|
c3afb23625 | ||
|
|
f15d97585b | ||
|
|
5f03c072ff | ||
|
|
ce94aea51a | ||
|
|
a27ae070fc | ||
|
|
7bbdc6a4e2 | ||
|
|
3188002ecb | ||
|
|
284d2ca70f | ||
|
|
57961aec5d | ||
|
|
1675d7f2d0 | ||
|
|
baec8d24c3 | ||
|
|
820d0b37db | ||
|
|
c18ac16208 | ||
|
|
2bbc09bf13 | ||
|
|
a968723277 | ||
|
|
8668957738 | ||
|
|
4498729e75 | ||
|
|
ac550fad5b | ||
|
|
c511ac32b6 | ||
|
|
ee48ce0f4e | ||
|
|
8a3d64491b | ||
|
|
b72cc803f0 | ||
|
|
69dd5c85ef | ||
|
|
ea17b2e142 | ||
|
|
da62fc4dc6 | ||
|
|
239630f681 | ||
|
|
d95d0cf8cf | ||
|
|
2b25ba942c | ||
|
|
c65369a746 | ||
|
|
fa7d5e7853 | ||
|
|
8ac47cbd4d | ||
|
|
eb85844503 | ||
|
|
010d0ed331 | ||
|
|
1f8f7765a3 | ||
|
|
68f416dda3 | ||
|
|
49e45faec0 | ||
|
|
c81516350a | ||
|
|
890f393fd6 | ||
|
|
e46969c5c4 | ||
|
|
1ec9b55645 | ||
|
|
b0caf7c13b | ||
|
|
302fc15dd7 | ||
|
|
6a2cf1a1c9 | ||
|
|
8ea73bc54a | ||
|
|
7cbab3925f | ||
|
|
246a1bd2be | ||
|
|
f7e2a89ed6 | ||
|
|
f94252edb9 | ||
|
|
b7b6b9803f | ||
|
|
807d868b74 | ||
|
|
c3e8c4666c | ||
|
|
926651ebb3 | ||
|
|
a7d5624582 | ||
|
|
03209740ec | ||
|
|
af6ae3433e | ||
|
|
41f4dd1d57 | ||
|
|
d5c1b67675 | ||
|
|
0b18d74ac9 | ||
|
|
fb3fe5f8c0 | ||
|
|
796c973fd4 | ||
|
|
7c6335c4d1 | ||
|
|
af2267c486 | ||
|
|
56d9e62610 | ||
|
|
7e18a169d4 | ||
|
|
74280eda34 | ||
|
|
e1309d4d95 | ||
|
|
14aa6f7454 | ||
|
|
1368d7d24e | ||
|
|
8c09b170c3 | ||
|
|
080409b984 | ||
|
|
f0ec276547 | ||
|
|
23aafcd7bc | ||
|
|
3718a126ac | ||
|
|
846dd07bf4 | ||
|
|
ba60062a24 | ||
|
|
ed4f928fde | ||
|
|
2a09d550e5 | ||
|
|
bb1ae4e616 | ||
|
|
828aa70a56 | ||
|
|
4021f3131d | ||
|
|
80ef81ca64 | ||
|
|
fec13d012b | ||
|
|
e8ca3fc287 | ||
|
|
d5260d801c | ||
|
|
916b1ec1fc | ||
|
|
7380bb5001 | ||
|
|
2e95fb556a | ||
|
|
90591cbfa2 | ||
|
|
929409db71 | ||
|
|
4263375fb2 | ||
|
|
bb5d149ba4 | ||
|
|
1a322dc0d3 | ||
|
|
d10da94f20 | ||
|
|
7eb28881cb | ||
|
|
823e04d189 | ||
|
|
ca5d4aeadb | ||
|
|
a4d937c4f3 | ||
|
|
fa4add6797 | ||
|
|
ec2ed4e6c5 | ||
|
|
6bd41d9a54 | ||
|
|
1ff2a205bc | ||
|
|
dd73c3249b | ||
|
|
75eef49317 | ||
|
|
e8858e0c7d | ||
|
|
df9142a6bf | ||
|
|
36f312403b | ||
|
|
d8983889ae | ||
|
|
bfaf2f2d29 | ||
|
|
2ba9c284ba | ||
|
|
ef2b4af28a | ||
|
|
ba042cd07d | ||
|
|
f8cb4cff4f | ||
|
|
92010b787b | ||
|
|
e142a8c587 | ||
|
|
759dcaa8b8 | ||
|
|
05939dcf1e | ||
|
|
34494819f5 | ||
|
|
a9491b7fa5 | ||
|
|
311a676aea | ||
|
|
f08714f25a | ||
|
|
1f3352ff80 | ||
|
|
2601844970 |
112
.github/workflows/build.yml
vendored
112
.github/workflows/build.yml
vendored
@@ -31,12 +31,12 @@ on:
|
||||
build_web:
|
||||
description: 'Build for Web'
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.35.5
|
||||
FLUTTER_VERSION: 3.38.4
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -102,12 +102,24 @@ jobs:
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: 🚀 Shorebird Release macOS
|
||||
if: inputs.build_mac
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: macos
|
||||
args: "--dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
|
||||
|
||||
- name: Decode Keystore
|
||||
if: inputs.build_android
|
||||
@@ -123,13 +135,6 @@ jobs:
|
||||
platform: android
|
||||
args: "--artifact=apk"
|
||||
|
||||
- name: Set Up Flutter
|
||||
if: inputs.build_web
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Build Web
|
||||
if: inputs.build_web
|
||||
run: flutter build web --release --base-href "/swiftcontrol/"
|
||||
@@ -152,13 +157,19 @@ jobs:
|
||||
mkdir -p whatsnew
|
||||
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
|
||||
|
||||
- name: Generate release body
|
||||
if: inputs.build_github
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
- name: 🚀 Shorebird Release iOS
|
||||
if: inputs.build_ios
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: ios
|
||||
args: "--export-options-plist ios/ExportOptions.plist"
|
||||
args: "--export-options-plist ios/ExportOptions.plist --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
|
||||
|
||||
- name: Prepare App Store authentication key
|
||||
if: inputs.build_ios || inputs.build_mac
|
||||
@@ -176,7 +187,7 @@ jobs:
|
||||
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
packageName: de.jonasbark.swiftcontrol
|
||||
releaseFiles: build/app/outputs/bundle/release/app-release.aab
|
||||
track: production
|
||||
track: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }}
|
||||
whatsNewDirectory: whatsnew
|
||||
|
||||
- name: Upload to macOS App Store
|
||||
@@ -185,8 +196,8 @@ jobs:
|
||||
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
run: |
|
||||
productbuild --component "build/macos/Build/Products/Release/SwiftControl.app" /Applications "SwiftControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
|
||||
xcrun altool --upload-app -f SwiftControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
productbuild --component "build/macos/Build/Products/Release/BikeControl.app" /Applications "BikeControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
|
||||
xcrun altool --upload-app -f BikeControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
|
||||
- name: Upload to iOS App Store
|
||||
if: inputs.build_ios
|
||||
@@ -197,40 +208,40 @@ jobs:
|
||||
xcrun altool --upload-app -f build/ios/ipa/swift_play.ipa -t ios --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
|
||||
- name: Handle Android archives
|
||||
if: inputs.build_android && inputs.build_github
|
||||
if: inputs.build_android && inputs.build_github && false
|
||||
run: |
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/BikeControl.android.apk
|
||||
|
||||
- name: Code Signing of macOS app
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v
|
||||
if: inputs.build_mac && inputs.build_github && false
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime BikeControl.app -v
|
||||
working-directory: build/macos/Build/Products/Release
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
|
||||
- name: Handle macOS archives
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
if: inputs.build_mac && inputs.build_github && false
|
||||
run: |
|
||||
cd build/macos/Build/Products/Release/
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/
|
||||
zip -r BikeControl.macos.zip BikeControl.app/
|
||||
|
||||
- name: Upload Android Artifacts
|
||||
if: inputs.build_android && inputs.build_github
|
||||
if: inputs.build_android && inputs.build_github && false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
build/app/outputs/flutter-apk/BikeControl.android.apk
|
||||
|
||||
- name: Upload macOS Artifacts
|
||||
if: inputs.build_mac && inputs.build_github
|
||||
if: inputs.build_mac && inputs.build_github && false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
build/macos/Build/Products/Release/BikeControl.macos.zip
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
@@ -245,29 +256,47 @@ jobs:
|
||||
if: inputs.build_github
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
artifacts: "build/app/outputs/flutter-apk/BikeControl.android.apk,build/macos/Build/Products/Release/BikeControl.macos.zip"
|
||||
allowUpdates: true
|
||||
prerelease: true
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
bodyFile: /tmp/release_body.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
windows:
|
||||
needs: build
|
||||
if: inputs.build_windows
|
||||
name: Build & Release on Windows
|
||||
runs-on: windows-latest
|
||||
runs-on: windows-2025
|
||||
|
||||
steps:
|
||||
#1 Checkout Repository
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
|
||||
($_ -split ' ')[1].Trim()
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: 🚀 Shorebird Release Windows
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
@@ -296,7 +325,7 @@ jobs:
|
||||
Write-Warning "$dll not found in $source"
|
||||
}
|
||||
}
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/SwiftControl.windows.zip"
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/bike_control.windows.zip"
|
||||
|
||||
- uses: microsoft/setup-msstore-cli@v1
|
||||
if: false
|
||||
@@ -305,12 +334,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -318,35 +341,20 @@ jobs:
|
||||
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:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.zip
|
||||
build/windows/x64/runner/Release/SwiftControl.windows.msix
|
||||
|
||||
- name: Extract version from pubspec.yaml (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
|
||||
($_ -split ' ')[1].Trim()
|
||||
}
|
||||
echo "VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
build/windows/x64/runner/Release/bike_control.windows.zip
|
||||
build/windows/x64/runner/Release/bike_control.msix
|
||||
|
||||
- name: Update Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip,build/windows/x64/runner/Release/SwiftControl.windows.msix"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
artifacts: "build/windows/x64/runner/Release/bike_control.msix"
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
59
.github/workflows/patch.yml
vendored
59
.github/workflows/patch.yml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.35.5
|
||||
FLUTTER_VERSION: 3.38.5
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -27,6 +27,17 @@ jobs:
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: Install certificates
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64_MAC }}
|
||||
@@ -75,6 +86,7 @@ jobs:
|
||||
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
|
||||
|
||||
- name: 🚀 Shorebird Patch macOS
|
||||
if: false # patch doesn't work: https://github.com/jonasbark/swiftcontrol/issues/143
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: macos
|
||||
@@ -95,42 +107,47 @@ jobs:
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs --allow-native-diffs'
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
# shorebird struggles with the app from GitHub
|
||||
- name: Build macOS
|
||||
if: false
|
||||
run: flutter build macos --release;
|
||||
|
||||
- name: Sign macOS build
|
||||
if: false
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r');
|
||||
echo "VERSION=$version" >> $GITHUB_ENV;
|
||||
cd build/macos/Build/Products/Release/;
|
||||
/usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v;
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/;
|
||||
/usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime BikeControl.app -v;
|
||||
zip -r BikeControl.macos.zip BikeControl.app/;
|
||||
|
||||
#9 Upload Artifacts
|
||||
- name: Upload Artifacts
|
||||
if: false
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
build/macos/Build/Products/Release/BikeControl.macos.zip
|
||||
|
||||
- name: Generate release body
|
||||
if: false
|
||||
run: |
|
||||
chmod +x scripts/generate_release_body.sh
|
||||
./scripts/generate_release_body.sh > /tmp/release_body.md
|
||||
|
||||
# add artifact to release
|
||||
- name: Create Release
|
||||
if: false
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
artifacts: "build/macos/Build/Products/Release/BikeControl.macos.zip"
|
||||
bodyFile: /tmp/release_body.md
|
||||
prerelease: true
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
@@ -143,18 +160,22 @@ 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:
|
||||
cache: true
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Generate translation files
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
- name: 🚀 Shorebird Patch Windows
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
|
||||
6
.github/workflows/web.yml
vendored
6
.github/workflows/web.yml
vendored
@@ -34,6 +34,12 @@ jobs:
|
||||
with:
|
||||
channel: 'stable'
|
||||
|
||||
- name: Generate translation files
|
||||
|
||||
run: |
|
||||
flutter pub global activate intl_utils;
|
||||
flutter pub global run intl_utils:generate;
|
||||
|
||||
#4 Install Dependencies
|
||||
- name: Install Dependencies
|
||||
run: flutter pub get
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -10,6 +10,7 @@
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
debug/
|
||||
migrate_working_dir/
|
||||
|
||||
android/keystore.properties
|
||||
@@ -40,10 +41,15 @@ app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
localazy.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
lib/gen/
|
||||
|
||||
service-account.json
|
||||
.env
|
||||
lib/generated
|
||||
|
||||
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -5,7 +5,8 @@
|
||||
{
|
||||
"name": "swiftcontrol",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
"type": "dart",
|
||||
"program": "lib/main.dart"
|
||||
},
|
||||
{
|
||||
"name": "swiftcontrol (profile mode)",
|
||||
|
||||
107
CHANGELOG.md
107
CHANGELOG.md
@@ -1,7 +1,92 @@
|
||||
### 4.2.0 (20-12-2025)
|
||||
|
||||
BikeControl now offers a free trial period of 5 days for all features, so you can test everything before deciding to purchase a license. Please contact the support if you experience any issues!
|
||||
|
||||
**Features**:
|
||||
- support for SRAM AXS/eTap
|
||||
- only single or double click is supported (no individual button mapping possible, yet)
|
||||
- use your phone/tablet for steering by attaching your device on your handlebar!
|
||||
- App is now available in Polish (thanks to Wandrocet)
|
||||
|
||||
**Fixes**:
|
||||
- You will now be notified when a connection to your controller is lost
|
||||
- improved UI of the Keymap customization screen
|
||||
|
||||
### 4.1.0 (16-12-2025)
|
||||
|
||||
**Features**:
|
||||
- control your trainer manually without requiring a controller - just like a Companion app
|
||||
- support for Wahoo KICKR HEADWIND: control the fan via your controller
|
||||
|
||||
**Fixes**:
|
||||
- Gamepads: handle analog values correctly on Windows
|
||||
- MyWhoosh: updated default keymap to use the new A+D keys for steering
|
||||
|
||||
### 4.0.0 (07-12-2025)
|
||||
|
||||
- a brand-new design
|
||||
- Accessibility Permission is now optional on Android
|
||||
- Zwift is now fully supported on all operating systems
|
||||
- you can choose between network based control or bluetooth based control
|
||||
- MyWhoosh can now also be controlled with BikeControl running on the same iPad / iPhone
|
||||
- Translations available in German and French
|
||||
- support for Wahoo KICKR BIKE PRO
|
||||
- support for the OpenBikeControl protocol for supported Trainer apps
|
||||
- this enables seamless and official integration, independent of the operating system
|
||||
- learn more at https://openbikecontrol.org
|
||||
|
||||
### 3.6.0 (23-11-2025)
|
||||
|
||||
SwiftControl is now called BikeControl!
|
||||
|
||||
**Features:**
|
||||
- show a list of predefined keymaps for the selected trainer app when using a custom keymap
|
||||
- status icons so it's clear what's missing
|
||||
|
||||
**Fixes:**
|
||||
- Update Rouvy keymap to support virtual shifting in their latest version
|
||||
|
||||
### 3.5.0 (16-11-2025)
|
||||
**New Features:**
|
||||
- Dark mode support
|
||||
- Cycplus BC2 support (thanks @schneewoehner)
|
||||
- Ignored devices now persist across app restarts - remove them from ignored devices via the menu
|
||||
|
||||
**Fixes:**
|
||||
- resolve issues during app start
|
||||
|
||||
### 3.4.0 (08-11-2025)
|
||||
**New Features:**
|
||||
- Support for Shimano Di2
|
||||
- Support Keyboard shortcuts with modifier keys (Ctrl, Alt, Shift, ...)
|
||||
- Support cheap BLE HID remotes
|
||||
- add Keymap for Rouvy, supporting the new keyboard shortcuts for virtual shifting
|
||||
|
||||
**Fixes:**
|
||||
- fix detection of Elite Square Sterzo devices
|
||||
- recognize cheap Bluetooth device clicks also when BikeControl is in the background
|
||||
|
||||
### 3.3.0 (31-10-2025)
|
||||
|
||||
**New 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
|
||||
- BikeControl can now stay in the background
|
||||
- more devices can be controlled
|
||||
- do more, such as define Emotes, Camera angles and steering
|
||||
|
||||
@@ -11,16 +96,16 @@
|
||||
- support for Wahook Kickr Bike Shift (thanks @MattW2)
|
||||
- initial support for Elite Square Smart Frame
|
||||
- reconnects to your device automatically when connection is lost
|
||||
- SwiftControl now warns you if your device firmware is outdated
|
||||
- SwiftControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
|
||||
- BikeControl now warns you if your device firmware is outdated
|
||||
- BikeControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
|
||||
|
||||
### 3.0.3 (2025-10-12)
|
||||
- SwiftControl now supports iOS!
|
||||
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations but...:
|
||||
- You can now use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Click devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
- BikeControl now supports iOS!
|
||||
- Note that you can't run BikeControl and your trainer app on the same iPhone due to iOS limitations but...:
|
||||
- You can now use BikeControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs BikeControl and connects to your Click devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have BikeControl installed)
|
||||
- after pairing BikeControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
- Ride: analog paddles are now supported thanks to contributor @jmoro
|
||||
- you can now zoom in and out in the Keymap customization screen
|
||||
|
||||
@@ -40,8 +125,8 @@
|
||||
|
||||
### 2.5.0 (2025-09-25)
|
||||
- Improve usability
|
||||
- SwiftControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
|
||||
- SwiftControl will continue to be available to download for free on GitHub
|
||||
- BikeControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
|
||||
- BikeControl will continue to be available to download for free on GitHub
|
||||
- contact me if you already donated and I'll get a voucher for you :)
|
||||
|
||||
### 2.4.0+1 (2025-09-17)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Instructions will be added soon
|
||||
@@ -1 +1 @@
|
||||
Instructions will be added soon
|
||||
Moved to [INSTRUCTIONS_MYWHOOSH_LINK.md](INSTRUCTIONS_MYWHOOSH_LINK.md)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Instructions will be added soon
|
||||
31
INSTRUCTIONS_MYWHOOSH_LINK.md
Normal file
31
INSTRUCTIONS_MYWHOOSH_LINK.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## Instructions for using the MyWhoosh "Link" connection method
|
||||
*
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) launch MyWhoosh Link, check if the "Link" connection works
|
||||
3) **close MyWhoosh Link** - very important!
|
||||
4) open BikeControl, follow on screen instructions
|
||||
|
||||
Once you've confirmed the connection in BikeControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
|
||||
|
||||
And here's a video with a few explanations:
|
||||
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
|
||||
## The MyWhoosh Link app itself works fine, but BikeControl doesn't connect
|
||||
*
|
||||
The MyWhoosh Link app must not run simultaneously with BikeControl. Make sure the MyWhoosh Link app is fully closed, then reopen BikeControl and try connecting again.
|
||||
|
||||
## MyWhoosh "Link" method never connects
|
||||
*
|
||||
The same network restrictions apply for BikeControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if a connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
|
||||
In essence:
|
||||
- your two devices (phone, tablet) need to be on the same WiFi network
|
||||
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
|
||||
- Limit IP Address Tracking may need to be disabled
|
||||
- mesh networks may not work
|
||||
13
INSTRUCTIONS_REMOTE_CONTROL.md
Normal file
13
INSTRUCTIONS_REMOTE_CONTROL.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## Remote control is not working - nothing happens
|
||||
*
|
||||
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
|
||||
- Try restarting the pairing process in BikeControl
|
||||
- try restarting Bluetooth on your phone and on the device you want to control
|
||||
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
|
||||
- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in BikeControl / restart BikeControl.
|
||||
|
||||
## Remote control only clicks on a single coordinate on my iPad
|
||||
*
|
||||
iOS seems to be buggy here - try this in the iOS settings:
|
||||
AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or BikeControl iOS) > Button 1
|
||||
switch the setting to None, then back to Single-Tap and it should work again
|
||||
4
INSTRUCTIONS_ROUVY.md
Normal file
4
INSTRUCTIONS_ROUVY.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## Local Connection method
|
||||
*
|
||||
The local connection method (avalable on Android, Windows and macOS) allows BikeControl to directly control Rouvy either using touch or keyboard keys. This way you don't need to select any "Controllers" at all in Rouvy.
|
||||
Make sure the "Virtual Shifting Controls" are enabled: https://support.rouvy.com/hc/en-us/articles/32452137189393-Virtual-Shifting#h_01K9SWGWYMAVQV108SQ9KWQAKC
|
||||
@@ -1 +0,0 @@
|
||||
Instructions will be added soon
|
||||
0
INSTRUCTIONS_ZWIFT.md
Normal file
0
INSTRUCTIONS_ZWIFT.md
Normal file
99
README.md
99
README.md
@@ -1,15 +1,15 @@
|
||||
# SwiftControl
|
||||
# BikeControl (formerly SwiftControl)
|
||||
|
||||
<img src="logo.png" alt="SwiftControl Logo"/>
|
||||
<img src="logo.png" alt="BikeControl Logo"/>
|
||||
|
||||
## Description
|
||||
|
||||
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
|
||||
With BikeControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, Shimano Di2, or other similar devices. Here's what you can do with it, depending on your configuration:
|
||||
- Virtual Gear shifting
|
||||
- Steering / turning
|
||||
- Steering / navigation
|
||||
- adjust workout intensity
|
||||
- control music on your device
|
||||
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
|
||||
- more? If you can do it via keyboard, mouse, or touch, you can do it with BikeControl
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
@@ -17,8 +17,8 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
|
||||
|
||||
## Downloads
|
||||
Check the compatibility matrix below!
|
||||
## Download
|
||||
Best follow our landing page and the "Get Started" button: [bikecontrol.app](https://bikecontrol.app/) to understand on which platform you want to run BikeControl. A testing period is available, allowing you to try out the full functionality of BikeControl:
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
|
||||
|
||||
@@ -30,61 +30,74 @@ Check the compatibility matrix below!
|
||||
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- Zwift
|
||||
- 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
|
||||
- [OpenBikeControl](https://openbikecontrol.org) compatible apps
|
||||
- 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
|
||||
- SRAM AXS/eTap
|
||||
- only single or double click is supported (no individual button mapping possible, yet)
|
||||
- Wahoo Kickr Bike Shift
|
||||
- Wahoo Kickr Bike Pro
|
||||
- CYCPLUS BC2 Virtual Shifter
|
||||
- Elite Sterzo Smart (for steering support)
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Your Phone!
|
||||
- Mount your phone on the handlebar to detect e.g. steering
|
||||
- Available on Android and iOS
|
||||
- Gamepads
|
||||
- Keyboard input
|
||||
- like a Companion App
|
||||
- some trainers do not support keyboard input for all functions - now they do!
|
||||
- useful when remapping keys from other devices using e.g. AutoHotkey
|
||||
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
|
||||
- BLE HID devices and classic Bluetooth HID devices are supported
|
||||
- works out of the box on Android
|
||||
- on Windows, iOS and macOS requires BikeControl to act as media player
|
||||
- We're working on creating an affordable alternative based on an open standard, supported by all major trainer apps
|
||||
- register your interest [here](https://openbikecontrol.org/#HARDWARE)
|
||||
|
||||
Support for other devices can be added - check the issues tab here on GithUb.
|
||||
Support for other devices can be added; check the issues tab here on GitHub.
|
||||
|
||||
## Supported Accessories
|
||||
- Wahoo KICKR HEADWIND (beta)
|
||||
- control fan speed using your controller
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
Follow this compatibility matrix. It all depends on where you want to run your trainer app (e.g. MyWhoosh on):
|
||||
Follow the "Get Started" button over at [bikecontrol.app](https://bikecontrol.app) to understand on which platform you want to run BikeControl.
|
||||
You can even try it out in your [Browser](https://jonasbark.github.io/swiftcontrol/), if it supports Bluetooth connections. No controlling possible, though.
|
||||
|
||||
| Run Trainer app (MyWhoosh, ...) on: | Possible | Link | Information |
|
||||
|-------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Android | ✅ | <a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a> | |
|
||||
| iPad | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically you would use an iPhone or an Android phone for that. |
|
||||
| Windows | ✅ | <a href="https://apps.microsoft.com/detail/9NP42GS03Z26"><img width="270" alt="Microsoft Store" src="https://github.com/user-attachments/assets/7a8a3cd6-ec26-4678-a850-732eedd27c48" /></a> | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
|
||||
| macOS | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a> | |
|
||||
| iPhone | (✅) | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you could use the Link method on another device to control MyWhoosh (and only MyWhoosh) on an iPhone. |
|
||||
| Apple TV | (✅) | | Unconfirmed: only MyWhoosh using the Link method is supported |
|
||||
|
||||
|
||||
For testing purposes you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/) but this is just a tech demo - you won't be able to control other apps.
|
||||
|
||||
## Troubleshooting
|
||||
## Help
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
|
||||
## How does it work?
|
||||
The app connects to your Controller devices (such as Zwift ones) automatically. It does not connect to your trainer itself.
|
||||
|
||||
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- if you want to use MyWhoosh you can use the Link method to directly connect to MyWhoosh
|
||||
- for other trainer apps you need to pair SwiftControl to your iPad / tablet via Bluetooth and your phone will send the button presses to your iPad / tablet
|
||||
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
|
||||
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
|
||||
- you can also create your own Keymaps for any other app
|
||||
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
|
||||
</details>
|
||||
|
||||
## 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.
|
||||
The app connects to your Controller devices (such as Zwift ones) automatically. BikeControl uses different methods of connecting to the trainer app, depending on the trainer app and operating system:
|
||||
- Connect to the trainer app on the same device or on another device using Network
|
||||
- available on Android, iOS, macOS, Windows
|
||||
- supported by e.g. MyWhoosh, Rouvy and Zwift
|
||||
- Connect to the trainer app on another device by simulating a Bluetooth device
|
||||
- available on Android, iOS, macOS, Windows
|
||||
- supported by e.g. Rouvy and Zwift
|
||||
- Directly control the trainer app via Accessibility features (simulating touch and keyboard input)
|
||||
- available on Android, macOS, Windows
|
||||
- supported by all trainer apps
|
||||
- Connect to supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
|
||||
- available on Android, iOS, macOS, Windows
|
||||
|
||||
## Donate
|
||||
Please consider donating to support the development of this app :)
|
||||
|
||||
- [via PayPal](https://paypal.me/boni)
|
||||
- [via Credit Card, Google Pay, Apple Pay, etc (USD)](https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201)
|
||||
- [via Credit Card, Google Pay, Apple Pay, etc (EUR)](https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200)
|
||||
- [via Credit Card, Google Pay, Apple Pay, etc. (USD)](https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201)
|
||||
- [via Credit Card, Google Pay, Apple Pay, etc. (EUR)](https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200)
|
||||
|
||||
@@ -1,50 +1,37 @@
|
||||
## Click device cannot be found
|
||||
## Click / Ride device cannot be found
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## Click device does not send any data
|
||||
## Click / Ride device does not send any data
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## My Click v2 disconnects after a minute
|
||||
*
|
||||
Check [this](https://github.com/jonasbark/swiftcontrol/issues/68) discussion.
|
||||
|
||||
To make your Click V2 work best you should connect it in the Zwift app once each day.
|
||||
If you don't do that SwiftControl will need to reconnect every minute.
|
||||
If you don't do that BikeControl will need to reconnect every minute.
|
||||
|
||||
1. Open Zwift app (not the Companion)
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Click V2
|
||||
4. Close the Zwift app again and connect again in SwiftControl
|
||||
4. Optional: some users report that keeping the Click connected for more than a few seconds is more reliable.
|
||||
5. Close the Zwift app again and connect again in BikeControl
|
||||
|
||||
## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
|
||||
- especially for Redmi and other chinese Android devices please follow the instructions on https://dontkillmyapp.com/:
|
||||
- disable battery optimization for SwiftControl
|
||||
- enable auto start of SwiftControl
|
||||
- grant accessibility permission for SwiftControl
|
||||
- see https://github.com/jonasbark/swiftcontrol/issues/38 for more details
|
||||
*
|
||||
- especially for Redmi and other chinese Android devices please follow the instructions on [https://dontkillmyapp.com/](https://dontkillmyapp.com/):
|
||||
- disable battery optimization for BikeControl
|
||||
- enable auto start of BikeControl
|
||||
- grant accessibility permission for BikeControl
|
||||
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
|
||||
|
||||
## Remote control is not working - nothing happens
|
||||
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
|
||||
- Try restarting the pairing process in SwiftControl
|
||||
- try restarting Bluetooth on your phone and on the device you want to control
|
||||
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
|
||||
- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in SwiftControl / restart SwiftControl.
|
||||
## BikeControl crashes on Windows when searching for the device
|
||||
*
|
||||
You're probably running into [this](https://github.com/OpenBikeControl/bikecontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
|
||||
|
||||
## Remote control only clicks on a single coordinate on my iPad
|
||||
iOS seems to be buggy here - try this in the iOS settings:
|
||||
AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or SwiftControl iOS) > Button 1
|
||||
switch the setting to None, then back to Single-Tap and it should work again
|
||||
|
||||
## SwiftControl crashes on Windows when searching for the device
|
||||
You're probably running into [this](https://github.com/jonasbark/swiftcontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
|
||||
|
||||
## Link requirement for MyWhoosh stuck at "Waiting for MyWhoosh"
|
||||
The same network restrictions apply for SwiftControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
https://mywhoosh.com/troubleshoot/
|
||||
https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
|
||||
In essence:
|
||||
- your two devices (phone, tablet) need to be on the same WiFi network
|
||||
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
|
||||
- Limit IP Address Tracking may need to be disabled
|
||||
- mesh networks may not work
|
||||
|
||||
## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control
|
||||
*
|
||||
Make sure you've enabled Virtual Shifting in MyWhoosh's settings
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.1.1
|
||||
4.1.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Autogenerated from Pigeon (v25.2.0), do not edit directly.
|
||||
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
|
||||
|
||||
@@ -12,25 +12,57 @@ import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
private object AccessibilityApiPigeonUtils {
|
||||
|
||||
private fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
private fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
fun wrapError(exception: Throwable): List<Any?> {
|
||||
return if (exception is FlutterError) {
|
||||
listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
fun deepEquals(a: Any?, b: Any?): Boolean {
|
||||
if (a is ByteArray && b is ByteArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is IntArray && b is IntArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is LongArray && b is LongArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is DoubleArray && b is DoubleArray) {
|
||||
return a.contentEquals(b)
|
||||
}
|
||||
if (a is Array<*> && b is Array<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is List<*> && b is List<*>) {
|
||||
return a.size == b.size &&
|
||||
a.indices.all{ deepEquals(a[it], b[it]) }
|
||||
}
|
||||
if (a is Map<*, *> && b is Map<*, *>) {
|
||||
return a.size == b.size && a.all {
|
||||
(b as Map<Any?, Any?>).containsKey(it.key) &&
|
||||
deepEquals(it.value, b[it.key])
|
||||
}
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,12 +125,44 @@ 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()
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class AKeyEvent (
|
||||
val source: String,
|
||||
val hidKey: String,
|
||||
val keyDown: Boolean,
|
||||
val keyUp: Boolean
|
||||
)
|
||||
{
|
||||
companion object {
|
||||
fun fromList(pigeonVar_list: List<Any?>): AKeyEvent {
|
||||
val source = pigeonVar_list[0] as String
|
||||
val hidKey = pigeonVar_list[1] as String
|
||||
val keyDown = pigeonVar_list[2] as Boolean
|
||||
val keyUp = pigeonVar_list[3] as Boolean
|
||||
return AKeyEvent(source, hidKey, keyDown, keyUp)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf(
|
||||
source,
|
||||
hidKey,
|
||||
keyDown,
|
||||
keyUp,
|
||||
)
|
||||
}
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is AKeyEvent) {
|
||||
return false
|
||||
}
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
return AccessibilityApiPigeonUtils.deepEquals(toList(), other.toList()) }
|
||||
|
||||
override fun hashCode(): Int = toList().hashCode()
|
||||
}
|
||||
@@ -115,6 +179,11 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
|
||||
WindowEvent.fromList(it)
|
||||
}
|
||||
}
|
||||
131.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
AKeyEvent.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
@@ -128,6 +197,10 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
|
||||
stream.write(130)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
is AKeyEvent -> {
|
||||
stream.write(131)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
@@ -141,6 +214,8 @@ interface Accessibility {
|
||||
fun openPermissions()
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
fun controlMedia(action: MediaAction)
|
||||
fun isRunning(): Boolean
|
||||
fun ignoreHidDevices()
|
||||
|
||||
companion object {
|
||||
/** The codec used by Accessibility. */
|
||||
@@ -158,7 +233,7 @@ interface Accessibility {
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.hasPermission())
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -174,7 +249,7 @@ interface Accessibility {
|
||||
api.openPermissions()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -195,7 +270,7 @@ interface Accessibility {
|
||||
api.performTouch(xArg, yArg, isKeyDownArg, isKeyUpArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -213,7 +288,38 @@ interface Accessibility {
|
||||
api.controlMedia(actionArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.isRunning$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
listOf(api.isRunning())
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
val wrapped: List<Any?> = try {
|
||||
api.ignoreHidDevices()
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
@@ -274,3 +380,16 @@ abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWra
|
||||
}
|
||||
}
|
||||
|
||||
abstract class HidKeyPressedStreamHandler : AccessibilityApiPigeonEventChannelWrapper<AKeyEvent> {
|
||||
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<AKeyEvent>(streamHandler)
|
||||
EventChannel(messenger, channelName, AccessibilityApiPigeonMethodCodec).setStreamHandler(internalStreamHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.jonasbark.accessibility
|
||||
|
||||
import AKeyEvent
|
||||
import Accessibility
|
||||
import HidKeyPressedStreamHandler
|
||||
import MediaAction
|
||||
import PigeonEventSink
|
||||
import StreamEventsStreamHandler
|
||||
@@ -10,6 +12,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 +26,21 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
/// when the Flutter Engine is detached from the Activity
|
||||
private lateinit var channel : MethodChannel
|
||||
private lateinit var context: Context
|
||||
private lateinit var eventHandler: EventListener
|
||||
private lateinit var windowEventHandler: WindowEventListener
|
||||
private lateinit var hidEventHandler: HidEventListener
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "accessibility")
|
||||
|
||||
eventHandler = EventListener()
|
||||
windowEventHandler = WindowEventListener()
|
||||
hidEventHandler = HidEventListener()
|
||||
|
||||
context = flutterPluginBinding.applicationContext
|
||||
Accessibility.setUp(flutterPluginBinding.binaryMessenger, this)
|
||||
StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, eventHandler)
|
||||
Observable.fromService = eventHandler
|
||||
StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, windowEventHandler)
|
||||
HidKeyPressedStreamHandler.register(flutterPluginBinding.binaryMessenger, hidEventHandler)
|
||||
Observable.fromServiceWindow = windowEventHandler
|
||||
Observable.fromServiceKeys = hidEventHandler
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -45,6 +52,10 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
return enabledServices != null && enabledServices.contains(context.packageName)
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean {
|
||||
return Observable.toService != null
|
||||
}
|
||||
|
||||
override fun openPermissions() {
|
||||
startActivity(context, Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
@@ -59,12 +70,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 +86,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 +108,34 @@ 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<AKeyEvent>? = null
|
||||
|
||||
override fun onListen(p0: Any?, sink: PigeonEventSink<AKeyEvent>) {
|
||||
keyEventSink = sink
|
||||
}
|
||||
|
||||
override fun onChange(packageName: String, window: Rect) {
|
||||
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent) {
|
||||
val keyString = KeyEvent.keyCodeToString(event.keyCode)
|
||||
keyEventSink?.success(
|
||||
AKeyEvent(
|
||||
hidKey = keyString,
|
||||
source = event.device.name,
|
||||
keyUp = event.action == KeyEvent.ACTION_UP,
|
||||
keyDown = event.action == KeyEvent.ACTION_DOWN
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ package de.jonasbark.accessibility
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.accessibilityservice.GestureDescription.StrokeDescription
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.Context
|
||||
import android.graphics.Path
|
||||
import android.graphics.Rect
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
|
||||
@@ -37,7 +42,7 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
val currentPackageName = event.packageName.toString()
|
||||
val windowSize = getWindowSize()
|
||||
Observable.fromService?.onChange(packageName = currentPackageName, window = windowSize)
|
||||
Observable.fromServiceWindow?.onChange(packageName = currentPackageName, window = windowSize)
|
||||
}
|
||||
|
||||
private fun getWindowSize(): Rect {
|
||||
@@ -51,6 +56,45 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
Log.d("AccessibilityService", "Service Interrupted")
|
||||
}
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
// Request key event filtering so we receive onKeyEvent for hardware/HID media keys
|
||||
try {
|
||||
val info = serviceInfo ?: AccessibilityServiceInfo()
|
||||
info.flags = info.flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
|
||||
// keep other capabilities as defined in XML
|
||||
setServiceInfo(info)
|
||||
} catch (e: Exception) {
|
||||
Log.w("AccessibilityService", "Failed to set service info for key events: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
|
||||
// Handle media and volume keys from HID devices here
|
||||
Log.d(
|
||||
"AccessibilityService",
|
||||
"onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}"
|
||||
)
|
||||
|
||||
// Forward key events to the plugin (Flutter) and swallow them so they don't propagate.
|
||||
Observable.fromServiceKeys?.onKeyEvent(event)
|
||||
// Return true to indicate we've handled the event and it should be swallowed.
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBleRemote(event: KeyEvent): Boolean {
|
||||
val dev = InputDevice.getDevice(event.deviceId) ?: return false
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
dev.isExternal
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
|
||||
val gestureBuilder = GestureDescription.Builder()
|
||||
val path = Path()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package de.jonasbark.accessibility
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.KeyEvent
|
||||
|
||||
object Observable {
|
||||
var toService: Listener? = null
|
||||
var fromService: Receiver? = null
|
||||
var fromServiceWindow: Receiver? = null
|
||||
var fromServiceKeys: Receiver? = null
|
||||
var ignoreHidDevices: Boolean = false
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
@@ -13,4 +16,5 @@ interface Listener {
|
||||
|
||||
interface Receiver {
|
||||
fun onChange(packageName: String, window: Rect)
|
||||
fun onKeyEvent(event: KeyEvent)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ abstract class Accessibility {
|
||||
void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
|
||||
void controlMedia(MediaAction action);
|
||||
|
||||
bool isRunning();
|
||||
|
||||
void ignoreHidDevices();
|
||||
}
|
||||
|
||||
enum MediaAction { playPause, next, volumeUp, volumeDown }
|
||||
@@ -29,7 +33,17 @@ class WindowEvent {
|
||||
});
|
||||
}
|
||||
|
||||
class AKeyEvent {
|
||||
final String source;
|
||||
final String hidKey;
|
||||
final bool keyDown;
|
||||
final bool keyUp;
|
||||
|
||||
AKeyEvent({required this.source, required this.hidKey, required this.keyDown, required this.keyUp});
|
||||
}
|
||||
|
||||
@EventChannelApi()
|
||||
abstract class EventChannelMethods {
|
||||
WindowEvent streamEvents();
|
||||
AKeyEvent hidKeyPressed();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Autogenerated from Pigeon (v25.2.0), do not edit directly.
|
||||
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
|
||||
|
||||
@@ -14,6 +14,20 @@ PlatformException _createConnectionError(String channelName) {
|
||||
message: 'Unable to establish connection on channel: "$channelName".',
|
||||
);
|
||||
}
|
||||
bool _deepEquals(Object? a, Object? b) {
|
||||
if (a is List && b is List) {
|
||||
return a.length == b.length &&
|
||||
a.indexed
|
||||
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
|
||||
(b as Map<Object?, Object?>).containsKey(entry.key) &&
|
||||
_deepEquals(entry.value, b[entry.key]));
|
||||
}
|
||||
return a == b;
|
||||
}
|
||||
|
||||
|
||||
enum MediaAction {
|
||||
playPause,
|
||||
@@ -74,12 +88,63 @@ 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
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
int get hashCode => Object.hashAll(_toList())
|
||||
;
|
||||
}
|
||||
|
||||
class AKeyEvent {
|
||||
AKeyEvent({
|
||||
required this.source,
|
||||
required this.hidKey,
|
||||
required this.keyDown,
|
||||
required this.keyUp,
|
||||
});
|
||||
|
||||
String source;
|
||||
|
||||
String hidKey;
|
||||
|
||||
bool keyDown;
|
||||
|
||||
bool keyUp;
|
||||
|
||||
List<Object?> _toList() {
|
||||
return <Object?>[
|
||||
source,
|
||||
hidKey,
|
||||
keyDown,
|
||||
keyUp,
|
||||
];
|
||||
}
|
||||
|
||||
Object encode() {
|
||||
return _toList(); }
|
||||
|
||||
static AKeyEvent decode(Object result) {
|
||||
result as List<Object?>;
|
||||
return AKeyEvent(
|
||||
source: result[0]! as String,
|
||||
hidKey: result[1]! as String,
|
||||
keyDown: result[2]! as bool,
|
||||
keyUp: result[3]! as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
// ignore: avoid_equals_and_hash_code_on_mutable_classes
|
||||
bool operator ==(Object other) {
|
||||
if (other is! AKeyEvent || other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return _deepEquals(encode(), other.encode());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -102,6 +167,9 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
} else if (value is WindowEvent) {
|
||||
buffer.putUint8(130);
|
||||
writeValue(buffer, value.encode());
|
||||
} else if (value is AKeyEvent) {
|
||||
buffer.putUint8(131);
|
||||
writeValue(buffer, value.encode());
|
||||
} else {
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
@@ -115,6 +183,8 @@ class _PigeonCodec extends StandardMessageCodec {
|
||||
return value == null ? null : MediaAction.values[value];
|
||||
case 130:
|
||||
return WindowEvent.decode(readValue(buffer)!);
|
||||
case 131:
|
||||
return AKeyEvent.decode(readValue(buffer)!);
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
}
|
||||
@@ -232,6 +302,57 @@ class Accessibility {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isRunning() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.isRunning$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else if (pigeonVar_replyList[0] == null) {
|
||||
throw PlatformException(
|
||||
code: 'null-error',
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (pigeonVar_replyList[0] as bool?)!;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ignoreHidDevices() async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
|
||||
@@ -245,3 +366,14 @@ Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
|
||||
});
|
||||
}
|
||||
|
||||
Stream<AKeyEvent> 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 AKeyEvent;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -16,6 +16,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
|
||||
<uses-permission android:name="android.permission.BILLING"/>
|
||||
|
||||
<!-- legacy for Android 9 or lower -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" tools:replace="android:maxSdkVersion" />
|
||||
@@ -25,7 +26,7 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="SwiftControl"
|
||||
android:label="BikeControl"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
package de.jonasbark.swiftcontrol
|
||||
|
||||
import android.hardware.input.InputManager
|
||||
import android.os.Handler
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import org.flame_engine.gamepads_android.GamepadsCompatibleActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity: FlutterActivity(), GamepadsCompatibleActivity {
|
||||
var keyListener: ((KeyEvent) -> Boolean)? = null
|
||||
var motionListener: ((MotionEvent) -> Boolean)? = null
|
||||
|
||||
override fun isGamepadsInputDevice(device: InputDevice): Boolean {
|
||||
return device.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
|| device.sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
// Some bluetooth keyboards are identified as GamePad. Check if it is ALPHABETIC keyboard.
|
||||
// && device.keyboardType != InputDevice.KEYBOARD_TYPE_ALPHABETIC
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(motionEvent: MotionEvent): Boolean {
|
||||
return motionListener?.invoke(motionEvent) ?: false
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(keyEvent: KeyEvent): Boolean {
|
||||
if (keyListener?.invoke(keyEvent) == true) {
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(keyEvent)
|
||||
}
|
||||
|
||||
override fun registerInputDeviceListener(
|
||||
listener: InputManager.InputDeviceListener, handler: Handler?) {
|
||||
val inputManager = getSystemService(INPUT_SERVICE) as InputManager
|
||||
inputManager.registerInputDeviceListener(listener, null)
|
||||
}
|
||||
|
||||
override fun registerKeyEventHandler(handler: (KeyEvent) -> Boolean) {
|
||||
keyListener = handler
|
||||
}
|
||||
|
||||
override fun registerMotionEventHandler(handler: (MotionEvent) -> Boolean) {
|
||||
motionListener = handler
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<accessibility-service
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeViewClicked"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:accessibilityFlags="flagDefault"
|
||||
android:accessibilityFlags="flagDefault|flagRequestFilterKeyEvents"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:canRequestFilterKeyEvents="true"
|
||||
android:canPerformGestures="true"
|
||||
android:notificationTimeout="100"/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
|
||||
platform :ios, '15.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -7,14 +7,37 @@ PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage_darwin (10.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- gamepads_ios (0.1.1):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- in_app_purchase_storekit (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- in_app_review (2.0.0):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- ios_receipt (0.0.1):
|
||||
- Flutter
|
||||
- media_key_detector_ios (0.0.1):
|
||||
- Flutter
|
||||
- nsd_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):
|
||||
- Flutter
|
||||
- sensors_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -31,10 +54,20 @@ DEPENDENCIES:
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- ios_receipt (from `.symlinks/plugins/ios_receipt/ios`)
|
||||
- media_key_detector_ios (from `.symlinks/plugins/media_key_detector_ios/ios`)
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_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`)
|
||||
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
@@ -49,14 +82,34 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_secure_storage_darwin:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||
gamepads_ios:
|
||||
:path: ".symlinks/plugins/gamepads_ios/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
in_app_purchase_storekit:
|
||||
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
ios_receipt:
|
||||
:path: ".symlinks/plugins/ios_receipt/ios"
|
||||
media_key_detector_ios:
|
||||
:path: ".symlinks/plugins/media_key_detector_ios/ios"
|
||||
nsd_ios:
|
||||
:path: ".symlinks/plugins/nsd_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:
|
||||
:path: ".symlinks/plugins/restart_app/ios"
|
||||
sensors_plus:
|
||||
:path: ".symlinks/plugins/sensors_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
universal_ble:
|
||||
@@ -71,15 +124,25 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d
|
||||
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a
|
||||
in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
ios_receipt: c2d5b4c36953c377a024992393976214ce6951e6
|
||||
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
|
||||
nsd_ios: 8c37babdc6538e3350dbed3a52674d2edde98173
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
|
||||
sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
PODFILE CHECKSUM: 7ebd5c9b932b3af79d5c67e3af873118b74e970f
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
DFFDC4B9C4D6EF6A3BDE2E73 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
F0D040E82EEF2560009B19C0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -152,6 +153,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F0D040E82EEF2560009B19C0 /* Runner.entitlements */,
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
@@ -487,12 +489,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -673,12 +677,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -699,12 +705,14 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -8,6 +8,8 @@ import UIKit
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
@@ -14,13 +16,14 @@
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-16" y="-40"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>SwiftControl</string>
|
||||
<string>BikeControl</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -24,12 +24,21 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app connects to your trainer app on your local network.</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>SwiftControl uses Bluetooth to connect to accessories.</string>
|
||||
<string>BikeControl uses Bluetooth to connect to accessories.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app connects to your trainer app on your local network.</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>Access your accelerometer and gyroscope for steering support via your phone.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_wahoo-fitness-tnp._tcp</string>
|
||||
<string>_openbikecontrol._tcp</string>
|
||||
</array>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
@@ -37,8 +46,6 @@
|
||||
<string>bluetooth-peripheral</string>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
|
||||
8
ios/Runner/Runner.entitlements
Normal file
8
ios/Runner/Runner.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
2
ios_receipt/.gitattributes
vendored
Normal file
2
ios_receipt/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
30
ios_receipt/.gitignore
vendored
Normal file
30
ios_receipt/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# 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
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
30
ios_receipt/.metadata
Normal file
30
ios_receipt/.metadata
Normal file
@@ -0,0 +1,30 @@
|
||||
# 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 and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
- platform: ios
|
||||
create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2
|
||||
|
||||
# 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'
|
||||
7
ios_receipt/CHANGELOG.md
Normal file
7
ios_receipt/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## 1.1.0
|
||||
|
||||
* Migrate to StoreKit2
|
||||
|
||||
## 1.0.0
|
||||
|
||||
* Implement main method
|
||||
21
ios_receipt/LICENSE
Normal file
21
ios_receipt/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Dmytro O. Kut'ko
|
||||
|
||||
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.
|
||||
32
ios_receipt/README.md
Normal file
32
ios_receipt/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# IosReceipt
|
||||
|
||||
The IosReceipt package allows you to easily fetch the App Store receipt in your Flutter application on the iOS platform.
|
||||
|
||||
## Installation
|
||||
|
||||
Add the following dependency to your `pubspec.yaml` file:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
ios_receipt: ^0.0.1 # Use the latest version of the package
|
||||
```
|
||||
Then, run flutter pub get to install the package.
|
||||
|
||||
## Usage
|
||||
Method for get transactions with StoreKit2
|
||||
```dart
|
||||
final transactions = await IosReceipt.getAllTransactions();
|
||||
```
|
||||
|
||||
## Based on
|
||||
This package is based on the [appStoreReceiptURL](https://developer.apple.com/documentation/foundation/nsbundle/1407276-appstorereceipturl) from the official Apple documentation.
|
||||
|
||||
## Note
|
||||
- The receipt isn't necessary if you use AppTransaction to validate the app download, or Transaction to validate in-app purchases.
|
||||
- If the receipt is invalid or missing in your app, use SKReceiptRefreshRequest to request a new receipt.
|
||||
|
||||
## Testing Environments
|
||||
Keep in mind that receipts aren't initially present in iOS and iPadOS apps in the sandbox environment and in Xcode. Apps receive a receipt after the tester completes the first in-app purchase.
|
||||
|
||||
## License
|
||||
This project is licensed under the MIT License.
|
||||
4
ios_receipt/analysis_options.yaml
Normal file
4
ios_receipt/analysis_options.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
38
ios_receipt/ios/.gitignore
vendored
Normal file
38
ios_receipt/ios/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
.idea/
|
||||
.vagrant/
|
||||
.sconsign.dblite
|
||||
.svn/
|
||||
|
||||
.DS_Store
|
||||
*.swp
|
||||
profile
|
||||
|
||||
DerivedData/
|
||||
build/
|
||||
GeneratedPluginRegistrant.h
|
||||
GeneratedPluginRegistrant.m
|
||||
|
||||
.generated/
|
||||
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
|
||||
!default.pbxuser
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.perspectivev3
|
||||
|
||||
xcuserdata
|
||||
|
||||
*.moved-aside
|
||||
|
||||
*.pyc
|
||||
*sync/
|
||||
Icon?
|
||||
.tags*
|
||||
|
||||
/Flutter/Generated.xcconfig
|
||||
/Flutter/ephemeral/
|
||||
/Flutter/flutter_export_environment.sh
|
||||
0
ios_receipt/ios/Assets/.gitkeep
Normal file
0
ios_receipt/ios/Assets/.gitkeep
Normal file
73
ios_receipt/ios/Classes/IosReceiptPlugin.swift
Normal file
73
ios_receipt/ios/Classes/IosReceiptPlugin.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import StoreKit
|
||||
|
||||
public class IosReceiptPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
private func getAppleReceipt() -> String? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
let data = try? Data(contentsOf: url, options: .alwaysMapped)
|
||||
return data?.base64EncodedString()
|
||||
}
|
||||
|
||||
private func isSandbox() -> Bool {
|
||||
guard let path = Bundle.main.appStoreReceiptURL?.path else {
|
||||
return false
|
||||
}
|
||||
return path.contains("CoreSimulator") || path.contains("sandboxReceipt")
|
||||
}
|
||||
|
||||
private func getAllTransactions() async -> [[String: Any]] {
|
||||
var list: [[String: Any]] = []
|
||||
if #available(iOS 15.0, *) {
|
||||
for await result in Transaction.all {
|
||||
switch result {
|
||||
case .verified(let tx):
|
||||
var item: [String: Any] = [
|
||||
"productId": tx.productID,
|
||||
"transactionId": String(tx.id),
|
||||
"originalTransactionId": String(tx.originalID),
|
||||
"purchaseDate": ISO8601DateFormatter().string(from: tx.purchaseDate)
|
||||
]
|
||||
if let revocationDate = tx.revocationDate {
|
||||
item["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate)
|
||||
}
|
||||
if let reason = tx.revocationReason {
|
||||
item["revocationReason"] = reason.rawValue
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
item["jws"] = result.jwsRepresentation
|
||||
}
|
||||
list.append(item)
|
||||
|
||||
case .unverified(_, _):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "ios_receipt", binaryMessenger: registrar.messenger())
|
||||
let instance = IosReceiptPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
|
||||
case "getAppleReceipt":
|
||||
result(getAppleReceipt())
|
||||
case "isSandbox":
|
||||
result(isSandbox())
|
||||
case "getAllTransactions":
|
||||
Task { result(await self.getAllTransactions()) }
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ios_receipt/ios/ios_receipt.podspec
Normal file
23
ios_receipt/ios/ios_receipt.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint ios_receipt.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ios_receipt'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '15.0'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
end
|
||||
22
ios_receipt/lib/ios_receipt.dart
Normal file
22
ios_receipt/lib/ios_receipt.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:ios_receipt/models/transaction.dart';
|
||||
|
||||
import 'ios_receipt_platform_interface.dart';
|
||||
|
||||
class IosReceipt {
|
||||
static Future<String?> getAppleReceipt() {
|
||||
return IosReceiptPlatform.instance.getAppleReceipt();
|
||||
}
|
||||
|
||||
static Future<bool> isSandbox() {
|
||||
return IosReceiptPlatform.instance.isSandbox();
|
||||
}
|
||||
|
||||
static Future<List<Transaction>> getAllTransactions() async {
|
||||
final list = await IosReceiptPlatform.instance.getAllTransactions();
|
||||
final result = <Transaction>[];
|
||||
for (var data in list) {
|
||||
result.add(Transaction.fromMap(data));
|
||||
}
|
||||
return result..sort((a, b) => a.purchaseDate.compareTo(b.purchaseDate));
|
||||
}
|
||||
}
|
||||
29
ios_receipt/lib/ios_receipt_method_channel.dart
Normal file
29
ios_receipt/lib/ios_receipt_method_channel.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'ios_receipt_platform_interface.dart';
|
||||
|
||||
/// An implementation of [IosReceiptPlatform] that uses method channels.
|
||||
class MethodChannelIosReceipt extends IosReceiptPlatform {
|
||||
/// The method channel used to interact with the native platform.
|
||||
@visibleForTesting
|
||||
final methodChannel = const MethodChannel('ios_receipt');
|
||||
|
||||
@override
|
||||
Future<String?> getAppleReceipt() async {
|
||||
final version = await methodChannel.invokeMethod<String>('getAppleReceipt');
|
||||
return version;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> getAllTransactions() async {
|
||||
final list = await methodChannel.invokeMethod('getAllTransactions');
|
||||
return (list as List).map((e) => Map<String, dynamic>.from(e)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isSandbox() async {
|
||||
final isSandbox = await methodChannel.invokeMethod<bool>('isSandbox');
|
||||
return isSandbox ?? false;
|
||||
}
|
||||
}
|
||||
37
ios_receipt/lib/ios_receipt_platform_interface.dart
Normal file
37
ios_receipt/lib/ios_receipt_platform_interface.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
import 'ios_receipt_method_channel.dart';
|
||||
|
||||
abstract class IosReceiptPlatform extends PlatformInterface {
|
||||
/// Constructs a IosReceiptPlatform.
|
||||
IosReceiptPlatform() : super(token: _token);
|
||||
|
||||
static final Object _token = Object();
|
||||
|
||||
static IosReceiptPlatform _instance = MethodChannelIosReceipt();
|
||||
|
||||
/// The default instance of [IosReceiptPlatform] to use.
|
||||
///
|
||||
/// Defaults to [MethodChannelIosReceipt].
|
||||
static IosReceiptPlatform get instance => _instance;
|
||||
|
||||
/// Platform-specific implementations should set this with their own
|
||||
/// platform-specific class that extends [IosReceiptPlatform] when
|
||||
/// they register themselves.
|
||||
static set instance(IosReceiptPlatform instance) {
|
||||
PlatformInterface.verifyToken(instance, _token);
|
||||
_instance = instance;
|
||||
}
|
||||
|
||||
Future<String?> getAppleReceipt() {
|
||||
throw UnimplementedError('platformVersion() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAllTransactions() {
|
||||
throw UnimplementedError('getAllTransactions() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<bool> isSandbox() async {
|
||||
throw UnimplementedError('isSandbox() has not been implemented.');
|
||||
}
|
||||
}
|
||||
41
ios_receipt/lib/models/transaction.dart
Normal file
41
ios_receipt/lib/models/transaction.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
class Transaction {
|
||||
const Transaction({
|
||||
required this.jws,
|
||||
required this.productId,
|
||||
required this.transactionId,
|
||||
required this.purchaseDate,
|
||||
required this.originalTransactionId,
|
||||
});
|
||||
|
||||
final String? jws;
|
||||
final String productId;
|
||||
final String transactionId;
|
||||
final DateTime purchaseDate;
|
||||
final String originalTransactionId;
|
||||
|
||||
@override
|
||||
bool operator ==(covariant Transaction other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other.jws == jws &&
|
||||
other.productId == productId &&
|
||||
other.transactionId == transactionId &&
|
||||
other.purchaseDate == purchaseDate &&
|
||||
other.originalTransactionId == originalTransactionId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
jws.hashCode ^
|
||||
productId.hashCode ^
|
||||
transactionId.hashCode ^
|
||||
purchaseDate.hashCode ^
|
||||
originalTransactionId.hashCode;
|
||||
|
||||
factory Transaction.fromMap(Map<String, dynamic> map) => Transaction(
|
||||
jws: map['jws'] as String?,
|
||||
productId: map['productId'] as String,
|
||||
transactionId: map['transactionId'] as String,
|
||||
purchaseDate: DateTime.parse(map['purchaseDate'] as String),
|
||||
originalTransactionId: map['originalTransactionId'] as String,
|
||||
);
|
||||
}
|
||||
38
ios_receipt/macos/.gitignore
vendored
Normal file
38
ios_receipt/macos/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
.idea/
|
||||
.vagrant/
|
||||
.sconsign.dblite
|
||||
.svn/
|
||||
|
||||
.DS_Store
|
||||
*.swp
|
||||
profile
|
||||
|
||||
DerivedData/
|
||||
build/
|
||||
GeneratedPluginRegistrant.h
|
||||
GeneratedPluginRegistrant.m
|
||||
|
||||
.generated/
|
||||
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
|
||||
!default.pbxuser
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.perspectivev3
|
||||
|
||||
xcuserdata
|
||||
|
||||
*.moved-aside
|
||||
|
||||
*.pyc
|
||||
*sync/
|
||||
Icon?
|
||||
.tags*
|
||||
|
||||
/Flutter/Generated.xcconfig
|
||||
/Flutter/ephemeral/
|
||||
/Flutter/flutter_export_environment.sh
|
||||
0
ios_receipt/macos/Assets/.gitkeep
Normal file
0
ios_receipt/macos/Assets/.gitkeep
Normal file
73
ios_receipt/macos/Classes/IosReceiptPlugin.swift
Normal file
73
ios_receipt/macos/Classes/IosReceiptPlugin.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
|
||||
import FlutterMacOS
|
||||
import StoreKit
|
||||
|
||||
public class IosReceiptPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
private func isSandbox() -> Bool {
|
||||
guard let path = Bundle.main.appStoreReceiptURL?.path else {
|
||||
return false
|
||||
}
|
||||
return path.contains("CoreSimulator") || path.contains("sandboxReceipt")
|
||||
}
|
||||
|
||||
private func getAppleReceipt() -> String? {
|
||||
guard let url = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
let data = try? Data(contentsOf: url, options: .alwaysMapped)
|
||||
return data?.base64EncodedString()
|
||||
}
|
||||
|
||||
private func getAllTransactions() async -> [[String: Any]] {
|
||||
var list: [[String: Any]] = []
|
||||
if #available(iOS 15.0, *) {
|
||||
for await result in Transaction.all {
|
||||
switch result {
|
||||
case .verified(let tx):
|
||||
var item: [String: Any] = [
|
||||
"productId": tx.productID,
|
||||
"transactionId": String(tx.id),
|
||||
"originalTransactionId": String(tx.originalID),
|
||||
"purchaseDate": ISO8601DateFormatter().string(from: tx.purchaseDate)
|
||||
]
|
||||
if let revocationDate = tx.revocationDate {
|
||||
item["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate)
|
||||
}
|
||||
if let reason = tx.revocationReason {
|
||||
item["revocationReason"] = reason.rawValue
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
item["jws"] = result.jwsRepresentation
|
||||
}
|
||||
list.append(item)
|
||||
|
||||
case .unverified(_, _):
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "ios_receipt", binaryMessenger: registrar.messenger)
|
||||
let instance = IosReceiptPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
|
||||
case "getAppleReceipt":
|
||||
result(getAppleReceipt())
|
||||
case "isSandbox":
|
||||
result(isSandbox())
|
||||
case "getAllTransactions":
|
||||
Task { result(await self.getAllTransactions()) }
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
ios_receipt/macos/ios_receipt.podspec
Normal file
23
ios_receipt/macos/ios_receipt.podspec
Normal file
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint ios_receipt.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ios_receipt'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'FlutterMacOS'
|
||||
s.platform = :osx, '12.00'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
end
|
||||
26
ios_receipt/pubspec.yaml
Normal file
26
ios_receipt/pubspec.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: ios_receipt
|
||||
description: The IosReceipt package allows you to easily fetch the App Store receipt in your Flutter application on the iOS platform.
|
||||
version: 1.1.0
|
||||
homepage: https://github.com/DimaKutko/ios_receipt
|
||||
|
||||
environment:
|
||||
sdk: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.32.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
plugin_platform_interface: ^2.1.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
ios:
|
||||
pluginClass: IosReceiptPlugin
|
||||
macos:
|
||||
pluginClass: IosReceiptPlugin
|
||||
@@ -88,6 +88,38 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to send modifier key events
|
||||
auto sendModifierKey = [](UINT vkCode, bool down) {
|
||||
WORD sc = (WORD)MapVirtualKey(vkCode, MAPVK_VK_TO_VSC);
|
||||
INPUT in = {0};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wVk = 0;
|
||||
in.ki.wScan = sc;
|
||||
in.ki.dwFlags = KEYEVENTF_SCANCODE | (down ? 0 : KEYEVENTF_KEYUP);
|
||||
SendInput(1, &in, sizeof(INPUT));
|
||||
};
|
||||
|
||||
// Helper function to process modifiers
|
||||
auto processModifiers = [&sendModifierKey](const std::vector<std::string>& mods, bool down) {
|
||||
for (const std::string& modifier : mods) {
|
||||
if (modifier == "shiftModifier") {
|
||||
sendModifierKey(VK_SHIFT, down);
|
||||
} else if (modifier == "controlModifier") {
|
||||
sendModifierKey(VK_CONTROL, down);
|
||||
} else if (modifier == "altModifier") {
|
||||
sendModifierKey(VK_MENU, down);
|
||||
} else if (modifier == "metaModifier") {
|
||||
sendModifierKey(VK_LWIN, down);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Press modifier keys first (if keyDown)
|
||||
if (keyDown) {
|
||||
processModifiers(modifiers, true);
|
||||
}
|
||||
|
||||
// Send the main key
|
||||
WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
|
||||
|
||||
INPUT in = {0};
|
||||
@@ -102,6 +134,11 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
}
|
||||
SendInput(1, &in, sizeof(INPUT));
|
||||
|
||||
// Release modifier keys (if keyUp)
|
||||
if (!keyDown) {
|
||||
processModifiers(modifiers, false);
|
||||
}
|
||||
|
||||
/*BYTE byteValue = static_cast<BYTE>(keyCode);
|
||||
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);*/
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class BleUuid {
|
||||
static final DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb"
|
||||
.toLowerCase();
|
||||
static const DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb";
|
||||
static const DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb";
|
||||
|
||||
static final DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static const DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb";
|
||||
static const DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002A19-0000-1000-8000-00805F9B34FB";
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gamepad/gamepad_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/requirements/android.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/requirements/android.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import 'devices/base_device.dart';
|
||||
@@ -15,6 +25,18 @@ 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<GyroscopeSteering> get gyroscopeDevices => devices.whereType<GyroscopeSteering>().toList();
|
||||
List<WahooKickrHeadwind> get accessories => devices.whereType<WahooKickrHeadwind>().toList();
|
||||
List<BaseDevice> get controllerDevices => [
|
||||
...bluetoothDevices.where((d) => d is! WahooKickrHeadwind),
|
||||
...gamepadDevices,
|
||||
...gyroscopeDevices,
|
||||
...devices.whereType<HidDevice>(),
|
||||
];
|
||||
|
||||
var _androidNotificationsSetup = false;
|
||||
|
||||
final _connectionQueue = <BaseDevice>[];
|
||||
@@ -23,6 +45,7 @@ class Connection {
|
||||
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
|
||||
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => _actionStreams.stream;
|
||||
List<({DateTime date, String entry})> lastLogEntries = [];
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
@@ -32,43 +55,111 @@ class Connection {
|
||||
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isScanning = ValueNotifier(false);
|
||||
|
||||
Timer? _gamePadSearchTimer;
|
||||
|
||||
void initialize() {
|
||||
actionStream.listen((log) {
|
||||
lastLogEntries.add((date: DateTime.now(), entry: log.toString()));
|
||||
lastLogEntries = lastLogEntries.takeLast(kIsWeb ? 1000 : 60).toList();
|
||||
});
|
||||
|
||||
UniversalBle.onAvailabilityChange = (available) {
|
||||
_actionStreams.add(LogNotification('Bluetooth availability changed: $available'));
|
||||
if (available == AvailabilityState.poweredOn) {
|
||||
performScanning();
|
||||
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
|
||||
if (available == AvailabilityState.poweredOn && !kIsWeb) {
|
||||
core.permissions.getScanRequirements().then((perms) {
|
||||
if (perms.isEmpty) {
|
||||
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) {
|
||||
debugPrint('Scan result: ${result.name} - ${result.deviceId}');
|
||||
}
|
||||
|
||||
final scanResult = BluetoothDevice.fromScanResult(result);
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${scanResult.runtimeType}'));
|
||||
_addDevices([scanResult]);
|
||||
_actionStreams.add(LogNotification('Found new device: ${kIsWeb ? scanResult.name : scanResult.runtimeType}'));
|
||||
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 ${result.name} with identifier: ${data.firstOrNull}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
|
||||
final device = devices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) async {
|
||||
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
if (device == null) {
|
||||
_actionStreams.add(LogNotification('Device not found: $deviceId'));
|
||||
UniversalBle.disconnect(deviceId);
|
||||
return;
|
||||
} else {
|
||||
device.processCharacteristic(characteristicUuid, value);
|
||||
if (kIsWeb) {
|
||||
// on web, log all characteristic changes for debugging
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
'Characteristic update for device ${device.name}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await device.processCharacteristic(characteristicUuid, value);
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(
|
||||
LogNotification(
|
||||
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
|
||||
),
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
UniversalBle.onConnectionChange = (String deviceId, bool isConnected, String? error) {
|
||||
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
if (device != null && !isConnected) {
|
||||
// allow reconnection
|
||||
_lastScanResult.removeWhere((d) => d.deviceId == deviceId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!kIsWeb && !screenshotMode) {
|
||||
core.permissions.getScanRequirements().then((perms) {
|
||||
if (perms.isEmpty) {
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
if (core.settings.getPhoneSteeringEnabled()) {
|
||||
toggleGyroscopeSteering(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performScanning() async {
|
||||
@@ -78,57 +169,132 @@ class Connection {
|
||||
isScanning.value = true;
|
||||
_actionStreams.add(LogNotification('Scanning for devices...'));
|
||||
|
||||
if (screenshotMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// does not work on web, may not work on Windows
|
||||
if (!kIsWeb && !Platform.isWindows) {
|
||||
UniversalBle.getSystemDevices(
|
||||
withServices: BaseDevice.servicesToScan,
|
||||
withServices: BluetoothDevice.servicesToScan,
|
||||
).then((devices) async {
|
||||
final baseDevices = devices.mapNotNull(BaseDevice.fromScanResult).toList();
|
||||
final baseDevices = devices.mapNotNull(BluetoothDevice.fromScanResult).toList();
|
||||
if (baseDevices.isNotEmpty) {
|
||||
_addDevices(baseDevices);
|
||||
addDevices(baseDevices);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await UniversalBle.startScan(
|
||||
scanFilter: ScanFilter(withServices: BaseDevice.servicesToScan),
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BaseDevice.servicesToScan)),
|
||||
// allow all to enable Wahoo Kickr Bike Shift detection
|
||||
//scanFilter: kIsWeb ? ScanFilter(withServices: BluetoothDevice.servicesToScan) : null,
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BluetoothDevice.servicesToScan)),
|
||||
);
|
||||
}
|
||||
|
||||
void _addDevices(List<BaseDevice> dev) {
|
||||
final newDevices = dev.where((device) => !devices.contains(device)).toList();
|
||||
devices.addAll(newDevices);
|
||||
if (!kIsWeb) {
|
||||
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
|
||||
Gamepads.list().then((list) {
|
||||
final pads = list.map((pad) => GamepadDevice(pad.name.isEmpty ? 'Gamepad' : pad.name, id: pad.id)).toList();
|
||||
addDevices(pads);
|
||||
|
||||
_connectionQueue.addAll(newDevices);
|
||||
_handleConnectionQueue();
|
||||
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
|
||||
for (var device in removedDevices) {
|
||||
devices.remove(device);
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
signalChange(device);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
isScanning.value = false;
|
||||
}
|
||||
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
_androidNotificationsSetup = true;
|
||||
NotificationRequirement.setup().catchError((e) {
|
||||
// start foreground service only when app is in foreground
|
||||
NotificationRequirement.addPersistentNotification().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startMyWhooshServer() {
|
||||
return core.whooshLink.startServer().catchError((e) {
|
||||
core.settings.setMyWhooshLinkEnabled(false);
|
||||
_actionStreams.add(LogNotification('Error starting MyWhoosh "Link" server: $e'));
|
||||
_actionStreams.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_ERROR,
|
||||
'Error starting MyWhoosh "Link" server. Please make sure the "MyWhoosh Link" app is not already running on this device.',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void addDevices(List<BaseDevice> dev) {
|
||||
final ignoredDevices = core.settings.getIgnoredDevices();
|
||||
final ignoredDeviceIds = ignoredDevices.map((d) => d.id).toSet();
|
||||
final newDevices = dev.where((device) {
|
||||
if (devices.contains(device)) return false;
|
||||
|
||||
// Check if device is in the ignored list
|
||||
if (device is BluetoothDevice) {
|
||||
if (ignoredDeviceIds.contains(device.device.deviceId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
devices.addAll(newDevices);
|
||||
_connectionQueue.addAll(newDevices);
|
||||
|
||||
_handleConnectionQueue();
|
||||
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
}
|
||||
|
||||
void toggleGyroscopeSteering(bool enable) {
|
||||
final existing = gyroscopeDevices.firstOrNull;
|
||||
if (existing != null && !enable) {
|
||||
// Remove gyroscope steering
|
||||
disconnect(existing, forget: true, persistForget: false);
|
||||
} else if (enable) {
|
||||
// Add gyroscope steering
|
||||
final gyroDevice = GyroscopeSteering();
|
||||
addDevices([gyroDevice]);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnectionQueue() {
|
||||
// windows apparently has issues when connecting to multiple devices at once, so don't
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue) {
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue && !screenshotMode) {
|
||||
_handlingConnectionQueue = true;
|
||||
final device = _connectionQueue.removeAt(0);
|
||||
_actionStreams.add(LogNotification('Connecting to: ${device.device.name ?? device.runtimeType}'));
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.name}'));
|
||||
_connect(device)
|
||||
.then((_) {
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(LogNotification('Connection finished: ${device.device.name ?? device.runtimeType}'));
|
||||
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.name}'));
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
})
|
||||
.catchError((e) {
|
||||
device.isConnected = false;
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(LogNotification('Connection failed: ${device.device.name ?? device.runtimeType} - $e'));
|
||||
if (e is TimeoutException) {
|
||||
_actionStreams.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.name}: Timeout'),
|
||||
);
|
||||
} else {
|
||||
_actionStreams.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.name} - $e'),
|
||||
);
|
||||
}
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
@@ -136,30 +302,39 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect(BaseDevice bleDevice) async {
|
||||
Future<void> _connect(BaseDevice device) async {
|
||||
try {
|
||||
final actionSubscription = bleDevice.actionStream.listen((data) {
|
||||
final actionSubscription = device.actionStream.listen((data) {
|
||||
_actionStreams.add(data);
|
||||
});
|
||||
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((state) {
|
||||
bleDevice.isConnected = state;
|
||||
_connectionStreams.add(bleDevice);
|
||||
if (!bleDevice.isConnected) {
|
||||
devices.remove(bleDevice);
|
||||
_streamSubscriptions[bleDevice]?.cancel();
|
||||
_streamSubscriptions.remove(bleDevice);
|
||||
_connectionSubscriptions[bleDevice]?.cancel();
|
||||
_connectionSubscriptions.remove(bleDevice);
|
||||
_lastScanResult.clear();
|
||||
// try reconnect
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
|
||||
if (device is BluetoothDevice) {
|
||||
final connectionStateSubscription = device.device.connectionStream.listen((state) {
|
||||
device.isConnected = state;
|
||||
_connectionStreams.add(device);
|
||||
core.flutterLocalNotificationsPlugin.show(
|
||||
1338,
|
||||
'${device.name} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}',
|
||||
!state ? AppLocalizations.current.tryingToConnectAgain : null,
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails('Connection', 'Connection Status'),
|
||||
iOS: DarwinNotificationDetails(presentAlert: true),
|
||||
),
|
||||
);
|
||||
if (!device.isConnected) {
|
||||
disconnect(device, forget: false, persistForget: false);
|
||||
// try reconnect
|
||||
performScanning();
|
||||
}
|
||||
});
|
||||
_connectionSubscriptions[device] = connectionStateSubscription;
|
||||
}
|
||||
|
||||
await bleDevice.connect();
|
||||
await device.connect();
|
||||
signalChange(device);
|
||||
|
||||
_streamSubscriptions[bleDevice] = actionSubscription;
|
||||
core.actionHandler.supportedApp?.keymap.addNewButtons(device.availableButtons);
|
||||
|
||||
_streamSubscriptions[device] = actionSubscription;
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(LogNotification("$e\n$backtrace"));
|
||||
if (kDebugMode) {
|
||||
@@ -170,15 +345,18 @@ class Connection {
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
Future<void> reset() async {
|
||||
_actionStreams.add(LogNotification('Disconnecting all devices'));
|
||||
if (actionHandler is AndroidActions) {
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
|
||||
_androidNotificationsSetup = false;
|
||||
}
|
||||
UniversalBle.stopScan();
|
||||
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
|
||||
if (isBtEnabled) {
|
||||
UniversalBle.stopScan();
|
||||
}
|
||||
isScanning.value = false;
|
||||
for (var device in devices) {
|
||||
for (var device in bluetoothDevices) {
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
@@ -186,6 +364,7 @@ class Connection {
|
||||
UniversalBle.disconnect(device.device.deviceId);
|
||||
signalChange(device);
|
||||
}
|
||||
_gamePadSearchTimer?.cancel();
|
||||
_lastScanResult.clear();
|
||||
hasDevices.value = false;
|
||||
devices.clear();
|
||||
@@ -198,4 +377,44 @@ class Connection {
|
||||
void signalChange(BaseDevice baseDevice) {
|
||||
_connectionStreams.add(baseDevice);
|
||||
}
|
||||
|
||||
Future<void> disconnect(BaseDevice device, {required bool persistForget, required bool forget}) async {
|
||||
if (device.isConnected) {
|
||||
await device.disconnect();
|
||||
}
|
||||
|
||||
if (device is BluetoothDevice) {
|
||||
if (persistForget) {
|
||||
// Add device to ignored list when forgetting
|
||||
await core.settings.addIgnoredDevice(device.device.deviceId, device.name);
|
||||
_actionStreams.add(LogNotification('Device ignored: ${device.name}'));
|
||||
}
|
||||
if (!forget) {
|
||||
// allow reconnection
|
||||
_lastScanResult.removeWhere((d) => d.deviceId == device.device.deviceId);
|
||||
}
|
||||
|
||||
// Clean up subscriptions and scan results for reconnection
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
|
||||
// Remove device from the list
|
||||
devices.remove(device);
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
} else if (device is GyroscopeSteering) {
|
||||
// Clean up subscriptions
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
|
||||
// Remove device from the list
|
||||
devices.remove(device);
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
}
|
||||
|
||||
signalChange(device);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart' show LogLevel;
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
import 'elite/elite_sterzo.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
final String name;
|
||||
final bool isBeta;
|
||||
final List<ControllerButton> availableButtons;
|
||||
|
||||
BaseDevice(this.scanResult, {required this.availableButtons, this.isBeta = false});
|
||||
BaseDevice(this.name, {required this.availableButtons, this.isBeta = false}) {
|
||||
if (availableButtons.isEmpty && core.actionHandler.supportedApp is CustomApp) {
|
||||
// TODO we should verify where the buttons came from
|
||||
final allButtons = core.actionHandler.supportedApp!.keymap.keyPairs.flatMap((e) => e.buttons);
|
||||
availableButtons.addAll(allButtons);
|
||||
}
|
||||
}
|
||||
|
||||
bool isConnected = false;
|
||||
int? batteryLevel;
|
||||
String? firmwareVersion;
|
||||
|
||||
Timer? _longPressTimer;
|
||||
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
|
||||
|
||||
static List<String> servicesToScan = [
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static BaseDevice? fromScanResult(BleDevice scanResult) {
|
||||
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
|
||||
BaseDevice? device;
|
||||
if (kIsWeb) {
|
||||
device = switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
'SQUARE' => EliteSquare(scanResult),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
|
||||
device = WahooKickrBikeShift(scanResult);
|
||||
}
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
|
||||
device = EliteSterzo(scanResult);
|
||||
}
|
||||
} else {
|
||||
device = switch (scanResult.name) {
|
||||
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
|
||||
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
|
||||
device = EliteSterzo(scanResult);
|
||||
}
|
||||
}
|
||||
|
||||
if (device != null) {
|
||||
return device;
|
||||
} else if (scanResult.services.containsAny([
|
||||
ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID,
|
||||
ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
])) {
|
||||
// otherwise use the manufacturer data to identify the device
|
||||
final manufacturerData = scanResult.manufacturerDataList;
|
||||
final data = manufacturerData
|
||||
.firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID)
|
||||
?.payload;
|
||||
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final type = ZwiftDeviceType.fromManufacturerData(data.first);
|
||||
return switch (type) {
|
||||
ZwiftDeviceType.click => ZwiftClick(scanResult),
|
||||
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
} else if (scanResult.services.contains(SquareConstants.SERVICE_UUID)) {
|
||||
return EliteSquare(scanResult);
|
||||
} else if (scanResult.services.contains(SterzoConstants.SERVICE_UUID)) {
|
||||
return EliteSterzo(scanResult);
|
||||
} else if (scanResult.services.contains(WahooKickrBikeShiftConstants.SERVICE_UUID)) {
|
||||
if (scanResult.name != null && !scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT')) {
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
} else if (kIsWeb && scanResult.name == null) {
|
||||
// some devices don't broadcast the name, so we must rely on the service UUID
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BaseDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
|
||||
identical(this, other) || other is BaseDevice && runtimeType == other.runtimeType && name == other.name;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.hashCode;
|
||||
int get hashCode => name.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return runtimeType.toString();
|
||||
return name;
|
||||
}
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
|
||||
|
||||
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
|
||||
|
||||
Future<void> connect() async {
|
||||
actionStream.listen((message) {
|
||||
print("Received message: $message");
|
||||
});
|
||||
Future<void> connect();
|
||||
|
||||
await UniversalBle.connect(device.deviceId);
|
||||
|
||||
if (!kIsWeb) {
|
||||
await UniversalBle.requestMtu(device.deviceId, 517);
|
||||
Future<void> handleButtonsClickedWithoutLongPressSupport(List<ControllerButton> clickedButtons) async {
|
||||
await handleButtonsClicked(clickedButtons, longPress: true);
|
||||
if (clickedButtons.length == 1) {
|
||||
final keyPair = core.actionHandler.supportedApp?.keymap.getKeyPair(clickedButtons.single);
|
||||
if (keyPair != null && (keyPair.isLongPress || keyPair.inGameAction?.isLongPress == true)) {
|
||||
// simulate release after click
|
||||
_longPressTimer?.cancel();
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
await handleButtonsClicked([], longPress: true);
|
||||
} else {
|
||||
await handleButtonsClicked([], longPress: true);
|
||||
}
|
||||
} else {
|
||||
await handleButtonsClicked([]);
|
||||
}
|
||||
|
||||
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> handleButtonsClicked(List<ControllerButton>? buttonsClicked, {bool longPress = false}) async {
|
||||
try {
|
||||
await _handleButtonsClickedInternal(buttonsClicked, longPress: longPress);
|
||||
} catch (e, st) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification('Error handling button clicks: $e\n$st'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked, {required bool longPress}) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
@@ -163,8 +88,9 @@ abstract class BaseDevice {
|
||||
// Handle release events for long press keys
|
||||
final buttonsReleased = _previouslyPressedButtons.toList();
|
||||
final isLongPress =
|
||||
longPress ||
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && isLongPress) {
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
@@ -175,19 +101,21 @@ abstract class BaseDevice {
|
||||
// Handle release events for buttons that are no longer pressed
|
||||
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
|
||||
final wasLongPress =
|
||||
longPress ||
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && wasLongPress) {
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
|
||||
final isLongPress =
|
||||
longPress ||
|
||||
buttonsClicked.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
core.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 {
|
||||
@@ -205,39 +133,113 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
String _getCommandLimitMessage() {
|
||||
return AppLocalizations.current.dailyCommandLimitReachedNotification;
|
||||
}
|
||||
|
||||
String _getCommandLimitTitle() {
|
||||
return AppLocalizations.current
|
||||
.dailyLimitReached(IAPManager.dailyCommandLimit, IAPManager.dailyCommandLimit)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
)
|
||||
.replaceAll(
|
||||
'${IAPManager.dailyCommandLimit} / ${IAPManager.dailyCommandLimit}',
|
||||
IAPManager.dailyCommandLimit.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
//actionStreamInternal.add(AlertNotification(LogLevel.LOGLEVEL_ERROR, _getCommandLimitMessage()));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: false)),
|
||||
);
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: false);
|
||||
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true)),
|
||||
);
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
_showCommandLimitAlert();
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: true, isKeyUp: true);
|
||||
actionStreamInternal.add(ActionNotification(result));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true)),
|
||||
);
|
||||
// Check IAP status before executing command
|
||||
if (!IAPManager.instance.canExecuteCommand) {
|
||||
_showCommandLimitAlert();
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await core.actionHandler.performAction(action, isKeyDown: false, isKeyUp: true);
|
||||
actionStreamInternal.add(LogNotification(result.message));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_longPressTimer?.cancel();
|
||||
// Release any held keys in long press mode
|
||||
if (actionHandler is DesktopActions) {
|
||||
await (actionHandler as DesktopActions).releaseAllHeldKeys(_previouslyPressedButtons.toList());
|
||||
if (core.actionHandler is DesktopActions) {
|
||||
await (core.actionHandler as DesktopActions).releaseAllHeldKeys(_previouslyPressedButtons.toList());
|
||||
}
|
||||
_previouslyPressedButtons.clear();
|
||||
await UniversalBle.disconnect(device.deviceId);
|
||||
isConnected = false;
|
||||
}
|
||||
|
||||
Widget showInformation(BuildContext context);
|
||||
|
||||
ControllerButton getOrAddButton(String key, ControllerButton Function() creator) {
|
||||
if (core.actionHandler.supportedApp == null) {
|
||||
return creator();
|
||||
}
|
||||
if (core.actionHandler.supportedApp is! CustomApp) {
|
||||
final currentProfile = core.actionHandler.supportedApp!.name;
|
||||
// should we display this to the user?
|
||||
KeymapManager().duplicateSync(currentProfile, '$currentProfile (Copy)');
|
||||
}
|
||||
final button = core.actionHandler.supportedApp!.keymap.getOrAddButton(key, creator);
|
||||
|
||||
if (availableButtons.none((e) => e.name == button.name)) {
|
||||
availableButtons.add(button);
|
||||
core.settings.setKeyMap(core.actionHandler.supportedApp!);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
|
||||
void _showCommandLimitAlert() {
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_ERROR,
|
||||
_getCommandLimitMessage(),
|
||||
buttonTitle: AppLocalizations.current.purchase,
|
||||
onTap: () {
|
||||
IAPManager.instance.purchaseFullVersion();
|
||||
},
|
||||
),
|
||||
);
|
||||
core.flutterLocalNotificationsPlugin.show(
|
||||
1337,
|
||||
_getCommandLimitTitle(),
|
||||
_getCommandLimitMessage(),
|
||||
NotificationDetails(
|
||||
android: AndroidNotificationDetails('Limit', 'Limit reached'),
|
||||
iOS: DarwinNotificationDetails(presentAlert: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
349
lib/bluetooth/devices/bluetooth_device.dart
Normal file
349
lib/bluetooth/devices/bluetooth_device.dart
Normal file
@@ -0,0 +1,349 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/sram/sram_axs.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/device_info.dart';
|
||||
import 'package:bike_control/widgets/ui/loading_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package: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,
|
||||
WahooKickrHeadwindConstants.SERVICE_UUID,
|
||||
SterzoConstants.SERVICE_UUID,
|
||||
CycplusBc2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE,
|
||||
OpenBikeControlConstants.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),
|
||||
'OpenBike' => OpenBikeControlDevice(scanResult),
|
||||
null => null,
|
||||
_ when scanResult.name!.toUpperCase().startsWith('HEADWIND') => WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SRAM') => SramAxs(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('HEADWIND') => WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(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),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE.toLowerCase()) => ShimanoDi2(
|
||||
scanResult,
|
||||
),
|
||||
_ when scanResult.services.contains(SramAxsConstants.SERVICE_UUID.toLowerCase()) => SramAxs(
|
||||
scanResult,
|
||||
),
|
||||
_ when scanResult.services.contains(OpenBikeControlConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
OpenBikeControlDevice(scanResult),
|
||||
_ when scanResult.services.contains(WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
WahooKickrHeadwind(scanResult),
|
||||
// otherwise the service UUIDs will be used
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
_
|
||||
when scanResult.name == 'Zwift Ride' &&
|
||||
type != ZwiftDeviceType.rideRight &&
|
||||
type != ZwiftDeviceType.rideLeft =>
|
||||
ZwiftRide(scanResult), // e.g. old firmware
|
||||
_ => null,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult.deviceId == other.scanResult.deviceId;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.deviceId.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
|
||||
}
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
try {
|
||||
await UniversalBle.connect(device.deviceId);
|
||||
} catch (e) {
|
||||
isConnected = false;
|
||||
rethrow;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
core.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;
|
||||
core.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 Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
device.name?.screenshot ?? runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
Expanded(child: SizedBox()),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return LoadingWidget(
|
||||
futureCallback: () async {
|
||||
final completer = showDropdown<bool>(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
child: Text('Disconnect and Forget for this session'),
|
||||
onPressed: (context) {
|
||||
closeOverlay(context, false);
|
||||
},
|
||||
),
|
||||
MenuButton(
|
||||
child: Text('Disconnect and Forget'),
|
||||
onPressed: (context) {
|
||||
closeOverlay(context, true);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final persist = await completer.future;
|
||||
if (persist != null) {
|
||||
await core.connection.disconnect(this, forget: true, persistForget: persist);
|
||||
}
|
||||
},
|
||||
renderChild: (isLoading, tap) => IconButton(
|
||||
variance: ButtonVariance.muted,
|
||||
icon: isLoading ? SmallProgressIndicator() : Icon(Icons.clear),
|
||||
onPressed: tap,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
DeviceInfo(
|
||||
title: context.i18n.connection,
|
||||
icon: switch (isConnected) {
|
||||
true => Icons.bluetooth_connected_outlined,
|
||||
false => Icons.bluetooth_disabled_outlined,
|
||||
},
|
||||
value: isConnected ? context.i18n.connected : context.i18n.disconnected,
|
||||
),
|
||||
|
||||
if (batteryLevel != null)
|
||||
DeviceInfo(
|
||||
title: context.i18n.battery,
|
||||
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,
|
||||
},
|
||||
value: '$batteryLevel%',
|
||||
),
|
||||
if (firmwareVersion != null)
|
||||
DeviceInfo(
|
||||
title: context.i18n.signal,
|
||||
icon: this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion
|
||||
? Icons.warning
|
||||
: Icons.text_fields_sharp,
|
||||
value: firmwareVersion!,
|
||||
additionalInfo: (this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion)
|
||||
? Text(
|
||||
' (${context.i18n.latestVersion((this as ZwiftDevice).latestFirmwareVersion)})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.destructive, fontSize: 12),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
||||
if (rssi != null)
|
||||
DeviceInfo(
|
||||
title: context.i18n.signal,
|
||||
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,
|
||||
},
|
||||
value: '$rssi dBm',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void debugSubscribeToAll(List<BleService> services) {
|
||||
for (final service in services) {
|
||||
for (final characteristic in service.characteristics) {
|
||||
if (characteristic.properties.contains(CharacteristicProperty.indicate)) {
|
||||
debugPrint('Subscribing to indications for ${service.uuid} / ${characteristic.uuid}');
|
||||
UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
if (characteristic.properties.contains(CharacteristicProperty.notify)) {
|
||||
debugPrint('Subscribing to notifications for ${service.uuid} / ${characteristic.uuid}');
|
||||
UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
lib/bluetooth/devices/cycplus/cycplus_bc2.dart
Normal file
128
lib/bluetooth/devices/cycplus/cycplus_bc2.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class CycplusBc2 extends BluetoothDevice {
|
||||
CycplusBc2(super.scanResult)
|
||||
: super(
|
||||
availableButtons: CycplusBc2Buttons.values,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${CycplusBc2Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${CycplusBc2Constants.TX_CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
// Track last state for index 6 and 7
|
||||
int _lastStateIndex6 = 0x00;
|
||||
int _lastStateIndex7 = 0x00;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
if (bytes.length > 7) {
|
||||
final buttonsToPress = <ControllerButton>[];
|
||||
|
||||
// Process index 6 (shift up)
|
||||
final currentByte6 = bytes[6];
|
||||
if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftUp);
|
||||
_lastStateIndex6 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte6, (val) => _lastStateIndex6 = val);
|
||||
}
|
||||
|
||||
// Process index 7 (shift down)
|
||||
final currentByte7 = bytes[7];
|
||||
if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) {
|
||||
buttonsToPress.add(CycplusBc2Buttons.shiftDown);
|
||||
_lastStateIndex7 = 0x00; // Reset after successful press
|
||||
} else {
|
||||
_updateState(currentByte7, (val) => _lastStateIndex7 = val);
|
||||
}
|
||||
|
||||
handleButtonsClicked(buttonsToPress);
|
||||
} else {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'CYCPLUS BC2 received unexpected packet: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join()}',
|
||||
),
|
||||
);
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
// Check if we should trigger a shift based on current and last state
|
||||
bool _shouldTriggerShift(int currentByte, int lastByte) {
|
||||
const pressedValues = {0x01, 0x02, 0x03};
|
||||
|
||||
// State change from one pressed value to another different pressed value
|
||||
// This is the ONLY time we trigger a shift
|
||||
if (pressedValues.contains(currentByte) && pressedValues.contains(lastByte) && currentByte != lastByte) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update state tracking
|
||||
void _updateState(int currentByte, void Function(int) setState) {
|
||||
const pressedValues = {0x01, 0x02, 0x03};
|
||||
const releaseValue = 0x00;
|
||||
|
||||
// Button released: current is 0x00 and last was pressed
|
||||
if (currentByte == releaseValue) {
|
||||
setState(releaseValue);
|
||||
}
|
||||
// Lock the new pressed state
|
||||
else if (pressedValues.contains(currentByte)) {
|
||||
setState(currentByte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CycplusBc2Constants {
|
||||
// Nordic UART Service (NUS) - commonly used by CYCPLUS BC2
|
||||
static const String SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
// TX Characteristic - device sends data to app
|
||||
static const String TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
// RX Characteristic - app sends data to device (not used for button reading)
|
||||
static const String RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
}
|
||||
|
||||
class CycplusBc2Buttons {
|
||||
static const ControllerButton shiftUp = ControllerButton(
|
||||
'shiftUp',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
);
|
||||
|
||||
static const ControllerButton shiftDown = ControllerButton(
|
||||
'shiftDown',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
shiftUp,
|
||||
shiftDown,
|
||||
];
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
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:flutter/foundation.dart';
|
||||
import 'package:bike_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,
|
||||
);
|
||||
|
||||
@@ -37,19 +36,19 @@ class EliteSquare extends BaseDevice {
|
||||
if (characteristic == SquareConstants.CHARACTERISTIC_UUID) {
|
||||
final fullValue = _bytesToHex(bytes);
|
||||
final currentValue = _extractButtonCode(fullValue);
|
||||
actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)'));
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)'));
|
||||
}
|
||||
|
||||
if (_lastValue != null) {
|
||||
final currentRelevantPart = fullValue.length >= 19
|
||||
? fullValue.substring(6, fullValue.length - 13)
|
||||
: fullValue.substring(6);
|
||||
final lastRelevantPart = _lastValue!.length >= 19
|
||||
? _lastValue!.substring(6, _lastValue!.length - 13)
|
||||
: _lastValue!.substring(6);
|
||||
final currentRelevantPart = fullValue.length >= 14 ? fullValue.substring(6, 14) : fullValue.substring(6);
|
||||
final lastRelevantPart = _lastValue!.length >= 14 ? _lastValue!.substring(6, 14) : _lastValue!.substring(6);
|
||||
|
||||
if (currentRelevantPart != lastRelevantPart) {
|
||||
final buttonClicked = SquareConstants.BUTTON_MAPPING[currentValue];
|
||||
actionStreamInternal.add(LogNotification('Button pressed: $buttonClicked'));
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(LogNotification('Button pressed: $buttonClicked'));
|
||||
}
|
||||
handleButtonsClicked([
|
||||
if (buttonClicked != null) buttonClicked,
|
||||
]);
|
||||
@@ -83,25 +82,74 @@ class SquareConstants {
|
||||
|
||||
// Button mapping https://images.bike24.com/i/mb/c7/36/d9/elite-square-smart-frame-indoor-bike-3-1724305.jpg
|
||||
static const Map<String, ControllerButton> BUTTON_MAPPING = {
|
||||
"00000200": ControllerButton.navigationUp, //"Up",
|
||||
"00000100": ControllerButton.navigationLeft, //"Left",
|
||||
"00000800": ControllerButton.navigationDown, // "Down",
|
||||
"00000400": ControllerButton.navigationRight, //"Right",
|
||||
"00002000": ControllerButton.powerUpLeft, //"X",
|
||||
"00001000": ControllerButton.sideButtonLeft, // "Square",
|
||||
"00008000": ControllerButton.campagnoloLeft, // "Left Campagnolo",
|
||||
"00004000": ControllerButton.onOffLeft, //"Left brake",
|
||||
"00000002": ControllerButton.shiftDownLeft, //"Left shift 1",
|
||||
"00000001": ControllerButton.paddleLeft, // "Left shift 2",
|
||||
"02000000": ControllerButton.y, // "Y",
|
||||
"01000000": ControllerButton.a, //"A",
|
||||
"08000000": ControllerButton.b, // "B",
|
||||
"04000000": ControllerButton.z, // "Z",
|
||||
"20000000": ControllerButton.powerUpRight, // "Circle",
|
||||
"10000000": ControllerButton.sideButtonRight, //"Triangle",
|
||||
"80000000": ControllerButton.campagnoloRight, // "Right Campagnolo",
|
||||
"40000000": ControllerButton.onOffRight, //"Right brake",
|
||||
"00020000": ControllerButton.sideButtonRight, //"Right shift 1",
|
||||
"00010000": ControllerButton.paddleRight, //"Right shift 2",
|
||||
"00000200": EliteSquareButtons.up, //"Up",
|
||||
"00000100": EliteSquareButtons.left, //"Left",
|
||||
"00000800": EliteSquareButtons.down, // "Down",
|
||||
"00000400": EliteSquareButtons.right, //"Right",
|
||||
"00002000": EliteSquareButtons.x, //"X",
|
||||
"00001000": EliteSquareButtons.square, // "Square",
|
||||
"00008000": EliteSquareButtons.campagnoloLeft, // "Left Campagnolo",
|
||||
"00004000": EliteSquareButtons.leftBrake, //"Left brake",
|
||||
"00000002": EliteSquareButtons.leftShift1, //"Left shift 1",
|
||||
"00000001": EliteSquareButtons.leftShift2, // "Left shift 2",
|
||||
"02000000": EliteSquareButtons.y, // "Y",
|
||||
"01000000": EliteSquareButtons.a, //"A",
|
||||
"08000000": EliteSquareButtons.b, // "B",
|
||||
"04000000": EliteSquareButtons.z, // "Z",
|
||||
"20000000": EliteSquareButtons.circle, // "Circle",
|
||||
"10000000": EliteSquareButtons.triangle, //"Triangle",
|
||||
"80000000": EliteSquareButtons.campagnoloRight, // "Right Campagnolo",
|
||||
"40000000": EliteSquareButtons.rightBrake, //"Right brake",
|
||||
"00020000": EliteSquareButtons.rightShift1, //"Right shift 1",
|
||||
"00010000": EliteSquareButtons.rightShift2, //"Right shift 2",
|
||||
};
|
||||
}
|
||||
|
||||
class EliteSquareButtons {
|
||||
static const ControllerButton up = ControllerButton('eliteSquareUp', action: null);
|
||||
static const ControllerButton left = ControllerButton('eliteSquareLeft', action: InGameAction.navigateLeft);
|
||||
static const ControllerButton down = ControllerButton('eliteSquareDown', action: null);
|
||||
static const ControllerButton right = ControllerButton('eliteSquareRight', action: InGameAction.navigateRight);
|
||||
static const ControllerButton x = ControllerButton('eliteSquareX', action: null);
|
||||
static const ControllerButton square = ControllerButton('eliteSquareSquare', action: null);
|
||||
static const ControllerButton campagnoloLeft = ControllerButton('eliteSquareCampagnoloLeft', action: null);
|
||||
static const ControllerButton leftBrake = ControllerButton('eliteSquareLeftBrake', action: null);
|
||||
static const ControllerButton leftShift1 = ControllerButton('eliteSquareLeftShift1', action: InGameAction.shiftUp);
|
||||
static const ControllerButton leftShift2 = ControllerButton('eliteSquareLeftShift2', action: InGameAction.shiftDown);
|
||||
static const ControllerButton y = ControllerButton('y', action: null);
|
||||
static const ControllerButton a = ControllerButton('a', action: null);
|
||||
static const ControllerButton b = ControllerButton('b', action: null);
|
||||
static const ControllerButton z = ControllerButton('z', action: null);
|
||||
static const ControllerButton circle = ControllerButton('eliteSquareCircle', action: null);
|
||||
static const ControllerButton triangle = ControllerButton('eliteSquareTriangle', action: null);
|
||||
static const ControllerButton campagnoloRight = ControllerButton('eliteSquareCampagnoloRight', action: null);
|
||||
static const ControllerButton rightBrake = ControllerButton('eliteSquareRightBrake', action: null);
|
||||
static const ControllerButton rightShift1 = ControllerButton('eliteSquareRightShift1', action: InGameAction.shiftUp);
|
||||
static const ControllerButton rightShift2 = ControllerButton(
|
||||
'eliteSquareRightShift2',
|
||||
action: InGameAction.shiftDown,
|
||||
);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
up,
|
||||
left,
|
||||
down,
|
||||
right,
|
||||
x,
|
||||
square,
|
||||
campagnoloLeft,
|
||||
leftBrake,
|
||||
leftShift1,
|
||||
leftShift2,
|
||||
y,
|
||||
a,
|
||||
b,
|
||||
z,
|
||||
circle,
|
||||
triangle,
|
||||
campagnoloRight,
|
||||
rightBrake,
|
||||
rightShift1,
|
||||
rightShift2,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../messages/notification.dart';
|
||||
|
||||
class EliteSterzo extends BaseDevice {
|
||||
EliteSterzo(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
ControllerButton.navigationLeft,
|
||||
ControllerButton.navigationRight,
|
||||
],
|
||||
isBeta: true,
|
||||
);
|
||||
class EliteSterzo extends BluetoothDevice {
|
||||
EliteSterzo(super.scanResult) : super(availableButtons: SterzoButtons.values);
|
||||
|
||||
double _lastAngle = 0.0;
|
||||
int? _latestChallenge;
|
||||
@@ -26,6 +20,18 @@ class EliteSterzo extends BaseDevice {
|
||||
static Uint8List? _challengeCodesData;
|
||||
static bool _isLoadingChallenges = false;
|
||||
|
||||
// Calibration state
|
||||
final List<double> _calibrationSamples = [];
|
||||
double _calibrationOffset = 0.0;
|
||||
bool _isCalibrated = false;
|
||||
|
||||
// Last rounded angle for logging optimization
|
||||
int? _lastRoundedAngle;
|
||||
|
||||
// Debounce timer for PWM-like keypress behavior
|
||||
Timer? _keypressTimer;
|
||||
bool _isProcessingKeypresses = false;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstOrNullWhere(
|
||||
@@ -53,7 +59,7 @@ class EliteSterzo extends BaseDevice {
|
||||
}
|
||||
|
||||
// Subscribe to challenge code indications
|
||||
await UniversalBle.subscribeNotifications(
|
||||
await UniversalBle.subscribeIndications(
|
||||
device.deviceId,
|
||||
service.uuid,
|
||||
challengeChar.uuid,
|
||||
@@ -222,32 +228,91 @@ class EliteSterzo extends BaseDevice {
|
||||
void _handleSteeringMeasurement(Uint8List bytes) {
|
||||
if (bytes.length >= 4) {
|
||||
// Steering angle is a 32-bit float (little-endian)
|
||||
final angle = ByteData.sublistView(bytes).getFloat32(0, Endian.little);
|
||||
final rawAngle = ByteData.sublistView(bytes).getFloat32(0, Endian.little);
|
||||
|
||||
actionStreamInternal.add(LogNotification('Steering angle: ${angle.toStringAsFixed(1)}°'));
|
||||
|
||||
// Determine steering direction based on angle threshold
|
||||
final button = _getSteeringButton(angle);
|
||||
|
||||
if (button != null) {
|
||||
handleButtonsClicked([button]);
|
||||
} else if (_getSteeringButton(_lastAngle) != null) {
|
||||
// Release button if we were steering but now we're centered
|
||||
handleButtonsClicked([]);
|
||||
// Ignore NaN readings during initial connection
|
||||
if (rawAngle.isNaN) {
|
||||
return;
|
||||
}
|
||||
|
||||
_lastAngle = angle;
|
||||
// Handle calibration: collect initial samples to compute offset
|
||||
if (!_isCalibrated) {
|
||||
_calibrationSamples.add(rawAngle);
|
||||
if (_calibrationSamples.length >= SterzoConstants.CALIBRATION_SAMPLE_COUNT) {
|
||||
// Compute average offset from collected samples
|
||||
_calibrationOffset = _calibrationSamples.reduce((a, b) => a + b) / _calibrationSamples.length;
|
||||
_isCalibrated = true;
|
||||
actionStreamInternal.add(
|
||||
LogNotification('Elite Sterzo: Calibration complete, offset: ${_calibrationOffset.toStringAsFixed(2)}°'),
|
||||
);
|
||||
}
|
||||
return; // Don't process steering during calibration
|
||||
}
|
||||
|
||||
// Apply calibration offset
|
||||
final calibratedAngle = rawAngle - _calibrationOffset;
|
||||
|
||||
// Round to whole degrees to reduce noise
|
||||
final roundedAngle = calibratedAngle.round();
|
||||
|
||||
// Only log and process steering when rounded value changes to reduce verbosity
|
||||
if (_lastRoundedAngle != roundedAngle) {
|
||||
actionStreamInternal.add(LogNotification('Steering angle: $roundedAngle°'));
|
||||
_lastRoundedAngle = roundedAngle;
|
||||
|
||||
// Apply PWM-like steering behavior only when angle changes
|
||||
_applyPWMSteering(roundedAngle);
|
||||
}
|
||||
|
||||
_lastAngle = calibratedAngle;
|
||||
}
|
||||
}
|
||||
|
||||
ControllerButton? _getSteeringButton(double angle) {
|
||||
// Use a threshold to avoid jitter around center
|
||||
if (angle < -SterzoConstants.STEERING_THRESHOLD) {
|
||||
return ControllerButton.navigationLeft;
|
||||
} else if (angle > SterzoConstants.STEERING_THRESHOLD) {
|
||||
return ControllerButton.navigationRight;
|
||||
/// Applies PWM-like steering behavior with repeated keypresses proportional to angle magnitude
|
||||
void _applyPWMSteering(int roundedAngle) {
|
||||
// Cancel any pending keypress timer
|
||||
_keypressTimer?.cancel();
|
||||
|
||||
// Determine if we're steering
|
||||
if (roundedAngle.abs() > SterzoConstants.STEERING_THRESHOLD) {
|
||||
// Determine direction
|
||||
final button = roundedAngle > 0 ? SterzoButtons.rightSteer : SterzoButtons.leftSteer;
|
||||
|
||||
// Calculate number of keypress levels based on angle magnitude
|
||||
final levels = _calculateKeypressLevels(roundedAngle.abs());
|
||||
|
||||
// Only trigger new keypresses when rounded angle changes to avoid overlap
|
||||
// The check for _lastRoundedAngle change is already done in _handleSteeringMeasurement
|
||||
// so we know this is a new angle value
|
||||
_scheduleRepeatedKeypresses(button, levels);
|
||||
} else {
|
||||
// Center position - release any held buttons
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Calculates the number of keypress levels based on angle magnitude
|
||||
int _calculateKeypressLevels(int absAngle) {
|
||||
final levels = (absAngle / SterzoConstants.LEVEL_DEGREE_STEP).floor();
|
||||
return levels.clamp(1, SterzoConstants.MAX_LEVELS);
|
||||
}
|
||||
|
||||
/// Schedules repeated keypresses to simulate PWM behavior
|
||||
Future<void> _scheduleRepeatedKeypresses(ControllerButton button, int levels) async {
|
||||
// Don't overlap keypress sequences
|
||||
if (_isProcessingKeypresses) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isProcessingKeypresses = true;
|
||||
|
||||
// Send keypresses in sequence with delays between them
|
||||
for (int i = 0; i < levels; i++) {
|
||||
await Future.delayed(Duration(milliseconds: SterzoConstants.KEY_REPEAT_INTERVAL_MS));
|
||||
handleButtonsClicked([button]);
|
||||
}
|
||||
|
||||
_isProcessingKeypresses = false;
|
||||
}
|
||||
|
||||
List<int> _getChallengeResponse(int challenge) {
|
||||
@@ -264,6 +329,12 @@ class EliteSterzo extends BaseDevice {
|
||||
// Fallback for out of range challenges
|
||||
return [0x96, 0x96];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_keypressTimer?.cancel();
|
||||
await super.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
class SterzoConstants {
|
||||
@@ -278,7 +349,7 @@ class SterzoConstants {
|
||||
static const String SERVICE_UUID = "347b0001-7635-408b-8918-8ff3949ce592";
|
||||
|
||||
// Steering angle threshold in degrees to trigger steering action
|
||||
static const double STEERING_THRESHOLD = 5.0;
|
||||
static const double STEERING_THRESHOLD = 10.0;
|
||||
|
||||
static const int RECONNECT_DELAY = 5; // seconds between reconnection attempts
|
||||
|
||||
@@ -288,4 +359,32 @@ class SterzoConstants {
|
||||
|
||||
// Cache key for SharedPreferences
|
||||
static const String CACHE_KEY = 'elite_sterzo_challenge_codes';
|
||||
|
||||
// Calibration settings
|
||||
// Number of initial valid samples to collect for calibration offset
|
||||
static const int CALIBRATION_SAMPLE_COUNT = 10;
|
||||
|
||||
// PWM-like steering behavior constants
|
||||
// Degrees per level for repeated keypress behavior
|
||||
static const double LEVEL_DEGREE_STEP = 10.0;
|
||||
// Maximum number of keypress levels
|
||||
static const int MAX_LEVELS = 5;
|
||||
// Interval between repeated keypresses in milliseconds
|
||||
static const int KEY_REPEAT_INTERVAL_MS = 40;
|
||||
}
|
||||
|
||||
class SterzoButtons {
|
||||
static final ControllerButton leftSteer = ControllerButton(
|
||||
'leftSteer',
|
||||
action: InGameAction.steerLeft,
|
||||
);
|
||||
static final ControllerButton rightSteer = ControllerButton(
|
||||
'rightSteer',
|
||||
action: InGameAction.steerRight,
|
||||
);
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
leftSteer,
|
||||
rightSteer,
|
||||
];
|
||||
}
|
||||
|
||||
81
lib/bluetooth/devices/gamepad/gamepad_device.dart
Normal file
81
lib/bluetooth/devices/gamepad/gamepad_device.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gamepads/gamepads.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
|
||||
class GamepadDevice extends BaseDevice {
|
||||
final String id;
|
||||
|
||||
GamepadDevice(super.name, {required this.id}) : super(availableButtons: []);
|
||||
|
||||
List<ControllerButton> _lastButtonsClicked = [];
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
Gamepads.eventsByGamepad(id).listen((event) async {
|
||||
actionStreamInternal.add(LogNotification('Gamepad event: ${event.key} value ${event.value} type ${event.type}'));
|
||||
|
||||
final int normalizedValue = switch (event.value) {
|
||||
> 1.0 => 1,
|
||||
< -1.0 => -1,
|
||||
_ => event.value.toInt(),
|
||||
};
|
||||
|
||||
final buttonKey = event.type == KeyType.analog ? '${event.key}_$normalizedValue' : event.key;
|
||||
ControllerButton button = getOrAddButton(
|
||||
buttonKey,
|
||||
() => ControllerButton(buttonKey),
|
||||
);
|
||||
|
||||
switch (event.type) {
|
||||
case KeyType.analog:
|
||||
final releasedValue = Platform.isWindows ? 1 : 0;
|
||||
|
||||
if (event.value.round().abs() != releasedValue) {
|
||||
final buttonsClicked = [button];
|
||||
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
|
||||
handleButtonsClicked(buttonsClicked);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
} else {
|
||||
_lastButtonsClicked = [];
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
case KeyType.button:
|
||||
final buttonsClicked = event.value.toInt() != 1 ? [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(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
315
lib/bluetooth/devices/gyroscope/gyroscope_steering.dart
Normal file
315
lib/bluetooth/devices/gyroscope/gyroscope_steering.dart
Normal file
@@ -0,0 +1,315 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/gyroscope/steering_estimator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/pages/device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/device_info.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
/// Gyroscope and Accelerometer based steering device
|
||||
/// Detects handlebar movement when the phone is mounted on the handlebar
|
||||
class GyroscopeSteering extends BaseDevice {
|
||||
GyroscopeSteering()
|
||||
: super(
|
||||
'Phone Steering',
|
||||
availableButtons: GyroscopeSteeringButtons.values,
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
StreamSubscription<GyroscopeEvent>? _gyroscopeSubscription;
|
||||
StreamSubscription<AccelerometerEvent>? _accelerometerSubscription;
|
||||
|
||||
// Calibration state
|
||||
final SteeringEstimator _estimator = SteeringEstimator();
|
||||
bool _isCalibrated = false;
|
||||
ControllerButton? _lastSteeringButton;
|
||||
|
||||
// Accelerometer raw data
|
||||
bool _hasAccelData = false;
|
||||
|
||||
// Time tracking for integration
|
||||
DateTime? _lastGyroUpdate;
|
||||
|
||||
// Last rounded angle for change detection
|
||||
int? _lastRoundedAngle;
|
||||
|
||||
// Debounce timer for PWM-like keypress behavior
|
||||
Timer? _keypressTimer;
|
||||
bool _isProcessingKeypresses = false;
|
||||
|
||||
// Configuration (can be made customizable later)
|
||||
static const double STEERING_THRESHOLD = 5.0; // degrees
|
||||
static const double LEVEL_DEGREE_STEP = 10.0; // degrees per level
|
||||
static const int MAX_LEVELS = 5;
|
||||
static const int KEY_REPEAT_INTERVAL_MS = 40;
|
||||
static const double COMPLEMENTARY_FILTER_ALPHA = 0.98; // Weight for gyroscope
|
||||
static const double LOW_PASS_FILTER_ALPHA = 0.9; // Smoothing factor
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
if (isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Start listening to sensors
|
||||
_gyroscopeSubscription = gyroscopeEventStream().listen(
|
||||
_handleGyroscopeEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Gyroscope error: $error'));
|
||||
},
|
||||
);
|
||||
|
||||
_accelerometerSubscription = accelerometerEventStream().listen(
|
||||
_handleAccelerometerEvent,
|
||||
onError: (error) {
|
||||
actionStreamInternal.add(LogNotification('Accelerometer error: $error'));
|
||||
},
|
||||
);
|
||||
|
||||
isConnected = true;
|
||||
actionStreamInternal.add(LogNotification('Gyroscope Steering: Connected - Calibrating...'));
|
||||
|
||||
// Reset calibration/estimator
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_lastGyroUpdate = null;
|
||||
_lastRoundedAngle = null;
|
||||
_lastSteeringButton = null;
|
||||
} catch (e) {
|
||||
actionStreamInternal.add(LogNotification('Failed to connect Gyroscope Steering: $e'));
|
||||
isConnected = false;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleGyroscopeEvent(GyroscopeEvent event) {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (!_hasAccelData) {
|
||||
_lastGyroUpdate = now;
|
||||
return;
|
||||
}
|
||||
|
||||
final dt = _lastGyroUpdate != null ? (now.difference(_lastGyroUpdate!).inMicroseconds / 1000000.0) : 0.0;
|
||||
_lastGyroUpdate = now;
|
||||
|
||||
if (dt <= 0 || dt >= 1.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS drift fix:
|
||||
// - integrate bias-corrected gyro z (yaw) into an estimator
|
||||
// - learn bias while the device is still
|
||||
final angleDeg = _estimator.updateGyro(wz: event.z, dt: dt);
|
||||
|
||||
if (!_isCalibrated) {
|
||||
// Consider calibration complete once we have a bit of stillness and sensor data.
|
||||
// This gives the bias estimator time to settle.
|
||||
if (_estimator.stillTimeSec >= 0.6) {
|
||||
_estimator.calibrate(seedBiasZRadPerSec: _estimator.biasZRadPerSec);
|
||||
_isCalibrated = true;
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Calibration complete.'),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_processSteeringAngle(angleDeg);
|
||||
}
|
||||
|
||||
void _handleAccelerometerEvent(AccelerometerEvent event) {
|
||||
_hasAccelData = true;
|
||||
_estimator.updateAccel(x: event.x, y: event.y, z: event.z);
|
||||
}
|
||||
|
||||
void _processSteeringAngle(double steeringAngleDeg) {
|
||||
final roundedAngle = steeringAngleDeg.round();
|
||||
|
||||
if (_lastRoundedAngle != roundedAngle) {
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Steering angle: $roundedAngle° (biasZ=${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s)',
|
||||
),
|
||||
);
|
||||
}
|
||||
_lastRoundedAngle = roundedAngle;
|
||||
_applyPWMSteering(roundedAngle);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() > core.settings.getPhoneSteeringThreshold()) {
|
||||
// Determine direction
|
||||
final button = roundedAngle < 0 ? GyroscopeSteeringButtons.rightSteer : GyroscopeSteeringButtons.leftSteer;
|
||||
|
||||
if (_lastSteeringButton != button) {
|
||||
// New steering direction - reset any previous state
|
||||
_lastSteeringButton = button;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
handleButtonsClicked([button]);
|
||||
} else {
|
||||
_lastSteeringButton = null;
|
||||
// Center position - release any held buttons
|
||||
handleButtonsClicked([]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
await _gyroscopeSubscription?.cancel();
|
||||
await _accelerometerSubscription?.cancel();
|
||||
_gyroscopeSubscription = null;
|
||||
_accelerometerSubscription = null;
|
||||
_keypressTimer?.cancel();
|
||||
isConnected = false;
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
actionStreamInternal.add(LogNotification('Gyroscope Steering: Disconnected'));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (c, setState) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Text(
|
||||
name.screenshot,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (isBeta) BetaPill(),
|
||||
],
|
||||
),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
DeviceInfo(
|
||||
title: 'Calibration',
|
||||
icon: BootstrapIcons.wrenchAdjustable,
|
||||
value: _isCalibrated ? 'Complete' : 'In Progress',
|
||||
),
|
||||
DeviceInfo(
|
||||
title: 'Steering Angle',
|
||||
icon: RadixIcons.angle,
|
||||
value: _isCalibrated ? '${_estimator.angleDeg.toStringAsFixed(2)}°' : 'Calibrating...',
|
||||
),
|
||||
if (kDebugMode)
|
||||
DeviceInfo(
|
||||
title: 'Gyro Bias',
|
||||
icon: BootstrapIcons.speedometer,
|
||||
value: '${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s',
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
leading: !_isCalibrated ? SmallProgressIndicator() : null,
|
||||
onPressed: !_isCalibrated
|
||||
? null
|
||||
: () {
|
||||
// Reset calibration
|
||||
_isCalibrated = false;
|
||||
_hasAccelData = false;
|
||||
_estimator.reset();
|
||||
_lastGyroUpdate = null;
|
||||
_lastRoundedAngle = null;
|
||||
_lastSteeringButton = null;
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Calibrating the sensors now.'),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(_isCalibrated ? 'Calibrate' : 'Calibrating...'),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
trailing: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.destructive,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('${core.settings.getPhoneSteeringThreshold().toInt()}°'),
|
||||
),
|
||||
onPressed: () {
|
||||
final values = [for (var i = 3; i <= 12; i += 1) i];
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (b) => DropdownMenu(
|
||||
children: values
|
||||
.map(
|
||||
(v) => MenuButton(
|
||||
child: Text('$v°'),
|
||||
onPressed: (c) {
|
||||
core.settings.setPhoneSteeringThreshold(v);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Trigger Threshold:'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!_isCalibrated)
|
||||
Text(
|
||||
'Calibrating the sensors now. Attach your phone/tablet on your handlebar and keep it still for a second.',
|
||||
).xSmall,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GyroscopeSteeringButtons {
|
||||
static final ControllerButton leftSteer = ControllerButton(
|
||||
'gyroLeftSteer',
|
||||
action: InGameAction.steerLeft,
|
||||
);
|
||||
static final ControllerButton rightSteer = ControllerButton(
|
||||
'gyroRightSteer',
|
||||
action: InGameAction.steerRight,
|
||||
);
|
||||
|
||||
static List<ControllerButton> get values => [
|
||||
leftSteer,
|
||||
rightSteer,
|
||||
];
|
||||
}
|
||||
200
lib/bluetooth/devices/gyroscope/steering_estimator.dart
Normal file
200
lib/bluetooth/devices/gyroscope/steering_estimator.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Pure-Dart steering estimator for phone-on-handlebar steering.
|
||||
///
|
||||
/// Design goals:
|
||||
/// - Avoid long-term drift on platforms like iOS by continuously estimating
|
||||
/// and subtracting gyro bias.
|
||||
/// - Keep it testable (no Flutter/sensors dependencies).
|
||||
///
|
||||
/// NOTE: This is not a full AHRS. It uses bias-corrected integration and
|
||||
/// a "stillness" detector to learn gyro bias and optionally auto-recenter.
|
||||
class SteeringEstimator {
|
||||
SteeringEstimator({
|
||||
this.biasLearningRate = 0.02,
|
||||
this.gyroStillThresholdRadPerSec = 0.03,
|
||||
this.accelStillThresholdMS2 = 0.6,
|
||||
this.minStillTimeForBiasSec = 0.35,
|
||||
this.biasLearningDeadbandDeg = 3.0,
|
||||
this.minStillTimeForRecenterSec = double.infinity,
|
||||
this.recenterHalfLifeSec = 0.7,
|
||||
this.recenterDeadbandDeg = 2.0,
|
||||
this.maxAngleAbsDeg = 60,
|
||||
this.lowPassAlpha = 0.9,
|
||||
|
||||
// Responsiveness / smoothing tuning.
|
||||
// When steering changes quickly we reduce smoothing, but keep more
|
||||
// smoothing when stable to avoid jitter.
|
||||
this.lowPassAlphaStable = 0.9,
|
||||
this.lowPassAlphaMoving = 0.55,
|
||||
this.motionAngleRateDegPerSecForMinAlpha = 90.0,
|
||||
|
||||
// Cap dt to avoid "freezing" the estimator on occasional long frames.
|
||||
this.maxDtSec = 0.05,
|
||||
});
|
||||
|
||||
// Tunables
|
||||
final double biasLearningRate;
|
||||
final double gyroStillThresholdRadPerSec;
|
||||
final double accelStillThresholdMS2;
|
||||
final double minStillTimeForBiasSec;
|
||||
final double biasLearningDeadbandDeg;
|
||||
final double minStillTimeForRecenterSec;
|
||||
final double recenterHalfLifeSec;
|
||||
final double recenterDeadbandDeg;
|
||||
final double maxAngleAbsDeg;
|
||||
|
||||
/// Backwards-compatible, kept as-is.
|
||||
///
|
||||
/// If you set `lowPassAlpha = 0.0`, filtering is disabled.
|
||||
final double lowPassAlpha;
|
||||
|
||||
/// Smoothing used when the angle is stable.
|
||||
///
|
||||
/// Default mirrors the original behavior (`0.9`).
|
||||
final double lowPassAlphaStable;
|
||||
|
||||
/// Smoothing used when the angle is changing quickly.
|
||||
///
|
||||
/// Lower alpha => faster response.
|
||||
final double lowPassAlphaMoving;
|
||||
|
||||
/// Angle rate (deg/s) at which we reach `lowPassAlphaMoving`.
|
||||
final double motionAngleRateDegPerSecForMinAlpha;
|
||||
|
||||
/// Maximum timestep used for integration/bias learning.
|
||||
final double maxDtSec;
|
||||
|
||||
// State
|
||||
double _accelX = 0, _accelY = 0, _accelZ = 0;
|
||||
bool _hasAccel = false;
|
||||
|
||||
double _biasZ = 0.0; // rad/s
|
||||
double _yawDeg = 0.0;
|
||||
double _filteredYawDeg = 0.0;
|
||||
|
||||
double _stillTimeSec = 0.0;
|
||||
|
||||
/// Resets the estimator state.
|
||||
void reset() {
|
||||
_biasZ = 0.0;
|
||||
_yawDeg = 0.0;
|
||||
_filteredYawDeg = 0.0;
|
||||
_stillTimeSec = 0.0;
|
||||
_hasAccel = false;
|
||||
_accelX = _accelY = _accelZ = 0;
|
||||
}
|
||||
|
||||
/// One-time calibration: assume device is held still and centered.
|
||||
///
|
||||
/// This resets yaw and also seeds the bias to the current z gyro rate.
|
||||
void calibrate({double? seedBiasZRadPerSec}) {
|
||||
_yawDeg = 0.0;
|
||||
_filteredYawDeg = 0.0;
|
||||
_stillTimeSec = 0.0;
|
||||
if (seedBiasZRadPerSec != null) {
|
||||
_biasZ = seedBiasZRadPerSec;
|
||||
}
|
||||
}
|
||||
|
||||
void updateAccel({required double x, required double y, required double z}) {
|
||||
_accelX = x;
|
||||
_accelY = y;
|
||||
_accelZ = z;
|
||||
_hasAccel = true;
|
||||
}
|
||||
|
||||
/// Update with gyro z-rate (rad/s) and dt (seconds).
|
||||
///
|
||||
/// Returns the current filtered steering angle in degrees.
|
||||
double updateGyro({required double wz, required double dt}) {
|
||||
if (dt <= 0) {
|
||||
return angleDeg;
|
||||
}
|
||||
|
||||
// If dt spikes (app paused/jank), cap it instead of bailing out.
|
||||
// This keeps the estimator responsive and avoids "stuck" output.
|
||||
final usedDt = dt > maxDtSec ? maxDtSec : dt;
|
||||
|
||||
final still = _isStill(wz);
|
||||
if (still) {
|
||||
_stillTimeSec += usedDt;
|
||||
|
||||
// Learn gyro bias only when we're still AND near our calibrated center.
|
||||
// Otherwise, if the user holds a steady steering angle, wz≈0 and we'd
|
||||
// incorrectly move bias towards 0 and cause the angle to be wrong when
|
||||
// they return to center.
|
||||
final nearCenter = _yawDeg.abs() <= biasLearningDeadbandDeg;
|
||||
if (nearCenter && _stillTimeSec >= minStillTimeForBiasSec) {
|
||||
// Exponential moving average towards the observed rate.
|
||||
_biasZ = (1.0 - biasLearningRate) * _biasZ + biasLearningRate * wz;
|
||||
}
|
||||
|
||||
// IMPORTANT: only auto-recenter when we're already close to center.
|
||||
// Users may hold a constant steering angle for several seconds.
|
||||
final canRecenter = _stillTimeSec >= minStillTimeForRecenterSec && _yawDeg.abs() <= recenterDeadbandDeg;
|
||||
if (canRecenter) {
|
||||
_applyRecenter(usedDt);
|
||||
}
|
||||
} else {
|
||||
_stillTimeSec = 0.0;
|
||||
}
|
||||
|
||||
final correctedWz = wz - _biasZ;
|
||||
_yawDeg += correctedWz * usedDt * (180.0 / pi);
|
||||
|
||||
// Clamp to avoid runaway if something goes wrong.
|
||||
_yawDeg = _yawDeg.clamp(-maxAngleAbsDeg, maxAngleAbsDeg).toDouble();
|
||||
|
||||
// Low-pass filter for noise smoothing.
|
||||
//
|
||||
// Make it adaptive: when the angle is changing fast, we reduce smoothing
|
||||
// (more responsive). When stable, we keep stronger smoothing.
|
||||
//
|
||||
// If user forces lowPassAlpha=0.0 (existing tests do), we keep behavior
|
||||
// equivalent (no filtering).
|
||||
if (lowPassAlpha <= 0.0) {
|
||||
_filteredYawDeg = _yawDeg;
|
||||
} else {
|
||||
final stableAlpha = ((lowPassAlphaStable.isFinite ? lowPassAlphaStable : lowPassAlpha)).clamp(0.0, 0.999);
|
||||
final movingAlpha = lowPassAlphaMoving.clamp(0.0, stableAlpha);
|
||||
|
||||
// Use a rate estimate derived from the filtered-vs-raw divergence.
|
||||
final rateDegPerSec = ((_yawDeg - _filteredYawDeg).abs()) / usedDt;
|
||||
final t = (rateDegPerSec / motionAngleRateDegPerSecForMinAlpha).clamp(0.0, 1.0);
|
||||
|
||||
final alpha = stableAlpha + (movingAlpha - stableAlpha) * t;
|
||||
_filteredYawDeg = alpha * _filteredYawDeg + (1 - alpha) * _yawDeg;
|
||||
}
|
||||
|
||||
return angleDeg;
|
||||
}
|
||||
|
||||
double get angleDeg => _filteredYawDeg;
|
||||
|
||||
double get biasZRadPerSec => _biasZ;
|
||||
|
||||
double get stillTimeSec => _stillTimeSec;
|
||||
|
||||
bool _isStill(double wz) {
|
||||
// If we don't have accel yet, be conservative: don't learn bias.
|
||||
if (!_hasAccel) return false;
|
||||
|
||||
final gyroOk = wz.abs() < gyroStillThresholdRadPerSec;
|
||||
|
||||
// Check accel magnitude close to gravity (device not being bumped).
|
||||
final aMag = sqrt(_accelX * _accelX + _accelY * _accelY + _accelZ * _accelZ);
|
||||
const g = 9.80665;
|
||||
final accelOk = (aMag - g).abs() < accelStillThresholdMS2;
|
||||
|
||||
return gyroOk && accelOk;
|
||||
}
|
||||
|
||||
void _applyRecenter(double dt) {
|
||||
// Exponential decay towards 0 with given half-life.
|
||||
// decay = 0.5^(dt/halfLife)
|
||||
if (recenterHalfLifeSec <= 0) return;
|
||||
final decay = pow(0.5, dt / recenterHalfLifeSec).toDouble();
|
||||
_yawDeg *= decay;
|
||||
}
|
||||
}
|
||||
37
lib/bluetooth/devices/hid/hid_device.dart
Normal file
37
lib/bluetooth/devices/hid/hid_device.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
|
||||
class HidDevice extends BaseDevice {
|
||||
HidDevice(super.name) : super(availableButtons: []);
|
||||
|
||||
@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: () {
|
||||
core.connection.disconnect(this, forget: true, persistForget: true);
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
(core.actionHandler as AndroidActions).ignoreHidDevices();
|
||||
} else if (core.mediaKeyHandler.isMediaKeyDetectionEnabled.value) {
|
||||
core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
178
lib/bluetooth/devices/mywhoosh/link.dart
Normal file
178
lib/bluetooth/devices/mywhoosh/link.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class WhooshLink extends TrainerConnection {
|
||||
Socket? _socket;
|
||||
ServerSocket? _server;
|
||||
|
||||
static const String connectionTitle = 'MyWhoosh Link';
|
||||
|
||||
WhooshLink()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.cameraAngle,
|
||||
InGameAction.emote,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
],
|
||||
);
|
||||
|
||||
void stopServer() async {
|
||||
await _socket?.close();
|
||||
await _server?.close();
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
if (kDebugMode) {
|
||||
print('Server stopped.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
try {
|
||||
// Create and bind server socket
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
21587,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to start server: $e');
|
||||
}
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
rethrow;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_server!.port}');
|
||||
}
|
||||
|
||||
// Accept connection
|
||||
_server!.listen(
|
||||
(Socket socket) {
|
||||
_socket = socket;
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.myWhooshLinkConnected),
|
||||
);
|
||||
isConnected.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
// TODO we could check if virtual shifting is enabled
|
||||
final message = utf8.decode(data);
|
||||
print('Received message: $message');
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
isConnected.value = false;
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'MyWhoosh Link disconnected'),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final jsonObject = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'GearShifting': '1',
|
||||
},
|
||||
},
|
||||
InGameAction.shiftDown => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'GearShifting': '-1',
|
||||
},
|
||||
},
|
||||
InGameAction.cameraAngle => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'CameraAngle': '${keyPair.inGameActionValue}',
|
||||
},
|
||||
},
|
||||
InGameAction.emote => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Emote': '${keyPair.inGameActionValue}',
|
||||
},
|
||||
},
|
||||
InGameAction.uturn => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'UTurn': 'true',
|
||||
},
|
||||
},
|
||||
InGameAction.steerLeft => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Steering': isKeyDown ? '-1' : '0',
|
||||
},
|
||||
},
|
||||
InGameAction.steerRight => {
|
||||
'MessageType': 'Controls',
|
||||
'InGameControls': {
|
||||
'Steering': isKeyDown ? '1' : '0',
|
||||
},
|
||||
},
|
||||
InGameAction.increaseResistance => null,
|
||||
InGameAction.decreaseResistance => null,
|
||||
InGameAction.navigateLeft => null,
|
||||
InGameAction.navigateRight => null,
|
||||
InGameAction.toggleUi => null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
final supportsIsKeyUpActions = [
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
];
|
||||
if (jsonObject != null && !isKeyDown && !supportsIsKeyUpActions.contains(keyPair.inGameAction)) {
|
||||
return Ignored('No Action sent on key down for action: ${keyPair.inGameAction}');
|
||||
} else if (jsonObject != null) {
|
||||
final jsonString = jsonEncode(jsonObject);
|
||||
_socket?.writeln(jsonString);
|
||||
return Success('Sent action to MyWhoosh: ${keyPair.inGameAction} ${keyPair.inGameActionValue ?? ''}');
|
||||
} else {
|
||||
return NotHandled('No action available for button: ${keyPair.inGameAction}');
|
||||
}
|
||||
}
|
||||
|
||||
bool isCompatible(Target target) {
|
||||
return kIsWeb
|
||||
? false
|
||||
: switch (target) {
|
||||
Target.thisDevice => !Platform.isWindows,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
267
lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart
Normal file
267
lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart
Normal file
@@ -0,0 +1,267 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../messages/notification.dart' show AlertNotification;
|
||||
|
||||
class OpenBikeControlBluetoothEmulator extends TrainerConnection {
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier<AppInfo?>(null);
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
Central? _central;
|
||||
|
||||
late GATTCharacteristic _buttonCharacteristic;
|
||||
|
||||
static const String connectionTitle = 'OpenBikeControl BLE Emulator';
|
||||
|
||||
OpenBikeControlBluetoothEmulator()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
|
||||
_peripheralManager.stateChanged.forEach((state) {
|
||||
print('Peripheral manager state: ${state.state}');
|
||||
});
|
||||
|
||||
if (!kIsWeb && 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) {
|
||||
if (connectedApp.value != null) {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'),
|
||||
);
|
||||
}
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_central = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getObpBleEnabled()) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
_buttonCharacteristic = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString(OpenBikeControlConstants.BUTTON_STATE_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()) {
|
||||
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) {
|
||||
_central = char.central;
|
||||
print(
|
||||
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
|
||||
);
|
||||
});
|
||||
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
|
||||
final characteristic = eventArgs.characteristic;
|
||||
final request = eventArgs.request;
|
||||
final value = request.value;
|
||||
print(
|
||||
'Write request for characteristic: ${characteristic.uuid}',
|
||||
);
|
||||
|
||||
switch (eventArgs.characteristic.uuid.toString().toLowerCase()) {
|
||||
case OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID:
|
||||
try {
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(value);
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
print('Parsed App Info: $appInfo');
|
||||
} catch (e) {
|
||||
print('Error parsing App Info: $e');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
|
||||
}
|
||||
|
||||
await _peripheralManager.respondWriteRequest(request);
|
||||
});
|
||||
}
|
||||
|
||||
if (!Platform.isWindows) {
|
||||
// Device Information
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('1.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Battery Service
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
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(OpenBikeControlConstants.SERVICE_UUID),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
_buttonCharacteristic,
|
||||
GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString(OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID),
|
||||
descriptors: [],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.writeWithoutResponse,
|
||||
GATTCharacteristicProperty.write,
|
||||
],
|
||||
permissions: [
|
||||
GATTCharacteristicPermission.read,
|
||||
GATTCharacteristicPermission.write,
|
||||
],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
_isServiceAdded = true;
|
||||
}
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name: 'BikeControl',
|
||||
serviceUUIDs: [UUID.fromString(OpenBikeControlConstants.SERVICE_UUID)],
|
||||
);
|
||||
print('Starting advertising with OpenBikeControl service...');
|
||||
|
||||
await _peripheralManager.startAdvertising(advertisement);
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
if (kDebugMode) {
|
||||
print('Stopping OpenBikeControl BLE server...');
|
||||
}
|
||||
await _peripheralManager.stopAdvertising();
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final inGameAction = keyPair.inGameAction;
|
||||
|
||||
final mappedButtons = connectedApp.value!.supportedButtons.filter(
|
||||
(supportedButton) => supportedButton.action == inGameAction,
|
||||
);
|
||||
|
||||
if (inGameAction == null) {
|
||||
return Error('Invalid in-game action for key pair: $keyPair');
|
||||
} else if (_central == null) {
|
||||
return Error('No central connected');
|
||||
} else if (connectedApp.value == null) {
|
||||
return Error('No app info received from central');
|
||||
} else if (mappedButtons.isEmpty) {
|
||||
return NotHandled('App does not support all buttons for action: ${inGameAction.title}');
|
||||
}
|
||||
|
||||
if (isKeyDown && isKeyUp) {
|
||||
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataDown);
|
||||
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataUp);
|
||||
} else {
|
||||
final responseData = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
|
||||
);
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseData);
|
||||
}
|
||||
|
||||
return Success('Buttons ${inGameAction.title} sent');
|
||||
}
|
||||
}
|
||||
207
lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart
Normal file
207
lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
|
||||
class OpenBikeControlMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _server;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
static const String connectionTitle = 'OpenBikeControl mDNS Emulator';
|
||||
|
||||
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier(null);
|
||||
|
||||
Socket? _socket;
|
||||
|
||||
OpenBikeControlMdnsEmulator()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
print('Starting mDNS server...');
|
||||
isStarted.value = true;
|
||||
|
||||
// Get local IP
|
||||
final interfaces = await NetworkInterface.list();
|
||||
InternetAddress? localIP;
|
||||
|
||||
for (final interface in interfaces) {
|
||||
for (final addr in interface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||
localIP = addr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (localIP != null) break;
|
||||
}
|
||||
|
||||
if (localIP == null) {
|
||||
throw 'Could not find network interface';
|
||||
}
|
||||
|
||||
_createTcpServer();
|
||||
|
||||
if (kDebugMode) {
|
||||
enableLogging(LogTopic.calls);
|
||||
enableLogging(LogTopic.errors);
|
||||
}
|
||||
disableServiceTypeValidation(true);
|
||||
|
||||
try {
|
||||
// Create service
|
||||
_mdnsRegistration = await register(
|
||||
Service(
|
||||
name: 'BikeControl',
|
||||
type: '_openbikecontrol._tcp',
|
||||
port: 36867,
|
||||
//hostName: 'KICKR BIKE SHIFT B84D.local',
|
||||
addresses: [localIP],
|
||||
txt: {
|
||||
'version': Uint8List.fromList([0x01]),
|
||||
'id': Uint8List.fromList('1337'.codeUnits),
|
||||
'name': Uint8List.fromList('BikeControl'.codeUnits),
|
||||
'service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits),
|
||||
'manufacturer': Uint8List.fromList('OpenBikeControl'.codeUnits),
|
||||
'model': Uint8List.fromList('BikeControl app'.codeUnits),
|
||||
},
|
||||
),
|
||||
);
|
||||
print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration');
|
||||
print('Server started - advertising service!');
|
||||
} catch (e, s) {
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start mDNS server: $e'));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopServer() async {
|
||||
if (kDebugMode) {
|
||||
print('Stopping OpenBikeControl mDNS server...');
|
||||
}
|
||||
if (_mdnsRegistration != null) {
|
||||
unregister(_mdnsRegistration!);
|
||||
_mdnsRegistration = null;
|
||||
}
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_socket?.destroy();
|
||||
_socket = null;
|
||||
}
|
||||
|
||||
Future<void> _createTcpServer() async {
|
||||
try {
|
||||
_server = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
36867,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
} catch (e) {
|
||||
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start server: $e'));
|
||||
rethrow;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_server!.port}');
|
||||
}
|
||||
|
||||
// Accept connection
|
||||
_server!.listen(
|
||||
(Socket socket) {
|
||||
_socket = socket;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
if (kDebugMode) {
|
||||
print('Received message: ${bytesToHex(data)}');
|
||||
}
|
||||
final messageType = data[0];
|
||||
switch (messageType) {
|
||||
case OpenBikeProtocolParser.MSG_TYPE_APP_INFO:
|
||||
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data));
|
||||
isConnected.value = true;
|
||||
connectedApp.value = appInfo;
|
||||
|
||||
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
print('Unknown message type: $messageType');
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'),
|
||||
);
|
||||
isConnected.value = false;
|
||||
connectedApp.value = null;
|
||||
_socket = null;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final inGameAction = keyPair.inGameAction;
|
||||
|
||||
final mappedButtons = connectedApp.value!.supportedButtons.filter(
|
||||
(supportedButton) => supportedButton.action == inGameAction,
|
||||
);
|
||||
|
||||
if (inGameAction == null) {
|
||||
return Error('Invalid in-game action for key pair: $keyPair');
|
||||
} else if (_socket == null) {
|
||||
print('No client connected, cannot send button press');
|
||||
return Error('No client connected');
|
||||
} else if (connectedApp.value == null) {
|
||||
return Error('No app info received from central');
|
||||
} else if (mappedButtons.isEmpty) {
|
||||
return NotHandled('App does not support: ${inGameAction.title}');
|
||||
}
|
||||
|
||||
if (isKeyDown && isKeyUp) {
|
||||
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
|
||||
);
|
||||
_write(_socket!, responseDataDown);
|
||||
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
|
||||
);
|
||||
_write(_socket!, responseDataUp);
|
||||
} else {
|
||||
final responseData = OpenBikeProtocolParser.encodeButtonState(
|
||||
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
|
||||
);
|
||||
_write(_socket!, responseData);
|
||||
}
|
||||
|
||||
return Success('Sent ${inGameAction.title} button press');
|
||||
}
|
||||
|
||||
void _write(Socket socket, List<int> responseData) {
|
||||
debugPrint('Sending response: ${bytesToHex(responseData)}');
|
||||
socket.add(responseData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
import 'protocol_parser.dart';
|
||||
|
||||
class OpenBikeControlDevice extends BluetoothDevice {
|
||||
OpenBikeControlDevice(super.scanResult)
|
||||
: super(
|
||||
availableButtons: OpenBikeProtocolParser.BUTTON_NAMES.values.toList(),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == OpenBikeControlConstants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${OpenBikeControlConstants.SERVICE_UUID}'),
|
||||
);
|
||||
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () =>
|
||||
throw Exception('Characteristic not found: ${OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
|
||||
final appInfoCharacteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () =>
|
||||
throw Exception('Characteristic not found: ${OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service.uuid,
|
||||
appInfoCharacteristic.uuid,
|
||||
OpenBikeProtocolParser.encodeAppInfo(
|
||||
appId: 'BikeControl',
|
||||
appVersion: packageInfoValue!.version,
|
||||
supportedButtons: OpenBikeProtocolParser.BUTTON_NAMES.values.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
final charLower = characteristic.toLowerCase();
|
||||
|
||||
if (charLower == OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
try {
|
||||
final parsed = OpenBikeProtocolParser.parseButtonState(bytes);
|
||||
|
||||
final buttonsToPress = parsed.where((e) => e.state == 1).map((e) => e.button).toList();
|
||||
|
||||
await handleButtonsClicked(buttonsToPress);
|
||||
} catch (e) {
|
||||
actionStreamInternal.add(AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Error parsing OpenBike message: $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OpenBikeControlConstants {
|
||||
// OpenBikeControl BLE service and characteristic UUIDs (see BLE.md)
|
||||
static const String SERVICE_UUID = 'd273f680-d548-419d-b9d1-fa0472345229';
|
||||
|
||||
// Button State Characteristic (Notify)
|
||||
static const String BUTTON_STATE_CHARACTERISTIC_UUID = 'd273f681-d548-419d-b9d1-fa0472345229';
|
||||
|
||||
// Haptic Feedback Characteristic (Write)
|
||||
static const String HAPTIC_CHARACTERISTIC_UUID = 'd273f682-d548-419d-b9d1-fa0472345229';
|
||||
|
||||
// App Info Characteristic (Write)
|
||||
static const String APPINFO_CHARACTERISTIC_UUID = 'd273f683-d548-419d-b9d1-fa0472345229';
|
||||
}
|
||||
308
lib/bluetooth/devices/openbikecontrol/protocol_parser.dart
Normal file
308
lib/bluetooth/devices/openbikecontrol/protocol_parser.dart
Normal file
@@ -0,0 +1,308 @@
|
||||
// OpenBikeControl Protocol Parser (Dart)
|
||||
|
||||
// This file is a translation of the Python `protocol_parser.py` example into Dart.
|
||||
// It provides simple encoding/decoding utilities for the OpenBikeControl message
|
||||
// types used in the Python example. This is intentionally a small, focused
|
||||
// module that mirrors the original Python API.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
|
||||
class ProtocolParseException implements Exception {
|
||||
final String message;
|
||||
final Uint8List? raw;
|
||||
ProtocolParseException(this.message, [this.raw]);
|
||||
|
||||
@override
|
||||
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${raw!.length}' : ''}';
|
||||
}
|
||||
|
||||
class OpenBikeProtocolParser {
|
||||
// Button ID to name mapping (based on PROTOCOL.md in Python example)
|
||||
static const Map<int, ControllerButton> BUTTON_NAMES = {
|
||||
// Gear Shifting (0x01-0x0F)
|
||||
0x01: ControllerButton('Shift Up', identifier: 0x01, action: InGameAction.shiftUp),
|
||||
0x02: ControllerButton('Shift Down', identifier: 0x02, action: InGameAction.shiftDown),
|
||||
0x03: ControllerButton('Gear Set', identifier: 0x03),
|
||||
// Navigation (0x10-0x1F)
|
||||
0x10: ControllerButton('Up/Steer Left', identifier: 0x10, action: InGameAction.steerLeft),
|
||||
0x11: ControllerButton('Down/Steer Right', identifier: 0x11, action: InGameAction.steerRight),
|
||||
0x12: ControllerButton('Left/Look Left', identifier: 0x12, action: InGameAction.navigateLeft),
|
||||
0x13: ControllerButton('Right/Look Right', identifier: 0x13, action: InGameAction.navigateRight),
|
||||
0x14: ControllerButton('Select/Confirm', identifier: 0x14, action: InGameAction.select),
|
||||
0x15: ControllerButton('Back/Cancel', identifier: 0x15, action: InGameAction.back),
|
||||
0x16: ControllerButton('Menu', identifier: 0x16),
|
||||
0x17: ControllerButton('Home', identifier: 0x17),
|
||||
// Social/Emotes (0x20-0x2F)
|
||||
0x20: ControllerButton('Wave', identifier: 0x20, action: InGameAction.emote),
|
||||
0x21: ControllerButton('Thumbs Up', identifier: 0x21, action: InGameAction.emote),
|
||||
0x22: ControllerButton('Hammer Time', identifier: 0x22, action: InGameAction.emote),
|
||||
0x23: ControllerButton('Bell', identifier: 0x23),
|
||||
0x24: ControllerButton('Screenshot', identifier: 0x24),
|
||||
// Training Controls (0x30-0x3F)
|
||||
0x30: ControllerButton('ERG Up', identifier: 0x30, action: InGameAction.increaseResistance),
|
||||
0x31: ControllerButton('ERG Down', identifier: 0x31, action: InGameAction.decreaseResistance),
|
||||
0x32: ControllerButton('Skip Interval', identifier: 0x32),
|
||||
0x33: ControllerButton('Pause', identifier: 0x33),
|
||||
0x34: ControllerButton('Resume', identifier: 0x34),
|
||||
0x35: ControllerButton('Lap', identifier: 0x35),
|
||||
// View Controls (0x40-0x4F)
|
||||
0x40: ControllerButton('Camera Angle', identifier: 0x40, action: InGameAction.cameraAngle),
|
||||
0x41: ControllerButton('Camera 1', identifier: 0x41, action: InGameAction.cameraAngle),
|
||||
0x42: ControllerButton('Camera 2', identifier: 0x42, action: InGameAction.cameraAngle),
|
||||
0x43: ControllerButton('Camera 3', identifier: 0x43, action: InGameAction.cameraAngle),
|
||||
0x44: ControllerButton('HUD Toggle', identifier: 0x44, action: InGameAction.toggleUi),
|
||||
0x45: ControllerButton('Map Toggle', identifier: 0x45),
|
||||
// Power-ups (0x50-0x5F)
|
||||
0x50: ControllerButton('Power-up 1', identifier: 0x50, action: InGameAction.usePowerUp),
|
||||
0x51: ControllerButton('Power-up 2', identifier: 0x51, action: InGameAction.usePowerUp),
|
||||
0x52: ControllerButton('Power-up 3', identifier: 0x52, action: InGameAction.usePowerUp),
|
||||
};
|
||||
|
||||
// Haptic feedback patterns
|
||||
static const Map<String, int> HAPTIC_PATTERNS = {
|
||||
'none': 0x00,
|
||||
'short': 0x01,
|
||||
'double': 0x02,
|
||||
'triple': 0x03,
|
||||
'long': 0x04,
|
||||
'success': 0x05,
|
||||
'warning': 0x06,
|
||||
'error': 0x07,
|
||||
};
|
||||
|
||||
// Message types (for TCP/mDNS protocol)
|
||||
static const int MSG_TYPE_BUTTON_STATE = 0x01;
|
||||
static const int MSG_TYPE_DEVICE_STATUS = 0x02;
|
||||
static const int MSG_TYPE_HAPTIC_FEEDBACK = 0x03;
|
||||
static const int MSG_TYPE_APP_INFO = 0x04;
|
||||
|
||||
/// Parse button state data from binary format.
|
||||
/// Data format: [Message_Type, Button_ID_1, State_1, Button_ID_2, State_2, ...]
|
||||
static List<ButtonState> parseButtonState(Uint8List data) {
|
||||
final buttons = <ButtonState>[];
|
||||
|
||||
if (data.isEmpty) return buttons;
|
||||
|
||||
if (data[0] != MSG_TYPE_BUTTON_STATE) return buttons;
|
||||
|
||||
for (var i = 1; i < data.length; i += 2) {
|
||||
if (i + 1 < data.length) {
|
||||
final buttonId = data[i];
|
||||
final state = data[i + 1];
|
||||
if (BUTTON_NAMES[buttonId] != null) {
|
||||
buttons.add(ButtonState(BUTTON_NAMES[buttonId]!, state));
|
||||
} else {
|
||||
throw ProtocolParseException('Unknown button ID: 0x${buttonId.toRadixString(16).padLeft(2, '0')}', data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
static Uint8List encodeButtonState(List<ButtonState> buttons) {
|
||||
final bytes = BytesBuilder();
|
||||
bytes.addByte(MSG_TYPE_BUTTON_STATE);
|
||||
for (final b in buttons) {
|
||||
bytes.addByte(b.button.identifier!);
|
||||
bytes.addByte(b.state);
|
||||
}
|
||||
return bytes.toBytes();
|
||||
}
|
||||
|
||||
static DeviceStatus parseDeviceStatus(Uint8List data) {
|
||||
if (data.length < 3) {
|
||||
throw ProtocolParseException('Device status message too short', data);
|
||||
}
|
||||
if (data[0] != MSG_TYPE_DEVICE_STATUS) {
|
||||
throw ProtocolParseException('Invalid message type: ${data[0]}, expected $MSG_TYPE_DEVICE_STATUS', data);
|
||||
}
|
||||
final battery = data[1] == 0xFF ? null : data[1];
|
||||
final connected = data[2] == 0x01;
|
||||
return DeviceStatus(battery: battery, connected: connected);
|
||||
}
|
||||
|
||||
static Uint8List encodeDeviceStatus({int? battery, bool connected = true}) {
|
||||
final batteryByte = battery == null ? 0xFF : (battery & 0xFF);
|
||||
final connectedByte = connected ? 0x01 : 0x00;
|
||||
return Uint8List.fromList([MSG_TYPE_DEVICE_STATUS, batteryByte, connectedByte]);
|
||||
}
|
||||
|
||||
static Uint8List encodeHapticFeedback({String pattern = 'short', int duration = 0, int intensity = 0}) {
|
||||
final patternByte = HAPTIC_PATTERNS[pattern] ?? HAPTIC_PATTERNS['short']!;
|
||||
final bytes = Uint8List(4);
|
||||
bytes[0] = MSG_TYPE_HAPTIC_FEEDBACK;
|
||||
bytes[1] = patternByte;
|
||||
bytes[2] = duration & 0xFF;
|
||||
bytes[3] = intensity & 0xFF;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static HapticFeedbackMessage parseHapticFeedback(Uint8List data) {
|
||||
if (data.length < 4) {
|
||||
throw ProtocolParseException('Haptic feedback message too short', data);
|
||||
}
|
||||
if (data[0] != MSG_TYPE_HAPTIC_FEEDBACK) {
|
||||
throw ProtocolParseException('Invalid message type: ${data[0]}', data);
|
||||
}
|
||||
final patternByte = data[1];
|
||||
final duration = data[2];
|
||||
final intensity = data[3];
|
||||
String patternName = 'unknown';
|
||||
HAPTIC_PATTERNS.forEach((name, value) {
|
||||
if (value == patternByte) patternName = name;
|
||||
});
|
||||
|
||||
return HapticFeedbackMessage(
|
||||
pattern: patternName,
|
||||
patternByte: patternByte,
|
||||
duration: duration,
|
||||
intensity: intensity,
|
||||
);
|
||||
}
|
||||
|
||||
static Uint8List encodeAppInfo({
|
||||
required String appId,
|
||||
required String appVersion,
|
||||
required List<ControllerButton> supportedButtons,
|
||||
}) {
|
||||
final appIdBytes = utf8.encode(appId).take(32).toList();
|
||||
final appVersionBytes = utf8.encode(appVersion).take(32).toList();
|
||||
|
||||
final builder = BytesBuilder();
|
||||
builder.addByte(MSG_TYPE_APP_INFO);
|
||||
builder.addByte(0x01); // Version
|
||||
builder.addByte(appIdBytes.length);
|
||||
builder.add(appIdBytes);
|
||||
builder.addByte(appVersionBytes.length);
|
||||
builder.add(appVersionBytes);
|
||||
builder.addByte(supportedButtons.length);
|
||||
builder.add(supportedButtons.map((e) => e.identifier!).toList());
|
||||
|
||||
return builder.toBytes();
|
||||
}
|
||||
|
||||
static AppInfo parseAppInfo(Uint8List data) {
|
||||
if (data.isEmpty || data[0] != MSG_TYPE_APP_INFO) {
|
||||
throw ProtocolParseException('Invalid message type', data);
|
||||
}
|
||||
|
||||
var idx = 1;
|
||||
if (data.length < idx + 3) {
|
||||
throw ProtocolParseException('App info message too short', data);
|
||||
}
|
||||
|
||||
final version = data[idx];
|
||||
idx += 1;
|
||||
if (version != 0x01) {
|
||||
throw ProtocolParseException('Unsupported app info version: $version', data);
|
||||
}
|
||||
|
||||
if (idx >= data.length) throw ProtocolParseException('Missing app ID length', data);
|
||||
final appIdLen = data[idx];
|
||||
idx += 1;
|
||||
if (idx + appIdLen > data.length) throw ProtocolParseException('App ID length exceeds buffer', data);
|
||||
final appId = utf8.decode(data.sublist(idx, idx + appIdLen));
|
||||
idx += appIdLen;
|
||||
|
||||
if (idx >= data.length) throw ProtocolParseException('Missing app version length', data);
|
||||
final appVersionLen = data[idx];
|
||||
idx += 1;
|
||||
if (idx + appVersionLen > data.length) throw ProtocolParseException('App version length exceeds buffer', data);
|
||||
final appVersion = utf8.decode(data.sublist(idx, idx + appVersionLen));
|
||||
idx += appVersionLen;
|
||||
|
||||
if (idx >= data.length) throw ProtocolParseException('Missing button count', data);
|
||||
final buttonCount = data[idx];
|
||||
idx += 1;
|
||||
if (idx + buttonCount > data.length) throw ProtocolParseException('Button count exceeds buffer', data);
|
||||
final buttonIds = data.sublist(idx, idx + buttonCount).toList();
|
||||
|
||||
final controllerButtons = buttonIds.mapNotNull((id) => BUTTON_NAMES[id]).toList();
|
||||
|
||||
return AppInfo(
|
||||
appId: appId,
|
||||
appVersion: appVersion,
|
||||
supportedButtons: controllerButtons,
|
||||
supportedActions: controllerButtons.mapNotNull((b) => b.action).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppInfo {
|
||||
final String appId;
|
||||
final String appVersion;
|
||||
final List<ControllerButton> supportedButtons;
|
||||
final List<InGameAction> supportedActions;
|
||||
|
||||
AppInfo({
|
||||
required this.appId,
|
||||
required this.appVersion,
|
||||
required this.supportedButtons,
|
||||
required this.supportedActions,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'AppInfo(appId: $appId, appVersion: $appVersion, supportedButtons: $supportedButtons, supportedActions: $supportedActions)';
|
||||
}
|
||||
|
||||
/// DeviceStatus message representation
|
||||
class DeviceStatus {
|
||||
final int? battery; // 0-100, null if 0xFF
|
||||
final bool connected;
|
||||
|
||||
DeviceStatus({required this.battery, required this.connected});
|
||||
|
||||
@override
|
||||
String toString() => 'DeviceStatus(battery: ${battery ?? 'unknown'}, connected: $connected)';
|
||||
}
|
||||
|
||||
class ButtonState {
|
||||
/// Represents a single button id/state pair.class ButtonState {
|
||||
final ControllerButton button;
|
||||
final int state; // 0=released,1=pressed,2-255=analog
|
||||
|
||||
const ButtonState(this.button, this.state);
|
||||
|
||||
@override
|
||||
String toString() => formatButtonState(button, state);
|
||||
|
||||
String formatButtonState(ControllerButton button, int state) {
|
||||
final buttonName = button.name;
|
||||
|
||||
String stateStr;
|
||||
if (state == 0) {
|
||||
stateStr = 'RELEASED';
|
||||
} else if (state == 1) {
|
||||
stateStr = 'PRESSED';
|
||||
} else {
|
||||
final percentage = ((state - 2) / (255 - 2) * 100).round();
|
||||
stateStr = 'ANALOG $percentage%';
|
||||
}
|
||||
|
||||
return '$buttonName: $stateStr';
|
||||
}
|
||||
}
|
||||
|
||||
/// Haptic feedback representation
|
||||
class HapticFeedbackMessage {
|
||||
final String pattern;
|
||||
final int patternByte;
|
||||
final int duration; // in 10ms units
|
||||
final int intensity; // 0-255
|
||||
|
||||
HapticFeedbackMessage({
|
||||
required this.pattern,
|
||||
required this.patternByte,
|
||||
required this.duration,
|
||||
required this.intensity,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'HapticFeedback(pattern: $pattern, duration: $duration, intensity: $intensity)';
|
||||
}
|
||||
101
lib/bluetooth/devices/shimano/shimano_di2.dart
Normal file
101
lib/bluetooth/devices/shimano/shimano_di2.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class ShimanoDi2 extends BluetoothDevice {
|
||||
ShimanoDi2(super.scanResult) : super(availableButtons: []);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ShimanoDi2Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${ShimanoDi2Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${ShimanoDi2Constants.D_FLY_CHANNEL_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
final _lastButtons = <int, int>{};
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
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;
|
||||
|
||||
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 = getOrAddButton(
|
||||
'D-Fly Channel $readableIndex',
|
||||
() => ControllerButton('D-Fly Channel $readableIndex'),
|
||||
);
|
||||
if (didChange) {
|
||||
clickedButtons.add(button);
|
||||
}
|
||||
});
|
||||
|
||||
if (clickedButtons.isNotEmpty) {
|
||||
await handleButtonsClickedWithoutLongPressSupport(clickedButtons);
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
'Make sure to set your Di2 buttons to D-Fly channels in the Shimano E-TUBE app.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
if (core.actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
'Use a custom keymap to support ${scanResult.name}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShimanoDi2Constants {
|
||||
static const String SERVICE_UUID = "000018ef-5348-494d-414e-4f5f424c4500";
|
||||
static const String SERVICE_UUID_ALTERNATIVE = "000018ff-5348-494d-414e-4f5f424c4500";
|
||||
|
||||
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
|
||||
}
|
||||
171
lib/bluetooth/devices/sram/sram_axs.dart
Normal file
171
lib/bluetooth/devices/sram/sram_axs.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class SramAxs extends BluetoothDevice {
|
||||
SramAxs(super.scanResult) : super(availableButtons: [], isBeta: true);
|
||||
|
||||
Timer? _singleClickTimer;
|
||||
int _tapCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_tapCount = 0;
|
||||
await super.disconnect();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == SramAxsConstants.SERVICE_UUID_RELEVANT.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${SramAxsConstants.SERVICE_UUID_RELEVANT}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == SramAxsConstants.TRIGGER_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${SramAxsConstants.TRIGGER_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
|
||||
// add both buttons
|
||||
_singleClickButton();
|
||||
_doubleClickButton();
|
||||
}
|
||||
|
||||
ControllerButton _singleClickButton() => getOrAddButton(
|
||||
'SRAM Tap',
|
||||
() => const ControllerButton('SRAM Tap', action: InGameAction.shiftUp),
|
||||
);
|
||||
|
||||
ControllerButton _doubleClickButton() => getOrAddButton(
|
||||
'SRAM Double Tap',
|
||||
() => const ControllerButton('SRAM Double Tap', action: InGameAction.shiftDown),
|
||||
);
|
||||
|
||||
void _emitClick(ControllerButton button) {
|
||||
// Use the common pipeline so long-press handling and app action execution stays consistent.
|
||||
handleButtonsClickedWithoutLongPressSupport([button]);
|
||||
}
|
||||
|
||||
void _registerTap() {
|
||||
final windowMs = core.settings.getSramAxsDoubleClickWindowMs();
|
||||
|
||||
_tapCount++;
|
||||
|
||||
// First tap: start a timer. If no second tap arrives in time => single click.
|
||||
if (_tapCount == 1) {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = Timer(Duration(milliseconds: windowMs), () {
|
||||
if (_tapCount == 1) {
|
||||
_emitClick(_singleClickButton());
|
||||
}
|
||||
_tapCount = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Second tap within window: double click.
|
||||
if (_tapCount == 2) {
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_emitClick(_doubleClickButton());
|
||||
_tapCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we get more than two taps fast, treat as a double click and restart counting.
|
||||
_singleClickTimer?.cancel();
|
||||
_singleClickTimer = null;
|
||||
_emitClick(_doubleClickButton());
|
||||
_tapCount = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode) {
|
||||
debugPrint('SramAxs: Received data on characteristic $characteristic: ${bytesToHex(bytes)}');
|
||||
}
|
||||
|
||||
if (characteristic.toLowerCase() == SramAxsConstants.TRIGGER_UUID.toLowerCase()) {
|
||||
// At the moment we can only detect "some button pressed". We therefore interpret each
|
||||
// notification as a tap and provide two logical buttons (single & double click).
|
||||
_registerTap();
|
||||
}
|
||||
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
final windowMs = core.settings.getSramAxsDoubleClickWindowMs();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
Text(
|
||||
"Unfortunately, at the moment it's not possible to determine which physical button was pressed on your SRAM AXS device. Let us know if you have a contact at SRAM who can help :)\n\n"
|
||||
'So the app exposes two logical buttons:\n'
|
||||
'• SRAM Tap, assigned to Shift Up\n'
|
||||
'• SRAM Double Tap, assigned to Shift Down\n\n'
|
||||
'You can assign an action to each in the app settings.',
|
||||
).xSmall,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return PrimaryButton(
|
||||
size: ButtonSize.small,
|
||||
trailing: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('${windowMs}ms'),
|
||||
),
|
||||
onPressed: () {
|
||||
final values = [
|
||||
for (var v = 150; v <= 600; v += 50) v,
|
||||
];
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (b) => DropdownMenu(
|
||||
children: values
|
||||
.map(
|
||||
(v) => MenuButton(
|
||||
child: Text('${v}ms'),
|
||||
onPressed: (c) async {
|
||||
await core.settings.setSramAxsDoubleClickWindowMs(v);
|
||||
// Force rebuild to show new value.
|
||||
(context as Element).markNeedsBuild();
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Double-click window:'),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SramAxsConstants {
|
||||
static const String SERVICE_UUID = "0000fe51-0000-1000-8000-00805f9b34fb";
|
||||
static const String SERVICE_UUID_RELEVANT = "d9050053-90aa-4c7c-b036-1e01fb8eb7ee";
|
||||
|
||||
static const String TRIGGER_UUID = "d9050054-90aa-4c7c-b036-1e01fb8eb7ee";
|
||||
}
|
||||
16
lib/bluetooth/devices/trainer_connection.dart
Normal file
16
lib/bluetooth/devices/trainer_connection.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
abstract class TrainerConnection {
|
||||
final String title;
|
||||
List<InGameAction> supportedActions;
|
||||
|
||||
final ValueNotifier<bool> isStarted = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isConnected = ValueNotifier(false);
|
||||
|
||||
TrainerConnection({required this.title, required this.supportedActions});
|
||||
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp});
|
||||
}
|
||||
10
lib/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart
Normal file
10
lib/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
|
||||
import '../zwift/constants.dart';
|
||||
|
||||
class WahooKickrBikePro extends ZwiftRide {
|
||||
WahooKickrBikePro(super.scanResult) : super();
|
||||
|
||||
@override
|
||||
String get customServiceId => ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class WahooKickrBikeShift extends BaseDevice {
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class WahooKickrBikeShift extends BluetoothDevice {
|
||||
WahooKickrBikeShift(super.scanResult)
|
||||
: super(
|
||||
availableButtons: WahooKickrBikeShiftConstants.prefixToButton.values.toList(),
|
||||
@@ -111,17 +113,75 @@ class WahooKickrBikeShiftConstants {
|
||||
|
||||
// https://support.wahoofitness.com/hc/en-us/articles/22259367275410-Shifter-and-button-configuration-for-KICKR-BIKE-1-2
|
||||
static const Map<String, ControllerButton> prefixToButton = {
|
||||
'0001': ControllerButton.powerUpRight, //'Right Up',
|
||||
'8000': ControllerButton.sideButtonRight, //'Right Down',
|
||||
'0008': ControllerButton.navigationRight, //'Right Steer',
|
||||
'0200': ControllerButton.powerUpLeft, // 'Left Up',
|
||||
'0400': ControllerButton.sideButtonLeft, //'Left Down',
|
||||
'2000': ControllerButton.navigationLeft, //'Left Steer',
|
||||
'0004': ControllerButton.shiftUpRight, // 'Right Shift Up',
|
||||
'0002': ControllerButton.shiftDownRight, // 'Right Shift Down',
|
||||
'1000': ControllerButton.shiftUpLeft, //'Left Shift Up',
|
||||
'0800': ControllerButton.shiftDownLeft, //'Left Shift Down',
|
||||
'4000': ControllerButton.paddleRight, //'Right Brake',
|
||||
'0100': ControllerButton.paddleLeft, //'Left Brake',
|
||||
'0001': WahooKickrShiftButtons.rightUp, //'Right Up',
|
||||
'8000': WahooKickrShiftButtons.rightDown, //'Right Down',
|
||||
'0008': WahooKickrShiftButtons.rightSteer, //'Right Steer',
|
||||
'0200': WahooKickrShiftButtons.leftUp, // 'Left Up',
|
||||
'0400': WahooKickrShiftButtons.leftDown, //'Left Down',
|
||||
'2000': WahooKickrShiftButtons.leftSteer, //'Left Steer',
|
||||
'0004': WahooKickrShiftButtons.shiftUpRight, // 'Right Shift Up',
|
||||
'0002': WahooKickrShiftButtons.shiftDownRight, // 'Right Shift Down',
|
||||
'1000': WahooKickrShiftButtons.shiftUpLeft, //'Left Shift Up',
|
||||
'0800': WahooKickrShiftButtons.shiftDownLeft, //'Left Shift Down',
|
||||
'4000': WahooKickrShiftButtons.rightBrake, //'Right Brake',
|
||||
'0100': WahooKickrShiftButtons.leftBrake, //'Left Brake',
|
||||
};
|
||||
}
|
||||
|
||||
class WahooKickrShiftButtons {
|
||||
static const ControllerButton leftSteer = ControllerButton(
|
||||
'leftSteer',
|
||||
action: InGameAction.navigateLeft,
|
||||
icon: Icons.keyboard_arrow_left,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton rightSteer = ControllerButton(
|
||||
'rightSteer',
|
||||
action: InGameAction.navigateRight,
|
||||
icon: Icons.keyboard_arrow_right,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton leftDown = ControllerButton('leftDown', action: InGameAction.shiftDown);
|
||||
static const ControllerButton leftBrake = ControllerButton('leftBrake', action: InGameAction.shiftDown);
|
||||
|
||||
static const ControllerButton shiftUpLeft = ControllerButton(
|
||||
'shiftUpLeft',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton shiftDownLeft = ControllerButton(
|
||||
'shiftDownLeft',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton leftUp = ControllerButton('leftUp', action: InGameAction.shiftDown);
|
||||
|
||||
static const ControllerButton rightDown = ControllerButton('rightDown', action: InGameAction.shiftUp);
|
||||
static const ControllerButton rightBrake = ControllerButton('rightBrake', action: InGameAction.shiftUp);
|
||||
|
||||
static const ControllerButton shiftUpRight = ControllerButton(
|
||||
'shiftUpRight',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton shiftDownRight = ControllerButton('shiftDownRight', action: InGameAction.shiftUp);
|
||||
static const ControllerButton rightUp = ControllerButton('rightUp', action: InGameAction.shiftUp);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
leftSteer,
|
||||
rightSteer,
|
||||
leftDown,
|
||||
leftBrake,
|
||||
shiftUpLeft,
|
||||
shiftDownLeft,
|
||||
leftUp,
|
||||
rightDown,
|
||||
rightBrake,
|
||||
shiftUpRight,
|
||||
shiftDownRight,
|
||||
rightUp,
|
||||
];
|
||||
}
|
||||
|
||||
151
lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart
Normal file
151
lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class WahooKickrHeadwind extends BluetoothDevice {
|
||||
// Current mode state
|
||||
HeadwindMode _currentMode = HeadwindMode.unknown;
|
||||
int _currentSpeed = 0;
|
||||
|
||||
WahooKickrHeadwind(super.scanResult)
|
||||
: super(
|
||||
availableButtons: const [],
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid == WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${WahooKickrHeadwindConstants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid == WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${WahooKickrHeadwindConstants.CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
// Subscribe to notifications for status updates
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
// Analyze the received bytes to determine current state
|
||||
actionStreamInternal.add(LogNotification('Received ${bytesToHex(bytes)} from Headwind $characteristic'));
|
||||
if (bytes.length >= 4 && bytes[0] == 0xFD && bytes[1] == 0x01) {
|
||||
final mode = bytes[3];
|
||||
final speed = bytes[2];
|
||||
|
||||
switch (mode) {
|
||||
case 0x02:
|
||||
_currentMode = HeadwindMode.heartRate;
|
||||
break;
|
||||
case 0x03:
|
||||
_currentMode = HeadwindMode.speed;
|
||||
break;
|
||||
case 0x01:
|
||||
_currentMode = HeadwindMode.off;
|
||||
break;
|
||||
case 0x04:
|
||||
_currentMode = HeadwindMode.manual;
|
||||
_currentSpeed = speed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
Future<void> setSpeed(int speedPercent) async {
|
||||
// Validate against the allowed speed values
|
||||
const allowedSpeeds = [0, 25, 50, 75, 100];
|
||||
if (!allowedSpeeds.contains(speedPercent)) {
|
||||
throw ArgumentError('Speed must be one of: ${allowedSpeeds.join(", ")}');
|
||||
}
|
||||
|
||||
final service = WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase();
|
||||
final characteristic = WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase();
|
||||
|
||||
// Check if manual mode is enabled, if not enable it first
|
||||
if (_currentMode != HeadwindMode.manual) {
|
||||
final manualModeData = Uint8List.fromList([0x04, 0x04]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
manualModeData,
|
||||
);
|
||||
_currentMode = HeadwindMode.manual;
|
||||
}
|
||||
|
||||
// Command format: [0x02, speed_value]
|
||||
// Speed value: 0x00 to 0x64 (0-100 in hex)
|
||||
final data = Uint8List.fromList([0x02, speedPercent]);
|
||||
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
);
|
||||
_currentSpeed = speedPercent;
|
||||
}
|
||||
|
||||
Future<void> setHeartRateMode() async {
|
||||
final service = WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase();
|
||||
final characteristic = WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase();
|
||||
|
||||
// Command format: [0x04, 0x02] for HR mode
|
||||
final data = Uint8List.fromList([0x04, 0x02]);
|
||||
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
service,
|
||||
characteristic,
|
||||
data,
|
||||
);
|
||||
_currentMode = HeadwindMode.heartRate;
|
||||
}
|
||||
|
||||
Future<ActionResult> handleKeypair(KeyPair keyPair, {required bool isKeyDown}) async {
|
||||
if (!isKeyDown) {
|
||||
return NotHandled('');
|
||||
}
|
||||
|
||||
try {
|
||||
if (keyPair.inGameAction == InGameAction.headwindSpeed) {
|
||||
final speed = keyPair.inGameActionValue ?? 0;
|
||||
await setSpeed(speed);
|
||||
return Success('Headwind speed set to $speed%');
|
||||
} else if (keyPair.inGameAction == InGameAction.headwindHeartRateMode) {
|
||||
await setHeartRateMode();
|
||||
return Success('Headwind set to Heart Rate mode');
|
||||
}
|
||||
} catch (e) {
|
||||
return Error('Failed to control Headwind: $e');
|
||||
}
|
||||
|
||||
return NotHandled('');
|
||||
}
|
||||
}
|
||||
|
||||
class WahooKickrHeadwindConstants {
|
||||
// Wahoo KICKR Headwind service and characteristic UUIDs
|
||||
// These are standard Wahoo fitness equipment UUIDs
|
||||
static const String SERVICE_UUID = "A026EE0C-0A7D-4AB3-97FA-F1500F9FEB8B";
|
||||
static const String CHARACTERISTIC_UUID = "A026E038-0A7D-4AB3-97FA-F1500F9FEB8B";
|
||||
}
|
||||
|
||||
enum HeadwindMode {
|
||||
unknown,
|
||||
heartRate, // HR mode (0x02)
|
||||
speed, // Speed mode (0x03)
|
||||
off, // OFF mode (0x01)
|
||||
manual, // Manual speed mode (0x04)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:flutter/material.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,98 @@ 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.steerLeft,
|
||||
icon: Icons.keyboard_arrow_left,
|
||||
color: Colors.black,
|
||||
);
|
||||
static const ControllerButton navigationRight = ControllerButton(
|
||||
'navigationRight',
|
||||
action: InGameAction.steerRight,
|
||||
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: InGameAction.select, color: Colors.lightGreen);
|
||||
static const ControllerButton b = ControllerButton('b', action: InGameAction.back, color: Colors.pinkAccent);
|
||||
static const ControllerButton z = ControllerButton(
|
||||
'z',
|
||||
action: InGameAction.rideOnBomb,
|
||||
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,
|
||||
|
||||
503
lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart
Normal file
503
lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart
Normal file
@@ -0,0 +1,503 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' show RideKeyPadStatus;
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nsd/nsd.dart';
|
||||
|
||||
class FtmsMdnsEmulator extends TrainerConnection {
|
||||
ServerSocket? _tcpServer;
|
||||
Registration? _mdnsRegistration;
|
||||
|
||||
static const String connectionTitle = 'Zwift Network Emulator';
|
||||
|
||||
Socket? _socket;
|
||||
var lastMessageId = 0;
|
||||
|
||||
FtmsMdnsEmulator()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> startServer() async {
|
||||
isStarted.value = true;
|
||||
print('Starting mDNS server...');
|
||||
|
||||
// Get local IP
|
||||
final interfaces = await NetworkInterface.list();
|
||||
InternetAddress? localIP;
|
||||
|
||||
for (final interface in interfaces) {
|
||||
for (final addr in interface.addresses) {
|
||||
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
|
||||
localIP = addr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (localIP != null) break;
|
||||
}
|
||||
|
||||
if (localIP == null) {
|
||||
throw 'Could not find network interface';
|
||||
}
|
||||
|
||||
_createTcpServer();
|
||||
|
||||
if (kDebugMode) {
|
||||
enableLogging(LogTopic.calls);
|
||||
enableLogging(LogTopic.errors);
|
||||
}
|
||||
disableServiceTypeValidation(true);
|
||||
|
||||
_mdnsRegistration = await register(
|
||||
Service(
|
||||
name: 'KICKR BIKE PRO 1337',
|
||||
addresses: [localIP],
|
||||
port: 36867,
|
||||
type: '_wahoo-fitness-tnp._tcp',
|
||||
txt: {
|
||||
'ble-service-uuids': Uint8List.fromList('FC82'.codeUnits),
|
||||
'mac-address': Uint8List.fromList('50-50-25-6C-66-9C'.codeUnits),
|
||||
'serial-number': Uint8List.fromList('244700181'.codeUnits),
|
||||
},
|
||||
),
|
||||
);
|
||||
print('Server started - advertising service!');
|
||||
}
|
||||
|
||||
void stop() {
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
_tcpServer?.close();
|
||||
if (_mdnsRegistration != null) {
|
||||
unregister(_mdnsRegistration!);
|
||||
}
|
||||
_tcpServer = null;
|
||||
_mdnsRegistration = null;
|
||||
_socket = null;
|
||||
print('Stopped FtmsMdnsEmulator');
|
||||
}
|
||||
|
||||
Future<void> _createTcpServer() async {
|
||||
try {
|
||||
_tcpServer = await ServerSocket.bind(
|
||||
InternetAddress.anyIPv6,
|
||||
36867,
|
||||
shared: true,
|
||||
v6Only: false,
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Failed to start server: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Server started on port ${_tcpServer!.port}');
|
||||
}
|
||||
|
||||
// Accept connection
|
||||
_tcpServer!.listen(
|
||||
(Socket socket) {
|
||||
_socket = socket;
|
||||
isConnected.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.connected),
|
||||
);
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
if (kDebugMode) {
|
||||
print('Received message: ${bytesToHex(data)}');
|
||||
}
|
||||
|
||||
final mutable = data.toList();
|
||||
while (mutable.isNotEmpty) {
|
||||
final msgVersion = mutable.takeUInt8();
|
||||
final msgId = mutable.takeUInt8();
|
||||
lastMessageId = msgId;
|
||||
final seqNum = mutable.takeUInt8();
|
||||
final respCode = mutable.takeUInt8(); // Response Code
|
||||
final length = mutable.takeUInt16BE(); // Length of the message body
|
||||
|
||||
final body = mutable.takeBytes(length);
|
||||
if (kDebugMode) {
|
||||
print('Parsed message: ID: $msgId, Body: ${bytesToHex(body)}');
|
||||
}
|
||||
|
||||
Uint8List buildHeader(int responseCode, int bodyLength) {
|
||||
return Uint8List.fromList([
|
||||
msgVersion,
|
||||
msgId,
|
||||
seqNum,
|
||||
responseCode,
|
||||
(bodyLength >> 8) & 0xFF,
|
||||
bodyLength & 0xFF,
|
||||
]);
|
||||
}
|
||||
|
||||
switch (msgId) {
|
||||
case FtmsMdnsConstants.DC_MESSAGE_DISCOVER_SERVICES:
|
||||
final body = hexToBytes(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toNonDash());
|
||||
|
||||
final header = buildHeader(FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY, body.length);
|
||||
final bytes = [...header, ...body];
|
||||
|
||||
// Expected 0101000000100000fc8200001000800000805f9b34fb
|
||||
// Got 0101000000100000fc8200001000800000805f9b34fb
|
||||
_write(socket, bytes);
|
||||
case FtmsMdnsConstants.DC_MESSAGE_DISCOVER_CHARACTERISTICS:
|
||||
final rawUUID = body.takeBytes(16);
|
||||
final serviceUUID = bytesToHex(rawUUID).toUUID();
|
||||
if (serviceUUID == ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID) {
|
||||
final responseBody = [
|
||||
...rawUUID,
|
||||
...hexToBytes(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID.toNonDash()),
|
||||
...[
|
||||
_propertyVal(['write']),
|
||||
],
|
||||
...hexToBytes(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toNonDash()),
|
||||
...[
|
||||
_propertyVal(['notify']),
|
||||
],
|
||||
...hexToBytes(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toNonDash()),
|
||||
...[
|
||||
_propertyVal(['notify']),
|
||||
],
|
||||
];
|
||||
|
||||
final responseData = [
|
||||
...buildHeader(
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
responseBody.length,
|
||||
),
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
// OK: 0102010000430000fc8200001000800000805f9b34fb0000000319ca465186e5fa29dcdd09d1020000000219ca465186e5fa29dcdd09d1040000000419ca465186e5fa29dcdd09d104
|
||||
_write(socket, responseData);
|
||||
}
|
||||
case FtmsMdnsConstants.DC_MESSAGE_READ_CHARACTERISTIC:
|
||||
final rawUUID = body.takeBytes(16);
|
||||
final characteristicUUID = bytesToHex(rawUUID).toUUID();
|
||||
|
||||
print(
|
||||
'Got Read Characteristic UUID: $characteristicUUID',
|
||||
);
|
||||
|
||||
final responseBody = rawUUID;
|
||||
final responseData = [
|
||||
...buildHeader(
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
responseBody.length,
|
||||
),
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
_write(socket, responseData);
|
||||
case FtmsMdnsConstants.DC_MESSAGE_WRITE_CHARACTERISTIC:
|
||||
final rawUUID = body.takeBytes(16);
|
||||
final characteristicUUID = bytesToHex(rawUUID).toUUID();
|
||||
final characteristicData = body.takeBytes(body.length);
|
||||
|
||||
print(
|
||||
'Got Write Characteristic UUID: $characteristicUUID, Data: ${bytesToHex(characteristicData)}',
|
||||
);
|
||||
|
||||
final responseBody = rawUUID;
|
||||
final responseData = [
|
||||
...buildHeader(
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
responseBody.length,
|
||||
),
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
_write(socket, responseData);
|
||||
|
||||
final response = core.zwiftEmulator.handleWriteRequest(
|
||||
characteristicUUID,
|
||||
Uint8List.fromList(characteristicData),
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
final seqNum = (lastMessageId + 1) % 256;
|
||||
lastMessageId = seqNum;
|
||||
|
||||
final responseBody = [
|
||||
...hexToBytes(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toLowerCase().toNonDash()),
|
||||
...response,
|
||||
];
|
||||
final responseData = [
|
||||
// header
|
||||
...Uint8List.fromList([
|
||||
msgVersion,
|
||||
FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION,
|
||||
seqNum,
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
(responseBody.length >> 8) & 0xFF,
|
||||
responseBody.length & 0xFF,
|
||||
]),
|
||||
// body
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
// 0106050000180000000419ca465186e5fa29dcdd09d1526964654f6e0203
|
||||
_write(socket, responseData);
|
||||
|
||||
if (response.contentEquals(ZwiftConstants.RIDE_ON)) {
|
||||
_sendKeepAlive();
|
||||
}
|
||||
}
|
||||
return;
|
||||
case FtmsMdnsConstants.DC_MESSAGE_ENABLE_CHARACTERISTIC_NOTIFICATIONS:
|
||||
final rawUUID = body.takeBytes(16);
|
||||
final characteristicUUID = bytesToHex(rawUUID).toUUID();
|
||||
final enabled = body.takeUInt8();
|
||||
print(
|
||||
'Got Enable Notifications for Characteristic UUID: $characteristicUUID, Enabled: $enabled',
|
||||
);
|
||||
|
||||
final responseBody = rawUUID;
|
||||
final responseData = [
|
||||
...buildHeader(
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
responseBody.length,
|
||||
),
|
||||
...responseBody,
|
||||
];
|
||||
|
||||
_write(socket, responseData);
|
||||
case FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION:
|
||||
print('Hamlo');
|
||||
default:
|
||||
throw 'DC_ERROR_UNKNOWN_MESSAGE_TYPE';
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
isConnected.value = false;
|
||||
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
|
||||
);
|
||||
_socket = null;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _write(Socket socket, List<int> responseData) {
|
||||
if (kDebugMode) {
|
||||
print('Sending response: ${bytesToHex(responseData)}');
|
||||
}
|
||||
socket.add(responseData);
|
||||
}
|
||||
|
||||
int _propertyVal(List<String> properties) {
|
||||
int res = 0;
|
||||
|
||||
if (properties.contains('read')) res |= 0x01;
|
||||
if (properties.contains('write')) res |= 0x02;
|
||||
if (properties.contains('indicate')) res |= 0x03;
|
||||
if (properties.contains('notify')) res |= 0x04;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final button = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
|
||||
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
|
||||
InGameAction.uturn => RideButtonMask.DOWN_BTN,
|
||||
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 NotHandled('Action ${keyPair.inGameAction!.name} not supported by Zwift Emulator');
|
||||
}
|
||||
|
||||
if (isKeyDown) {
|
||||
final status = RideKeyPadStatus()
|
||||
..buttonMap = (~button.mask) & 0xFFFFFFFF
|
||||
..analogPaddles.clear();
|
||||
|
||||
final bytes = status.writeToBuffer();
|
||||
|
||||
final commandProto = _buildNotify(
|
||||
ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
Uint8List.fromList([
|
||||
Opcode.CONTROLLER_NOTIFICATION.value,
|
||||
...bytes,
|
||||
]),
|
||||
);
|
||||
|
||||
_write(_socket!, commandProto);
|
||||
}
|
||||
|
||||
if (isKeyUp) {
|
||||
final zero = _buildNotify(
|
||||
ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
|
||||
);
|
||||
|
||||
_write(_socket!, zero);
|
||||
}
|
||||
if (kDebugMode) {
|
||||
print('Sent action $isKeyUp vs $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator');
|
||||
}
|
||||
return Success('Sent action: ${keyPair.inGameAction!.title}');
|
||||
}
|
||||
|
||||
List<int> _buildNotify(String uuid, final List<int> data) {
|
||||
final seqNum = (lastMessageId + 1) % 256;
|
||||
lastMessageId = seqNum;
|
||||
|
||||
final responseBody = [
|
||||
...hexToBytes(uuid.toLowerCase().toNonDash()),
|
||||
...data,
|
||||
];
|
||||
final responseData = [
|
||||
// header
|
||||
...Uint8List.fromList([
|
||||
0x01,
|
||||
FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION,
|
||||
seqNum,
|
||||
FtmsMdnsConstants.DC_RC_REQUEST_COMPLETED_SUCCESSFULLY,
|
||||
(responseBody.length >> 8) & 0xFF,
|
||||
responseBody.length & 0xFF,
|
||||
]),
|
||||
// body
|
||||
...responseBody,
|
||||
];
|
||||
return responseData;
|
||||
}
|
||||
|
||||
Future<void> _sendKeepAlive() async {
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
if (_socket != null) {
|
||||
_write(
|
||||
_socket!,
|
||||
_buildNotify(
|
||||
ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
|
||||
hexToBytes('B70100002041201C00180004001B4F00B701000020798EC5BDEFCBE4563418269E4926FBE1'),
|
||||
),
|
||||
);
|
||||
_sendKeepAlive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
String toNonDash() {
|
||||
return replaceAll('-', '');
|
||||
}
|
||||
|
||||
String toUUID() {
|
||||
return '${substring(0, 8)}-${substring(8, 12)}-${substring(12, 16)}-${substring(16, 20)}-${substring(20)}';
|
||||
}
|
||||
}
|
||||
|
||||
extension on List<int> {
|
||||
int takeUInt8() {
|
||||
final value = this[0];
|
||||
removeAt(0);
|
||||
return value;
|
||||
}
|
||||
|
||||
int readUInt8(int offset) {
|
||||
return this[offset];
|
||||
}
|
||||
|
||||
int takeUInt16BE() {
|
||||
final value = (this[0] << 8) | this[0 + 1];
|
||||
removeAt(0);
|
||||
removeAt(0);
|
||||
return value;
|
||||
}
|
||||
|
||||
List<int> takeBytes(int length) {
|
||||
final value = sublist(0, length);
|
||||
removeRange(0, length);
|
||||
return value;
|
||||
}
|
||||
|
||||
int readUInt16BE(int i) {
|
||||
final value = (this[i] << 8) | this[i + 1];
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
String bytesToHex(List<int> bytes, {bool spaced = false}) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : '');
|
||||
}
|
||||
|
||||
String bytesToReadableHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
|
||||
}
|
||||
|
||||
List<int> hexToBytes(String hex) {
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < hex.length; i += 2) {
|
||||
final byte = hex.substring(i, i + 2);
|
||||
bytes.add(int.parse(byte, radix: 16));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
class FtmsMdnsConstants {
|
||||
static const DC_RC_REQUEST_COMPLETED_SUCCESSFULLY = 0; // Request completed successfully
|
||||
static const DC_RC_UNKNOWN_MESSAGE_TYPE = 1; // Unknown Message Type
|
||||
static const DC_RC_UNEXPECTED_ERROR = 2; // Unexpected Error
|
||||
static const DC_RC_SERVICE_NOT_FOUND = 3; // Service Not Found
|
||||
static const DC_RC_CHARACTERISTIC_NOT_FOUND = 4; // Characteristic Not Found
|
||||
static const DC_RC_CHARACTERISTIC_OPERATION_NOT_SUPPORTED =
|
||||
5; // Characteristic Operation Not Supported (See Characteristic Properties)
|
||||
static const DC_RC_CHARACTERISTIC_WRITE_FAILED_INVALID_SIZE =
|
||||
6; // Characteristic Write Failed – Invalid characteristic data size
|
||||
static const DC_RC_UNKNOWN_PROTOCOL_VERSION =
|
||||
7; // Unknown Protocol Version – the command contains a protocol version that the device does not recognize
|
||||
|
||||
static const DC_MESSAGE_DISCOVER_SERVICES = 0x01; // Discover Services
|
||||
static const DC_MESSAGE_DISCOVER_CHARACTERISTICS = 0x02; // Discover Characteristics
|
||||
static const DC_MESSAGE_READ_CHARACTERISTIC = 0x03; // Read Characteristic
|
||||
static const DC_MESSAGE_WRITE_CHARACTERISTIC = 0x04; // Write Characteristic
|
||||
static const DC_MESSAGE_ENABLE_CHARACTERISTIC_NOTIFICATIONS = 0x05; // Enable Characteristic Notifications
|
||||
static const DC_MESSAGE_CHARACTERISTIC_NOTIFICATION = 0x06; // Characteristic Notification
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
|
||||
import 'constants.dart';
|
||||
|
||||
class ZwiftClick extends ZwiftDevice {
|
||||
ZwiftClick(super.scanResult)
|
||||
: super(availableButtons: [ControllerButton.shiftUpRight, ControllerButton.shiftDownLeft]);
|
||||
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftUpLeft]);
|
||||
|
||||
@override
|
||||
List<ControllerButton> processClickNotification(Uint8List message) {
|
||||
final status = ClickKeyPadStatus.fromBuffer(message);
|
||||
final buttonsClicked = [
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ControllerButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ControllerButton.shiftDownLeft,
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButtons.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftUpLeft,
|
||||
];
|
||||
return buttonsClicked;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,144 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
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:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult) : 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,
|
||||
],
|
||||
);
|
||||
|
||||
bool _noLongerSendsEvents = false;
|
||||
|
||||
@override
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK_V2;
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.1.0';
|
||||
|
||||
@override
|
||||
bool get canVibrate => false;
|
||||
|
||||
@override
|
||||
Future<void> setupHandshake() async {
|
||||
super.setupHandshake();
|
||||
await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00]));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processData(Uint8List bytes) {
|
||||
if (bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_1) ||
|
||||
bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_2)) {
|
||||
_noLongerSendsEvents = true;
|
||||
}
|
||||
return super.processData(bytes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
if (isConnected && _noLongerSendsEvents)
|
||||
if (core.settings.getShowZwiftClickV2ReconnectWarning())
|
||||
Warning(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).clickV2Instructions,
|
||||
).xSmall,
|
||||
),
|
||||
IconButton.link(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () {
|
||||
core.settings.setShowZwiftClickV2ReconnectWarning(false);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
GhostButton(
|
||||
onPressed: () {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
OutlineButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
leading: const Icon(Icons.open_in_new),
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Warning(
|
||||
important: false,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context).clickV2EventInfo,
|
||||
).xSmall,
|
||||
LinkButton(
|
||||
child: Text(context.i18n.troubleshootingGuide),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> test() async {
|
||||
await sendCommand(Opcode.RESET, null);
|
||||
//await sendCommand(Opcode.GET, Get(dataObjectId: VendorDO.PAGE_DEVICE_PAIRING.value)); // 0008 82E0 03
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/bluetooth_device.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/single_line_exception.dart';
|
||||
import 'package: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/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
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;
|
||||
|
||||
BleService? customService;
|
||||
|
||||
String get latestFirmwareVersion;
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK;
|
||||
String get customServiceId => ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
bool get canVibrate => false;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
|
||||
customService =
|
||||
services.firstOrNullWhere(
|
||||
(service) => service.uuid == ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(),
|
||||
) ??
|
||||
services.firstOrNullWhere(
|
||||
(service) => service.uuid == ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(),
|
||||
);
|
||||
|
||||
if (customService == null) {
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_ERROR,
|
||||
'You may need to update the firmware of ${scanResult.name} in Zwift Companion app',
|
||||
),
|
||||
);
|
||||
throw Exception(
|
||||
'Custom service $customServiceId not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}',
|
||||
'Custom service ${[ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID, ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID]} not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}',
|
||||
);
|
||||
}
|
||||
|
||||
final deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
|
||||
final asyncCharacteristic = customService!.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
|
||||
final syncTxCharacteristic = customService!.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
if (firmwareVersion != latestFirmwareVersion) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
);
|
||||
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
|
||||
syncRxCharacteristic = customService!.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID.toLowerCase(),
|
||||
);
|
||||
|
||||
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
|
||||
throw Exception('Characteristics not found');
|
||||
}
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService!.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService!.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await setupHandshake();
|
||||
|
||||
if (firmwareVersion != latestFirmwareVersion && firmwareVersion != null) {
|
||||
actionStreamInternal.add(
|
||||
AlertNotification(
|
||||
LogLevel.LOGLEVEL_WARNING,
|
||||
'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 {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
customService!.uuid,
|
||||
syncRxCharacteristic!.uuid,
|
||||
ZwiftConstants.RIDE_ON,
|
||||
withoutResponse: true,
|
||||
@@ -87,9 +87,11 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode && false) {
|
||||
print(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
if (kDebugMode) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (bytes.isEmpty) {
|
||||
@@ -98,7 +100,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
|
||||
try {
|
||||
if (bytes.startsWith(startCommand)) {
|
||||
_processDevicePublicKeyResponse(bytes);
|
||||
processDevicePublicKeyResponse(bytes);
|
||||
} else {
|
||||
processData(bytes);
|
||||
}
|
||||
@@ -113,7 +115,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,
|
||||
);
|
||||
@@ -133,7 +135,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
case ZwiftConstants.BATTERY_LEVEL_TYPE:
|
||||
if (batteryLevel != message[1]) {
|
||||
batteryLevel = message[1];
|
||||
connection.signalChange(this);
|
||||
core.connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case ZwiftConstants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
@@ -150,10 +152,10 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked, {bool longPress = false}) async {
|
||||
// the same messages are sent multiple times, so ignore
|
||||
if (_lastButtonsClicked?.contentEquals(buttonsClicked ?? []) == false) {
|
||||
super.handleButtonsClicked(buttonsClicked);
|
||||
if (_lastButtonsClicked == null || _lastButtonsClicked?.contentEquals(buttonsClicked ?? []) == false) {
|
||||
super.handleButtonsClicked(buttonsClicked, longPress: longPress);
|
||||
}
|
||||
_lastButtonsClicked = buttonsClicked;
|
||||
}
|
||||
@@ -163,7 +165,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
@override
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
core.settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performDown(buttonsClicked);
|
||||
@@ -172,7 +174,8 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
@override
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
core.settings.getVibrationEnabled() &&
|
||||
canVibrate) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performClick(buttonsClicked);
|
||||
@@ -182,7 +185,7 @@ abstract class ZwiftDevice extends BaseDevice {
|
||||
final vibrateCommand = Uint8List.fromList([...ZwiftConstants.VIBRATE_PATTERN, 0x20]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
customService!.uuid,
|
||||
syncRxCharacteristic!.uuid,
|
||||
vibrateCommand,
|
||||
withoutResponse: true,
|
||||
|
||||
475
lib/bluetooth/devices/zwift/zwift_emulator.dart
Normal file
475
lib/bluetooth/devices/zwift/zwift_emulator.dart
Normal file
@@ -0,0 +1,475 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/ble.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pbserver.dart' hide RideButtonMask;
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/widgets/title.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class ZwiftEmulator extends TrainerConnection {
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
static const String connectionTitle = 'Zwift BLE Emulator';
|
||||
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
Central? _central;
|
||||
GATTCharacteristic? _asyncCharacteristic;
|
||||
GATTCharacteristic? _syncTxCharacteristic;
|
||||
|
||||
ZwiftEmulator()
|
||||
: super(
|
||||
title: connectionTitle,
|
||||
supportedActions: [
|
||||
InGameAction.shiftUp,
|
||||
InGameAction.shiftDown,
|
||||
InGameAction.uturn,
|
||||
InGameAction.steerLeft,
|
||||
InGameAction.steerRight,
|
||||
InGameAction.openActionBar,
|
||||
InGameAction.usePowerUp,
|
||||
InGameAction.select,
|
||||
InGameAction.back,
|
||||
InGameAction.rideOnBomb,
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await _peripheralManager.stopAdvertising();
|
||||
await _peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
startAdvertising(() {});
|
||||
}
|
||||
|
||||
Future<void> startAdvertising(VoidCallback onUpdate) async {
|
||||
_isLoading = true;
|
||||
isStarted.value = true;
|
||||
onUpdate();
|
||||
|
||||
_peripheralManager.stateChanged.forEach((state) {
|
||||
print('Peripheral manager state: ${state.state}');
|
||||
});
|
||||
|
||||
if (!kIsWeb && 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;
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
|
||||
);
|
||||
onUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
final status = await Permission.bluetoothAdvertise.request();
|
||||
if (!status.isGranted) {
|
||||
print('Bluetooth advertise permission not granted');
|
||||
isStarted.value = false;
|
||||
onUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn &&
|
||||
core.settings.getZwiftBleEmulatorEnabled()) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
if (core.settings.getLastTarget() == Target.thisDevice) {
|
||||
return;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
_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;
|
||||
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.connected),
|
||||
);
|
||||
|
||||
final request = eventArgs.request;
|
||||
final response = handleWriteRequest(eventArgs.characteristic.uuid.toString(), request.value);
|
||||
if (response != null) {
|
||||
await _peripheralManager.notifyCharacteristic(
|
||||
_central!,
|
||||
_syncTxCharacteristic!,
|
||||
value: response,
|
||||
);
|
||||
onUpdate();
|
||||
if (response == ZwiftConstants.RIDE_ON) {
|
||||
_sendKeepAlive();
|
||||
}
|
||||
}
|
||||
|
||||
await _peripheralManager.respondWriteRequest(request);
|
||||
});
|
||||
}
|
||||
|
||||
if (!Platform.isWindows) {
|
||||
// Device Information
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180A'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A29'),
|
||||
value: Uint8List.fromList('BikeControl'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A25'),
|
||||
value: Uint8List.fromList('09-B48123283828F1337'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A27'),
|
||||
value: Uint8List.fromList('A.0'.codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A26'),
|
||||
value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Battery Service
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
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_CUSTOM_SERVICE_UUID),
|
||||
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: 'KICKR BIKE PRO 1337',
|
||||
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);
|
||||
_isLoading = false;
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
Future<void> stopAdvertising() async {
|
||||
await _peripheralManager.stopAdvertising();
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
Future<void> _sendKeepAlive() async {
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
if (isConnected.value && _central != null) {
|
||||
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
|
||||
_peripheralManager.notifyCharacteristic(_central!, _syncTxCharacteristic!, value: zero);
|
||||
_sendKeepAlive();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final button = switch (keyPair.inGameAction) {
|
||||
InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN,
|
||||
InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN,
|
||||
InGameAction.uturn => RideButtonMask.DOWN_BTN,
|
||||
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 NotHandled('Action ${keyPair.inGameAction!.name} not supported by Zwift Emulator');
|
||||
}
|
||||
|
||||
final status = RideKeyPadStatus()
|
||||
..buttonMap = (~button.mask) & 0xFFFFFFFF
|
||||
..analogPaddles.clear();
|
||||
|
||||
final bytes = status.writeToBuffer();
|
||||
|
||||
if (isKeyDown) {
|
||||
final commandProto = Uint8List.fromList([
|
||||
Opcode.CONTROLLER_NOTIFICATION.value,
|
||||
...bytes,
|
||||
]);
|
||||
|
||||
_peripheralManager.notifyCharacteristic(
|
||||
_central!,
|
||||
_asyncCharacteristic!,
|
||||
value: commandProto,
|
||||
);
|
||||
}
|
||||
|
||||
if (isKeyUp) {
|
||||
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
|
||||
_peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
|
||||
}
|
||||
|
||||
return Success('Sent action: ${keyPair.inGameAction!.name}');
|
||||
}
|
||||
|
||||
Uint8List? handleWriteRequest(String characteristic, Uint8List value) {
|
||||
print(
|
||||
'Write request for characteristic: $characteristic',
|
||||
);
|
||||
|
||||
switch (characteristic.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)}',
|
||||
);
|
||||
|
||||
Opcode? opcode = Opcode.valueOf(value[0]);
|
||||
Uint8List message = value.sublist(1);
|
||||
|
||||
switch (opcode) {
|
||||
case Opcode.RIDE_ON:
|
||||
print('Sending handshake');
|
||||
return ZwiftConstants.RIDE_ON;
|
||||
case Opcode.GET:
|
||||
final response = Get.fromBuffer(message);
|
||||
final dataObjectType = DO.valueOf(response.dataObjectId);
|
||||
print('Received GET for data object: $dataObjectType');
|
||||
switch (dataObjectType) {
|
||||
case DO.PAGE_DEV_INFO:
|
||||
/*final devInfo = DevInfoPage(
|
||||
deviceName: 'Zwift Click'.codeUnits,
|
||||
deviceUid: '0B-58D15ABB4363'.codeUnits,
|
||||
manufacturerId: 0x01,
|
||||
serialNumber: '58D15ABB4363'.codeUnits,
|
||||
protocolVersion: 515,
|
||||
systemFwVersion: [0, 0, 1, 1],
|
||||
productId: 11,
|
||||
systemHwRevision: 'B.0'.codeUnits,
|
||||
deviceCapabilities: [DevInfoPage_DeviceCapabilities(deviceType: 2, capabilities: 1)],
|
||||
);
|
||||
final serverInfoResponse = Uint8List.fromList([
|
||||
Opcode.GET_RESPONSE.value,
|
||||
...GetResponse(
|
||||
dataObjectId: DO.PAGE_DEV_INFO.value,
|
||||
dataObjectData: devInfo.writeToBuffer(),
|
||||
).writeToBuffer(),
|
||||
]);*/
|
||||
// 3C080012460A440883041204000001011A0B5A7769667420436C69636B320F30422D3538443135414242343336333A03422E304204080210014801500B5A0C353844313541424234333633
|
||||
final expected = Uint8List.fromList(
|
||||
hexToBytes(
|
||||
'3C080012460A440883041204000001011A0B5A7769667420436C69636B320F30422D3538443135414242343336333A03422E304204080210014801500B5A0C353844313541424234333633',
|
||||
),
|
||||
);
|
||||
return expected;
|
||||
case DO.PAGE_CLIENT_SERVER_CONFIGURATION:
|
||||
final response = Uint8List.fromList([
|
||||
Opcode.GET_RESPONSE.value,
|
||||
...GetResponse(
|
||||
dataObjectId: DO.PAGE_CLIENT_SERVER_CONFIGURATION.value,
|
||||
dataObjectData: ClientServerCfgPage(
|
||||
notifications: 0,
|
||||
).writeToBuffer(),
|
||||
).writeToBuffer(),
|
||||
]);
|
||||
return response;
|
||||
case DO.PAGE_CONTROLLER_INPUT_CONFIG:
|
||||
final response = Uint8List.fromList([
|
||||
Opcode.GET_RESPONSE.value,
|
||||
...GetResponse(
|
||||
dataObjectId: DO.PAGE_CONTROLLER_INPUT_CONFIG.value,
|
||||
dataObjectData: ControllerInputConfigPage(
|
||||
supportedDigitalInputs: 4607,
|
||||
supportedAnalogInputs: 0,
|
||||
analogDeadZone: [],
|
||||
analogInputRange: [],
|
||||
).writeToBuffer(),
|
||||
).writeToBuffer(),
|
||||
]);
|
||||
return response;
|
||||
case DO.BATTERY_STATE:
|
||||
final response = Uint8List.fromList([
|
||||
Opcode.GET_RESPONSE.value,
|
||||
...GetResponse(
|
||||
dataObjectId: DO.BATTERY_STATE.value,
|
||||
dataObjectData: BatteryStatus(
|
||||
chgState: ChargingState.CHARGING_IDLE,
|
||||
percLevel: 100,
|
||||
timeToEmpty: 0,
|
||||
timeToFull: 0,
|
||||
).writeToBuffer(),
|
||||
).writeToBuffer(),
|
||||
]);
|
||||
return response;
|
||||
default:
|
||||
print('Unhandled data object type for GET: $dataObjectType');
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
print('Unhandled write request for characteristic: $characteristic $value');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
_peripheralManager.stopAdvertising();
|
||||
_peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isSubscribedToEvents = false;
|
||||
_central = null;
|
||||
isConnected.value = false;
|
||||
isStarted.value = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,84 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
|
||||
class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
: 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,
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
List<int> get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_PLAY;
|
||||
|
||||
@override
|
||||
bool get canVibrate => true;
|
||||
|
||||
@override
|
||||
List<ControllerButton> processClickNotification(Uint8List message) {
|
||||
final status = PlayKeyPadStatus.fromBuffer(message);
|
||||
|
||||
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';
|
||||
|
||||
@override
|
||||
Widget showInformation(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
super.showInformation(context),
|
||||
|
||||
Checkbox(
|
||||
trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)),
|
||||
state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked,
|
||||
onChanged: (value) async {
|
||||
await core.settings.setVibrationEnabled(value == CheckboxState.checked);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp_vendor.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_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';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp_vendor.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class ZwiftRide extends ZwiftDevice {
|
||||
@@ -18,35 +17,37 @@ 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;
|
||||
String get latestFirmwareVersion => '1.2.0';
|
||||
|
||||
@override
|
||||
String get latestFirmwareVersion => '1.2.0';
|
||||
bool get canVibrate => true;
|
||||
|
||||
@override
|
||||
Future<void> processData(Uint8List bytes) async {
|
||||
@@ -59,15 +60,6 @@ class ZwiftRide extends ZwiftDevice {
|
||||
);
|
||||
}
|
||||
|
||||
if (bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2) && this is ZwiftClickV2) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once per day. Resetting the device now.',
|
||||
),
|
||||
);
|
||||
sendCommand(Opcode.RESET, null);
|
||||
}
|
||||
|
||||
switch (opcode) {
|
||||
case Opcode.RIDE_ON:
|
||||
//print("Empty RideOn response - unencrypted mode");
|
||||
@@ -154,7 +146,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
final notification = BatteryNotification.fromBuffer(message);
|
||||
if (batteryLevel != notification.newPercLevel) {
|
||||
batteryLevel = notification.newPercLevel;
|
||||
connection.signalChange(this);
|
||||
core.connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case Opcode.CONTROLLER_NOTIFICATION:
|
||||
@@ -184,31 +176,23 @@ class ZwiftRide extends ZwiftDevice {
|
||||
|
||||
// Process DIGITAL buttons separately
|
||||
final buttonsClicked = [
|
||||
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationLeft,
|
||||
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationRight,
|
||||
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.navigationUp,
|
||||
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationDown,
|
||||
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.a,
|
||||
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.b,
|
||||
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.y,
|
||||
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.z,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftDownLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftDownRight,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.powerUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.powerUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.onOffLeft,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.onOffRight,
|
||||
if (status.buttonMap & RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationLeft,
|
||||
if (status.buttonMap & RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationRight,
|
||||
if (status.buttonMap & RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationUp,
|
||||
if (status.buttonMap & RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationDown,
|
||||
if (status.buttonMap & RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.a,
|
||||
if (status.buttonMap & RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.b,
|
||||
if (status.buttonMap & RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.y,
|
||||
if (status.buttonMap & RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.z,
|
||||
if (status.buttonMap & RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpLeft,
|
||||
if (status.buttonMap & RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftDownLeft,
|
||||
if (status.buttonMap & RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpRight,
|
||||
if (status.buttonMap & RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ZwiftButtons.shiftDownRight,
|
||||
if (status.buttonMap & RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpLeft,
|
||||
if (status.buttonMap & RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpRight,
|
||||
if (status.buttonMap & RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffLeft,
|
||||
if (status.buttonMap & RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffRight,
|
||||
];
|
||||
|
||||
// Process ANALOG inputs separately - now properly separated from digital
|
||||
@@ -219,8 +203,8 @@ class ZwiftRide extends ZwiftDevice {
|
||||
if (paddle.hasLocation() && paddle.hasAnalogValue()) {
|
||||
if (paddle.analogValue.abs() >= analogPaddleThreshold) {
|
||||
final button = switch (paddle.location.value) {
|
||||
0 => ControllerButton.paddleLeft, // L0 = left paddle
|
||||
1 => ControllerButton.paddleRight, // L1 = right paddle
|
||||
0 => ZwiftButtons.paddleLeft, // L0 = left paddle
|
||||
1 => ZwiftButtons.paddleRight, // L1 = right paddle
|
||||
_ => null, // L2, L3 unused
|
||||
};
|
||||
|
||||
@@ -246,7 +230,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
}
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
customService!.uuid,
|
||||
syncRxCharacteristic!.uuid,
|
||||
buffer,
|
||||
withoutResponse: true,
|
||||
@@ -260,7 +244,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
}
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
customService!.uuid,
|
||||
syncRxCharacteristic!.uuid,
|
||||
buffer,
|
||||
withoutResponse: true,
|
||||
@@ -268,7 +252,7 @@ class ZwiftRide extends ZwiftDevice {
|
||||
}
|
||||
}
|
||||
|
||||
enum _RideButtonMask {
|
||||
enum RideButtonMask {
|
||||
LEFT_BTN(0x00001),
|
||||
UP_BTN(0x00002),
|
||||
RIGHT_BTN(0x00004),
|
||||
@@ -291,5 +275,5 @@ enum _RideButtonMask {
|
||||
|
||||
final int mask;
|
||||
|
||||
const _RideButtonMask(this.mask);
|
||||
const RideButtonMask(this.mask);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class BaseNotification {}
|
||||
|
||||
class LogNotification extends BaseNotification {
|
||||
final String message;
|
||||
|
||||
LogNotification(this.message);
|
||||
LogNotification(this.message) {
|
||||
if (kDebugMode) {
|
||||
print('LogNotification: $message');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -15,6 +22,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;
|
||||
|
||||
@@ -35,3 +53,28 @@ class ButtonNotification extends BaseNotification {
|
||||
@override
|
||||
int get hashCode => buttonsClicked.hashCode;
|
||||
}
|
||||
|
||||
class ActionNotification extends BaseNotification {
|
||||
final ActionResult result;
|
||||
|
||||
ActionNotification(this.result);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return result.message;
|
||||
}
|
||||
}
|
||||
|
||||
class AlertNotification extends LogNotification {
|
||||
final LogLevel level;
|
||||
final String alertMessage;
|
||||
final VoidCallback? onTap;
|
||||
final String? buttonTitle;
|
||||
|
||||
AlertNotification(this.level, this.alertMessage, {this.onTap, this.buttonTitle}) : super(alertMessage);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Warning: $alertMessage';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,87 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/actions/remote.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
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/utils/actions/remote.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
import '../../pages/markdown.dart';
|
||||
import '../utils/keymap/keymap.dart';
|
||||
|
||||
final peripheralManager = PeripheralManager();
|
||||
bool _isAdvertising = false;
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
class RemotePairing extends TrainerConnection {
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
class RemoteRequirement extends PlatformRequirement {
|
||||
RemoteRequirement()
|
||||
late final _peripheralManager = PeripheralManager();
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
|
||||
Central? _central;
|
||||
GATTCharacteristic? _inputReport;
|
||||
|
||||
static const String connectionTitle = 'Remote Control';
|
||||
|
||||
RemotePairing()
|
||||
: super(
|
||||
'Connect to your target device',
|
||||
title: connectionTitle,
|
||||
supportedActions: InGameAction.values,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
|
||||
@override
|
||||
Widget? buildDescription() {
|
||||
return settings.getLastTarget() == null
|
||||
? null
|
||||
: Text(
|
||||
switch (settings.getLastTarget()) {
|
||||
Target.iPad =>
|
||||
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
|
||||
_ =>
|
||||
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required to use the remote feature.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
await _peripheralManager.stopAdvertising();
|
||||
await _peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
startAdvertising(() {});
|
||||
startAdvertising().catchError((e) {
|
||||
core.settings.setRemoteControlEnabled(false);
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Remote Control pairing: $e'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> startAdvertising(VoidCallback onUpdate) async {
|
||||
// Input report characteristic (notify)
|
||||
Future<void> startAdvertising() async {
|
||||
_isLoading = true;
|
||||
isStarted.value = true;
|
||||
|
||||
_peripheralManager.stateChanged.forEach((state) {
|
||||
print('Peripheral manager state: ${state.state}');
|
||||
});
|
||||
|
||||
if (!kIsWeb && 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;
|
||||
core.connection.signalNotification(
|
||||
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
final status = await Permission.bluetoothAdvertise.request();
|
||||
if (!status.isGranted) {
|
||||
print('Bluetooth advertise permission not granted');
|
||||
isStarted.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getRemoteControlEnabled()) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
if (core.settings.getLastTarget() == Target.thisDevice) {
|
||||
return;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
final inputReport = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('2A4D'),
|
||||
permissions: [GATTCharacteristicPermission.read],
|
||||
@@ -65,43 +95,9 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
],
|
||||
);
|
||||
|
||||
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) {
|
||||
/*(actionHandler as RemoteActions).setConnectedCentral(state.central, inputReport);
|
||||
//peripheralManager.stopAdvertising();
|
||||
onUpdate();*/
|
||||
} else if (state.state == ConnectionState.disconnected) {
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
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));
|
||||
}
|
||||
|
||||
if (!_isServiceAdded) {
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
|
||||
final reportMapDataAbsolute = Uint8List.fromList([
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x02, // Usage (Mouse)
|
||||
@@ -212,29 +208,30 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
|
||||
if (!_isSubscribedToEvents) {
|
||||
_isSubscribedToEvents = true;
|
||||
peripheralManager.characteristicReadRequested.forEach((char) {
|
||||
_peripheralManager.characteristicReadRequested.forEach((char) {
|
||||
print('Read request for characteristic: ${char}');
|
||||
// You can respond to read requests here if needed
|
||||
});
|
||||
|
||||
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
if (char.characteristic.uuid == inputReport.uuid) {
|
||||
if (char.state) {
|
||||
(actionHandler as RemoteActions).setConnectedCentral(char.central, char.characteristic);
|
||||
_inputReport = char.characteristic;
|
||||
_central = char.central;
|
||||
} else {
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
_inputReport = null;
|
||||
_central = null;
|
||||
}
|
||||
onUpdate();
|
||||
}
|
||||
print(
|
||||
'Notify state changed for characteristic: ${char.characteristic.uuid} vs ${char.characteristic.uuid == inputReport.uuid}: ${char.state}',
|
||||
);
|
||||
});
|
||||
}
|
||||
await peripheralManager.addService(hidService);
|
||||
await _peripheralManager.addService(hidService);
|
||||
|
||||
// 3) Optional Battery service
|
||||
await peripheralManager.addService(
|
||||
await _peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180F'),
|
||||
isPrimary: true,
|
||||
@@ -253,97 +250,61 @@ class RemoteRequirement extends PlatformRequirement {
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name:
|
||||
'SwiftControl ${Platform.isIOS
|
||||
'BikeControl ${Platform.isIOS
|
||||
? 'iOS'
|
||||
: Platform.isAndroid
|
||||
? 'Android'
|
||||
: ''}',
|
||||
serviceUUIDs: [UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB')],
|
||||
);
|
||||
/*pm.connectionStateChanged.forEach((state) {
|
||||
print('Peripheral connection state: $state');
|
||||
});*/
|
||||
print('Starting advertising with HID service...');
|
||||
print('Starting advertising with Zwift service...');
|
||||
|
||||
await peripheralManager.startAdvertising(advertisement);
|
||||
_isAdvertising = true;
|
||||
onUpdate();
|
||||
await _peripheralManager.startAdvertising(advertisement);
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
return _PairWidget(onUpdate: onUpdate, requirement: this);
|
||||
Future<void> stopAdvertising() async {
|
||||
await _peripheralManager.stopAdvertising();
|
||||
isStarted.value = false;
|
||||
isConnected.value = false;
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = (actionHandler as RemoteActions).isConnected || screenshotMode;
|
||||
}
|
||||
}
|
||||
|
||||
class _PairWidget extends StatefulWidget {
|
||||
final RemoteRequirement requirement;
|
||||
final VoidCallback onUpdate;
|
||||
const _PairWidget({super.key, required this.onUpdate, required this.requirement});
|
||||
|
||||
@override
|
||||
State<_PairWidget> createState() => _PairWidgetState();
|
||||
}
|
||||
|
||||
class _PairWidgetState extends State<_PairWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
toggle();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 10,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 10,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await toggle();
|
||||
},
|
||||
child: Text(_isAdvertising ? 'Stop Pairing' : 'Start Pairing'),
|
||||
),
|
||||
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
|
||||
],
|
||||
),
|
||||
if (_isAdvertising) ...[
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));
|
||||
},
|
||||
child: Text('Check the troubleshooting guide'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggle() async {
|
||||
if (_isAdvertising) {
|
||||
await peripheralManager.stopAdvertising();
|
||||
_isAdvertising = false;
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
widget.onUpdate();
|
||||
_isLoading = false;
|
||||
setState(() {});
|
||||
} else {
|
||||
_isLoading = true;
|
||||
setState(() {});
|
||||
await widget.requirement.startAdvertising(widget.onUpdate);
|
||||
_isLoading = false;
|
||||
if (mounted) setState(() {});
|
||||
Future<void> notifyCharacteristic(Uint8List value) async {
|
||||
if (_inputReport != null && _central != null) {
|
||||
await _peripheralManager.notifyCharacteristic(_central!, _inputReport!, value: value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ActionResult> sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async {
|
||||
final point = await (core.actionHandler as RemoteActions).resolveTouchPosition(keyPair: keyPair, windowInfo: null);
|
||||
final point2 = point; //Offset(100, 99.0);
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
|
||||
return Success('Mouse clicked at: ${point2.dx.toInt()} ${point2.dy.toInt()}');
|
||||
}
|
||||
|
||||
Uint8List absMouseReport(int buttons3bit, int x, int y) {
|
||||
final b = buttons3bit & 0x07;
|
||||
final xi = x.clamp(0, 100);
|
||||
final yi = y.clamp(0, 100);
|
||||
return Uint8List.fromList([b, xi, yi]);
|
||||
}
|
||||
|
||||
// Send a relative mouse move + button state as 3-byte report: [buttons, dx, dy]
|
||||
Future<void> sendAbsMouseReport(int buttons, int dx, int dy) async {
|
||||
final bytes = absMouseReport(buttons, dx, dy);
|
||||
if (kDebugMode) {
|
||||
print('Preparing to send abs mouse report: buttons=$buttons, dx=$dx, dy=$dy');
|
||||
print('Sending abs mouse report: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0'))}');
|
||||
}
|
||||
|
||||
await notifyCharacteristic(bytes);
|
||||
|
||||
// we don't want to overwhelm the target device
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
427
lib/i10n/intl_de.arb
Normal file
427
lib/i10n/intl_de.arb
Normal file
@@ -0,0 +1,427 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl benötigt Zugriffsberechtigungen, um Ihre Trainings-Apps zu steuern.",
|
||||
"accessibilityDisclaimer": "BikeControl greift nur auf Ihren Bildschirm zu, um die von Ihnen konfigurierten Gesten auszuführen. Es werden keine weiteren Bedienungshilfen oder persönlichen Daten abgerufen.",
|
||||
"accessibilityReasonControl": "• Um Ihnen die Steuerung von Apps wie MyWhoosh, IndieVelo und anderen über Ihre Zwift-Geräte zu ermöglichen",
|
||||
"accessibilityReasonTouch": "• Um Berührungsgesten auf Ihrem Bildschirm zur Steuerung von Trainer-Apps zu simulieren.",
|
||||
"accessibilityReasonWindow": "• Um zu erkennen, welches Trainings-App-Fenster aktuell aktiv ist",
|
||||
"accessibilityServiceExplanation": "BikeControl benötigt die AccessibilityService API von Android, um ordnungsgemäß zu funktionieren.",
|
||||
"accessibilityServiceNotRunning": "Der Bedienungshilfe-Dienst ist nicht verfügbar. \nFolge den Anweisungen unter",
|
||||
"accessibilityServicePermissionRequired": "Berechtigung für barrierefreie Dienste erforderlich",
|
||||
"accessibilityUsageGestures": "• Wenn Du die Tasten auf Deinem Zwift Click-, Zwift Ride- oder Zwift Play-Geräten drückst, simuliert BikeControl Berührungsgesten an bestimmten Bildschirmpositionen.",
|
||||
"accessibilityUsageMonitor": "• Die App überwacht, welches Trainings-App-Fenster aktiv ist, um sicherzustellen, dass Gesten an die richtige App gesendet werden.",
|
||||
"accessibilityUsageNoData": "• Über diesen Dienst werden keine personenbezogenen Daten abgerufen oder erfasst.",
|
||||
"accessories": "Zubehör",
|
||||
"action": "Aktion",
|
||||
"adjustControllerButtons": "Controller-Tasten anpassen",
|
||||
"allow": "Erlauben",
|
||||
"allowAccessibilityService": "Barrierefreiheitsdienst zulassen",
|
||||
"allowBluetoothConnections": "Bluetooth-Verbindungen zulassen",
|
||||
"allowBluetoothScan": "Bluetooth-Scan zulassen",
|
||||
"allowLocationForBluetooth": "Standortzugriff erlauben, damit Bluetooth-Scan funktioniert",
|
||||
"allowPersistentNotification": "Benachrichtigungen zulassen",
|
||||
"allowsRunningInBackground": "Ermöglicht es BikeControl, im Hintergrund weiterzulaufen.",
|
||||
"appIdActions": "{appId} Aktionen",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery": "Batterie",
|
||||
"bluetoothAdvertiseAccess": "Bluetooth-Zugriff",
|
||||
"bluetoothTurnedOn": "Bluetooth ist eingeschaltet",
|
||||
"browserNotSupported": "Dieser Browser unterstützt kein Web-Bluetooth und die Plattform wird nicht unterstützt :(",
|
||||
"button": "Taste.",
|
||||
"cancel": "Abbrechen",
|
||||
"changelog": "Änderungsprotokoll",
|
||||
"checkMyWhooshConnectionScreen": "Überprüfe den Verbindungsbildschirm in MyWhoosh, um zu sehen, ob „Link“ verbunden ist.",
|
||||
"chooseAnotherScreenshot": "Wähle einen anderen Screenshot aus",
|
||||
"chooseBikeControlInConnectionScreen": "Wähle im Verbindungsbildschirm BikeControl aus.",
|
||||
"clickAButtonOnYourController": "Klicke eine Controller-Taste, um deren Aktion zu bearbeiten, oder tippe auf das Bearbeitungssymbol.",
|
||||
"clickV2EventInfo": "Dein Click V2 sendet möglicherweise keine Tastenereignisse mehr. Probier mal ein paar Tasten aus und schau, ob sie in BikeControl angezeigt werden.",
|
||||
"clickV2Instructions": "Damit dein Zwift Click V2 optimal funktioniert, solltest du vor jeder Trainings-Session in der Zwift-App verbinden.\nWenn du das nicht machst, funktioniert der Click V2 nach einer Minute nicht mehr.\n\n1. Öffne die Zwift-App.\n2. Melde dich an (kein Abonnement nötig) und öffne den Bildschirm für die Geräteverbindung.\n3. Verbinde deinen Trainer und dann den Zwift Click V2.\n4. Schließe die Zwift-App wieder und verbinde dich erneut in BikeControl.",
|
||||
"close": "Schließen",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} verbleibende Befehle heute",
|
||||
"configuration": "Konfiguration",
|
||||
"connectControllerToPreview": "Schließe ein Controller-Gerät an, um die Tastaturbelegung in der Vorschau anzuzeigen und anzupassen.",
|
||||
"connectControllers": "Controller verbinden",
|
||||
"connectDirectlyOverNetwork": "Direkte Verbindung über das Netzwerk",
|
||||
"connectToTrainerApp": "Mit der Trainer-App verbinden",
|
||||
"connectUsingBluetooth": "Verbindung über Bluetooth herstellen",
|
||||
"connectUsingMyWhooshLink": "Verbinden über MyWhoosh „Link“",
|
||||
"connected": "Verbunden",
|
||||
"connectedControllers": "Verbundene Controller",
|
||||
"connectedTo": "Verbunden mit {appId}",
|
||||
"@connectedTo": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connection": "Verbindung",
|
||||
"continueAction": "Weitermachen",
|
||||
"controlAppUsingModes": "Steuere {appName} über {modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"modes": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllers": "Controller",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "{button} konnte nicht ausgeführt werden: Keine Konfiguration festgelegt",
|
||||
"create": "Erstellen",
|
||||
"createNewKeymap": "Neue Tastaturbelegung erstellen",
|
||||
"createNewProfileByDuplicating": "Erstelle ein neues benutzerdefiniertes Profil, indem „{profileName} “ dupliziert wird.",
|
||||
"@createNewProfileByDuplicating": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"createdNewCustomProfile": "Ein neues benutzerdefiniertes Profil wurde erstellt: {profileName}",
|
||||
"@createdNewCustomProfile": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeControllerButtons": "Controller-Tasten anpassen für {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Passe die Tastaturbelegung an, falls Probleme auftreten (z. B. falsche Tastatureingaben oder falsch ausgerichtete Touch-Positionen).",
|
||||
"dailyCommandLimitReachedNotification": "Das tägliche Befehlslimit wurde für heute erreicht. Führe ein Upgrade durch, um die Vollversion mit unbegrenzten Befehlen freizuschalten.",
|
||||
"dailyLimitReached": "Tageslimit erreicht ({dailyCommandCount} / {dailyCommandLimit} verbraucht)",
|
||||
"delete": "Löschen",
|
||||
"deleteProfile": "Profil löschen",
|
||||
"deleteProfileConfirmation": "„{profileName} “ wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"@deleteProfileConfirmation": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deny": "Verweigern",
|
||||
"deviceButton": "{deviceName} Taste",
|
||||
"@deviceButton": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disconnectDevices": "Geräte trennen",
|
||||
"disconnected": "Getrennt",
|
||||
"donateByBuyingFromPlayStore": "App im Play Store kaufen",
|
||||
"donateViaCreditCard": "per Kreditkarte, Google Pay, Apple Pay und anderen Zahlungsarten",
|
||||
"donateViaPaypal": "via PayPal",
|
||||
"download": "Herunterladen",
|
||||
"dragToReposition": "Zum Verschieben ziehen",
|
||||
"duplicate": "Duplikat",
|
||||
"enableAutoRotation": "Aktiviere die automatische Drehung auf Ihrem Gerät, um sicherzustellen, dass die App ordnungsgemäß funktioniert.",
|
||||
"enableBluetooth": "Bluetooth aktivieren",
|
||||
"enableKeyboardAccessMessage": "Aktiviere im folgenden Bildschirm die Tastatursteuerung für BikeControl. Falls BikeControl nicht angezeigt wird, füge es bitte manuell hinzu.",
|
||||
"enableKeyboardMouseControl": "Aktiviere die Tastatur- und Maussteuerung für eine bessere Interaktion mit {appName}. Sobald die Funktion aktiviert ist, sind keine weiteren Aktionen oder Verbindungen erforderlich. BikeControl sendet die Maus- oder Tastatureingaben direkt an {appName} weiter.",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableMediaKeyDetection": "Medientastenerkennung aktivieren",
|
||||
"enablePairingProcess": "Kopplungsprozess aktivieren",
|
||||
"enablePermissions": "Berechtigungen aktivieren",
|
||||
"enableSteeringWithPhone": "Sensoren Ihres Telefons aktivieren z.B. zum Lenken",
|
||||
"enableVibrationFeedback": "Vibrationsfeedback beim Gangwechsel aktivieren",
|
||||
"enableZwiftControllerBluetooth": "Zwift Controller aktivieren (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Zwift Controller aktivieren (Netzwerk)",
|
||||
"errorStartingMyWhooshLink": "Fehler beim Starten des MyWhoosh Link-Servers. Bitte stelle sicher, dass die App „MyWhoosh Link“ nicht bereits auf diesem Gerät ausgeführt wird.",
|
||||
"errorStartingOpenBikeControlBluetoothServer": "Fehler beim Starten des OpenBikeControl Bluetooth-Servers.",
|
||||
"errorStartingOpenBikeControlServer": "Fehler beim Starten des OpenBikeControl-Servers.",
|
||||
"exportAction": "Export",
|
||||
"failedToImportProfile": "Profilimport fehlgeschlagen. Ungültiges Format.",
|
||||
"failedToUpdate": "Aktualisierung fehlgeschlagen: {error}",
|
||||
"@failedToUpdate": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Schließen die App, um die neue Version zu verwenden.",
|
||||
"fullVersion": "Vollversion",
|
||||
"fullVersionDescription": "Die Vollversion beinhaltet: \n- Unbegrenzte Befehle pro Tag \n- Zugriff auf alle zukünftigen Updates \n- Kein Abonnement! Nur eine einmalige Gebühr :)",
|
||||
"getSupport": "Unterstützung erhalten",
|
||||
"gotIt": "Verstanden!",
|
||||
"grant": "Gewähren",
|
||||
"granted": "Gewährt",
|
||||
"helpRequested": "Hilfe für BikeControl v{version} angefordert",
|
||||
"@helpRequested": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"howBikeControlUsesPermission": "Wie nutzt BikeControl diese Berechtigung?",
|
||||
"ignoredDevices": "Ignorierte Geräte",
|
||||
"importAction": "Import",
|
||||
"importProfile": "Profil importieren",
|
||||
"instructions": "Anleitung",
|
||||
"jsonData": "JSON-Daten",
|
||||
"keyboardAccess": "Tastaturzugriff",
|
||||
"latestVersion": "aktuell: {version}",
|
||||
"@latestVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveAReview": "Hinterlasse eine Bewertung",
|
||||
"letsAppConnectOverBluetooth": "Lässt {appName} die Verbindung zu BikeControl über Bluetooth herstellen.",
|
||||
"@letsAppConnectOverBluetooth": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsAppConnectOverNetwork": "{appName} direkt über das Netzwerk verbinden. Wähle BikeControl im Verbindungsbildschirm aus.",
|
||||
"@letsAppConnectOverNetwork": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"license": "Lizenz",
|
||||
"licenseStatus": "Lizenzstatus",
|
||||
"loadScreenshotForPlacement": "Lade einen Screenshot aus dem Spiel zur Platzierung hoch.",
|
||||
"logViewer": "Protokollanzeige",
|
||||
"logs": "Protokolle",
|
||||
"logsAreAlsoAt": "Die Protokolle befinden sich auch unter",
|
||||
"logsHaveBeenCopiedToClipboard": "Die Protokolle wurden in die Zwischenablage kopiert.",
|
||||
"longPress": "langes\nDrücken",
|
||||
"longPressMode": "Modus „Langes Drücken“ (statt Wiederholen)",
|
||||
"mailSupportExplanation": "Die individuelle Unterstützung per E-Mail ist für mich sehr aufwendig.\n\nBitte nutze daher Reddit, Facebook oder GitHub für Fragen und Probleme, damit die gesamte Community davon profitieren kann.",
|
||||
"manageIgnoredDevices": "Ignorierte Geräte verwalten",
|
||||
"manageProfile": "Profil verwalten",
|
||||
"manualyControllingButton": "{trainerApp} manuell steuern!",
|
||||
"mediaKeyDetectionTooltip": "Aktiviere diese Option, damit BikeControl Bluetooth-Fernbedienungen erkennt. \nDazu muss BikeControl als Mediaplayer fungieren.",
|
||||
"miuiDeviceDetected": "MIUI-Gerät erkannt",
|
||||
"miuiDisableBatteryOptimization": "• Batterieoptimierung für BikeControl deaktivieren",
|
||||
"miuiEnableAutostart": "• Aktiviere den automatischen Start für BikeControl.",
|
||||
"miuiEnsureProperWorking": "Um sicherzustellen, dass BikeControl ordnungsgemäß funktioniert:",
|
||||
"miuiLockInRecentApps": "• Sperre die App in den zuletzt verwendeten Apps",
|
||||
"miuiWarningDescription": "Auf Ihrem Gerät läuft MIUI, das dafür bekannt ist, Hintergrunddienste und Bedienungshilfen aggressiv zu beenden.",
|
||||
"moreInformation": "Weitere Informationen",
|
||||
"mustChooseAllowOrDeny": "Diese Berechtigung muss entweder erteilt oder verweigert werden, um fortfahren zu können.",
|
||||
"myWhooshDirectConnectAction": "MyWhoosh „Link”-Aktion",
|
||||
"myWhooshDirectConnection": " z. B. mit MyWhoosh „Link”.",
|
||||
"myWhooshLinkConnected": "MyWhoosh „Link“ verbunden",
|
||||
"myWhooshLinkDescriptionLocal": "Verbinde dich direkt mit MyWhoosh über die „Link”-Methode. Unterstützte Aktionen sind unter anderem Schalten, Emotes und Richtungswechsel. Die MyWhoosh Link-Begleit-App darf dabei NICHT gleichzeitig laufen.",
|
||||
"myWhooshLinkInfo": "Schau mal im Abschnitt zur Fehlerbehebung nach, wenn du Probleme hast. Eine deutlich zuverlässigere Verbindungsmethode kommt bald!",
|
||||
"needHelpClickHelp": "Hilfe benötigt? Klicke auf",
|
||||
"needHelpDontHesitate": "den Button oben und zögere nicht, uns zu kontaktieren.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp} wird in Kürze deutlich bessere und zuverlässigere Verbindungsmethoden unterstützen!",
|
||||
"newCustomProfile": "Neues benutzerdefiniertes Profil",
|
||||
"newProfileName": "Neuer Profilname",
|
||||
"newVersionAvailable": "Neue Version verfügbar",
|
||||
"newVersionAvailableWithVersion": "Neue Version verfügbar: {version}",
|
||||
"@newVersionAvailableWithVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"next": "Nächste",
|
||||
"noActionAssigned": "Keine Maßnahmen zugewiesen",
|
||||
"noActionAssignedForButton": "{button} konnte nicht ausgeführt werden: Keine Aktion zugewiesen",
|
||||
"noConnectionMethodIsConnectedOrActive": "Es ist keine Verbindungsmethode verbunden oder aktiv.",
|
||||
"noConnectionMethodSelected": "Keine Verbindungsmethode ausgewählt",
|
||||
"noControllerConnected": "Keine Verbindung",
|
||||
"noControllerUseCompanionMode": "Kein Controller? Nutze den Companion Mode",
|
||||
"noIgnoredDevices": "Keine ignorierten Geräte.",
|
||||
"noTrainerSelected": "Kein Trainer ausgewählt",
|
||||
"notConnected": "Nicht verbunden",
|
||||
"notificationDescription": "Dadurch bleibt die App im Hintergrund aktiv und informiert, wenn sich die Verbindung zu Geräten ändert.",
|
||||
"ok": "OK",
|
||||
"openBikeControlActions": "OpenBikeControl-Aktionen",
|
||||
"openBikeControlAnnouncement": "Tolle Neuigkeiten! {trainerApp} unterstützt das OpenBikeControl-Protokoll für eine bestmögliche Benutzererfahrung.",
|
||||
"openBikeControlConnection": " z. B. durch Verwendung einer OpenBikeControl-Verbindung",
|
||||
"otherConnectionMethods": "Andere Verbindungsmethoden",
|
||||
"pairingDescription": "Die Kopplung ermöglicht volle Anpassungsmöglichkeiten, funktioniert aber möglicherweise nicht auf allen Geräten.",
|
||||
"pairingInstructions": "Gehe auf Deinem {targetName} in die Bluetooth-Einstellungen und suche nach BikeControl oder dem Namen Ihres Geräts. Wenn Du die Fernbedienungsfunktion nutzen möchtest, ist eine Kopplung erforderlich.",
|
||||
"@pairingInstructions": {
|
||||
"placeholders": {
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pairingInstructionsIOS": "Gehe auf Deinem iPad zu „Einstellungen“ > „Bedienungshilfen“ > „Berühren“ > „AssistiveTouch“ > „Zeigergeräte“ > „Geräte“ und koppel Dein Gerät. Stelle sicher, dass AssistiveTouch aktiviert ist.",
|
||||
"pasteExportedJsonData": "Füge die exportierten JSON-Daten unten ein:",
|
||||
"pathCopiedToClipboard": "Der Pfad wurde in die Zwischenablage kopiert.",
|
||||
"permissionsRequired": "Damit BikeControl nach Geräten in der Nähe suchen und bei Verbindungsänderungen informieren kann, aktiviere bitte die folgenden Berechtigungen:",
|
||||
"platformNotSupported": "{platform} wird nicht unterstützt :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"platformRestrictionNotSupported": "Aufgrund von Plattformbeschränkungen wird dieses Szenario nicht unterstützt.",
|
||||
"platformRestrictionOtherDevicesOnly": "Aufgrund von Plattformbeschränkungen wird das Kontrollieren von {appName} nur auf anderen Geräten unterstützt.",
|
||||
"@platformRestrictionOtherDevicesOnly": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playPause": "Wiedergabe/Pause",
|
||||
"pleaseSelectAConnectionMethodFirst": "Wähle bitte zuerst in den Trainereinstellungen eine Verbindungsmethode aus.",
|
||||
"predefinedAction": "Vordefinierte {appName} Aktion",
|
||||
"@predefinedAction": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressButtonOnClickDevice": "Drücke eine Taste auf Ihrem Click-Gerät.",
|
||||
"pressKeyToAssign": "Drücke eine Taste auf Ihrer Tastatur, um sie {buttonName} zuzuweisen.",
|
||||
"@pressKeyToAssign": {
|
||||
"placeholders": {
|
||||
"buttonName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": "Vorherige",
|
||||
"profileExportedToClipboard": "Profil „{profileName} “ in die Zwischenablage kopiert",
|
||||
"@profileExportedToClipboard": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileImportedSuccessfully": "Profil erfolgreich importiert",
|
||||
"profileName": "Profilname",
|
||||
"purchase": "Kaufen",
|
||||
"recommendedConnectionMethods": "Empfohlene Verbindungsmethoden",
|
||||
"removeFromIgnoredList": "Aus der Ignorierliste entfernen",
|
||||
"rename": "Umbenennen",
|
||||
"renameProfile": "Profil umbenennen",
|
||||
"requirement": "Anforderung",
|
||||
"reset": "Zurücksetzen",
|
||||
"restart": "Neustart",
|
||||
"runAppOnPlatformRemotely": "{appName} auf {platform} laufen lassen und es von diesem Gerät aus fernsteuern via {preferredConnection}.",
|
||||
"@runAppOnPlatformRemotely": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"platform": {
|
||||
"type": "String"
|
||||
},
|
||||
"preferredConnection": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runAppOnThisDevice": "{appName} läuft auf diesem Gerät.",
|
||||
"@runAppOnThisDevice": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": "Speichern",
|
||||
"scan": "SCAN",
|
||||
"scanningForDevices": "Suche nach Geräten... Stelle sicher, dass diese eingeschaltet und in Reichweite sind und nicht mit einem anderen Gerät verbunden sind.",
|
||||
"selectKeymap": "Tastaturbelegung auswählen",
|
||||
"selectTargetWhereAppRuns": "Ziel auswählen, auf dem {appName} läuft",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Trainer-App auswählen",
|
||||
"selectTrainerAppAndTarget": "Trainer-App und Zielgerät auswählen",
|
||||
"selectTrainerAppPlaceholder": "Trainer-App auswählen",
|
||||
"setting": "Einstellung",
|
||||
"setupTrainer": "Trainer einrichten",
|
||||
"share": "Teilen",
|
||||
"showDonation": "Zeige Deine Wertschätzung durch eine Spende.",
|
||||
"showSupportedControllers": "Unterstützte Controller anzeigen",
|
||||
"showTroubleshootingGuide": "Anleitung zur Fehlerbehebung anzeigen",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Trainersteuerung",
|
||||
"simulateKeyboardShortcut": "Tastenkombination simulieren",
|
||||
"simulateMediaKey": "Medientaste simulieren",
|
||||
"simulateTouch": "Berührung simulieren",
|
||||
"stop": "Stoppen",
|
||||
"targetOtherDevice": "Anderes Gerät",
|
||||
"targetThisDevice": "Dieses Gerät",
|
||||
"theFollowingPermissionsRequired": "Folgende Berechtigungen sind erforderlich:",
|
||||
"touchAreaInstructions": "1. Erstelle einen Screenshot Ihrer App (z. B. innerhalb von MyWhoosh) im Querformat.\n2. Lade den Screenshot über die Schaltfläche unten.\n3. Die App wird automatisch auf Querformat eingestellt, um eine genaue Zuordnung zu gewährleisten.\n4. Drücke eine Taste auf Ihrem Click-Gerät, um einen Touch-Bereich zu erstellen.\n5. Ziehe die Touch-Bereiche an die gewünschte Position auf dem Screenshot.\n6. Speicher und schließe diesen Bildschirm.",
|
||||
"touchSimulationForegroundMessage": "Um Berührungen zu simulieren, muss die App im Vordergrund bleiben.",
|
||||
"trainer": "Trainer",
|
||||
"trialDaysRemaining": "{trialDaysRemaining} verbleibende Tage",
|
||||
"trialExpired": "Testphase abgelaufen. Befehle beschränkt auf {dailyCommandLimit} pro Tag.",
|
||||
"trialPeriodActive": "Testphase aktiv - {trialDaysRemaining} verbleibende Tage",
|
||||
"trialPeriodDescription": "Während der Testphase stehen unbegrenzt viele Befehle zur Verfügung. Nach Ablauf der Testphase sind die Befehle auf {dailyCommandLimit} pro Tag eingeschränkt.",
|
||||
"troubleshootingGuide": "Leitfaden zur Fehlerbehebung",
|
||||
"tryingToConnectAgain": "Verbinde erneut...",
|
||||
"unassignAction": "Zuweisung aufheben",
|
||||
"unlockFullVersion": "Vollversion freischalten",
|
||||
"update": "Aktualisieren",
|
||||
"useCustomKeymapForButton": "Verwende eine benutzerdefinierte Tastaturbelegung, um die",
|
||||
"version": "Version {version}",
|
||||
"@version": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewDetailedInstructions": "Detaillierte Anweisungen ansehen",
|
||||
"volumeDown": "Lautstärke verringern",
|
||||
"volumeUp": "Lautstärke erhöhen",
|
||||
"waiting": "Warten...",
|
||||
"waitingForConnectionKickrBike": "Verbindung wird hergestellt. Wähle KICKR BIKE PRO im {appName} Verbindungs-Menü.",
|
||||
"@waitingForConnectionKickrBike": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whatsNew": "Was ist neu",
|
||||
"whyPermissionNeeded": "Wozu wird diese Berechtigung benötigt?",
|
||||
"zwiftControllerAction": "Zwift Controller-Aktion",
|
||||
"zwiftControllerDescription": "Ermöglicht es BikeControl, als Zwift-kompatibler Controller zu fungieren."
|
||||
}
|
||||
427
lib/i10n/intl_en.arb
Normal file
427
lib/i10n/intl_en.arb
Normal file
@@ -0,0 +1,427 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl needs accessibility permission to control your training apps.",
|
||||
"accessibilityDisclaimer": "BikeControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.",
|
||||
"accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices",
|
||||
"accessibilityReasonTouch": "• To simulate touch gestures on your screen for controlling trainer apps",
|
||||
"accessibilityReasonWindow": "• To detect which training app window is currently active",
|
||||
"accessibilityServiceExplanation": "BikeControl needs to use Android's AccessibilityService API to function properly.",
|
||||
"accessibilityServiceNotRunning": "Accessibility Service is not running.\nFollow instructions at",
|
||||
"accessibilityServicePermissionRequired": "Accessibility Service Permission Required",
|
||||
"accessibilityUsageGestures": "• When you press buttons on your Zwift Click, Zwift Ride, or Zwift Play devices, BikeControl simulates touch gestures at specific screen locations",
|
||||
"accessibilityUsageMonitor": "• The app monitors which training app window is active to ensure gestures are sent to the correct app",
|
||||
"accessibilityUsageNoData": "• No personal data is accessed or collected through this service",
|
||||
"accessories": "Accessories",
|
||||
"action": "Action",
|
||||
"adjustControllerButtons": "Adjust Controller Buttons",
|
||||
"allow": "Allow",
|
||||
"allowAccessibilityService": "Allow Accessibility Service",
|
||||
"allowBluetoothConnections": "Allow Bluetooth Connections",
|
||||
"allowBluetoothScan": "Allow Bluetooth Scan",
|
||||
"allowLocationForBluetooth": "Allow Location so Bluetooth scan works",
|
||||
"allowPersistentNotification": "Allow Notifications",
|
||||
"allowsRunningInBackground": "Allows BikeControl to keep running in background",
|
||||
"appIdActions": "{appId} actions",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery": "Battery",
|
||||
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
|
||||
"bluetoothTurnedOn": "Bluetooth turned on",
|
||||
"browserNotSupported": "This Browser does not support Web Bluetooth and platform is not supported :(",
|
||||
"button": "button.",
|
||||
"cancel": "Cancel",
|
||||
"changelog": "Changelog",
|
||||
"checkMyWhooshConnectionScreen": "Check the connection screen in MyWhoosh to see if \"Link\" is connected.",
|
||||
"chooseAnotherScreenshot": "Choose another screenshot",
|
||||
"chooseBikeControlInConnectionScreen": "Choose BikeControl in the connection screen.",
|
||||
"clickAButtonOnYourController": "Click a button on your controller to edit its action or tap the edit icon.",
|
||||
"clickV2EventInfo": "Your Click V2 may no longer send button events. Please check by tapping a few buttons and see if they are visible in BikeControl.",
|
||||
"clickV2Instructions": "To make your Zwift Click V2 work best, you should connect it in the Zwift app before each training session.\nIf you don't do that, the Click V2 will stop working after a minute.\n\n1. Open Zwift app\n2. Log in (subscription not required) and open the device connection screen\n3. Connect your Trainer, then connect the Zwift Click V2\n4. Close the Zwift app again and connect again in BikeControl",
|
||||
"close": "Close",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} commands remaining today",
|
||||
"configuration": "Configuration",
|
||||
"connectControllerToPreview": "Connect a controller device to preview and customize the keymap.",
|
||||
"connectControllers": "Connect Controllers",
|
||||
"connectDirectlyOverNetwork": "Connect directly over Network",
|
||||
"connectToTrainerApp": "Connect to Trainer app",
|
||||
"connectUsingBluetooth": "Connect using Bluetooth",
|
||||
"connectUsingMyWhooshLink": "Connect using MyWhoosh \"Link\"",
|
||||
"connected": "Connected",
|
||||
"connectedControllers": "Connected Controllers",
|
||||
"connectedTo": "Connected to {appId}",
|
||||
"@connectedTo": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connection": "Connection",
|
||||
"continueAction": "Continue",
|
||||
"controlAppUsingModes": "Control {appName} using {modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"modes": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllers": "Controllers",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Could not perform {button}: No keymap set",
|
||||
"create": "Create",
|
||||
"createNewKeymap": "Create new keymap",
|
||||
"createNewProfileByDuplicating": "Create new custom profile by duplicating \"{profileName}\"",
|
||||
"@createNewProfileByDuplicating": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"createdNewCustomProfile": "Created a new custom profile: {profileName}",
|
||||
"@createdNewCustomProfile": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeControllerButtons": "Customize Controller buttons for {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)",
|
||||
"dailyCommandLimitReachedNotification": "Daily command limit reached for today. Upgrade to unlock the full version with unlimited commands.",
|
||||
"dailyLimitReached": "Daily limit reached ({dailyCommandCount}/{dailyCommandLimit} used)",
|
||||
"delete": "Delete",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirmation": "Are you sure you want to delete \"{profileName}\"? This action cannot be undone.",
|
||||
"@deleteProfileConfirmation": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deny": "Deny",
|
||||
"deviceButton": "{deviceName} button",
|
||||
"@deviceButton": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disconnectDevices": "Disconnect Devices",
|
||||
"disconnected": "Disconnected",
|
||||
"donateByBuyingFromPlayStore": "by buying the app from Play Store",
|
||||
"donateViaCreditCard": "via Credit Card, Google Pay, Apple Pay and others",
|
||||
"donateViaPaypal": "via PayPal",
|
||||
"download": "Download",
|
||||
"dragToReposition": "Drag to reposition",
|
||||
"duplicate": "Duplicate",
|
||||
"enableAutoRotation": "Enable auto-rotation on your device to make sure the app works correctly.",
|
||||
"enableBluetooth": "Enable Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Enable keyboard access in the following screen for BikeControl. If you don't see BikeControl, please add it manually.",
|
||||
"enableKeyboardMouseControl": "Enable keyboard and mouse control for better interaction with {appName}. Once active, no further action or connection is needed. BikeControl will directly send the mouse or keyboard input to {appName}.",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableMediaKeyDetection": "Enable Media Key Detection",
|
||||
"enablePairingProcess": "Enable Pairing Process",
|
||||
"enablePermissions": "Enable Permissions",
|
||||
"enableSteeringWithPhone": "Enable Phones' sensors to enable e.g. steering",
|
||||
"enableVibrationFeedback": "Enable vibration feedback when shifting gears",
|
||||
"enableZwiftControllerBluetooth": "Enable Zwift Controller (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Enable Zwift Controller (Network)",
|
||||
"errorStartingMyWhooshLink": "Error starting MyWhoosh Link server. Please make sure the \"MyWhoosh Link\" app is not already running on this device.",
|
||||
"errorStartingOpenBikeControlBluetoothServer": "Error starting OpenBikeControl Bluetooth server.",
|
||||
"errorStartingOpenBikeControlServer": "Error starting OpenBikeControl server.",
|
||||
"exportAction": "Export",
|
||||
"failedToImportProfile": "Failed to import profile. Invalid format.",
|
||||
"failedToUpdate": "Failed to update: {error}",
|
||||
"@failedToUpdate": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Force-close the app to use the new version",
|
||||
"fullVersion": "Full Version",
|
||||
"fullVersionDescription": "The full version includes:\n- Unlimited commands per day\n- Access to all future updates\n- No subscription! A one-time fee only :)",
|
||||
"getSupport": "Get Support",
|
||||
"gotIt": "Got it!",
|
||||
"grant": "Grant",
|
||||
"granted": "Granted",
|
||||
"helpRequested": "Help requested for BikeControl v{version}",
|
||||
"@helpRequested": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"howBikeControlUsesPermission": "How does BikeControl use this permission?",
|
||||
"ignoredDevices": "Ignored Devices",
|
||||
"importAction": "Import",
|
||||
"importProfile": "Import Profile",
|
||||
"instructions": "Instructions",
|
||||
"jsonData": "JSON Data",
|
||||
"keyboardAccess": "Keyboard access",
|
||||
"latestVersion": "latest: {version}",
|
||||
"@latestVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveAReview": "Leave a Review",
|
||||
"letsAppConnectOverBluetooth": "Lets {appName} connect to BikeControl over Bluetooth.",
|
||||
"@letsAppConnectOverBluetooth": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsAppConnectOverNetwork": "Lets {appName} connect directly over the Network. Choose BikeControl in the connection screen.",
|
||||
"@letsAppConnectOverNetwork": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"license": "License",
|
||||
"licenseStatus": "License Status",
|
||||
"loadScreenshotForPlacement": "Load in-game screenshot for placement",
|
||||
"logViewer": "Log Viewer",
|
||||
"logs": "Logs",
|
||||
"logsAreAlsoAt": "Logs are also at",
|
||||
"logsHaveBeenCopiedToClipboard": "Logs have been copied to clipboard",
|
||||
"longPress": "long\npress",
|
||||
"longPressMode": "Long Press Mode (vs. repeating)",
|
||||
"mailSupportExplanation": "Providing individual support via email is a lot of work for me.\n\nPlease consider using Reddit, Facebook or GitHub for questions and issues so that the whole community can benefit from it.",
|
||||
"manageIgnoredDevices": "Manage Ignored Devices",
|
||||
"manageProfile": "Manage Profile",
|
||||
"manualyControllingButton": "Control {trainerApp} manually!",
|
||||
"mediaKeyDetectionTooltip": "Enable this option to allow BikeControl to detect bluetooth remotes.\nIn order to do so BikeControl needs to act as a media player.",
|
||||
"miuiDeviceDetected": "MIUI Device Detected",
|
||||
"miuiDisableBatteryOptimization": "• Disable battery optimization for BikeControl",
|
||||
"miuiEnableAutostart": "• Enable autostart for BikeControl",
|
||||
"miuiEnsureProperWorking": "To ensure BikeControl works properly:",
|
||||
"miuiLockInRecentApps": "• Lock the app in recent apps",
|
||||
"miuiWarningDescription": "Your device is running MIUI, which is known to aggressively kill background services and accessibility services.",
|
||||
"moreInformation": "More Information",
|
||||
"mustChooseAllowOrDeny": "You must choose to either Allow or Deny this permission to continue.",
|
||||
"myWhooshDirectConnectAction": "MyWhoosh \"Link\" Action",
|
||||
"myWhooshDirectConnection": " e.g. by using MyWhoosh \"Link\"",
|
||||
"myWhooshLinkConnected": "MyWhoosh \"Link\" connected",
|
||||
"myWhooshLinkDescriptionLocal": "Connect directly to MyWhoosh via the \"Link\" method. Supported actions are shifting, Emotes, turn directions, among others. The MyWhoosh Link companion app must NOT be running at the same time.",
|
||||
"myWhooshLinkInfo": "Please check the troubleshooting section if you encounter any issues. A much more reliable connection method is coming soon!",
|
||||
"needHelpClickHelp": "Need help? Click on the",
|
||||
"needHelpDontHesitate": "button on top and don't hesitate to contact us.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp} will soon support much better, reliable connection methods - stay tuned for updates!",
|
||||
"newCustomProfile": "New Custom Profile",
|
||||
"newProfileName": "New Profile Name",
|
||||
"newVersionAvailable": "New version available",
|
||||
"newVersionAvailableWithVersion": "New version available: {version}",
|
||||
"@newVersionAvailableWithVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"next": "Next",
|
||||
"noActionAssigned": "No action assigned",
|
||||
"noActionAssignedForButton": "Could not perform {button}: No action assigned",
|
||||
"noConnectionMethodIsConnectedOrActive": "No connection method is connected or active.",
|
||||
"noConnectionMethodSelected": "No Connection Method selected",
|
||||
"noControllerConnected": "None connected",
|
||||
"noControllerUseCompanionMode": "No Controller? Use Companion Mode.",
|
||||
"noIgnoredDevices": "No ignored devices.",
|
||||
"noTrainerSelected": "No Trainer selected",
|
||||
"notConnected": "Not connected",
|
||||
"notificationDescription": "This keeps the app alive in background and updates you when the connection to your devices changes.",
|
||||
"ok": "OK",
|
||||
"openBikeControlActions": "OpenBikeControl actions",
|
||||
"openBikeControlAnnouncement": "Great news - {trainerApp} supports the OpenBikeControl Protocol, so you'll have the best possible experience!",
|
||||
"openBikeControlConnection": " e.g. by using OpenBikeControl connection",
|
||||
"otherConnectionMethods": "Other Connection Methods",
|
||||
"pairingDescription": "Pairing allows full customizability, but may not work on all devices.",
|
||||
"pairingInstructions": "On your {targetName} go into Bluetooth settings and look for BikeControl or your machines name. Pairing is required if you want to use the remote control feature.",
|
||||
"@pairingInstructions": {
|
||||
"placeholders": {
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pairingInstructionsIOS": "On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.",
|
||||
"pasteExportedJsonData": "Paste the exported JSON data below:",
|
||||
"pathCopiedToClipboard": "Path has been copied to clipboard",
|
||||
"permissionsRequired": "In order for BikeControl to search for nearby devices and update you when the connection changes, please enable the following permissions:",
|
||||
"platformNotSupported": "This {platform} is not supported :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"platformRestrictionNotSupported": "Due to platform restrictions this scenario is not supported.",
|
||||
"platformRestrictionOtherDevicesOnly": "Due to platform restrictions only controlling {appName} on other devices is supported.",
|
||||
"@platformRestrictionOtherDevicesOnly": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playPause": "Play/Pause",
|
||||
"pleaseSelectAConnectionMethodFirst": "Please select a connection method in the Trainer settings, first.",
|
||||
"predefinedAction": "Predefined {appName} action",
|
||||
"@predefinedAction": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressButtonOnClickDevice": "Press a button on your Click device",
|
||||
"pressKeyToAssign": "Press a key on your keyboard to assign to {buttonName}",
|
||||
"@pressKeyToAssign": {
|
||||
"placeholders": {
|
||||
"buttonName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": "Previous",
|
||||
"profileExportedToClipboard": "Profile \"{profileName}\" exported to clipboard",
|
||||
"@profileExportedToClipboard": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileImportedSuccessfully": "Profile imported successfully",
|
||||
"profileName": "Profile name",
|
||||
"purchase": "Purchase",
|
||||
"recommendedConnectionMethods": "Recommended Connection Methods",
|
||||
"removeFromIgnoredList": "Remove from ignored list",
|
||||
"rename": "Rename",
|
||||
"renameProfile": "Rename Profile",
|
||||
"requirement": "Requirement",
|
||||
"reset": "Reset",
|
||||
"restart": "Restart",
|
||||
"runAppOnPlatformRemotely": "Run {appName} on {platform} and control it remotely from this device{preferredConnection}.",
|
||||
"@runAppOnPlatformRemotely": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"platform": {
|
||||
"type": "String"
|
||||
},
|
||||
"preferredConnection": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runAppOnThisDevice": "Run {appName} on this device.",
|
||||
"@runAppOnThisDevice": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": "Save",
|
||||
"scan": "SCAN",
|
||||
"scanningForDevices": "Scanning for devices... Make sure they are powered on and in range and not connected to another device.",
|
||||
"selectKeymap": "Select Keymap",
|
||||
"selectTargetWhereAppRuns": "Select Target where {appName} runs on",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Select Trainer App",
|
||||
"selectTrainerAppAndTarget": "Select Trainer App & Target Device",
|
||||
"selectTrainerAppPlaceholder": "Select Trainer app",
|
||||
"setting": "Setting",
|
||||
"setupTrainer": "Setup Trainer",
|
||||
"share": "Share",
|
||||
"showDonation": "Show your appreciation by donating",
|
||||
"showSupportedControllers": "Show Supported Controllers",
|
||||
"showTroubleshootingGuide": "Show Troubleshooting Guide",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Trainer Controls",
|
||||
"simulateKeyboardShortcut": "Simulate Keyboard shortcut",
|
||||
"simulateMediaKey": "Simulate Media key",
|
||||
"simulateTouch": "Simulate Touch",
|
||||
"stop": "Stop",
|
||||
"targetOtherDevice": "Other Device",
|
||||
"targetThisDevice": "This Device",
|
||||
"theFollowingPermissionsRequired": "The following permissions are required:",
|
||||
"touchAreaInstructions": "1. Create an in-game screenshot of your app (e.g. within MyWhoosh) in landscape orientation\n2. Load the screenshot with the button below\n3. The app is automatically set to landscape orientation for accurate mapping\n4. Press a button on your Click device to create a touch area\n5. Drag the touch areas to the desired position on the screenshot\n6. Save and close this screen",
|
||||
"touchSimulationForegroundMessage": "To simulate touches the app needs to stay in the foreground.",
|
||||
"trainer": "Trainer",
|
||||
"trialDaysRemaining": "{trialDaysRemaining} days remaining",
|
||||
"trialExpired": "Trial expired. Commands limited to {dailyCommandLimit} per day.",
|
||||
"trialPeriodActive": "Trial Period Active - {trialDaysRemaining} days remaining",
|
||||
"trialPeriodDescription": "Enjoy unlimited commands during your trial period. After the trial, commands will be limited to {dailyCommandLimit} per day.",
|
||||
"troubleshootingGuide": "Troubleshooting Guide",
|
||||
"tryingToConnectAgain": "Trying to connect again...",
|
||||
"unassignAction": "Unassign action",
|
||||
"unlockFullVersion": "Unlock Full Version",
|
||||
"update": "Update",
|
||||
"useCustomKeymapForButton": "Use a custom keymap to support the",
|
||||
"version": "Version {version}",
|
||||
"@version": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewDetailedInstructions": "View Detailed Instructions",
|
||||
"volumeDown": "Volume Down",
|
||||
"volumeUp": "Volume Up",
|
||||
"waiting": "Waiting...",
|
||||
"waitingForConnectionKickrBike": "Waiting for connection. Choose KICKR BIKE PRO in {appName}'s controller pairing menu.",
|
||||
"@waitingForConnectionKickrBike": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whatsNew": "What's New",
|
||||
"whyPermissionNeeded": "Why is this permission needed?",
|
||||
"zwiftControllerAction": "Zwift Controller Action",
|
||||
"zwiftControllerDescription": "Enables BikeControl to act as a Zwift-compatible controller."
|
||||
}
|
||||
427
lib/i10n/intl_fr.arb
Normal file
427
lib/i10n/intl_fr.arb
Normal file
@@ -0,0 +1,427 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl a besoin d'une autorisation d'accessibilité pour contrôler vos applications d'entraînement.",
|
||||
"accessibilityDisclaimer": "BikeControl n'accédera à votre écran que pour effectuer les gestes que vous aurez configurés. Aucune autre fonctionnalité d'accessibilité ni aucune autre information personnelle ne sera accessible.",
|
||||
"accessibilityReasonControl": "• Pour vous permettre de contrôler des applications telles que MyWhoosh, IndieVelo et d'autres à l'aide de vos appareils Zwift.",
|
||||
"accessibilityReasonTouch": "• Pour simuler des gestes tactiles sur votre écran afin de contrôler les applications d'entraînement",
|
||||
"accessibilityReasonWindow": "• Pour détecter quelle fenêtre de l'application d'entraînement est actuellement active",
|
||||
"accessibilityServiceExplanation": "BikeControl doit utiliser l'API AccessibilityService d'Android pour fonctionner correctement.",
|
||||
"accessibilityServiceNotRunning": "Le service d'accessibilité ne fonctionne pas.\nSuivez les instructions à l'adresse",
|
||||
"accessibilityServicePermissionRequired": "Autorisation requise pour le service d'accessibilité",
|
||||
"accessibilityUsageGestures": "• Lorsque vous appuyez sur les boutons de vos appareils Zwift Click, Zwift Ride ou Zwift Play, BikeControl simule des gestes tactiles à des emplacements spécifiques de l'écran.",
|
||||
"accessibilityUsageMonitor": "• L'application surveille quelle fenêtre de l'application d'entraînement est active afin de s'assurer que les gestes sont envoyés à la bonne application.",
|
||||
"accessibilityUsageNoData": "• Aucune donnée personnelle n'est consultée ou collectée par le biais de ce service.",
|
||||
"accessories": "Accessoires",
|
||||
"action": "Action",
|
||||
"adjustControllerButtons": "Ajuster les boutons de la manette",
|
||||
"allow": "Permettre",
|
||||
"allowAccessibilityService": "Autoriser le service d'accessibilité",
|
||||
"allowBluetoothConnections": "Autoriser les connexions Bluetooth",
|
||||
"allowBluetoothScan": "Autoriser la recherche Bluetooth",
|
||||
"allowLocationForBluetooth": "Autoriser la localisation pour que la recherche Bluetooth fonctionne",
|
||||
"allowPersistentNotification": "Autoriser les notifications",
|
||||
"allowsRunningInBackground": "Permet à BikeControl de continuer à fonctionner en arrière-plan",
|
||||
"appIdActions": "{appId} actions",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery": "Batterie",
|
||||
"bluetoothAdvertiseAccess": "Accès à la publicité Bluetooth",
|
||||
"bluetoothTurnedOn": "Bluetooth activé",
|
||||
"browserNotSupported": "Ce navigateur ne prend pas en charge Web Bluetooth et la plateforme n'est pas prise en charge :(",
|
||||
"button": "bouton.",
|
||||
"cancel": "Annuler",
|
||||
"changelog": "Journal des modifications",
|
||||
"checkMyWhooshConnectionScreen": "Vérifiez l'écran de connexion dans MyWhoosh pour voir si « Link » est connecté.",
|
||||
"chooseAnotherScreenshot": "Choisissez une autre capture d'écran",
|
||||
"chooseBikeControlInConnectionScreen": "Sélectionnez BikeControl dans l'écran de connexion.",
|
||||
"clickAButtonOnYourController": "Cliquez sur un bouton de votre manette pour modifier son action ou appuyez sur l'icône de modification.",
|
||||
"clickV2EventInfo": "Votre Click V2 n'envoie peut-être plus les événements liés aux boutons. Vérifiez en appuyant sur quelques boutons et voyez s'ils apparaissent dans BikeControl.",
|
||||
"clickV2Instructions": "Pour que ton Zwift Click V2 marche super bien, tu dois le connecter à l'appli Zwift une fois par jour.\nSi tu ne le fais pas, le Click V2 s'arrêtera de fonctionner au bout d'une minute.\n\n1. Ouvre l'appli Zwift.\n2. Connecte-toi (pas besoin d'abonnement) et ouvre l'écran de connexion des appareils.\n3. Connecte ton home trainer, puis connecte le Zwift Click V2.\n4. Ferme l'appli Zwift et reconnecte-toi dans BikeControl.",
|
||||
"close": "Fermer",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} commandes restantes aujourd'hui",
|
||||
"configuration": "Configuration",
|
||||
"connectControllerToPreview": "Connectez un périphérique de contrôle pour prévisualiser et personnaliser la configuration des touches.",
|
||||
"connectControllers": "Connectez les contrôleurs",
|
||||
"connectDirectlyOverNetwork": "Connexion directe via le réseau",
|
||||
"connectToTrainerApp": "Se connecter à l'application Trainer",
|
||||
"connectUsingBluetooth": "Se connecter via Bluetooth",
|
||||
"connectUsingMyWhooshLink": "Connectez-vous à l'aide de MyWhoosh « Link »",
|
||||
"connected": "Connecté",
|
||||
"connectedControllers": "Contrôleurs connectés",
|
||||
"connectedTo": "Connecté à {appId}",
|
||||
"@connectedTo": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connection": "Connexion",
|
||||
"continueAction": "Continuer",
|
||||
"controlAppUsingModes": "Contrôle {appName} en utilisant{modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"modes": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllers": "Contrôleurs",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Impossible d'effectuer {button}: Aucun clavier défini",
|
||||
"create": "Créer",
|
||||
"createNewKeymap": "Créer un nouveau clavier",
|
||||
"createNewProfileByDuplicating": "Créer un nouveau profil personnalisé en dupliquant «{profileName} ».",
|
||||
"@createNewProfileByDuplicating": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"createdNewCustomProfile": "Création d'un nouveau profil personnalisé : {profileName}",
|
||||
"@createdNewCustomProfile": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeControllerButtons": "Personnalisez les boutons de la manette pour {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Personnalisez la configuration des touches si vous rencontrez des problèmes (par exemple, une sortie clavier incorrecte ou un placement des touches mal aligné).",
|
||||
"dailyCommandLimitReachedNotification": "Limite de commandes journalières atteinte pour aujourd'hui. Passez à la version supérieure pour débloquer la version complète avec commandes illimitées.",
|
||||
"dailyLimitReached": "Limite journalière atteinte ({dailyCommandCount} /{dailyCommandLimit} utilisé)",
|
||||
"delete": "Supprimer",
|
||||
"deleteProfile": "Supprimer le profil",
|
||||
"deleteProfileConfirmation": "Êtes-vous sûr de vouloir supprimer «{profileName} » ? Cette action ne peut pas être annulée.",
|
||||
"@deleteProfileConfirmation": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deny": "Refuser",
|
||||
"deviceButton": "{deviceName} bouton",
|
||||
"@deviceButton": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disconnectDevices": "Déconnecter les appareils",
|
||||
"disconnected": "Déconnecté",
|
||||
"donateByBuyingFromPlayStore": "en achetant l'application sur le Play Store",
|
||||
"donateViaCreditCard": "par carte bancaire, Google Pay, Apple Pay et autres",
|
||||
"donateViaPaypal": "via PayPal",
|
||||
"download": "Télécharger",
|
||||
"dragToReposition": "Faites glisser pour repositionner",
|
||||
"duplicate": "Double",
|
||||
"enableAutoRotation": "Activez la rotation automatique sur votre appareil pour vous assurer que l'application fonctionne correctement.",
|
||||
"enableBluetooth": "Activer le Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Activez l'accès au clavier dans l'écran suivant pour BikeControl. Si vous ne voyez pas BikeControl, veuillez l'ajouter manuellement.",
|
||||
"enableKeyboardMouseControl": "Activez le contrôle du clavier et de la souris pour une meilleure interaction avec {appName} Une fois activé, aucune autre action ni connexion n'est nécessaire. BikeControl enverra directement les entrées de la souris ou du clavier à {appName} .",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableMediaKeyDetection": "Activer la détection des touches multimédias",
|
||||
"enablePairingProcess": "Activer le processus d'appairage",
|
||||
"enablePermissions": "Activer les autorisations",
|
||||
"enableSteeringWithPhone": "Activez les capteurs du téléphone pour permettre, par exemple, la direction.",
|
||||
"enableVibrationFeedback": "Activer le retour haptique par vibration lors du changement de vitesse",
|
||||
"enableZwiftControllerBluetooth": "Activer le contrôleur Zwift (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Activer le contrôleur Zwift (réseau)",
|
||||
"errorStartingMyWhooshLink": "Erreur lors du démarrage du serveur MyWhoosh Link. Veuillez vous assurer que l'application « MyWhoosh Link » n'est pas déjà en cours d'exécution sur cet appareil.",
|
||||
"errorStartingOpenBikeControlBluetoothServer": "Erreur lors du démarrage du serveur Bluetooth OpenBikeControl.",
|
||||
"errorStartingOpenBikeControlServer": "Erreur lors du démarrage du serveur OpenBikeControl.",
|
||||
"exportAction": "Exporter",
|
||||
"failedToImportProfile": "Échec de l'importation du profil. Format non valide.",
|
||||
"failedToUpdate": "Échec de la mise à jour: {error}",
|
||||
"@failedToUpdate": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Fermez de force l'application pour utiliser la nouvelle version.",
|
||||
"fullVersion": "Version complète",
|
||||
"fullVersionDescription": "La version complète comprend:\n- Commandes illimitées par jour\n- Accès à toutes les mises à jour futures \n- Aucun abonnement ! Un paiement unique :)",
|
||||
"getSupport": "Obtenir de l'aide",
|
||||
"gotIt": "Compris !",
|
||||
"grant": "Accorder",
|
||||
"granted": "Accordé",
|
||||
"helpRequested": "Aide demandée pour BikeControl v{version}",
|
||||
"@helpRequested": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"howBikeControlUsesPermission": "Comment BikeControl utilise-t-il cette autorisation ?",
|
||||
"ignoredDevices": "Appareils ignorés",
|
||||
"importAction": "Importer",
|
||||
"importProfile": "Profil d'importation",
|
||||
"instructions": "Mode d'emploi",
|
||||
"jsonData": "Données JSON",
|
||||
"keyboardAccess": "Accès clavier",
|
||||
"latestVersion": "dernier: {version}",
|
||||
"@latestVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveAReview": "Laisser un avis",
|
||||
"letsAppConnectOverBluetooth": "Connectons {appName} à BikeControl via Bluetooth.",
|
||||
"@letsAppConnectOverBluetooth": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsAppConnectOverNetwork": "Permet à {appName} de se connecter directement via le réseau. Sélectionnez BikeControl dans l'écran de connexion.",
|
||||
"@letsAppConnectOverNetwork": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"license": "Licence",
|
||||
"licenseStatus": "Statut de la licence",
|
||||
"loadScreenshotForPlacement": "Charger une capture d'écran du jeu pour le placement",
|
||||
"logViewer": "Visionneuse de journaux",
|
||||
"logs": "Journaux",
|
||||
"logsAreAlsoAt": "Les journaux sont également à",
|
||||
"logsHaveBeenCopiedToClipboard": "Les journaux ont été copiés dans le presse-papiers.",
|
||||
"longPress": "long\nappui",
|
||||
"longPressMode": "Mode appui long (par rapport à la répétition)",
|
||||
"mailSupportExplanation": "Répondre à tout le monde individuellement par e-mail, ça me prend beaucoup de temps.\n\nSi t'as des questions ou des problèmes, pense à utiliser Reddit, Facebook ou GitHub pour que tout le monde puisse en profiter.",
|
||||
"manageIgnoredDevices": "Gérer les périphériques ignorés",
|
||||
"manageProfile": "Gérer mon profil",
|
||||
"manualyControllingButton": "Contrôle {trainerApp} manuellement!",
|
||||
"mediaKeyDetectionTooltip": "Activez cette option pour permettre à BikeControl de détecter les télécommandes Bluetooth. Pour ce faire, BikeControl doit fonctionner comme un lecteur multimédia.",
|
||||
"miuiDeviceDetected": "Appareil MIUI détecté",
|
||||
"miuiDisableBatteryOptimization": "• Désactivez l'optimisation de la batterie pour BikeControl.",
|
||||
"miuiEnableAutostart": "• Activer le démarrage automatique pour BikeControl",
|
||||
"miuiEnsureProperWorking": "Pour garantir le bon fonctionnement de BikeControl :",
|
||||
"miuiLockInRecentApps": "• Verrouiller l'application dans les applications récentes",
|
||||
"miuiWarningDescription": "Votre appareil fonctionne sous MIUI, qui est connu pour supprimer de manière agressive les services d'arrière-plan et les services d'accessibilité.",
|
||||
"moreInformation": "Plus d'informations",
|
||||
"mustChooseAllowOrDeny": "Vous devez choisir d'autoriser ou de refuser cette autorisation pour continuer.",
|
||||
"myWhooshDirectConnectAction": "Action «Link» de MyWhoosh",
|
||||
"myWhooshDirectConnection": " par exemple en utilisant MyWhoosh «Link».",
|
||||
"myWhooshLinkConnected": "MyWhoosh « Link » connecté",
|
||||
"myWhooshLinkDescriptionLocal": "Connecte-toi directement à MyWhoosh avec la méthode « Link ». Tu peux faire des trucs comme changer de vitesse, utiliser des émoticônes, indiquer la direction à prendre, et plein d'autres choses. L'appli MyWhoosh Link ne doit PAS être ouverte en même temps.",
|
||||
"myWhooshLinkInfo": "Si tu rencontres des problèmes, jette un œil à la section dépannage. Une méthode de connexion bien plus fiable sera bientôt disponible !",
|
||||
"needHelpClickHelp": "Besoin d'aide ? Cliquez sur le",
|
||||
"needHelpDontHesitate": "bouton en haut et n'hésitez pas à nous contacter.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp} prendra bientôt en charge des méthodes de connexion bien meilleures et plus fiables - restez à l'écoute pour les mises à jour !",
|
||||
"newCustomProfile": "Nouveau profil personnalisé",
|
||||
"newProfileName": "Nouveau nom de profil",
|
||||
"newVersionAvailable": "Nouvelle version disponible",
|
||||
"newVersionAvailableWithVersion": "Nouvelle version disponible: {version}",
|
||||
"@newVersionAvailableWithVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"next": "Suivant",
|
||||
"noActionAssigned": "Aucune action assignée",
|
||||
"noActionAssignedForButton": "Impossible d'effectuer {button}: Aucune action assignée",
|
||||
"noConnectionMethodIsConnectedOrActive": "Aucune méthode de connexion n'est établie ou active.",
|
||||
"noConnectionMethodSelected": "Aucune méthode de connexion choisie",
|
||||
"noControllerConnected": "Aucun connecté",
|
||||
"noControllerUseCompanionMode": "Pas de manette ? Utilisez le mode compagnon.",
|
||||
"noIgnoredDevices": "Aucun appareil ignoré.",
|
||||
"noTrainerSelected": "Aucun Trainer sélectionné",
|
||||
"notConnected": "Non connecté",
|
||||
"notificationDescription": "Cela permet à l'application de rester active en arrière-plan et de vous informer lorsque la connexion à vos appareils change.",
|
||||
"ok": "OK",
|
||||
"openBikeControlActions": "Actions OpenBikeControl",
|
||||
"openBikeControlAnnouncement": "Excellente nouvelle! {trainerApp} Il prend en charge le protocole OpenBikeControl, vous bénéficierez donc de la meilleure expérience possible !",
|
||||
"openBikeControlConnection": " par exemple en utilisant la connexion OpenBikeControl",
|
||||
"otherConnectionMethods": "Autres méthodes de connexion",
|
||||
"pairingDescription": "Le jumelage permet une personnalisation complète, mais peut ne pas fonctionner sur tous les appareils.",
|
||||
"pairingInstructions": "Sur votre {targetName}, accédez aux paramètres Bluetooth et recherchez BikeControl ou le nom de votre appareil. L'appairage est nécessaire si vous souhaitez utiliser la fonction de commande à distance.",
|
||||
"@pairingInstructions": {
|
||||
"placeholders": {
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pairingInstructionsIOS": "Sur votre iPad, allez dans Réglages > Accessibilité > Toucher > AssistiveTouch > Périphériques de pointage > Périphériques et appairez votre appareil. Assurez-vous que la fonction AssistiveTouch est activée.",
|
||||
"pasteExportedJsonData": "Collez ci-dessous les données JSON exportées :",
|
||||
"pathCopiedToClipboard": "Le chemin a été copié dans le presse-papiers.",
|
||||
"permissionsRequired": "Pour que BikeControl puisse rechercher les appareils à proximité et vous informer en cas de changement de connexion, veuillez activer les autorisations suivantes:",
|
||||
"platformNotSupported": "Cette {platform} n'est pas prise en charge :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"platformRestrictionNotSupported": "En raison des restrictions de la plateforme, ce scénario n'est pas pris en charge.",
|
||||
"platformRestrictionOtherDevicesOnly": "En raison des restrictions de la plateforme, seul le contrôle de l'{appName} s sur d'autres appareils est pris en charge.",
|
||||
"@platformRestrictionOtherDevicesOnly": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playPause": "Lecture/Pause",
|
||||
"pleaseSelectAConnectionMethodFirst": "Commence par choisir une méthode de connexion dans les paramètres du Trainer.",
|
||||
"predefinedAction": "Action prédéfinie d'{appName}",
|
||||
"@predefinedAction": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressButtonOnClickDevice": "Appuyez sur un bouton de votre appareil Click",
|
||||
"pressKeyToAssign": "Appuyez sur une touche de votre clavier pour l'attribuer à {buttonName}",
|
||||
"@pressKeyToAssign": {
|
||||
"placeholders": {
|
||||
"buttonName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": "Précédent",
|
||||
"profileExportedToClipboard": "Profil «{profileName} » exporté vers le presse-papiers",
|
||||
"@profileExportedToClipboard": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileImportedSuccessfully": "Profil importé avec succès",
|
||||
"profileName": "Nom du profil",
|
||||
"purchase": "Achat",
|
||||
"recommendedConnectionMethods": "Méthodes de connexion recommandées",
|
||||
"removeFromIgnoredList": "Supprimer de la liste ignorée",
|
||||
"rename": "Rebaptiser",
|
||||
"renameProfile": "Renommer le profil",
|
||||
"requirement": "Exigence",
|
||||
"reset": "Réinitialiser",
|
||||
"restart": "Redémarrage",
|
||||
"runAppOnPlatformRemotely": "Exécutez {appName} sur {platform} et contrôlez-le à distance depuis cet appareil{preferredConnection}.",
|
||||
"@runAppOnPlatformRemotely": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"platform": {
|
||||
"type": "String"
|
||||
},
|
||||
"preferredConnection": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runAppOnThisDevice": "Exécutez {appName} sur cet appareil.",
|
||||
"@runAppOnThisDevice": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": "Sauvegarder",
|
||||
"scan": "BALAYAGE",
|
||||
"scanningForDevices": "Recherche d'appareils en cours... Assurez-vous qu'ils sont allumés, à portée et non connectés à un autre appareil.",
|
||||
"selectKeymap": "Sélectionner le clavier",
|
||||
"selectTargetWhereAppRuns": "Sélectionnez la cible où {appName} fonctionne sur",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Sélectionner l'application Trainer",
|
||||
"selectTrainerAppAndTarget": "Sélectionnez l'application Trainer et l'appareil cible",
|
||||
"selectTrainerAppPlaceholder": "Sélectionner l'application Trainer",
|
||||
"setting": "Paramètre",
|
||||
"setupTrainer": "Configurer le Trainer",
|
||||
"share": "Partager",
|
||||
"showDonation": "Exprimez votre reconnaissance en faisant un don",
|
||||
"showSupportedControllers": "Afficher les manettes compatibles",
|
||||
"showTroubleshootingGuide": "Afficher le guide de dépannage",
|
||||
"signal": "Signal",
|
||||
"simulateButtons": "Commandes de l'entraîneur",
|
||||
"simulateKeyboardShortcut": "Simuler un raccourci clavier",
|
||||
"simulateMediaKey": "Simuler la touche média",
|
||||
"simulateTouch": "Simuler le toucher",
|
||||
"stop": "Arrêt",
|
||||
"targetOtherDevice": "Autre appareil",
|
||||
"targetThisDevice": "Cet appareil",
|
||||
"theFollowingPermissionsRequired": "Les autorisations suivantes sont requises :",
|
||||
"touchAreaInstructions": "1. Créez une capture d'écran de votre application (par exemple dans MyWhoosh) en mode paysage.\n2. Chargez la capture d'écran à l'aide du bouton ci-dessous.\n3. L'application est automatiquement réglée en mode paysage pour un mappage précis.\n4. Appuyez sur un bouton de votre appareil Click pour créer une zone tactile.\n5. Faites glisser les zones tactiles vers la position souhaitée sur la capture d'écran.\n6. Enregistrez et fermez cet écran.",
|
||||
"touchSimulationForegroundMessage": "Pour simuler les touches, l'application doit rester au premier plan.",
|
||||
"trainer": "Trainer",
|
||||
"trialDaysRemaining": "{trialDaysRemaining} jours restants",
|
||||
"trialExpired": "Période d'essai expirée. Commandes limitées à {dailyCommandLimit} par jour.",
|
||||
"trialPeriodActive": "Période d'essai active -{trialDaysRemaining} jours restants",
|
||||
"trialPeriodDescription": "Profitez de commandes illimitées pendant votre période d'essai. Après la période d'essai, le nombre de commandes sera limité à {dailyCommandLimit} par jour.",
|
||||
"troubleshootingGuide": "Guide de dépannage",
|
||||
"tryingToConnectAgain": "Tentative de reconnexion...",
|
||||
"unassignAction": "Action de désaffectation",
|
||||
"unlockFullVersion": "Débloquer la version complète",
|
||||
"update": "Mise à jour",
|
||||
"useCustomKeymapForButton": "Utilisez une configuration de touches personnalisée pour prendre en charge",
|
||||
"version": "Version {version}",
|
||||
"@version": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewDetailedInstructions": "Consultez les instructions détaillées",
|
||||
"volumeDown": "Baisser le volume",
|
||||
"volumeUp": "Augmenter le volume",
|
||||
"waiting": "En attendant...",
|
||||
"waitingForConnectionKickrBike": "En attente de connexion. Sélectionnez KICKR BIKE PRO dans le menu de couplage du contrôleur de l'{appName}.",
|
||||
"@waitingForConnectionKickrBike": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whatsNew": "Quoi de neuf",
|
||||
"whyPermissionNeeded": "Pourquoi cette autorisation est-elle nécessaire ?",
|
||||
"zwiftControllerAction": "Action du contrôleur Zwift",
|
||||
"zwiftControllerDescription": "Permet à BikeControl de fonctionner comme une manette compatible avec Zwift."
|
||||
}
|
||||
427
lib/i10n/intl_pl.arb
Normal file
427
lib/i10n/intl_pl.arb
Normal file
@@ -0,0 +1,427 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl potrzebuje uprawnień dostępu, aby móc sterować aplikacjami treningowymi.",
|
||||
"accessibilityDisclaimer": "BikeControl będzie miał dostęp do Twojego ekranu wyłącznie w celu wykonania skonfigurowanych przez Ciebie gestów. Nie będzie miał dostępu do żadnych innych funkcji ułatwień dostępu ani danych osobowych.",
|
||||
"accessibilityReasonControl": "• Aby umożliwić Ci sterowanie aplikacjami takimi jak MyWhoosh, IndieVelo i innymi za pomocą urządzeń Zwift",
|
||||
"accessibilityReasonTouch": "• Aby symulować gesty dotykowe na ekranie w celu sterowania aplikacją treningową",
|
||||
"accessibilityReasonWindow": "• Aby wykryć, które okno aplikacji treningowej jest aktualnie aktywne",
|
||||
"accessibilityServiceExplanation": "Aby działać prawidłowo, BikeControl musi korzystać z interfejsu API AccessibilityService systemu Android.",
|
||||
"accessibilityServiceNotRunning": "Usługa ułatwień dostępu nie działa.\nPostępuj zgodnie z instrukcjami na",
|
||||
"accessibilityServicePermissionRequired": "Wymagane uprawnienie usługi ułatwień dostępu",
|
||||
"accessibilityUsageGestures": "• Gdy naciskasz przyciski na urządzeniach Zwift Click, Zwift Ride lub Zwift Play, BikeControl symuluje gesty dotykowe w określonych miejscach ekranu",
|
||||
"accessibilityUsageMonitor": "• Aplikacja monitoruje, które okno aplikacji treningowej jest aktywne, aby zapewnić, że gesty są wysyłane do właściwej aplikacji",
|
||||
"accessibilityUsageNoData": "• Ta usługa nie uzyskuje dostępu do danych osobowych, ani ich nie gromadzi",
|
||||
"accessories": "Akcesoria",
|
||||
"action": "Działanie",
|
||||
"adjustControllerButtons": "Dostosuj przyciski kontrolera",
|
||||
"allow": "Zezwól",
|
||||
"allowAccessibilityService": "Zezwól na usługę ułatwień dostępu",
|
||||
"allowBluetoothConnections": "Zezwól na połączenia Bluetooth",
|
||||
"allowBluetoothScan": "Zezwól na skanowanie Bluetooth",
|
||||
"allowLocationForBluetooth": "Zezwól na dostęp do lokalizacji, aby umożliwić skanowanie Bluetooth",
|
||||
"allowPersistentNotification": "Zezwól na powiadomienia",
|
||||
"allowsRunningInBackground": "Umożliwia działanie BikeControl w tle",
|
||||
"appIdActions": "{appId} działania",
|
||||
"@appIdActions": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"battery": "Bateria",
|
||||
"bluetoothAdvertiseAccess": "Dostęp do reklamy Bluetooth",
|
||||
"bluetoothTurnedOn": "Włączono Bluetooth",
|
||||
"browserNotSupported": "Ta przeglądarka nie obsługuje technologii Web Bluetooth i platforma nie jest obsługiwana :(",
|
||||
"button": "przycisk.",
|
||||
"cancel": "Anuluj",
|
||||
"changelog": "Changelog",
|
||||
"checkMyWhooshConnectionScreen": "Sprawdź ekran połączenia w MyWhoosh, aby zobaczyć, czy „Link” jest połączony.",
|
||||
"chooseAnotherScreenshot": "Wybierz inny zrzut ekranu",
|
||||
"chooseBikeControlInConnectionScreen": "Wybierz BikeControl na ekranie połączenia.",
|
||||
"clickAButtonOnYourController": "Kliknij przycisk na kontrolerze, aby edytować jego akcję lub naciśnij ikonę edycji.",
|
||||
"clickV2EventInfo": "Twóje Click V2 może już nie wysyłać zdarzeń przycisków. Sprawdź, naciskając kilka przycisków, czy są one widoczne w BikeControl.",
|
||||
"clickV2Instructions": "Aby Twoje Zwift Click V2 działały optymalnie, należy połączyć je z aplikacją Zwift przed każdym treningiem.\nJeśli tego nie zrobisz, Click V2 przestanie działać po minucie.\n\n1. Otwórz aplikację Zwift\n2. Zaloguj się (nie wymaga subskrypcji) i otwórz ekran połączeń z urządzeniami\n3. Połącz się z trenażerem, a następnie z Zwift Click V2\n4. Zamknij aplikację Zwift i ponownie połącz się z BikeControl",
|
||||
"close": "Zamknij",
|
||||
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} dzisiejsze pozostałe polecenia",
|
||||
"configuration": "Konfiguracja",
|
||||
"connectControllerToPreview": "Podłącz urządzenie sterujące, aby wyświetlić podgląd i dostosować mapę klawiszy.",
|
||||
"connectControllers": "Połącz kontrolery",
|
||||
"connectDirectlyOverNetwork": "Połącz bezpośrednio przez sieć",
|
||||
"connectToTrainerApp": "Połącz z aplikacją treningową",
|
||||
"connectUsingBluetooth": "Połącz przez Bluetooth",
|
||||
"connectUsingMyWhooshLink": "Połącz się za pomocą MyWhoosh „Link”",
|
||||
"connected": "Połączono",
|
||||
"connectedControllers": "Połączone kontrolery",
|
||||
"connectedTo": "Połączono z {appId}",
|
||||
"@connectedTo": {
|
||||
"placeholders": {
|
||||
"appId": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connection": "Połączenie",
|
||||
"continueAction": "Kontynuuj",
|
||||
"controlAppUsingModes": "Kontroluj {appName} za pomocą {modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"modes": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"controllers": "Kontrolery",
|
||||
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Nie można wykonać {button}: Nie zmapowano klawisza",
|
||||
"create": "Utwórz",
|
||||
"createNewKeymap": "Utwórz nową mapę klawiszy",
|
||||
"createNewProfileByDuplicating": "Utwórz nowy profil niestandardowy, kopiując „{profileName}\"",
|
||||
"@createNewProfileByDuplicating": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"createdNewCustomProfile": "Utworzono nowy profil niestandardowy: {profileName}",
|
||||
"@createdNewCustomProfile": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeControllerButtons": "Dostosuj przyciski kontrolera dla {appName}",
|
||||
"@customizeControllerButtons": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"customizeKeymapHint": "Jeśli występują jakiekolwiek problemy (np. nieprawidłowe działanie klawiatury lub nieprawidłowe rozmieszczenie przycisków dotykowych), dostosuj mapę klawiszy.",
|
||||
"dailyCommandLimitReachedNotification": "Dzienny limit poleceń został osiągnięty. Zaktualizuj do pełnej wersji z nieograniczoną liczbą poleceń.",
|
||||
"dailyLimitReached": "Osiągnięto dzienny limit ({dailyCommandCount} /{dailyCommandLimit} wykorzystanych)",
|
||||
"delete": "Usuń",
|
||||
"deleteProfile": "Usuń profil",
|
||||
"deleteProfileConfirmation": "Czy na pewno chcesz usunąć „{profileName}\"? Tej czynności nie można cofnąć.",
|
||||
"@deleteProfileConfirmation": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deny": "Odmów",
|
||||
"deviceButton": "{deviceName} przycisk",
|
||||
"@deviceButton": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disconnectDevices": "Odłącz urządzenia",
|
||||
"disconnected": "Rozłączony",
|
||||
"donateByBuyingFromPlayStore": "kupując aplikację w Play Store",
|
||||
"donateViaCreditCard": "za pomocą karty kredytowej, Google Pay, Apple Pay i innych",
|
||||
"donateViaPaypal": "przez PayPal",
|
||||
"download": "Pobierz",
|
||||
"dragToReposition": "Przeciągnij, aby zmienić położenie",
|
||||
"duplicate": "Duplikuj",
|
||||
"enableAutoRotation": "Włącz funkcję automatycznego obracania na urządzeniu, aby mieć pewność, że aplikacja będzie działać prawidłowo.",
|
||||
"enableBluetooth": "Włącz Bluetooth",
|
||||
"enableKeyboardAccessMessage": "Włącz dostęp do klawiatury dla BikeControl na poniższym ekranie. Jeśli nie widzisz BikeControl, dodaj go ręcznie.",
|
||||
"enableKeyboardMouseControl": "Włącz sterowanie klawiaturą i myszą, aby zapewnić lepszą interakcję z {appName}. Po aktywacji nie jest wymagane żadne dalsze działanie, ani połączenie. BikeControl będzie bezpośrednio wysyłał dane z myszy lub klawiatury do {appName}.",
|
||||
"@enableKeyboardMouseControl": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"enableMediaKeyDetection": "Włącz rozpoznawanie klawiszy multimedialnych",
|
||||
"enablePairingProcess": "Włącz proces parowania",
|
||||
"enablePermissions": "Nadaj uprawnienia",
|
||||
"enableSteeringWithPhone": "Włącz czujniki telefonów, aby umożliwić np. sterowanie",
|
||||
"enableVibrationFeedback": "Włącz wibracje podczas zmiany biegów",
|
||||
"enableZwiftControllerBluetooth": "Włącz kontroler Zwift (Bluetooth)",
|
||||
"enableZwiftControllerNetwork": "Włącz kontroler Zwift (sieć)",
|
||||
"errorStartingMyWhooshLink": "Błąd podczas uruchamiania serwera MyWhoosh Link. Upewnij się, że aplikacja „MyWhoosh Link” nie jest już uruchomiona na tym urządzeniu.",
|
||||
"errorStartingOpenBikeControlBluetoothServer": "Błąd podczas uruchamiania serwera Bluetooth OpenBikeControl.",
|
||||
"errorStartingOpenBikeControlServer": "Błąd podczas uruchamiania serwera OpenBikeControl.",
|
||||
"exportAction": "Eksport",
|
||||
"failedToImportProfile": "Nie udało się zaimportować profilu. Nieprawidłowy format.",
|
||||
"failedToUpdate": "Nie udało się zaktualizować: {error}",
|
||||
"@failedToUpdate": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firmware": "Firmware",
|
||||
"forceCloseToUpdate": "Wymuś zamknięcie aplikacji, aby móc korzystać z nowej wersji",
|
||||
"fullVersion": "Pełna wersja",
|
||||
"fullVersionDescription": "Pełna wersja zawiera: \n- Nielimitowane polecenia dziennie \n- Dostęp do wszystkich przyszłych aktualizacji \n- Brak subskrypcji! Opłata jednorazowa :)",
|
||||
"getSupport": "Uzyskaj wsparcie",
|
||||
"gotIt": "Zrozumiałem!",
|
||||
"grant": "Nadaj",
|
||||
"granted": "Nadano",
|
||||
"helpRequested": "Prośba o pomoc dotyczącą BikeControl v{version}",
|
||||
"@helpRequested": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"howBikeControlUsesPermission": "W jaki sposób BikeControl wykorzystuje to uprawnienie?",
|
||||
"ignoredDevices": "Zignorowane urządzenia",
|
||||
"importAction": "Import",
|
||||
"importProfile": "Importuj profil",
|
||||
"instructions": "Instrukcje",
|
||||
"jsonData": "Dane JSON",
|
||||
"keyboardAccess": "Dostęp do klawiatury",
|
||||
"latestVersion": "najnowszy: {version}",
|
||||
"@latestVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"leaveAReview": "Zostaw recenzję",
|
||||
"letsAppConnectOverBluetooth": "Pozwala {appName} połączyć się z BikeControl przez Bluetooth.",
|
||||
"@letsAppConnectOverBluetooth": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"letsAppConnectOverNetwork": "Pozwala {appName} połączyć się bezpośrednio przez sieć. Wybierz BikeControl na ekranie połączenia.",
|
||||
"@letsAppConnectOverNetwork": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"license": "Licencja",
|
||||
"licenseStatus": "Status licencji",
|
||||
"loadScreenshotForPlacement": "Prześlij zrzut ekranu z gry w celu ustalenia miejsca",
|
||||
"logViewer": "Przeglądarka dziennika zdarzeń",
|
||||
"logs": "Dziennik zdarzeń",
|
||||
"logsAreAlsoAt": "Dziennik zdarzeń jest dostępny również na",
|
||||
"logsHaveBeenCopiedToClipboard": "Dziennik zdarzeń został skopiowany do schowka",
|
||||
"longPress": "długie\nnaciśnięcie",
|
||||
"longPressMode": "Tryb długiego naciśnięcia (zamiast powtarzania)",
|
||||
"mailSupportExplanation": "Udzielanie indywidualnego wsparcia przez e-mail to dla mnie dużo pracy.\n\nProszę rozważyć korzystanie z Reddita, Facebooka lub GitHuba w celu zadawania pytań i zgłaszania problemów, aby cała społeczność mogła z tego skorzystać.",
|
||||
"manageIgnoredDevices": "Zarządzaj ignorowanymi urządzeniami",
|
||||
"manageProfile": "Zarządzaj profilem",
|
||||
"manualyControllingButton": "Steruj {trainerApp} manualnie!",
|
||||
"mediaKeyDetectionTooltip": "Włącz tę opcję, aby umożliwić BikeControl wykrywanie pilotów Bluetooth.\nW tym celu BikeControl musi działać jako odtwarzacz multimedialny.",
|
||||
"miuiDeviceDetected": "Wykryto urządzenie MIUI",
|
||||
"miuiDisableBatteryOptimization": "• Wyłącz optymalizację baterii dla BikeControl",
|
||||
"miuiEnableAutostart": "• Włącz autostart dla BikeControl",
|
||||
"miuiEnsureProperWorking": "Aby mieć pewność, że BikeControl działa prawidłowo:",
|
||||
"miuiLockInRecentApps": "• Zablokuj aplikację w ostatnio używanych aplikacjach",
|
||||
"miuiWarningDescription": "Na Twoim urządzeniu działa oprogramowanie MIUI, który słynie z agresywnego wyłączania usług działających w tle i usług ułatwień dostępu.",
|
||||
"moreInformation": "Więcej informacji",
|
||||
"mustChooseAllowOrDeny": "Aby kontynuować, musisz zezwolić lub odmówić tego uprawnienia.",
|
||||
"myWhooshDirectConnectAction": "Akcja „Link” MyWhoosh",
|
||||
"myWhooshDirectConnection": " np. za pomocą MyWhoosh „Link”",
|
||||
"myWhooshLinkConnected": "Połączono MyWhoosh „Link”",
|
||||
"myWhooshLinkDescriptionLocal": "Połącz się bezpośrednio z MyWhoosh za pomocą „Link”. Obsługiwane akcje to m.in. zmiana biegów, emotikony i zmiana kierunku. Aplikacja MyWhoosh Link NIE może być uruchomiona w tym samym czasie.",
|
||||
"myWhooshLinkInfo": "W razie napotkania błędów prosimy o sprawdzenie sekcji rozwiązywania problemów. Wkrótce pojawi się znacznie bardziej niezawodna metoda połączenia!",
|
||||
"needHelpClickHelp": "Potrzebujesz pomocy? Kliknij",
|
||||
"needHelpDontHesitate": "przycisk na górze i prosimy o kontakt.",
|
||||
"newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będziemy wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądźcie na bieżąco z aktualizacjami!",
|
||||
"newCustomProfile": "Nowy profil niestandardowy",
|
||||
"newProfileName": "Nowa nazwa profilu",
|
||||
"newVersionAvailable": "Dostępna jest nowa wersja",
|
||||
"newVersionAvailableWithVersion": "Dostępna jest nowa wersja: {version}",
|
||||
"@newVersionAvailableWithVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"next": "Dalej",
|
||||
"noActionAssigned": "Brak przypisanej akcji",
|
||||
"noActionAssignedForButton": "Nie można wykonać {button}: Brak przypisanej akcji",
|
||||
"noConnectionMethodIsConnectedOrActive": "Żadna metoda połączenia nie jest podłączona lub aktywna.",
|
||||
"noConnectionMethodSelected": "Nie wybrano metody połączenia",
|
||||
"noControllerConnected": "Brak połączenia",
|
||||
"noControllerUseCompanionMode": "Nie masz kontrolera? Użyj Companion Mode.",
|
||||
"noIgnoredDevices": "Brak ignorowanych urządzeń.",
|
||||
"noTrainerSelected": "Nie wybrano aplikacji treningowej",
|
||||
"notConnected": "Nie połączono",
|
||||
"notificationDescription": "Dzięki temu aplikacja działa w tle i powiadamia Cię o każdej zmianie połączenia z Twoim urządzeniem.",
|
||||
"ok": "OK",
|
||||
"openBikeControlActions": "Akcje OpenBikeControl",
|
||||
"openBikeControlAnnouncement": "Świetna wiadomość - {trainerApp} obsługuje protokół OpenBikeControl, dzięki czemu uzyskasz najlepsze doświadczenia!",
|
||||
"openBikeControlConnection": " np. za pomocą połączenia OpenBikeControl",
|
||||
"otherConnectionMethods": "Inne metody połączenia",
|
||||
"pairingDescription": "Parowanie umożliwia pełną personalizację, jednak może nie działać na wszystkich urządzeniach.",
|
||||
"pairingInstructions": "Przejdź do ustawień Bluetooth na twoim {targetName} i wyszukaj BikeControl lub nazwę swojego urządzenia. Parowanie jest wymagane, jeśli chcesz korzystać z funkcji zdalnego sterowania.",
|
||||
"@pairingInstructions": {
|
||||
"placeholders": {
|
||||
"targetName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pairingInstructionsIOS": "Na iPadzie przejdź do Ustawienia > Ułatwienia dostępu > Dotyk > AssistiveTouch > Urządzenia wskazujące > Urządzenia i sparuj twoje urządzenie. Upewnij się, że funkcja AssistiveTouch jest włączona.",
|
||||
"pasteExportedJsonData": "Wklej wyeksportowane dane JSON poniżej:",
|
||||
"pathCopiedToClipboard": "Ścieżka została skopiowana do schowka",
|
||||
"permissionsRequired": "Aby aplikacja BikeControl mogła wyszukiwać urządzenia w pobliżu i informować o zmianie połączenia, włącz następujące uprawnienia:",
|
||||
"platformNotSupported": "Platforma {platform} nie jest obsługiwana :(",
|
||||
"@platformNotSupported": {
|
||||
"placeholders": {
|
||||
"platform": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"platformRestrictionNotSupported": "Ze względu na ograniczenia platformy ten scenariusz nie jest obsługiwany.",
|
||||
"platformRestrictionOtherDevicesOnly": "Ze względu na ograniczenia platformy obsługiwane jest tylko sterowanie {appName} na innych urządzeniach.",
|
||||
"@platformRestrictionOtherDevicesOnly": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"playPause": "Start/Pauza",
|
||||
"pleaseSelectAConnectionMethodFirst": "Najpierw wybierz metodę połączenia w ustawieniach aplikacji treningowej.",
|
||||
"predefinedAction": "Predefiniowana akcja {appName}",
|
||||
"@predefinedAction": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pressButtonOnClickDevice": "Wciśnij przycisk na swoim urządzeniu Click",
|
||||
"pressKeyToAssign": "Naciśnij klawisz na klawiaturze, aby go przypisać do {buttonName}",
|
||||
"@pressKeyToAssign": {
|
||||
"placeholders": {
|
||||
"buttonName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"previous": "Poprzedni",
|
||||
"profileExportedToClipboard": "Wyeksportowano profil „{profileName}\" do schowka",
|
||||
"@profileExportedToClipboard": {
|
||||
"placeholders": {
|
||||
"profileName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profileImportedSuccessfully": "Pomyślnie zaimportowano profil",
|
||||
"profileName": "Nazwa profilu",
|
||||
"purchase": "Zakup",
|
||||
"recommendedConnectionMethods": "Zalecane metody połączenia",
|
||||
"removeFromIgnoredList": "Usuń z listy ignorowanych",
|
||||
"rename": "Zmiana nazwy",
|
||||
"renameProfile": "Zmień nazwę profilu",
|
||||
"requirement": "Wymóg",
|
||||
"reset": "Reset",
|
||||
"restart": "Uruchom ponownie",
|
||||
"runAppOnPlatformRemotely": "Uruchom {appName} na {platform} i steruj nim zdalnie z tego urządzenia {preferredConnection}.",
|
||||
"@runAppOnPlatformRemotely": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
},
|
||||
"platform": {
|
||||
"type": "String"
|
||||
},
|
||||
"preferredConnection": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runAppOnThisDevice": "Uruchom {appName} na tym urządzeniu.",
|
||||
"@runAppOnThisDevice": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"save": "Zapisz",
|
||||
"scan": "SKANUJ",
|
||||
"scanningForDevices": "Skanowanie urządzeń... Upewnij się, że są włączone, znajdują się w zasięgu i nie są podłączone do innego urządzenia.",
|
||||
"selectKeymap": "Wybierz mapę klawiszy",
|
||||
"selectTargetWhereAppRuns": "Wybierz urządzenie docelowe, na którym działa {appName}",
|
||||
"@selectTargetWhereAppRuns": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectTrainerApp": "Wybierz aplikację treningową",
|
||||
"selectTrainerAppAndTarget": "Wybierz aplikację treningową i docelowe urządzenie",
|
||||
"selectTrainerAppPlaceholder": "Wybierz aplikację treningową",
|
||||
"setting": "Ustawienie",
|
||||
"setupTrainer": "Konfiguracja trenażera",
|
||||
"share": "Udostępnij",
|
||||
"showDonation": "Wyraź swoją wdzięczność poprzez darowiznę",
|
||||
"showSupportedControllers": "Pokaż wspierane kontrolery",
|
||||
"showTroubleshootingGuide": "Pokaż instrukcję rozwiązywania problemów",
|
||||
"signal": "Sygnał",
|
||||
"simulateButtons": "Sterowanie trenażerem",
|
||||
"simulateKeyboardShortcut": "Symuluj skrót klawiaturowy",
|
||||
"simulateMediaKey": "Symuluj przycisk multimedialny",
|
||||
"simulateTouch": "Symuluj dotyk",
|
||||
"stop": "Stop",
|
||||
"targetOtherDevice": "Inne urządzenie",
|
||||
"targetThisDevice": "To urządzenie",
|
||||
"theFollowingPermissionsRequired": "Wymagane są następujące uprawnienia:",
|
||||
"touchAreaInstructions": "1. Utwórz zrzut ekranu w grze swojej aplikacji (np. w MyWhoosh) w orientacji poziomej\n2. Załaduj zrzut ekranu za pomocą poniższego przycisku\n3. Aplikacja automatycznie ustawi się w orientacji poziomej, aby zapewnić dokładne odwzorowanie\n4. Naciśnij przycisk na urządzeniu Click, aby utworzyć obszar dotykowy\n5. Przeciągnij obszary dotykowe do żądanej pozycji na zrzucie ekranu\n6. Zapisz i zamknij ten ekran.",
|
||||
"touchSimulationForegroundMessage": "Aby symulować dotyk, aplikacja musi pozostać na pierwszym planie.",
|
||||
"trainer": "Trenażer",
|
||||
"trialDaysRemaining": "Pozostało {trialDaysRemaining} dni",
|
||||
"trialExpired": "Wersja próbna wygasła. Liczba poleceń ograniczona do {dailyCommandLimit} na dzień.",
|
||||
"trialPeriodActive": "Okres próbny jest aktywny - pozostało {trialDaysRemaining} dni",
|
||||
"trialPeriodDescription": "Korzystaj z nieograniczonej liczby poleceń w okresie próbnym. Po zakończeniu okresu próbnego polecenia będą ograniczone do {dailyCommandLimit} na dzień.",
|
||||
"troubleshootingGuide": "Instrukcja rozwiązywania problemów",
|
||||
"tryingToConnectAgain": "Próba ponownego połączenia...",
|
||||
"unassignAction": "Anuluj przypisanie akcji",
|
||||
"unlockFullVersion": "Odblokuj pełną wersję",
|
||||
"update": "Aktualizacja",
|
||||
"useCustomKeymapForButton": "Użyj niestandardowej mapy klawiszy, aby obsługiwać",
|
||||
"version": "Wersja {version}",
|
||||
"@version": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"viewDetailedInstructions": "Zobacz szczegółowe instrukcje",
|
||||
"volumeDown": "Zmniejsz głośność",
|
||||
"volumeUp": "Zwiększ głośność",
|
||||
"waiting": "Czekam...",
|
||||
"waitingForConnectionKickrBike": "Czekam na połączenie. Wybierz KICKR BIKE PRO w menu parowania kontrolera w {appName}.",
|
||||
"@waitingForConnectionKickrBike": {
|
||||
"placeholders": {
|
||||
"appName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"whatsNew": "Co nowego",
|
||||
"whyPermissionNeeded": "Dlaczego to uprawnienie jest potrzebne?",
|
||||
"zwiftControllerAction": "Akcja kontrolera Zwift",
|
||||
"zwiftControllerDescription": "Umożliwia BikeControl działanie jako kontroler kompatybilny ze Zwift."
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.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> isConnected = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isStarted = 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() 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;
|
||||
isConnected.value = true;
|
||||
if (kDebugMode) {
|
||||
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
|
||||
}
|
||||
|
||||
// Listen for data from the client
|
||||
socket.listen(
|
||||
(List<int> data) {
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
// TODO we could check if virtual shifting is enabled
|
||||
final message = utf8.decode(data);
|
||||
print('Received message: $message');
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
onDone: () {
|
||||
print('Client disconnected: $socket');
|
||||
isConnected.value = false;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String sendAction(InGameAction action, int? value) {
|
||||
if (!isConnected.value) {
|
||||
return 'Not connected to MyWhoosh.';
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
if (jsonObject != null) {
|
||||
final jsonString = jsonEncode(jsonObject);
|
||||
_socket?.writeln(jsonString);
|
||||
return 'Sent action to MyWhoosh: $action';
|
||||
} else {
|
||||
return 'No action available for button: $action';
|
||||
}
|
||||
}
|
||||
}
|
||||
220
lib/main.dart
220
lib/main.dart
@@ -1,84 +1,222 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/actions/remote.dart';
|
||||
import 'package:bike_control/widgets/menu.dart';
|
||||
import 'package:bike_control/widgets/testbed.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:swift_control/pages/requirements.dart';
|
||||
import 'package:swift_control/theme.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/actions/link.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/utils/settings/settings.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart'
|
||||
show GlobalMaterialLocalizations, GlobalWidgetsLocalizations;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import 'bluetooth/connection.dart';
|
||||
import 'link/link.dart';
|
||||
import 'pages/navigation.dart';
|
||||
import 'utils/actions/base_actions.dart';
|
||||
import 'utils/core.dart';
|
||||
|
||||
final connection = Connection();
|
||||
late BaseActions actionHandler;
|
||||
final accessibilityHandler = Accessibility();
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
final settings = Settings();
|
||||
final whooshLink = WhooshLink();
|
||||
const screenshotMode = false;
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
var screenshotMode = false;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// setup crash reporting
|
||||
|
||||
runApp(const SwiftPlayApp());
|
||||
// Catch errors that happen in other isolates
|
||||
if (!kIsWeb) {
|
||||
Isolate.current.addErrorListener(
|
||||
RawReceivePort((dynamic pair) {
|
||||
final List<dynamic> errorAndStack = pair as List<dynamic>;
|
||||
final error = errorAndStack.first;
|
||||
final stack = errorAndStack.last as StackTrace?;
|
||||
recordError(error, stack, context: 'Isolate');
|
||||
}).sendPort,
|
||||
);
|
||||
}
|
||||
|
||||
runZonedGuarded<Future<void>>(
|
||||
() async {
|
||||
// Catch Flutter framework errors (build/layout/paint)
|
||||
FlutterError.onError = (FlutterErrorDetails details) {
|
||||
_recordFlutterError(details);
|
||||
// Optionally forward to default behavior in debug:
|
||||
FlutterError.presentError(details);
|
||||
};
|
||||
|
||||
// Catch errors from platform dispatcher (async)
|
||||
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
|
||||
recordError(error, stack, context: 'PlatformDispatcher');
|
||||
// Return true means "handled"
|
||||
return true;
|
||||
};
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final error = await core.settings.init();
|
||||
|
||||
runApp(BikeControlApp(error: error));
|
||||
},
|
||||
(Object error, StackTrace stack) {
|
||||
if (kDebugMode) {
|
||||
print('App crashed: $error');
|
||||
debugPrintStack(stackTrace: stack);
|
||||
}
|
||||
recordError(error, stack, context: 'Zone');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _recordFlutterError(FlutterErrorDetails details) async {
|
||||
await _persistCrash(
|
||||
type: 'flutter',
|
||||
error: details.exceptionAsString(),
|
||||
stack: details.stack,
|
||||
information: details.informationCollector?.call().join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> recordError(
|
||||
Object error,
|
||||
StackTrace? stack, {
|
||||
required String context,
|
||||
}) async {
|
||||
await _persistCrash(
|
||||
type: 'dart',
|
||||
error: error.toString(),
|
||||
stack: stack,
|
||||
information: 'Context: $context',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _persistCrash({
|
||||
required String type,
|
||||
required String error,
|
||||
StackTrace? stack,
|
||||
String? information,
|
||||
}) async {
|
||||
try {
|
||||
final timestamp = DateTime.now().toIso8601String();
|
||||
final crashData = StringBuffer()
|
||||
..writeln('--- $timestamp ---')
|
||||
..writeln('Type: $type')
|
||||
..writeln('Error: $error')
|
||||
..writeln('Stack: ${stack ?? 'no stack'}')
|
||||
..writeln('Info: ${information ?? ''}')
|
||||
..writeln(debugText())
|
||||
..writeln()
|
||||
..writeln();
|
||||
|
||||
final directory = await _getLogDirectory();
|
||||
final file = File('${directory.path}/app.logs');
|
||||
final fileLength = await file.length();
|
||||
if (fileLength > 5 * 1024 * 1024) {
|
||||
// If log file exceeds 5MB, truncate it
|
||||
final lines = await file.readAsLines();
|
||||
final half = lines.length ~/ 2;
|
||||
final truncatedLines = lines.sublist(half);
|
||||
await file.writeAsString(truncatedLines.join('\n'));
|
||||
}
|
||||
|
||||
await file.writeAsString(crashData.toString(), mode: FileMode.append);
|
||||
core.connection.signalNotification(LogNotification('App crashed: $error'));
|
||||
} catch (_) {
|
||||
// Avoid throwing from the crash logger
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal implementation; customize per platform if needed.
|
||||
Future<Directory> _getLogDirectory() async {
|
||||
// On mobile, you might choose applicationDocumentsDirectory via platform channel,
|
||||
// but staying pure Dart, use currentDirectory as a placeholder.
|
||||
return Directory.current;
|
||||
}
|
||||
|
||||
enum ConnectionType {
|
||||
unknown,
|
||||
local,
|
||||
remote,
|
||||
link,
|
||||
}
|
||||
|
||||
Future<void> initializeActions(ConnectionType connectionType) async {
|
||||
if (connectionType != ConnectionType.link) {
|
||||
whooshLink.stopServer();
|
||||
}
|
||||
void initializeActions(ConnectionType connectionType) {
|
||||
if (kIsWeb) {
|
||||
actionHandler = StubActions();
|
||||
core.actionHandler = StubActions();
|
||||
} else if (Platform.isAndroid) {
|
||||
actionHandler = switch (connectionType) {
|
||||
core.actionHandler = switch (connectionType) {
|
||||
ConnectionType.local => AndroidActions(),
|
||||
ConnectionType.remote => RemoteActions(),
|
||||
ConnectionType.link => LinkActions(),
|
||||
ConnectionType.unknown => StubActions(),
|
||||
};
|
||||
} else if (Platform.isIOS) {
|
||||
actionHandler = switch (connectionType) {
|
||||
core.actionHandler = switch (connectionType) {
|
||||
ConnectionType.local => StubActions(),
|
||||
ConnectionType.remote => RemoteActions(),
|
||||
ConnectionType.link => LinkActions(),
|
||||
ConnectionType.unknown => StubActions(),
|
||||
};
|
||||
} else {
|
||||
actionHandler = switch (connectionType) {
|
||||
core.actionHandler = switch (connectionType) {
|
||||
ConnectionType.local => DesktopActions(),
|
||||
ConnectionType.remote => RemoteActions(),
|
||||
ConnectionType.link => LinkActions(),
|
||||
ConnectionType.unknown => StubActions(),
|
||||
};
|
||||
}
|
||||
core.actionHandler.init(core.settings.getKeyMap());
|
||||
}
|
||||
|
||||
class SwiftPlayApp extends StatelessWidget {
|
||||
const SwiftPlayApp({super.key});
|
||||
class BikeControlApp extends StatelessWidget {
|
||||
final Widget? customChild;
|
||||
final BCPage page;
|
||||
final String? error;
|
||||
const BikeControlApp({super.key, this.error, this.page = BCPage.devices, this.customChild});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
final isMobile = MediaQuery.sizeOf(context).width < 600;
|
||||
return ShadcnApp(
|
||||
navigatorKey: navigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'SwiftControl',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.light,
|
||||
home: const RequirementsPage(),
|
||||
menuHandler: PopoverOverlayHandler(),
|
||||
popoverHandler: PopoverOverlayHandler(),
|
||||
localizationsDelegates: [
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
ShadcnLocalizations.delegate,
|
||||
AppLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.delegate.supportedLocales,
|
||||
title: 'BikeControl',
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorSchemes.darkDefaultColor.copyWith(
|
||||
card: () => Color(0xFF001A29),
|
||||
background: () => Color(0xFF232323),
|
||||
muted: () => Color(0xFF3A3A3A),
|
||||
),
|
||||
),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorSchemes.lightDefaultColor.copyWith(
|
||||
card: () => BKColor.background,
|
||||
),
|
||||
),
|
||||
//themeMode: ThemeMode.dark,
|
||||
home: error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
'There was an error starting the App. Please contact support:\n$error',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
)
|
||||
: ToastLayer(
|
||||
key: ValueKey('Test'),
|
||||
padding: isMobile ? EdgeInsets.only(bottom: 60, left: 24, right: 24, top: 60) : null,
|
||||
child: Stack(
|
||||
children: [
|
||||
customChild ?? Navigation(page: page),
|
||||
Positioned.fill(child: Testbed()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
540
lib/pages/button_edit.dart
Normal file
540
lib/pages/button_edit.dart
Normal file
@@ -0,0 +1,540 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/touch_area.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/custom_keymap_selector.dart';
|
||||
import 'package:bike_control/widgets/ui/button_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/colors.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ButtonEditPage extends StatefulWidget {
|
||||
final Keymap keymap;
|
||||
final KeyPair keyPair;
|
||||
final VoidCallback onUpdate;
|
||||
const ButtonEditPage({super.key, required this.keyPair, required this.onUpdate, required this.keymap});
|
||||
|
||||
@override
|
||||
State<ButtonEditPage> createState() => _ButtonEditPageState();
|
||||
}
|
||||
|
||||
class _ButtonEditPageState extends State<ButtonEditPage> {
|
||||
late KeyPair _keyPair;
|
||||
late final ScrollController _scrollController = ScrollController();
|
||||
final double baseHeight = 46;
|
||||
bool _bumped = false;
|
||||
|
||||
void _triggerBump() async {
|
||||
setState(() {
|
||||
_bumped = true;
|
||||
});
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 150));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bumped = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyPair = widget.keyPair;
|
||||
_actionSubscription = core.connection.actionStream.listen((data) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (data is ButtonNotification && data.buttonsClicked.length == 1) {
|
||||
final clickedButton = data.buttonsClicked.first;
|
||||
final keyPair = widget.keymap.keyPairs.firstOrNullWhere(
|
||||
(kp) => kp.buttons.contains(clickedButton),
|
||||
);
|
||||
if (keyPair != null) {
|
||||
setState(() {
|
||||
_keyPair = keyPair;
|
||||
});
|
||||
_triggerBump();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_actionSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trainerApp = core.settings.getTrainerApp();
|
||||
|
||||
final actionsWithInGameAction = trainerApp?.keymap.keyPairs
|
||||
.where((kp) => kp.inGameAction != null)
|
||||
.distinctBy((kp) => kp.inGameAction)
|
||||
.toList();
|
||||
|
||||
return IntrinsicWidth(
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 300),
|
||||
padding: const EdgeInsets.only(right: 26.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('Editing').h3,
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
curve: Curves.easeOut,
|
||||
width: _keyPair.buttons.first.color != null ? baseHeight : null,
|
||||
height: _keyPair.buttons.first.color != null ? baseHeight : null,
|
||||
padding: EdgeInsets.all(_bumped ? 0 : 6.0),
|
||||
constraints: BoxConstraints(maxWidth: 120),
|
||||
child: ButtonWidget(button: _keyPair.buttons.first),
|
||||
),
|
||||
Expanded(child: SizedBox()),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close),
|
||||
variance: ButtonVariance.ghost,
|
||||
onPressed: () {
|
||||
closeDrawer(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (core.logic.hasNoConnectionMethod)
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 300),
|
||||
child: Warning(
|
||||
children: [
|
||||
Text(AppLocalizations.of(context).pleaseSelectAConnectionMethodFirst),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (core.logic.showObpActions) ...[
|
||||
ColoredTitle(text: context.i18n.openBikeControlActions),
|
||||
if (core.logic.obpConnectedApp == null)
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
core.logic.obpConnectedApp == null
|
||||
? 'Please connect to ${core.settings.getTrainerApp()?.name}, first.'
|
||||
: context.i18n.appIdActions(core.logic.obpConnectedApp!.appId),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
..._buildTrainerConnectionActions(core.logic.obpConnectedApp!.supportedActions),
|
||||
],
|
||||
|
||||
if (core.settings.getMyWhooshLinkEnabled() && core.logic.showMyWhooshLink) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.myWhooshDirectConnectAction),
|
||||
..._buildTrainerConnectionActions(core.whooshLink.supportedActions),
|
||||
],
|
||||
if (core.logic.isZwiftBleEnabled || core.logic.isZwiftMdnsEnabled) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.zwiftControllerAction),
|
||||
..._buildTrainerConnectionActions(core.zwiftEmulator.supportedActions),
|
||||
],
|
||||
|
||||
if (core.logic.showLocalRemoteOptions) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: 'Local / Remote Setting'),
|
||||
if (trainerApp != null && trainerApp is! CustomApp && actionsWithInGameAction?.isEmpty != true) ...[
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: null,
|
||||
title: Text(context.i18n.predefinedAction(trainerApp.name)),
|
||||
isActive: false,
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: actionsWithInGameAction!.map((keyPairAction) {
|
||||
return MenuButton(
|
||||
leading: keyPairAction.inGameAction?.icon != null
|
||||
? Icon(keyPairAction.inGameAction!.icon)
|
||||
: null,
|
||||
onPressed: (_) {
|
||||
// Copy all properties from the selected predefined action
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) {
|
||||
_keyPair.physicalKey = keyPairAction.physicalKey;
|
||||
_keyPair.logicalKey = keyPairAction.logicalKey;
|
||||
_keyPair.modifiers = List.of(keyPairAction.modifiers);
|
||||
} else {
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.modifiers = [];
|
||||
}
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.touch)) {
|
||||
_keyPair.touchPosition = keyPairAction.touchPosition;
|
||||
} else {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
}
|
||||
_keyPair.isLongPress = keyPairAction.isLongPress;
|
||||
_keyPair.inGameAction = keyPairAction.inGameAction;
|
||||
_keyPair.inGameActionValue = keyPairAction.inGameActionValue;
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(keyPairAction.toString()),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
SelectableCard(
|
||||
icon: RadixIcons.keyboard,
|
||||
title: Text(context.i18n.simulateKeyboardShortcut),
|
||||
isActive: _keyPair.physicalKey != null && !_keyPair.isSpecialKey,
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder: (c) => HotKeyListenerDialog(
|
||||
customApp: core.actionHandler.supportedApp! as CustomApp,
|
||||
keyPair: _keyPair,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.touch))
|
||||
SelectableCard(
|
||||
title: Text(context.i18n.simulateTouch),
|
||||
icon: core.actionHandler is AndroidActions ? Icons.touch_app_outlined : BootstrapIcons.mouse,
|
||||
isActive: _keyPair.physicalKey == null && _keyPair.touchPosition != Offset.zero,
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
|
||||
if (core.actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.music_note_outlined,
|
||||
isActive: _keyPair.isSpecialKey,
|
||||
title: Text(context.i18n.simulateMediaKey),
|
||||
value: _keyPair.toString(),
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
child: Text(context.i18n.playPause),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.stop),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.previous),
|
||||
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.next),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
MenuButton(
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
child: Text(context.i18n.volumeUp),
|
||||
),
|
||||
MenuButton(
|
||||
child: Text(context.i18n.volumeDown),
|
||||
onPressed: (c) {
|
||||
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
|
||||
_keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (core.connection.accessories.isNotEmpty) ...[
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: 'Accessory Actions'),
|
||||
Builder(
|
||||
builder: (context) => SelectableCard(
|
||||
icon: Icons.air,
|
||||
title: Text('KICKR Headwind'),
|
||||
isActive:
|
||||
_keyPair.inGameAction != null &&
|
||||
(_keyPair.inGameAction == InGameAction.headwindSpeed ||
|
||||
_keyPair.inGameAction == InGameAction.headwindHeartRateMode),
|
||||
value: _keyPair.inGameAction != null
|
||||
? '${_keyPair.inGameAction} ${_keyPair.inGameActionValue ?? ""}'.trim()
|
||||
: null,
|
||||
onPressed: () {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: [
|
||||
MenuButton(
|
||||
subMenu: [0, 25, 50, 75, 100]
|
||||
.map(
|
||||
(value) => MenuButton(
|
||||
child: Text('Set Speed to $value%'),
|
||||
onPressed: (_) {
|
||||
_keyPair.inGameAction = InGameAction.headwindSpeed;
|
||||
_keyPair.inGameActionValue = value;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Text('Set Speed'),
|
||||
),
|
||||
MenuButton(
|
||||
child: Text('Set to Heart Rate Mode'),
|
||||
onPressed: (_) {
|
||||
_keyPair.inGameAction = InGameAction.headwindHeartRateMode;
|
||||
_keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
SizedBox(height: 8),
|
||||
ColoredTitle(text: context.i18n.setting),
|
||||
SelectableCard(
|
||||
icon: _keyPair.isLongPress ? Icons.check_box : Icons.check_box_outline_blank,
|
||||
title: Text(context.i18n.longPressMode),
|
||||
isActive: _keyPair.isLongPress,
|
||||
onPressed: () {
|
||||
_keyPair.isLongPress = !_keyPair.isLongPress;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
DestructiveButton(
|
||||
onPressed: () {
|
||||
_keyPair.isLongPress = false;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.modifiers = [];
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.inGameAction = null;
|
||||
_keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(context.i18n.unassignAction),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTrainerConnectionActions(List<InGameAction> supportedActions) {
|
||||
return supportedActions.map((action) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return SelectableCard(
|
||||
icon: action.icon,
|
||||
title: Text(action.title),
|
||||
subtitle: (action.possibleValues != null && action == _keyPair.inGameAction)
|
||||
? Text(_keyPair.inGameActionValue!.toString())
|
||||
: null,
|
||||
isActive: _keyPair.inGameAction == action && supportedActions.contains(_keyPair.inGameAction),
|
||||
onPressed: () {
|
||||
if (action.possibleValues?.isNotEmpty == true) {
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (c) => DropdownMenu(
|
||||
children: action.possibleValues!.map(
|
||||
(ingame) {
|
||||
return MenuButton(
|
||||
child: Text(ingame.toString()),
|
||||
onPressed: (_) {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.inGameAction = action;
|
||||
_keyPair.inGameActionValue = ingame;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_keyPair.touchPosition = Offset.zero;
|
||||
_keyPair.physicalKey = null;
|
||||
_keyPair.logicalKey = null;
|
||||
_keyPair.inGameAction = action;
|
||||
_keyPair.inGameActionValue = null;
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
class SelectableCard extends StatelessWidget {
|
||||
final Widget title;
|
||||
final Widget? subtitle;
|
||||
final IconData? icon;
|
||||
final bool isActive;
|
||||
final String? value;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const SelectableCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.icon,
|
||||
this.subtitle,
|
||||
required this.isActive,
|
||||
this.value,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Button.outline(
|
||||
style:
|
||||
ButtonStyle(
|
||||
variance: ButtonVariance.outline,
|
||||
)
|
||||
.withBorder(
|
||||
border: isActive
|
||||
? Border.all(color: BKColor.main, width: 2)
|
||||
: Border.all(color: Theme.of(context).colorScheme.border, width: 2),
|
||||
hoverBorder: Border.all(color: BKColor.mainEnd, width: 2),
|
||||
focusBorder: Border.all(color: BKColor.main, width: 2),
|
||||
)
|
||||
.withBackgroundColor(
|
||||
color: isActive
|
||||
? Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.9)
|
||||
: Theme.of(context).colorScheme.background,
|
||||
hoverColor: Theme.of(context).colorScheme.card,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Basic(
|
||||
leading: icon != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 3.0),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: icon == Icons.delete_outline ? Theme.of(context).colorScheme.destructive : null,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
title: title,
|
||||
subtitle: value != null && isActive ? Text(value!) : subtitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
690
lib/pages/button_simulator.dart
Normal file
690
lib/pages/button_simulator.dart
Normal file
@@ -0,0 +1,690 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:bike_control/bluetooth/devices/mywhoosh/link.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/zwift_emulator.dart';
|
||||
import 'package:bike_control/bluetooth/remote_pairing.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/touch_area.dart';
|
||||
import 'package:bike_control/utils/actions/android.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/actions/desktop.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/keymap/keymap.dart';
|
||||
import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/zwift_mdns_tile.dart';
|
||||
import 'package:bike_control/widgets/apps/zwift_tile.dart';
|
||||
import 'package:bike_control/widgets/pair_widget.dart';
|
||||
import 'package:bike_control/widgets/ui/gradient_text.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart' show BackButton;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ButtonSimulator extends StatefulWidget {
|
||||
const ButtonSimulator({super.key});
|
||||
|
||||
@override
|
||||
State<ButtonSimulator> createState() => _ButtonSimulatorState();
|
||||
}
|
||||
|
||||
class _ButtonSimulatorState extends State<ButtonSimulator> {
|
||||
late final FocusNode _focusNode;
|
||||
Map<InGameAction, String> _hotkeys = {};
|
||||
|
||||
// Default hotkeys for actions
|
||||
static const List<String> _defaultHotkeyOrder = [
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'q',
|
||||
'w',
|
||||
'e',
|
||||
'r',
|
||||
't',
|
||||
'y',
|
||||
'u',
|
||||
'i',
|
||||
'o',
|
||||
'p',
|
||||
'a',
|
||||
's',
|
||||
'd',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'z',
|
||||
'x',
|
||||
'c',
|
||||
'v',
|
||||
'b',
|
||||
'n',
|
||||
'm',
|
||||
];
|
||||
|
||||
static const Duration _keyPressDuration = Duration(milliseconds: 200);
|
||||
|
||||
InGameAction? _pressedAction;
|
||||
|
||||
DateTime? _lastDown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = FocusNode(debugLabel: 'ButtonSimulatorFocus', canRequestFocus: true);
|
||||
_loadHotkeys();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadHotkeys() async {
|
||||
final savedHotkeys = core.settings.getButtonSimulatorHotkeys();
|
||||
|
||||
// If no saved hotkeys, initialize with defaults
|
||||
if (savedHotkeys.isEmpty) {
|
||||
final connectedTrainers = core.logic.enabledTrainerConnections;
|
||||
final allActions = <InGameAction>[];
|
||||
|
||||
for (final connection in connectedTrainers) {
|
||||
allActions.addAll(connection.supportedActions);
|
||||
}
|
||||
|
||||
// Assign default hotkeys to actions
|
||||
final Map<InGameAction, String> defaultHotkeys = {};
|
||||
int hotkeyIndex = 0;
|
||||
for (final action in allActions.distinct()) {
|
||||
if (hotkeyIndex < _defaultHotkeyOrder.length) {
|
||||
defaultHotkeys[action] = _defaultHotkeyOrder[hotkeyIndex];
|
||||
hotkeyIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
await core.settings.setButtonSimulatorHotkeys(defaultHotkeys);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hotkeys = defaultHotkeys;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
_hotkeys = savedHotkeys;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey.keyLabel.toLowerCase();
|
||||
|
||||
// Find the action associated with this key
|
||||
final action = _hotkeys.entries.firstOrNullWhere((entry) => entry.value == key)?.key;
|
||||
|
||||
if (action == null) return KeyEventResult.ignored;
|
||||
|
||||
_pressedAction = action;
|
||||
setState(() {});
|
||||
|
||||
// Find the connection that supports this action
|
||||
final connectedTrainers = core.logic.connectedTrainerConnections;
|
||||
final connection = connectedTrainers.firstOrNullWhere((c) => c.supportedActions.contains(action));
|
||||
|
||||
if (connection != null) {
|
||||
_sendKey(context, down: true, action: action, connection: connection);
|
||||
// Schedule key up event
|
||||
Future.delayed(
|
||||
_keyPressDuration,
|
||||
() {
|
||||
if (mounted) {
|
||||
_pressedAction = null;
|
||||
setState(() {});
|
||||
_sendKey(context, down: false, action: action, connection: connection);
|
||||
}
|
||||
},
|
||||
);
|
||||
return KeyEventResult.handled;
|
||||
} else {
|
||||
_pressedAction = null;
|
||||
setState(() {});
|
||||
buildToast(context, title: 'No connected trainer.');
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectedTrainers = core.logic.enabledTrainerConnections;
|
||||
|
||||
final isMobile = MediaQuery.sizeOf(context).width < 600;
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Scaffold(
|
||||
headers: [
|
||||
AppBar(
|
||||
leading: [BackButton()],
|
||||
title: Text(context.i18n.simulateButtons),
|
||||
trailing: [
|
||||
PrimaryButton(
|
||||
child: Icon(Icons.settings),
|
||||
onPressed: () => _showHotkeySettings(context, connectedTrainers),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 16,
|
||||
children: [
|
||||
if (connectedTrainers.isEmpty)
|
||||
Warning(
|
||||
children: [
|
||||
Text('No suitable connection method activated. Connect a trainer to simulate button presses.'),
|
||||
],
|
||||
),
|
||||
for (final connectedTrainer in connectedTrainers)
|
||||
if (!screenshotMode)
|
||||
switch (connectedTrainer.title) {
|
||||
WhooshLink.connectionTitle => MyWhooshLinkTile(),
|
||||
ZwiftEmulator.connectionTitle => ZwiftTile(
|
||||
onUpdate: () {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
FtmsMdnsEmulator.connectionTitle => ZwiftMdnsTile(
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
OpenBikeControlMdnsEmulator.connectionTitle => OpenBikeControlMdnsTile(),
|
||||
OpenBikeControlBluetoothEmulator.connectionTitle => OpenBikeControlBluetoothTile(),
|
||||
RemotePairing.connectionTitle => RemotePairingWidget(),
|
||||
_ => SizedBox.shrink(),
|
||||
},
|
||||
...connectedTrainers.map(
|
||||
(connection) {
|
||||
final supportedActions = connection.supportedActions;
|
||||
|
||||
final actionGroups = {
|
||||
if (supportedActions.contains(InGameAction.shiftUp) &&
|
||||
supportedActions.contains(InGameAction.shiftDown))
|
||||
'Shifting': [InGameAction.shiftDown, InGameAction.shiftUp],
|
||||
'Other': supportedActions
|
||||
.where(
|
||||
(action) =>
|
||||
action != InGameAction.shiftUp &&
|
||||
action != InGameAction.shiftDown &&
|
||||
action != InGameAction.steerLeft &&
|
||||
action != InGameAction.steerRight,
|
||||
)
|
||||
.toList(),
|
||||
if (supportedActions.contains(InGameAction.steerLeft) &&
|
||||
supportedActions.contains(InGameAction.steerRight))
|
||||
'Steering': [InGameAction.steerLeft, InGameAction.steerRight],
|
||||
};
|
||||
|
||||
return [
|
||||
GradientText(connection.title).bold.large,
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 800),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
for (final group in actionGroups.entries) ...[
|
||||
Text(group.key.toUpperCase()).bold.muted,
|
||||
if (group.value.length == 2)
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: group.value.map(
|
||||
(action) {
|
||||
final hotkey = _hotkeys[action];
|
||||
return Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
child: _buildButton(action, group, connection, isMobile),
|
||||
),
|
||||
if (hotkey != null)
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: KeyWidget(
|
||||
label: hotkey.toUpperCase(),
|
||||
invert: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
)
|
||||
else
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisCount: min(group.value.length, 3),
|
||||
childAspectRatio: isMobile ? 1 : 2.4,
|
||||
children: group.value.map(
|
||||
(action) {
|
||||
final hotkey = _hotkeys[action];
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildButton(action, group, connection, isMobile),
|
||||
|
||||
if (hotkey != null)
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: KeyWidget(
|
||||
label: hotkey.toUpperCase(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
).flatten(),
|
||||
// local control doesn't make much sense - it would send the key events to BikeControl itself
|
||||
if (false &&
|
||||
core.logic.showLocalControl &&
|
||||
core.settings.getLocalEnabled() &&
|
||||
core.actionHandler.supportedApp != null) ...[
|
||||
GradientText('Local Control'),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: core.actionHandler.supportedApp!.keymap.keyPairs
|
||||
.map(
|
||||
(keyPair) => PrimaryButton(
|
||||
child: Text(keyPair.toString()),
|
||||
onPressed: () async {
|
||||
if (core.actionHandler is AndroidActions) {
|
||||
await (core.actionHandler as AndroidActions).performAction(
|
||||
keyPair.buttons.first,
|
||||
isKeyDown: true,
|
||||
isKeyUp: false,
|
||||
);
|
||||
await (core.actionHandler as AndroidActions).performAction(
|
||||
keyPair.buttons.first,
|
||||
isKeyDown: false,
|
||||
isKeyUp: true,
|
||||
);
|
||||
} else {
|
||||
await (core.actionHandler as DesktopActions).performAction(
|
||||
keyPair.buttons.first,
|
||||
isKeyDown: true,
|
||||
isKeyUp: false,
|
||||
);
|
||||
await (core.actionHandler as DesktopActions).performAction(
|
||||
keyPair.buttons.first,
|
||||
isKeyDown: false,
|
||||
isKeyUp: true,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildButton(
|
||||
InGameAction action,
|
||||
MapEntry<String, List<InGameAction>> group,
|
||||
TrainerConnection connection,
|
||||
bool isMobile,
|
||||
) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
return Button(
|
||||
style: _pressedAction == action
|
||||
? ButtonStyle.outline()
|
||||
: group.key == 'Other'
|
||||
? ButtonStyle.outline()
|
||||
: ButtonStyle.primary(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (action.icon != null) ...[
|
||||
Icon(action.icon),
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
action.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(height: 1),
|
||||
maxLines: 2,
|
||||
).bold,
|
||||
if (action.alternativeTitle != null)
|
||||
Text(
|
||||
action.alternativeTitle!.toUpperCase(),
|
||||
style: TextStyle(fontSize: 10, color: Colors.gray),
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: () {},
|
||||
onTapDown: (c) async {
|
||||
_sendKey(context, down: true, action: action, connection: connection);
|
||||
/*final device = HidDevice('Simulator');
|
||||
final button = ControllerButton('action', action: InGameAction.openActionBar);
|
||||
device.getOrAddButton(button.name, () => button);
|
||||
device.handleButtonsClickedWithoutLongPressSupport([button]);*/
|
||||
},
|
||||
onTapUp: (c) async {
|
||||
_sendKey(context, down: false, action: action, connection: connection);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendKey(
|
||||
BuildContext context, {
|
||||
required bool down,
|
||||
required InGameAction action,
|
||||
required TrainerConnection connection,
|
||||
}) async {
|
||||
if (!connection.isConnected.value) {
|
||||
if (down) {
|
||||
buildToast(context, title: 'No connected trainer.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (action.possibleValues != null) {
|
||||
if (down) return;
|
||||
showDropdown(
|
||||
context: context,
|
||||
builder: (context) => DropdownMenu(
|
||||
children: action.possibleValues!
|
||||
.map(
|
||||
(e) => MenuButton(
|
||||
child: Text(e.toString()),
|
||||
onPressed: (c) async {
|
||||
await connection.sendAction(
|
||||
KeyPair(
|
||||
buttons: [],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: action,
|
||||
inGameActionValue: e,
|
||||
),
|
||||
isKeyDown: false,
|
||||
isKeyUp: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
if (!down && _lastDown != null) {
|
||||
final timeSinceLastDown = DateTime.now().difference(_lastDown!);
|
||||
if (timeSinceLastDown < Duration(milliseconds: 400)) {
|
||||
// wait a bit so actions actually get applied correctly for some trainer apps
|
||||
await Future.delayed(Duration(milliseconds: 800) - timeSinceLastDown);
|
||||
}
|
||||
} else if (down) {
|
||||
_lastDown = DateTime.now();
|
||||
}
|
||||
|
||||
final result = await connection.sendAction(
|
||||
KeyPair(
|
||||
buttons: [],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
inGameAction: action,
|
||||
),
|
||||
isKeyDown: down,
|
||||
isKeyUp: !down,
|
||||
);
|
||||
await IAPManager.instance.incrementCommandCount();
|
||||
if (result is! Success) {
|
||||
buildToast(context, title: result.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showHotkeySettings(BuildContext context, List<TrainerConnection> connections) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _HotkeySettingsDialog(
|
||||
connections: connections,
|
||||
currentHotkeys: _hotkeys,
|
||||
onSave: (newHotkeys) {
|
||||
setState(() {
|
||||
_hotkeys = newHotkeys;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HotkeySettingsDialog extends StatefulWidget {
|
||||
final List<TrainerConnection> connections;
|
||||
final Map<InGameAction, String> currentHotkeys;
|
||||
final Function(Map<InGameAction, String>) onSave;
|
||||
|
||||
const _HotkeySettingsDialog({
|
||||
required this.connections,
|
||||
required this.currentHotkeys,
|
||||
required this.onSave,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_HotkeySettingsDialog> createState() => _HotkeySettingsDialogState();
|
||||
}
|
||||
|
||||
class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> {
|
||||
late Map<InGameAction, String> _editableHotkeys;
|
||||
InGameAction? _editingAction;
|
||||
late FocusNode _focusNode;
|
||||
|
||||
static final _validHotkeyPattern = RegExp(r'[0-9a-z]');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_editableHotkeys = Map.from(widget.currentHotkeys);
|
||||
_focusNode = FocusNode(debugLabel: 'HotkeySettingsFocus', canRequestFocus: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (_editingAction == null || event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey.keyLabel.toLowerCase();
|
||||
|
||||
// Only allow single character 1-9 and a-z
|
||||
if (key.length == 1 && _validHotkeyPattern.hasMatch(key)) {
|
||||
setState(() {
|
||||
_editableHotkeys[_editingAction!] = key;
|
||||
_editingAction = null;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
setState(() {
|
||||
_editingAction = null;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allActions = <InGameAction>[];
|
||||
for (final connection in widget.connections) {
|
||||
allActions.addAll(connection.supportedActions);
|
||||
}
|
||||
final uniqueActions = allActions.distinct().toList();
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: AlertDialog(
|
||||
title: Text('Configure Keyboard Hotkeys'),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('Assign keyboard shortcuts to simulator buttons').muted,
|
||||
SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: uniqueActions.map((action) {
|
||||
final hotkey = _editableHotkeys[action];
|
||||
final isEditing = _editingAction == action;
|
||||
|
||||
return Card(
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(action.title),
|
||||
),
|
||||
if (isEditing)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.blue),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('Press a key...', style: TextStyle(color: Colors.blue)),
|
||||
)
|
||||
else if (hotkey != null)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.gray.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(hotkey.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else
|
||||
Text('No hotkey', style: TextStyle(color: Colors.gray)),
|
||||
SizedBox(width: 8),
|
||||
OutlineButton(
|
||||
size: ButtonSize.small,
|
||||
child: Text(isEditing ? 'Cancel' : 'Set'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_editingAction = isEditing ? null : action;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (hotkey != null && !isEditing) ...[
|
||||
SizedBox(width: 4),
|
||||
OutlineButton(
|
||||
size: ButtonSize.small,
|
||||
child: Text('Clear'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_editableHotkeys.remove(action);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
SecondaryButton(
|
||||
child: Text('Cancel'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
PrimaryButton(
|
||||
child: Text('Save'),
|
||||
onPressed: () async {
|
||||
await core.settings.setButtonSimulatorHotkeys(_editableHotkeys);
|
||||
widget.onSave(_editableHotkeys);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
206
lib/pages/configuration.dart
Normal file
206
lib/pages/configuration.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/requirements/multi.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class ConfigurationPage extends StatefulWidget {
|
||||
final VoidCallback onUpdate;
|
||||
const ConfigurationPage({super.key, required this.onUpdate});
|
||||
|
||||
@override
|
||||
State<ConfigurationPage> createState() => _ConfigurationPageState();
|
||||
}
|
||||
|
||||
class _ConfigurationPageState extends State<ConfigurationPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ColoredTitle(text: context.i18n.setupTrainer),
|
||||
Card(
|
||||
fillColor: Theme.of(context).colorScheme.background,
|
||||
filled: true,
|
||||
borderWidth: 1,
|
||||
borderColor: Theme.of(context).colorScheme.border,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (c, setState) => Column(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Select<SupportedApp>(
|
||||
constraints: BoxConstraints(maxWidth: 400, minWidth: 400),
|
||||
itemBuilder: (c, app) => Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(screenshotMode ? 'Trainer app' : app.name),
|
||||
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
|
||||
],
|
||||
),
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: SupportedApp.supportedApps.map((app) {
|
||||
return SelectItemButton(
|
||||
value: app,
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(app.name),
|
||||
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
).call,
|
||||
placeholder: Text(context.i18n.selectTrainerAppPlaceholder),
|
||||
value: core.settings.getTrainerApp(),
|
||||
onChanged: (selectedApp) async {
|
||||
if (selectedApp is! MyWhoosh) {
|
||||
if (core.whooshLink.isStarted.value) {
|
||||
core.whooshLink.stopServer();
|
||||
}
|
||||
}
|
||||
if (!selectedApp!.supportsZwiftEmulation) {
|
||||
if (core.zwiftMdnsEmulator.isStarted.value) {
|
||||
core.zwiftMdnsEmulator.stop();
|
||||
}
|
||||
if (core.zwiftEmulator.isStarted.value) {
|
||||
core.zwiftEmulator.stopAdvertising();
|
||||
}
|
||||
}
|
||||
if (!selectedApp.supportsOpenBikeProtocol) {
|
||||
if (core.obpMdnsEmulator.isStarted.value) {
|
||||
core.obpMdnsEmulator.stopServer();
|
||||
}
|
||||
if (core.obpBluetoothEmulator.isStarted.value) {
|
||||
core.obpBluetoothEmulator.stopServer();
|
||||
}
|
||||
}
|
||||
|
||||
core.settings.setTrainerApp(selectedApp);
|
||||
if (core.settings.getLastTarget() == null && Target.thisDevice.isCompatible) {
|
||||
await _setTarget(context, Target.thisDevice);
|
||||
} else if (core.settings.getLastTarget() == null && Target.otherDevice.isCompatible) {
|
||||
await _setTarget(context, Target.otherDevice);
|
||||
}
|
||||
if (core.actionHandler.supportedApp == null ||
|
||||
(core.actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
|
||||
core.actionHandler.init(selectedApp);
|
||||
core.settings.setKeyMap(selectedApp);
|
||||
}
|
||||
widget.onUpdate();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (core.settings.getTrainerApp() != null) ...[
|
||||
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true && !screenshotMode)
|
||||
Text(
|
||||
AppLocalizations.of(context).openBikeControlAnnouncement(core.settings.getTrainerApp()!.name),
|
||||
).xSmall,
|
||||
SizedBox(height: 0),
|
||||
Text(
|
||||
context.i18n.selectTargetWhereAppRuns(
|
||||
screenshotMode ? 'Trainer app' : core.settings.getTrainerApp()?.name ?? 'the Trainer app',
|
||||
),
|
||||
).small,
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [Target.thisDevice, Target.otherDevice]
|
||||
.map(
|
||||
(target) => Expanded(
|
||||
child: SelectableCard(
|
||||
title: Center(child: Icon(target.icon)),
|
||||
isActive: target == core.settings.getLastTarget(),
|
||||
subtitle: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(target.getTitle(context)),
|
||||
if (!target.isCompatible) Text(context.i18n.platformRestrictionNotSupported),
|
||||
],
|
||||
),
|
||||
),
|
||||
onPressed: !target.isCompatible
|
||||
? null
|
||||
: () async {
|
||||
await _setTarget(context, target);
|
||||
setState(() {});
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
|
||||
if (core.settings.getLastTarget() == Target.otherDevice &&
|
||||
!core.logic.hasRecommendedConnectionMethods) ...[
|
||||
SizedBox(height: 8),
|
||||
Warning(
|
||||
children: [
|
||||
Text(
|
||||
'BikeControl is available on iOS, Android, Windows and macOS. For proper support for ${core.settings.getTrainerApp()?.name} please download BikeControl on that device.',
|
||||
).small,
|
||||
],
|
||||
),
|
||||
],
|
||||
if (core.settings.getTrainerApp()?.star == true && !screenshotMode)
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(Icons.star),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
).newConnectionMethodAnnouncement(core.settings.getTrainerApp()!.name),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).xSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setTarget(BuildContext context, Target target) async {
|
||||
await core.settings.setLastTarget(target);
|
||||
|
||||
if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol == true && !core.logic.emulatorEnabled) {
|
||||
core.settings.setObpMdnsEnabled(true);
|
||||
}
|
||||
|
||||
// enable local connection on Windows if the app doesn't support OBP
|
||||
if (target == Target.thisDevice &&
|
||||
core.settings.getTrainerApp()?.supportsOpenBikeProtocol == false &&
|
||||
!kIsWeb &&
|
||||
Platform.isWindows) {
|
||||
core.settings.setLocalEnabled(true);
|
||||
}
|
||||
core.logic.startEnabledConnectionMethod();
|
||||
}
|
||||
}
|
||||
165
lib/pages/customize.dart
Normal file
165
lib/pages/customize.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:bike_control/utils/keymap/manager.dart';
|
||||
import 'package:bike_control/widgets/iap_status_widget.dart';
|
||||
import 'package:bike_control/widgets/keymap_explanation.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/warning.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class CustomizePage extends StatefulWidget {
|
||||
const CustomizePage({super.key});
|
||||
|
||||
@override
|
||||
State<CustomizePage> createState() => _CustomizeState();
|
||||
}
|
||||
|
||||
class _CustomizeState extends State<CustomizePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: true),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
width: double.infinity,
|
||||
child: ColoredTitle(
|
||||
text: context.i18n.customizeControllerButtons(
|
||||
screenshotMode ? 'Trainer app' : (core.settings.getTrainerApp()?.name ?? ''),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Select<SupportedApp?>(
|
||||
constraints: BoxConstraints(minWidth: 300),
|
||||
value: core.actionHandler.supportedApp,
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
..._getAllApps().map(
|
||||
(a) => SelectItemButton(
|
||||
value: a,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: Text(a.name)),
|
||||
if (a is CustomApp)
|
||||
BetaPill(text: 'CUSTOM')
|
||||
else if (a.supportsOpenBikeProtocol)
|
||||
Icon(Icons.star, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SelectItemButton(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(Icons.add, color: Theme.of(context).colorScheme.mutedForeground),
|
||||
Expanded(child: Text(context.i18n.createNewKeymap).normal.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).call,
|
||||
itemBuilder: (c, app) => Row(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: Text(screenshotMode ? 'Trainer app' : app!.name)),
|
||||
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
|
||||
],
|
||||
),
|
||||
placeholder: Text(context.i18n.selectKeymap),
|
||||
|
||||
onChanged: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await KeymapManager().showNewProfileDialog(context);
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
core.actionHandler.init(customApp);
|
||||
await core.settings.setKeyMap(customApp);
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
core.actionHandler.init(app);
|
||||
await core.settings.setKeyMap(app);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
KeymapManager().getManageProfileDialog(
|
||||
context,
|
||||
core.actionHandler.supportedApp is CustomApp ? core.actionHandler.supportedApp?.name : null,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (core.actionHandler.supportedApp is! CustomApp)
|
||||
Text(
|
||||
context.i18n.customizeKeymapHint,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
Gap(12),
|
||||
if (core.actionHandler.supportedApp != null && core.connection.controllerDevices.isNotEmpty)
|
||||
KeymapExplanation(
|
||||
key: Key(core.actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: core.actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
|
||||
if (core.actionHandler.supportedApp is CustomApp) {
|
||||
core.settings.setKeyMap(core.actionHandler.supportedApp!);
|
||||
}
|
||||
},
|
||||
)
|
||||
else if (core.connection.controllerDevices.isEmpty)
|
||||
Warning(
|
||||
children: [Text(context.i18n.connectControllerToPreview).small],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<SupportedApp> _getAllApps() {
|
||||
final baseApp = core.settings.getTrainerApp();
|
||||
final customProfiles = core.settings.getCustomAppProfiles();
|
||||
|
||||
final customApps = customProfiles.map((profile) {
|
||||
final customApp = CustomApp(profileName: profile);
|
||||
final savedKeymap = core.settings.getCustomAppKeymap(profile);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
return customApp;
|
||||
}).toList();
|
||||
|
||||
// If no custom profiles exist, add the default "Custom" one
|
||||
if (customApps.isEmpty) {
|
||||
customApps.add(CustomApp());
|
||||
}
|
||||
|
||||
return [if (baseApp != null) baseApp, ...customApps];
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,20 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/pages/touch_area.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/actions/link.dart';
|
||||
import 'package:swift_control/utils/keymap/manager.dart';
|
||||
import 'package:swift_control/widgets/beta_pill.dart';
|
||||
import 'package:swift_control/widgets/ingameactions_customizer.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/loading_widget.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/widgets/iap_status_widget.dart';
|
||||
import 'package:bike_control/widgets/scan.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
import '../utils/actions/remote.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/apps/supported_app.dart';
|
||||
import '../utils/requirements/remote.dart';
|
||||
import '../widgets/menu.dart';
|
||||
|
||||
class DevicePage extends StatefulWidget {
|
||||
const DevicePage({super.key});
|
||||
final VoidCallback onUpdate;
|
||||
const DevicePage({super.key, required this.onUpdate});
|
||||
|
||||
@override
|
||||
State<DevicePage> createState() => _DevicePageState();
|
||||
@@ -40,479 +22,92 @@ class DevicePage extends StatefulWidget {
|
||||
|
||||
class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
late StreamSubscription<BaseDevice> _connectionStateSubscription;
|
||||
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
|
||||
|
||||
List<SupportedApp> _getAllApps() {
|
||||
final baseApps = SupportedApp.supportedApps.where((app) => app is! CustomApp).toList();
|
||||
final customProfiles = settings.getCustomAppProfiles();
|
||||
|
||||
final customApps = customProfiles.map((profile) {
|
||||
final customApp = CustomApp(profileName: profile);
|
||||
final savedKeymap = settings.getCustomAppKeymap(profile);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
return customApp;
|
||||
}).toList();
|
||||
|
||||
// If no custom profiles exist, add the default "Custom" one
|
||||
if (customApps.isEmpty) {
|
||||
customApps.add(CustomApp());
|
||||
}
|
||||
|
||||
return [...baseApps, ...customApps];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// keep screen on - this is required for iOS to keep the bluetooth connection alive
|
||||
WakelockPlus.enable();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// show snackbar to inform user that the app needs to stay in foreground
|
||||
_snackBarMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('To keep working properly the app needs to stay in the foreground.'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
_connectionStateSubscription = connection.connectionStream.listen((state) async {
|
||||
_connectionStateSubscription = core.connection.connectionStream.listen((state) async {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
_connectionStateSubscription.cancel();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && actionHandler is RemoteActions && Platform.isIOS) {
|
||||
UniversalBle.getBluetoothAvailabilityState().then((state) {
|
||||
if (state == AvailabilityState.poweredOn) {
|
||||
final requirement = RemoteRequirement();
|
||||
requirement.reconnect();
|
||||
_snackBarMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('To keep working properly the app needs to stay in the foreground.'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canVibrate = connection.devices.any(
|
||||
(device) => (device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') && device.isConnected,
|
||||
);
|
||||
|
||||
final paddingMultiplicator = actionHandler is DesktopActions ? 2.5 : 1.0;
|
||||
|
||||
return ScaffoldMessenger(
|
||||
key: _snackBarMessengerKey,
|
||||
child: PopScope(
|
||||
onPopInvokedWithResult: (hello, _) {
|
||||
connection.reset();
|
||||
},
|
||||
child: Stack(
|
||||
return Scrollbar(
|
||||
child: SingleChildScrollView(
|
||||
primary: true,
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AppTitle(),
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
ValueListenableBuilder(
|
||||
valueListenable: IAPManager.instance.isPurchased,
|
||||
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: false),
|
||||
),
|
||||
|
||||
if (core.connection.controllerDevices.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(text: context.i18n.connectControllers),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
left: 8.0 * paddingMultiplicator,
|
||||
right: 8 * paddingMultiplicator,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: actionHandler is RemoteActions ? 0 : 12,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (connection.devices.isEmpty) Text('No devices connected. Searching...'),
|
||||
...connection.devices.map(
|
||||
(device) => Row(
|
||||
children: [
|
||||
Text(
|
||||
device.device.name?.screenshot ?? device.runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (device.isBeta) BetaPill(),
|
||||
if (device.batteryLevel != null) ...[
|
||||
Icon(switch (device.batteryLevel!) {
|
||||
>= 80 => Icons.battery_full,
|
||||
>= 60 => Icons.battery_6_bar,
|
||||
>= 50 => Icons.battery_5_bar,
|
||||
>= 25 => Icons.battery_4_bar,
|
||||
>= 10 => Icons.battery_2_bar,
|
||||
_ => Icons.battery_alert,
|
||||
}),
|
||||
Text('${device.batteryLevel}%'),
|
||||
if (device.firmwareVersion != null) Text(' - Firmware: ${device.firmwareVersion}'),
|
||||
if (device.firmwareVersion != null &&
|
||||
device is ZwiftDevice &&
|
||||
device.firmwareVersion != device.latestFirmwareVersion) ...[
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
||||
Text(
|
||||
' (latest: ${device.latestFirmwareVersion})',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (actionHandler is RemoteActions)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
|
||||
),
|
||||
LoadingWidget(
|
||||
futureCallback: () async {
|
||||
final requirement = RemoteRequirement();
|
||||
await requirement.reconnect();
|
||||
},
|
||||
renderChild: (isLoading, tap) => TextButton(
|
||||
onPressed: tap,
|
||||
child: isLoading ? SmallProgressIndicator() : Text('Reconnect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (actionHandler is LinkActions)
|
||||
ValueListenableBuilder(
|
||||
valueListenable: whooshLink.isConnected,
|
||||
builder: (BuildContext context, value, Widget? child) {
|
||||
return Text(
|
||||
'Link connected: ${value ? 'Connected' : 'Not connected'}',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
|
||||
Container(
|
||||
margin: EdgeInsets.only(bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that SwiftControl will need to reconnect every minute.
|
||||
// leave it in for the extra scanning options
|
||||
ScanWidget(),
|
||||
|
||||
1. Open Zwift app
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Zwift Click V2
|
||||
4. Close the Zwift app again and connect again in SwiftControl''',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
connection.devices.whereType<ZwiftClickV2>().forEach(
|
||||
(device) => device.sendCommand(Opcode.RESET, null),
|
||||
);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Troubleshooting'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(text: context.i18n.connectedControllers),
|
||||
),
|
||||
|
||||
if (!kIsWeb) ...[
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Customize', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: canVibrate ? 0 : 12,
|
||||
),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (actionHandler is! LinkActions)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<SupportedApp?>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries: [
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
label: Text('Select Keymap / app'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await KeypadManager().showNewProfileDialog(context);
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
controller.text = profileName;
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setApp(app);
|
||||
setState(() {});
|
||||
if (app is! CustomApp &&
|
||||
!kIsWeb &&
|
||||
(Platform.isMacOS || Platform.isWindows)) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Customize the keymap if you experience any issues (e.g. wrong keyboard output)',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (actionHandler.supportedApp != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
if (actionHandler.supportedApp is! CustomApp) {
|
||||
final result = await KeypadManager().duplicate(
|
||||
context,
|
||||
actionHandler.supportedApp!.name,
|
||||
);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
final result = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
|
||||
|
||||
if (result == true && actionHandler.supportedApp is CustomApp) {
|
||||
await settings.setApp(actionHandler.supportedApp!);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
icon: Icon(Icons.edit),
|
||||
label: Text('Edit'),
|
||||
),
|
||||
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final currentProfile = actionHandler.supportedApp?.name;
|
||||
final action = await KeypadManager().showManageProfileDialog(
|
||||
context,
|
||||
currentProfile,
|
||||
);
|
||||
if (action != null) {
|
||||
if (action == 'rename') {
|
||||
final newName = await KeypadManager().showRenameProfileDialog(
|
||||
context,
|
||||
currentProfile!,
|
||||
);
|
||||
if (newName != null &&
|
||||
newName.isNotEmpty &&
|
||||
newName != currentProfile) {
|
||||
await settings.duplicateCustomAppProfile(currentProfile, newName);
|
||||
await settings.deleteCustomAppProfile(currentProfile);
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
final savedKeymap = settings.getCustomAppKeymap(newName);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
controller.text = newName;
|
||||
setState(() {});
|
||||
}
|
||||
} else if (action == 'duplicate') {
|
||||
final newName = await KeypadManager().duplicate(
|
||||
context,
|
||||
currentProfile!,
|
||||
);
|
||||
|
||||
if (newName != null) {
|
||||
controller.text = newName;
|
||||
setState(() {});
|
||||
}
|
||||
} else if (action == 'delete') {
|
||||
final confirmed = await KeypadManager().showDeleteConfirmDialog(
|
||||
context,
|
||||
currentProfile!,
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await settings.deleteCustomAppProfile(currentProfile);
|
||||
controller.text = '';
|
||||
setState(() {});
|
||||
}
|
||||
} else if (action == 'import') {
|
||||
final jsonData = await KeypadManager().showImportDialog(context);
|
||||
if (jsonData != null && jsonData.isNotEmpty) {
|
||||
final success = await settings.importCustomAppProfile(jsonData);
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Profile imported successfully'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
} else {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to import profile. Invalid format.'),
|
||||
duration: Duration(seconds: 5),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (action == 'export') {
|
||||
final currentProfile =
|
||||
(actionHandler.supportedApp as CustomApp).profileName;
|
||||
final jsonData = settings.exportCustomAppProfile(currentProfile);
|
||||
if (jsonData != null) {
|
||||
await Clipboard.setData(ClipboardData(text: jsonData));
|
||||
if (mounted) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Profile "$currentProfile" exported to clipboard',
|
||||
),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.more_vert),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actionHandler is LinkActions)
|
||||
InGameActionsCustomizer()
|
||||
else if (actionHandler.supportedApp != null)
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
),
|
||||
if (canVibrate) ...[
|
||||
SwitchListTile(
|
||||
title: Text('Enable vibration feedback when shifting gears'),
|
||||
value: settings.getVibrationEnabled(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await settings.setVibrationEnabled(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Logs', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
LogViewer(),
|
||||
],
|
||||
),
|
||||
...core.connection.controllerDevices.map(
|
||||
(device) => Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: Testbed()),
|
||||
|
||||
if (core.connection.accessories.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ColoredTitle(text: AppLocalizations.of(context).accessories),
|
||||
),
|
||||
...core.connection.accessories.map(
|
||||
(device) => Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
SizedBox(),
|
||||
if (core.connection.controllerDevices.isNotEmpty)
|
||||
Row(
|
||||
spacing: 8,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
PrimaryButton(
|
||||
child: Text(context.i18n.connectToTrainerApp),
|
||||
onPressed: () {
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flex_color_scheme/flex_color_scheme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart' show BackButton;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_md/flutter_md.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class MarkdownPage extends StatefulWidget {
|
||||
@@ -14,7 +16,7 @@ class MarkdownPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChangelogPageState extends State<MarkdownPage> {
|
||||
Markdown? _markdown;
|
||||
List<_Group>? _groups;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
@@ -26,59 +28,99 @@ class _ChangelogPageState extends State<MarkdownPage> {
|
||||
Future<void> _loadChangelog() async {
|
||||
try {
|
||||
final md = await rootBundle.loadString(widget.assetPath);
|
||||
setState(() {
|
||||
_markdown = Markdown.fromString(md);
|
||||
});
|
||||
|
||||
// load latest version
|
||||
final response = await http.get(
|
||||
Uri.parse('https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/${widget.assetPath}'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final latestMd = response.body;
|
||||
if (latestMd != md) {
|
||||
setState(() {
|
||||
_markdown = Markdown.fromString(md);
|
||||
});
|
||||
}
|
||||
}
|
||||
_parseMarkdown(md);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Failed to load changelog: $e';
|
||||
});
|
||||
} finally {
|
||||
_loadOnlineVersion();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
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);
|
||||
},
|
||||
headers: [
|
||||
AppBar(
|
||||
leading: [
|
||||
BackButton(),
|
||||
],
|
||||
title: Text(
|
||||
widget.assetPath
|
||||
.replaceAll('.md', '')
|
||||
.split('_')
|
||||
.joinToString(separator: ' ', transform: (s) => s.toLowerCase().capitalize()),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _groups == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Accordion(
|
||||
items: _groups!
|
||||
.map(
|
||||
(group) => AccordionItem(
|
||||
trigger: AccordionTrigger(child: ColoredTitle(text: group.title)),
|
||||
content: MarkdownWidget(
|
||||
markdown: group.markdown,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Theme.of(context).colorScheme.brightness == Brightness.dark
|
||||
? Colors.white.withAlpha(255 * 70)
|
||||
: Colors.black.withAlpha(87 * 255),
|
||||
),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _parseMarkdown(String md) {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_groups = md
|
||||
.split('## ')
|
||||
.map((section) {
|
||||
final lines = section.split('\n');
|
||||
final title = lines.first.replaceFirst('# ', '').trim();
|
||||
final content = lines.skip(1).join('\n').trim();
|
||||
return _Group(
|
||||
title: title,
|
||||
markdown: Markdown.fromString('## $content'),
|
||||
);
|
||||
})
|
||||
.where((group) => group.title.isNotEmpty)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadOnlineVersion() async {
|
||||
// load latest version
|
||||
final response = await http.get(
|
||||
Uri.parse('https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/${widget.assetPath}'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final latestMd = response.body;
|
||||
_parseMarkdown(latestMd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Group {
|
||||
final String title;
|
||||
final Markdown markdown;
|
||||
|
||||
_Group({required this.title, required this.markdown});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user