Compare commits

..

211 Commits

Author SHA1 Message Date
Jonas Bark
86addc00fd Merge remote-tracking branch 'origin/win' 2026-01-13 18:42:52 +01:00
jonas.bark@gmx.de
9cebea225c fix import 2026-01-13 18:42:42 +01:00
jonasbark
59bdb30321 Merge pull request #255 from jonasbark/win
Win
2026-01-13 18:20:52 +01:00
Jonas Bark
d51fb7dfa2 update translations 2026-01-13 18:20:25 +01:00
jonas.bark@gmx.de
b955c51a91 cleanup 2026-01-13 18:07:35 +01:00
Jonas Bark
86ecd1ad20 check win package format 2026-01-13 17:57:01 +01:00
jonas.bark@gmx.de
c089b3bdbd fix test phase logic 2026-01-13 17:49:17 +01:00
Jonas Bark
9612b213aa adjust instructions 2026-01-13 11:49:27 +01:00
Jonas Bark
83c9b52708 Merge branch 'copilot/fix-bluetooth-keyboard-issue' 2026-01-12 18:43:46 +01:00
Jonas Bark
a7bde7c08a UI fix 2026-01-12 18:43:10 +01:00
Jonas Bark
c8613b5975 fix implementation 2026-01-12 18:42:54 +01:00
jonasbark
87bb728601 Merge pull request #253 from jonasbark/copilot/add-support-thinkrider-vs200
Add support for Thinkrider VS200 virtual shifter
2026-01-12 17:55:47 +01:00
copilot-swe-agent[bot]
e1f9d4fb08 Add debug logging for all received hexValues while in beta
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-01-12 16:55:12 +00:00
copilot-swe-agent[bot]
14e6c1186c Address code review feedback: memory leak and thread safety
- Store keymap update subscription to allow cancellation on re-init
- Fix List<String?> to List<String> in setHandledKeys signature
- Use ConcurrentHashMap.newKeySet() for thread-safe handledKeys access
- Clear and update the set instead of replacing it

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-01-12 16:52:22 +00:00
copilot-swe-agent[bot]
abeb142f0b Fix case-insensitive pattern matching for VS200
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-01-12 16:50:52 +00:00
copilot-swe-agent[bot]
d416756614 Update VS200 logic: use only FEA0 service with specific byte patterns
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-01-12 16:49:48 +00:00
copilot-swe-agent[bot]
823eb9e9a4 Change approach: Use keymap-based filtering for HID key events
- Add setHandledKeys API to pass list of keys with keymaps to Android side
- Android AccessibilityService checks if key is in handled keys set before swallowing
- Dart side updates handled keys list whenever keymap changes
- Remove hardcoded media/volume key filtering
- This allows keyboards to work for typing while still capturing mapped keys

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-01-12 16:49:19 +00:00
copilot-swe-agent[bot]
6579092f4a Update README with Thinkrider VS200 support
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-01-12 16:37:23 +00:00
copilot-swe-agent[bot]
c242c09025 Add support for Thinkrider VS200 virtual shifter
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-01-12 16:36:53 +00:00
copilot-swe-agent[bot]
89c9ed598c Fix Android accessibility service to only intercept media/volume keys
- Add isMediaOrVolumeKey() helper to filter key events by key code
- Only swallow media and volume keys (play/pause, next, volume up/down, etc.)
- Let all other keys (typing keys, navigation, etc.) pass through to the system
- This fixes Bluetooth keyboard interference when BikeControl is running

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2026-01-12 16:36:18 +00:00
copilot-swe-agent[bot]
a3a592bd16 Initial plan 2026-01-12 16:31:49 +00:00
copilot-swe-agent[bot]
a161829913 Initial plan 2026-01-12 16:31:43 +00:00
Jonas Bark
b4473ad067 patch it 2026-01-11 15:48:57 +01:00
jonas.bark@gmx.de
4752f99fcf fix windows issue 2026-01-11 15:47:29 +01:00
Jonas Bark
6e757cf15c list supported actions of connection method 2026-01-11 13:01:10 +01:00
Jonas Bark
a87810db88 list supported actions of connection method 2026-01-11 13:00:43 +01:00
Jonas Bark
0ddb3e8081 clenaup 2026-01-11 12:11:28 +01:00
Jonas Bark
e29aed8bcf version++ 2026-01-11 11:28:43 +01:00
Jonas Bark
d99a3257af optional notification permission on macOS 2026-01-11 11:28:29 +01:00
Jonas Bark
a772b210cd don't connect to both Zwift Ride instances 2026-01-11 11:25:20 +01:00
Jonas Bark
860700ab91 version++ 2026-01-08 10:37:51 +01:00
Jonas Bark
ff5d90d468 fix Apple full version detection 2026-01-08 10:37:36 +01:00
Jonas Bark
43773310d5 ui fix, sync purchases once, Zwift Ride adjustment 2026-01-08 09:43:52 +01:00
jonasbark
2da65645b0 Update Windows Store version to 4.3.0 2026-01-07 14:04:51 +01:00
Jonas Bark
0c62d64987 version++ 2026-01-07 11:21:22 +01:00
Jonas Bark
546f6c2f8f version++ 2026-01-07 11:03:03 +01:00
Jonas Bark
a6ee15e3ba changelog adjustment 2026-01-07 10:37:46 +01:00
Jonas Bark
51793847cf performance & ui fixes 2026-01-07 10:36:41 +01:00
Jonas Bark
f308aa3847 performance & ui fixes 2026-01-07 10:19:21 +01:00
Jonas Bark
695b994577 fix shorebird logic 2026-01-06 18:08:56 +01:00
Jonas Bark
2301d04c61 refactor update UI 2026-01-06 09:10:27 +01:00
Jonas Bark
e46ec5172c OBC adjustments 2026-01-05 13:23:33 +01:00
Jonas Bark
691e108c82 OBC adjustments 2026-01-05 13:22:23 +01:00
Jonas Bark
166146a8f8 Web fixes 2026-01-03 20:50:06 +01:00
Jonas Bark
2a42cfc80f make notifications optional on macOS & iOS 2026-01-03 09:42:03 +01:00
Jonas Bark
aff1f20ebe Merge branch '4.2.4'
# Conflicts:
#	CHANGELOG.md
#	lib/utils/iap/revenuecat_service.dart
2026-01-02 21:02:54 +01:00
Jonas Bark
804fed799d fix logic 2026-01-02 21:02:02 +01:00
Jonas Bark
b6450ba47c restore button 2026-01-02 20:42:23 +01:00
Jonas Bark
14a4234583 instructions UI change 2026-01-02 15:03:46 +01:00
Jonas Bark
72cd165992 instructions for local connection method 2026-01-02 14:03:08 +01:00
Jonas Bark
239aec2083 onboarding UI 2026-01-01 13:50:02 +01:00
Jonas Bark
a43ab60dcb onboarding UI 2026-01-01 13:47:17 +01:00
Jonas Bark
484b7f74e6 onboarding UI 2026-01-01 13:44:45 +01:00
Jonas Bark
8c1ddcb019 ui adjustments 2026-01-01 12:40:45 +01:00
Jonas Bark
306a5badef onboarding for new users 2025-12-31 16:26:40 +01:00
Jonas Bark
e82003cd57 onboarding for new users 2025-12-31 16:16:37 +01:00
Jonas Bark
6a6aafe0a9 onboarding for new users 2025-12-31 15:52:30 +01:00
Jonas Bark
4bd440e167 ui adjustments 2025-12-31 13:51:47 +01:00
Jonas Bark
80d198787f ui adjustments 2025-12-31 12:26:34 +01:00
Jonas Bark
7f7bae477b debug text adjustments 2025-12-31 12:11:06 +01:00
Jonas Bark
2917814ecc alternative title during button editor 2025-12-30 17:45:06 +01:00
Jonas Bark
e268a86f20 fix media key icons 2025-12-30 14:12:55 +01:00
jonasbark
9a13831e91 Merge pull request #240 from jonasbark/copilot/enable-media-key-dispatching
Add media key dispatching for Desktop platforms
2025-12-30 14:04:12 +01:00
copilot-swe-agent[bot]
9f6e57b9ff Fix Windows compilation error: Cast UINT to WORD for wVk field
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:57:32 +00:00
Jonas Bark
86cf7491c3 Revert "issue"
This reverts commit 681ec710a1.
2025-12-30 13:55:30 +01:00
jonas.bark@gmx.de
681ec710a1 issue 2025-12-30 13:54:34 +01:00
Jonas Bark
dc40d61766 fix dart 2025-12-30 13:46:30 +01:00
copilot-swe-agent[bot]
326ff4ce24 Style: Fix code formatting and remove trailing comma in C++
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:43:50 +00:00
copilot-swe-agent[bot]
ca81642c5c Optimize: Make key maps static const for better performance
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:42:38 +00:00
copilot-swe-agent[bot]
32e4b9762b Refactor: Use maps instead of if-else chains for key mappings
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:41:13 +00:00
copilot-swe-agent[bot]
0c94852246 Use string identifiers instead of keyCode for media keys
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:39:26 +00:00
copilot-swe-agent[bot]
d2f67e402b Complete media key dispatching implementation
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:36:10 +00:00
copilot-swe-agent[bot]
145e55fe68 Add proper error handling for media key dispatch
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:34:41 +00:00
copilot-swe-agent[bot]
fd6358c233 Fix command count increment order for media keys
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:33:21 +00:00
copilot-swe-agent[bot]
1d047ce9ef Fix code review issues: error message and type mismatch
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:31:44 +00:00
copilot-swe-agent[bot]
a27a0b8872 Improve documentation for macOS media stop key behavior
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:30:10 +00:00
copilot-swe-agent[bot]
0e98a9d500 Add media key dispatching support for Desktop platforms
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-30 12:27:55 +00:00
copilot-swe-agent[bot]
728b2e0b53 Initial plan 2025-12-30 12:21:42 +00:00
jonas.bark@gmx.de
220ffa49c6 fix windows notifications 2025-12-30 13:19:56 +01:00
jonas.bark@gmx.de
74d1ec58de fix a companion mode issue with delayed dispatches 2025-12-30 12:44:02 +01:00
Jonas Bark
b2d19f7e70 ignore messages when in background 2025-12-30 09:44:27 +01:00
Jonas Bark
7db8dfec62 message permission fix 2025-12-29 15:08:46 +01:00
Jonas Bark
b0c0767dc7 obp adjustments 2025-12-29 10:15:36 +01:00
Jonas Bark
2e880869f3 restore purchase 2025-12-29 09:19:06 +01:00
Jonas Bark
1edf949a7a Merge branch 'revenuecat' 2025-12-28 13:14:16 +01:00
Jonas Bark
04c85668fe Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-12-28 13:14:13 +01:00
Jonas Bark
fcefd863cd version++ 2025-12-28 13:13:57 +01:00
Jonas Bark
64bac7e50f revenue cat changes, italian translation 2025-12-28 13:12:20 +01:00
Jonas Bark
f94c1b1851 check original app version 2025-12-27 09:08:29 +01:00
Jonas Bark
3a013db311 Merge remote-tracking branch 'origin/main' into revenuecat 2025-12-27 09:04:13 +01:00
jonasbark
0fd141316e Revise MyWhoosh Link connection instructions
Updated instructions for using MyWhoosh Link connection method to clarify optional steps and improve readability.
2025-12-27 00:23:16 +01:00
Jonas Bark
2ed4f389c9 Merge branch 'main' into revenuecat 2025-12-26 23:15:47 +01:00
Jonas Bark
9a1a25ed17 unlock for now 2025-12-26 23:15:24 +01:00
Jonas Bark
173fae7472 handle old migrated purchases 2025-12-26 22:57:43 +01:00
Jonas Bark
82c9df2214 fix podfile 2025-12-26 20:35:59 +01:00
Jonas Bark
a49b6b81ae fix podfile 2025-12-26 20:35:53 +01:00
Jonas Bark
dffa7003bc Merge branch 'copilot/integrate-revenuecat-sdk' 2025-12-26 19:44:12 +01:00
Jonas Bark
818ff4909a implement logic 2025-12-26 19:36:20 +01:00
Jonas Bark
d1e054d5c5 implement logic 2025-12-26 18:48:57 +01:00
copilot-swe-agent[bot]
7689da9acd Add future improvements documentation
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-26 13:03:53 +00:00
copilot-swe-agent[bot]
0bf336643d Add implementation summary documentation
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-26 13:01:51 +00:00
copilot-swe-agent[bot]
70dc5d19e7 Fix async handling and improve error messages
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-26 13:00:52 +00:00
copilot-swe-agent[bot]
4da91b0fa3 Fix circular dependency and async issues in RevenueCat service
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-26 12:58:50 +00:00
copilot-swe-agent[bot]
cb219c57c4 Add comprehensive RevenueCat setup and configuration documentation
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-26 12:55:16 +00:00
copilot-swe-agent[bot]
0be5500d78 Add RevenueCat SDK integration with paywall and customer center support
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-26 12:53:51 +00:00
copilot-swe-agent[bot]
94dcccdce3 Initial plan 2025-12-26 12:48:20 +00:00
Jonas Bark
91b6ffdbe2 version++ 2025-12-24 22:38:30 +01:00
Jonas Bark
baa3a73984 Merge branch 'hotfix' 2025-12-24 10:46:53 +01:00
Jonas Bark
dd5c231c47 hopefully fix iap issue on Android 2025-12-24 10:28:58 +01:00
Jonas Bark
3a0d4e1cbc hopefully fix iap issue on Android 2025-12-24 10:08:55 +01:00
Jonas Bark
631f031daa unit test adjustments 2025-12-23 09:10:05 +01:00
Jonas Bark
a487539e6a hotfix 2025-12-22 20:37:44 +01:00
Jonas Bark
ad7454236a hotfix 2025-12-22 20:17:27 +01:00
Jonas Bark
e182fea4d1 hotfix 2025-12-22 20:05:09 +01:00
Jonas Bark
6615061658 hotfix macOS 2025-12-22 20:00:17 +01:00
jonasbark
5c1d423806 Update CHANGELOG.md 2025-12-22 19:34:37 +01:00
Jonas Bark
171f97645f Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-12-22 19:24:14 +01:00
jonasbark
9511c233a2 Add iPadOS support to connection methods in README
Updated README to include iPadOS in connection methods.
2025-12-22 18:23:47 +01:00
jonasbark
adfc2bd8cf Update Windows Store version to 4.2.2 2025-12-22 15:29:26 +01:00
jonasbark
2adba27dca Fix formatting and clarify trainer app connection 2025-12-22 11:41:41 +01:00
Jonas Bark
b18c85fc8e improve Android notification action handling 2025-12-22 11:13:37 +01:00
Jonas Bark
2e79a43827 axs info 2025-12-22 08:21:02 +01:00
Jonas Bark
0fec34bb56 allow redeeming manually 2025-12-21 22:21:40 +01:00
Jonas Bark
db3e133199 allow redeeming manually 2025-12-21 22:17:47 +01:00
Jonas Bark
971cb91615 allow redeeming manually 2025-12-21 22:16:12 +01:00
Jonas Bark
e5e04f3d59 clarify Android transition 2025-12-21 20:33:51 +01:00
Jonas Bark
fd9f7388e8 check purchases once a day 2025-12-21 16:41:30 +01:00
Jonas Bark
6ae2297246 version++ 2025-12-21 16:00:10 +01:00
Jonas Bark
c6fb2e68b5 win fix 2025-12-21 15:43:44 +01:00
jonas.bark@gmx.de
c84c685a8f attempt to fix remaining trial days calculation 2025-12-21 15:42:17 +01:00
Jonas Bark
102f4a8818 add info for Android users regarding existing purchase 2025-12-21 13:54:08 +01:00
Jonas Bark
661d72fa8c windows adjustments 2025-12-21 10:29:53 +01:00
jonas.bark@gmx.de
38df962e43 windows trial changes 2025-12-21 10:25:52 +01:00
Jonas Bark
e02563733f fix missing entitlements on release builds for macOS 2025-12-20 20:40:53 +01:00
Jonas Bark
c246d2d1fe fix missing entitlements on release builds for macOS 2025-12-20 20:40:12 +01:00
Jonas Bark
8dbed9a8b5 wrong translation 2025-12-20 18:08:13 +01:00
Jonas Bark
6ea4fa82a7 version++ 2025-12-20 11:39:02 +01:00
Jonas Bark
0d78ca6352 version++ 2025-12-20 11:20:39 +01:00
Jonas Bark
b82ad80d1c fix windows build version 2025-12-20 10:56:19 +01:00
Jonas Bark
eece2ce7dc update changelog 2025-12-20 10:30:32 +01:00
jonasbark
3bfd7dd7ab Merge pull request #222 from jonasbark/copilot/implement-in-app-purchase
Implement IAP system with 5-day trial and command limiting
2025-12-20 10:13:33 +01:00
Jonas Bark
b0f3dffc3c screenshots 2025-12-20 10:13:20 +01:00
Jonas Bark
f5234d0c11 update changelog 2025-12-20 10:06:57 +01:00
Jonas Bark
8a12ccb01e cleanup 2025-12-20 10:04:44 +01:00
Jonas Bark
fac2e86240 bugfixes and clarifications 2025-12-20 10:01:37 +01:00
Jonas Bark
39b49bb9de sram fix 2025-12-19 21:06:13 +01:00
Jonas Bark
406ccfa2ce adjust iOS receipt logic 2025-12-19 20:41:45 +01:00
Jonas Bark
16e6b96cc7 SRAM AXS support 2025-12-19 20:41:27 +01:00
Jonas Bark
1e37c8a742 press longer for the simulator, enable long press for devices not supporting long press (e.g. Shimano Di2) 2025-12-19 11:40:07 +01:00
Jonas Bark
5f0389b36f press longer for the simulator, enable long press for devices not supporting long press (e.g. Shimano Di2) 2025-12-19 11:38:30 +01:00
Jonas Bark
8762d85d57 press longer for the simulator, enable long press for devices not supporting long press (e.g. Shimano Di2) 2025-12-19 11:33:13 +01:00
Jonas Bark
018bbd43f1 fix 2025-12-19 10:13:01 +01:00
Jonas Bark
756e3fc556 version++ 2025-12-19 10:09:06 +01:00
Jonas Bark
7ac5f5dbcb fine tune gryoscope logic to react more quickly 2025-12-19 09:29:19 +01:00
Jonas Bark
65c613fd24 fine tune gryoscope logic to react more quickly 2025-12-19 09:29:05 +01:00
Jonas Bark
e0ad9007a4 ui adjustments 2025-12-19 09:21:11 +01:00
Jonas Bark
c3cda5f547 ui adjustments, add polish translations 2025-12-19 08:56:14 +01:00
Jonas Bark
12f379c03d screenshot fix 2025-12-18 15:39:16 +01:00
Jonas Bark
e760c34ede unit test fixes 2025-12-18 15:30:14 +01:00
Jonas Bark
187a15a55d improve trainer controller view 2025-12-18 14:51:49 +01:00
Jonas Bark
3e4751a6b5 refactor sensor logic 2025-12-18 12:24:52 +01:00
Jonas Bark
b05d87196a implement functionality, refactoring 2025-12-18 12:06:34 +01:00
Jonas Bark
b8297848f6 Merge branch 'copilot/add-steering-detection-algorithm' into copilot/implement-in-app-purchase 2025-12-18 11:37:39 +01:00
Jonas Bark
de0f004a48 implement functionality, refactoring 2025-12-18 11:37:21 +01:00
Jonas Bark
0bc9c1d4d2 implement functionality, refactoring 2025-12-18 11:33:14 +01:00
Jonas Bark
3fccb59544 Merge branch 'copilot/implement-in-app-purchase' into copilot/add-steering-detection-algorithm 2025-12-18 09:46:45 +01:00
Jonas Bark
ea76aabf91 update changelog 2025-12-18 09:46:32 +01:00
Jonas Bark
dc1c900bd2 bugfix and animation 2025-12-18 09:26:25 +01:00
Jonas Bark
b1e59d8f2a auto-open key editor on key press 2025-12-18 08:46:31 +01:00
Jonas Bark
006148dbd0 move button simulator page button 2025-12-18 08:33:04 +01:00
Jonas Bark
974ba258f5 teaser new connection methods 2025-12-18 08:11:06 +01:00
copilot-swe-agent[bot]
29d833e9d1 Update README with gyroscope steering device information
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-18 06:55:33 +00:00
copilot-swe-agent[bot]
eea72405bb Fix code review issues in gyroscope steering implementation
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-18 06:54:42 +00:00
copilot-swe-agent[bot]
e500f1ed0b Add unit tests for gyroscope steering algorithm
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-18 06:52:39 +00:00
copilot-swe-agent[bot]
6c3bd2e6a1 Add gyroscope/accelerometer steering support with sensor fusion
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-18 06:51:43 +00:00
copilot-swe-agent[bot]
e54d8968e8 Initial plan 2025-12-18 06:44:46 +00:00
Jonas Bark
9dca2e989a check sandbox on iOS / macOS 2025-12-17 15:57:44 +01:00
Jonas Bark
610c5d6ef5 check sandbox on iOS / macOS 2025-12-17 15:48:30 +01:00
Jonas Bark
7797b34852 improve keymap UI 2025-12-17 14:34:08 +01:00
Jonas Bark
99e9f326f7 fix build 2025-12-17 13:05:05 +01:00
Jonas Bark
0f2d73239b fix build 2025-12-17 12:57:32 +01:00
Jonas Bark
497b489ea9 remove Podfile.loc 2025-12-17 12:40:29 +01:00
Jonas Bark
51581f106a cleanup README.md 2025-12-17 12:17:00 +01:00
Jonas Bark
43e9aa02e0 cleanup README.md 2025-12-17 12:13:23 +01:00
Jonas Bark
089a41cc2b translations and ux changes 2025-12-17 11:39:39 +01:00
Jonas Bark
279ab101cc macOS fix 2025-12-17 11:13:42 +01:00
Jonas Bark
c0f278652e show connection update notifications 2025-12-17 10:54:16 +01:00
Jonas Bark
ac02cc78bc show connection update notifications 2025-12-17 10:34:40 +01:00
Jonas Bark
d42ac3af6b notifications on iOS 2025-12-17 10:20:36 +01:00
Jonas Bark
20cfe76091 limit to 80 on Android during trial period, only count sent out commands 2025-12-17 10:02:32 +01:00
Jonas Bark
b68170b489 fix purchause logic 2025-12-17 09:08:28 +01:00
Jonas Bark
21273fa9ca Merge branch 'main' into copilot/implement-in-app-purchase
# Conflicts:
#	CHANGELOG.md
#	lib/bluetooth/devices/base_device.dart
#	pubspec.yaml
2025-12-17 08:41:47 +01:00
Jonas Bark
fe9dd29964 cleanup, translations 2025-12-16 10:14:48 +01:00
Jonas Bark
eece2bcc0f Merge branch 'main' into copilot/implement-in-app-purchase
# Conflicts:
#	lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart
2025-12-16 09:19:50 +01:00
jonas.bark@gmx.de
f19c6b8dd0 fix windows implementation 2025-12-15 11:12:06 +01:00
Jonas Bark
44599b2d33 fix button mapping for OpenBikeControl, button simulator changes 2025-12-15 09:48:36 +01:00
Jonas Bark
613f75fd25 Merge branch 'main' into copilot/implement-in-app-purchase 2025-12-15 09:22:18 +01:00
Jonas Bark
6f68e6cb62 iap adjustment 2025-12-15 08:37:56 +01:00
Jonas Bark
43ac412efd iap adjustment 2025-12-14 21:35:06 +01:00
Jonas Bark
7149c98564 windows #2 2025-12-14 20:15:33 +01:00
Jonas Bark
0f4e46a758 windows #1 2025-12-14 19:54:41 +01:00
Jonas Bark
23fb927cd6 in app purchase implementation on macOS / iOS 2025-12-14 19:32:23 +01:00
Jonas Bark
d055a260ab fix shadcn design usage 2025-12-14 16:48:04 +01:00
Jonas Bark
d55fa5f7c0 Merge branch 'main' into copilot/implement-in-app-purchase
# Conflicts:
#	lib/bluetooth/devices/base_device.dart
#	lib/pages/configuration.dart
#	lib/utils/settings/settings.dart
2025-12-14 16:42:50 +01:00
copilot-swe-agent[bot]
f4fd658c36 Refactor to reduce code duplication and use constants for magic numbers
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-12 07:47:41 +00:00
copilot-swe-agent[bot]
0e80d5612c Fix code review issues: remove unused import and fix negative counter display
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-12 07:45:29 +00:00
copilot-swe-agent[bot]
9302ebc667 Add restore purchases functionality to IAP widget
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-12 07:43:00 +00:00
copilot-swe-agent[bot]
2265866f58 Clarify existing user detection logic with comments
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-12 07:41:11 +00:00
copilot-swe-agent[bot]
8ec6ee5ef0 Improve IAP error handling and add documentation
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-12 07:39:13 +00:00
copilot-swe-agent[bot]
a03d250bdb Add IAP service implementation with trial and command limiting
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-12 07:37:15 +00:00
copilot-swe-agent[bot]
a0ebac41ea Initial plan 2025-12-12 07:29:07 +00:00
200 changed files with 10088 additions and 2618 deletions

View File

@@ -119,6 +119,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: macos
args: "-- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}"
- name: Decode Keystore
if: inputs.build_android
@@ -132,7 +133,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: android
args: "--artifact=apk"
args: "--artifact=apk -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}"
- name: Build Web
if: inputs.build_web
@@ -168,7 +169,7 @@ jobs:
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 }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}"
- name: Prepare App Store authentication key
if: inputs.build_ios || inputs.build_mac
@@ -186,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

View File

@@ -10,6 +10,7 @@ env:
jobs:
build:
name: Patch iOS, Android & macOS
if: false
runs-on: macos-latest
permissions:
@@ -91,21 +92,21 @@ jobs:
with:
platform: macos
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs'
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
- name: 🚀 Shorebird Patch Android
uses: shorebirdtech/shorebird-patch@v1
with:
platform: android
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs'
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}'
- name: 🚀 Shorebird Patch iOS
uses: shorebirdtech/shorebird-patch@v1
with:
platform: ios
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs'
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
# shorebird struggles with the app from GitHub
- name: Build macOS

1
.gitignore vendored
View File

@@ -52,3 +52,4 @@ lib/gen/
service-account.json
.env
lib/generated

View File

@@ -1,3 +1,27 @@
### 4.3.0 (07-01-2026)
**Features**:
- Onboarding for new users
- support controlling music & volume for Windows, macOS and Android
- App is now available in Italian (thanks to Connect_Thanks2613)
**Fixes**:
- Vibration setting now available for Zwift Ride devices
### 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 Wandrocek)
**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**:

17
INSTRUCTIONS_LOCAL.md Normal file
View File

@@ -0,0 +1,17 @@
## What is the Local connection method?
*
The Local connection method works by directly controlling the target trainer app on the same device by simulating user input (taps, keyboard inputs). This method does not require any network connection or additional hardware, making it the simplest and most straightforward way to connect to the trainer app.
There are predefined keymaps (touch positions or keyboard shortcuts) for popular trainer apps, allowing users to easily set up and start using the Local connection method without needing to configure anything manually. You can configure these keymaps in the Configuration tab. Note though that supported keyboard keys depend on the trainer app.
## When to use the Local connection method?
*
The Local connection method is ideal for users who:
- Are running the trainer app on the same device as the controller app (e.g., both apps on a smartphone or tablet).
- Do not want to deal with network configurations or potential connectivity issues.
## Limitations of the Local connection method
*
While the Local connection method is easy to set up and use, it has some limitations:
- It may not work well with trainer apps that have complex user interfaces or require precise timing.
- It is limited to the device on which both the controller and trainer apps are running, meaning it cannot be used for remote control scenarios.

View File

@@ -1,31 +1,37 @@
## 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
2) make sure the "MyWhoosh Link" app is not active at the same time as BikeControl
3) open BikeControl, follow the 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:
Here's a video with a few explanations. Note it uses an older version, but the idea is the same.
[![BikeControl Instruction for iOS](https://img.youtube.com/vi/p8sgQhuufeI/0.jpg)](https://www.youtube.com/watch?v=p8sgQhuufeI)
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
## 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:
This is a network/local-discovery problem. BikeControl needs the same kind of local network access as MyWhoosh Link.
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
Checklist:
- Use the MyWhoosh Link app to confirm if "Link" works in general
- Both devices are on the **same WiFi SSID**
- Avoid “Guest” networks
- Avoid “extenders/mesh guest mode” and networks with device isolation
- If your router has it, disable:
- “AP isolation / client isolation”
- Try moving both devices to the same band:
- Prefer **2.4 GHz** (often more reliable for local discovery than mixed/steering)
- Temporarily disable:
- VPNs
- iCloud Private Relay (if enabled)
- “Limit IP Address Tracking” (iOS WiFi option)
- iOS WiFi settings for that network:
- Turn off **Private WiFi Address**
- Turn off **Limit IP Address Tracking**
- Mesh networks: may work, but if it doesnt, test with a simple router or phone hotspot.
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
Official MyWhoosh troubleshooting links:
- https://mywhoosh.com/troubleshoot/
- https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/

View File

@@ -6,7 +6,7 @@
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 BikeControl
@@ -17,8 +17,8 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
## Downloads
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.
## 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>
@@ -31,7 +31,7 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
## Supported Apps
- MyWhoosh
- Zwift
- TrainingPeaks Virtual / indieVelo
- TrainingPeaks Virtual
- Biketerra.com
- Rouvy
- [OpenBikeControl](https://openbikecontrol.org) compatible apps
@@ -45,13 +45,21 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
- Zwift Play
- Shimano Di2
- Configure your levers to use D-Fly channels with Shimano E-Tube app
- SRAM AXS/eTap
- Configure your levers not to do any action in the "SRAM AXS" app
- 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
- Thinkrider VS200 Virtual Shifter (beta)
- 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)
@@ -78,19 +86,16 @@ Check the troubleshooting guide [here](TROUBLESHOOTING.md).
## How does it work?
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
- available on Android, iOS, iPadOS, 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
- available on Android, iOS, iPadOS, 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
## 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.
- Connect to the supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
- available on Android, iOS, iPadOS, macOS, Windows
## Donate
Please consider donating to support the development of this app :)

View File

@@ -1,23 +1,28 @@
## Click / Ride device cannot be found
*
You may need to update the firmware in Zwift Companion app.
This means BikeControl does NOT see the device via Bluetooth.
- Put the controller into pairing mode (LED should blink)
- Ensure the controller is NOT connected to another app/device (e.g. Zwift)
- Update controller firmware in Zwift Companion, if available
- Reboot Bluetooth / reboot phone/PC
## 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
## My Click v2 disconnects after a minute or buttons do not work
*
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.
To make your Click V2 work best you should connect it in the Zwift app once before a workout session.
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. 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
2. Log in (subscription not required) device connection screen
3. Connect trainer, then connect Click v2
4. Keep it connected for ~1030 seconds
5. Close Zwift completely, then connect in BikeControl
Details/updates: https://github.com/jonasbark/swiftcontrol/issues/68
## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
*
@@ -27,10 +32,6 @@ If you don't do that BikeControl will need to reconnect every minute.
- grant accessibility permission for BikeControl
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
## 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.
## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control
*

View File

@@ -1 +1 @@
4.1.0
4.3.0

View File

@@ -216,6 +216,7 @@ interface Accessibility {
fun controlMedia(action: MediaAction)
fun isRunning(): Boolean
fun ignoreHidDevices()
fun setHandledKeys(keys: List<String>)
companion object {
/** The codec used by Accessibility. */
@@ -327,6 +328,24 @@ interface Accessibility {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val keysArg = args[0] as List<String>
val wrapped: List<Any?> = try {
api.setHandledKeys(keysArg)
listOf(null)
} catch (exception: Throwable) {
AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -90,6 +90,11 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
Observable.ignoreHidDevices = true
}
override fun setHandledKeys(keys: List<String>) {
// Clear and update the concurrent set
Observable.handledKeys = keys.toSet()
}
}
class WindowEventListener : StreamEventsStreamHandler(), Receiver {

View File

@@ -70,8 +70,9 @@ class AccessibilityService : AccessibilityService(), Listener {
}
override fun onKeyEvent(event: KeyEvent): Boolean {
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
// Handle media and volume keys from HID devices here
val keyString = KeyEvent.keyCodeToString(event.keyCode)
if (!Observable.ignoreHidDevices && isBleRemote(event) && Observable.handledKeys.contains(keyString)) {
// Handle keys that have a keymap defined
Log.d(
"AccessibilityService",
"onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}"

View File

@@ -2,12 +2,15 @@ package de.jonasbark.accessibility
import android.graphics.Rect
import android.view.KeyEvent
import java.util.concurrent.ConcurrentHashMap
object Observable {
var toService: Listener? = null
var fromServiceWindow: Receiver? = null
var fromServiceKeys: Receiver? = null
var ignoreHidDevices: Boolean = false
// Use concurrent set for thread-safe access from AccessibilityService and plugin
var handledKeys: Set<String> = ConcurrentHashMap.newKeySet()
}
interface Listener {

View File

@@ -13,6 +13,8 @@ abstract class Accessibility {
bool isRunning();
void ignoreHidDevices();
void setHandledKeys(List<String> keys);
}
enum MediaAction { playPause, next, volumeUp, volumeDown }

View File

@@ -353,6 +353,29 @@ class Accessibility {
return;
}
}
Future<void> setHandledKeys(List<String> keys) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[keys]);
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 = ''}) {

View File

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

View File

@@ -5,10 +5,10 @@ import android.os.Handler
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
import org.flame_engine.gamepads_android.GamepadsCompatibleActivity
class MainActivity: FlutterActivity(), GamepadsCompatibleActivity {
class MainActivity: FlutterFragmentActivity(), GamepadsCompatibleActivity {
var keyListener: ((KeyEvent) -> Boolean)? = null
var motionListener: ((MotionEvent) -> Boolean)? = null

View File

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

View File

@@ -7,14 +7,22 @@ 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):
@@ -26,8 +34,24 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- purchases_flutter (9.10.2):
- Flutter
- PurchasesHybridCommon (= 17.25.0)
- purchases_ui_flutter (9.10.2):
- Flutter
- PurchasesHybridCommonUI (= 17.25.0)
- PurchasesHybridCommon (17.25.0):
- RevenueCat (= 5.51.1)
- PurchasesHybridCommonUI (17.25.0):
- PurchasesHybridCommon (= 17.25.0)
- RevenueCatUI (= 5.51.1)
- restart_app (0.0.1):
- Flutter
- RevenueCat (5.51.1)
- RevenueCatUI (5.51.1):
- RevenueCat (= 5.51.1)
- sensors_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -44,21 +68,34 @@ 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`)
- purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`)
- purchases_ui_flutter (from `.symlinks/plugins/purchases_ui_flutter/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`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- PurchasesHybridCommon
- PurchasesHybridCommonUI
- RevenueCat
- RevenueCatUI
EXTERNAL SOURCES:
bluetooth_low_energy_darwin:
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
@@ -68,14 +105,20 @@ 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:
@@ -86,8 +129,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
purchases_flutter:
:path: ".symlinks/plugins/purchases_flutter/ios"
purchases_ui_flutter:
:path: ".symlinks/plugins/purchases_ui_flutter/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:
@@ -102,21 +151,31 @@ 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
purchases_flutter: 7cbb87481a018c1bb8b4966b7d446a7c19d96d87
purchases_ui_flutter: 1905cebf3f46e03aeefba0f6258000f3cdd32641
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
PurchasesHybridCommonUI: 3c1f78addfb3f470713548b4eac8c59254d0efe6
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
RevenueCatUI: c6acb3648fa58ccb4f0f178556ed0278b796234f
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

View File

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

View File

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

View File

@@ -32,6 +32,8 @@
<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>

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

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

30
ios_receipt/.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
## 1.1.0
* Migrate to StoreKit2
## 1.0.0
* Implement main method

21
ios_receipt/LICENSE Normal file
View 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
View 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.

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

View File

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

View 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

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

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

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

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

View File

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

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

View File

@@ -38,6 +38,11 @@ class KeyPressSimulator {
return _platform.simulateKeyPress(key: key, modifiers: modifiers, keyDown: false);
}
/// Simulate media key press.
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) {
return _platform.simulateMediaKey(mediaKey);
}
@Deprecated('Please use simulateKeyDown & simulateKeyUp methods.')
Future<void> simulateCtrlCKeyPress() async {
const key = PhysicalKeyboardKey.keyC;

View File

@@ -22,6 +22,9 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
case "simulateMouseClick":
simulateMouseClick(call, result: result)
break
case "simulateMediaKey":
simulateMediaKey(call, result: result)
break
default:
result(FlutterMethodNotImplemented)
}
@@ -114,4 +117,62 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
eventKeyPress!.flags = flags
return eventKeyPress!
}
public func simulateMediaKey(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
let args:[String: Any] = call.arguments as! [String: Any]
let keyIdentifier: String = args["key"] as! String
// Map string identifier to macOS NX key codes
var mediaKeyCode: Int32 = 0
switch keyIdentifier {
case "playPause":
mediaKeyCode = NX_KEYTYPE_PLAY
case "stop":
// macOS doesn't have a dedicated stop key in its media control API.
// Following macOS conventions, we map stop to play/pause which toggles playback.
// This matches the behavior of the physical media keys on Mac keyboards.
mediaKeyCode = NX_KEYTYPE_PLAY
case "next":
mediaKeyCode = NX_KEYTYPE_FAST
case "previous":
mediaKeyCode = NX_KEYTYPE_REWIND
case "volumeUp":
mediaKeyCode = NX_KEYTYPE_SOUND_UP
case "volumeDown":
mediaKeyCode = NX_KEYTYPE_SOUND_DOWN
default:
result(FlutterError(code: "UNSUPPORTED_KEY", message: "Unsupported media key identifier", details: nil))
return
}
// Create and post the media key event (key down)
let eventDown = NSEvent.otherEvent(
with: .systemDefined,
location: NSPoint.zero,
modifierFlags: NSEvent.ModifierFlags(rawValue: 0xa00),
timestamp: 0,
windowNumber: 0,
context: nil,
subtype: 8,
data1: Int((mediaKeyCode << 16) | (0xa << 8)),
data2: -1
)
eventDown?.cgEvent?.post(tap: .cghidEventTap)
// Create and post the media key event (key up)
let eventUp = NSEvent.otherEvent(
with: .systemDefined,
location: NSPoint.zero,
modifierFlags: NSEvent.ModifierFlags(rawValue: 0xb00),
timestamp: 0,
windowNumber: 0,
context: nil,
subtype: 8,
data1: Int((mediaKeyCode << 16) | (0xb << 8)),
data2: -1
)
eventUp?.cgEvent?.post(tap: .cghidEventTap)
result(true)
}
}

View File

@@ -61,4 +61,27 @@ class MethodChannelKeyPressSimulator extends KeyPressSimulatorPlatform {
};
await methodChannel.invokeMethod('simulateMouseClick', arguments);
}
@override
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) async {
// Map PhysicalKeyboardKey to string identifier since keyCode is null for media keys
final keyMap = {
PhysicalKeyboardKey.mediaPlayPause: 'playPause',
PhysicalKeyboardKey.mediaStop: 'stop',
PhysicalKeyboardKey.mediaTrackNext: 'next',
PhysicalKeyboardKey.mediaTrackPrevious: 'previous',
PhysicalKeyboardKey.audioVolumeUp: 'volumeUp',
PhysicalKeyboardKey.audioVolumeDown: 'volumeDown',
};
final keyIdentifier = keyMap[mediaKey];
if (keyIdentifier == null) {
throw UnsupportedError('Unsupported media key: $mediaKey');
}
final Map<String, Object?> arguments = {
'key': keyIdentifier,
};
await methodChannel.invokeMethod('simulateMediaKey', arguments);
}
}

View File

@@ -42,6 +42,10 @@ abstract class KeyPressSimulatorPlatform extends PlatformInterface {
}
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) {
throw UnimplementedError('simulateKeyPress() has not been implemented.');
throw UnimplementedError('simulateMouseClick() has not been implemented.');
}
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) {
throw UnimplementedError('simulateMediaKey() has not been implemented.');
}
}

View File

@@ -12,6 +12,7 @@
#include <memory>
#include <sstream>
#include <unordered_map>
using flutter::EncodableList;
using flutter::EncodableMap;
@@ -253,6 +254,49 @@ HWND FindTargetWindow(const std::string& processName, const std::string& windowT
return data.foundWindow;
}
void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
std::string keyIdentifier = std::get<std::string>(args.at(EncodableValue("key")));
// Map string identifier to Windows virtual key codes
static const std::unordered_map<std::string, UINT> keyMap = {
{"playPause", VK_MEDIA_PLAY_PAUSE},
{"stop", VK_MEDIA_STOP},
{"next", VK_MEDIA_NEXT_TRACK},
{"previous", VK_MEDIA_PREV_TRACK},
{"volumeUp", VK_VOLUME_UP},
{"volumeDown", VK_VOLUME_DOWN}
};
auto it = keyMap.find(keyIdentifier);
if (it == keyMap.end()) {
result->Error("UNSUPPORTED_KEY", "Unsupported media key identifier");
return;
}
UINT vkCode = it->second;
// Send key down event
INPUT inputs[2] = {};
inputs[0].type = INPUT_KEYBOARD;
inputs[0].ki.wVk = static_cast<WORD>(vkCode);
inputs[0].ki.dwFlags = 0; // Key down
// Send key up event
inputs[1].type = INPUT_KEYBOARD;
inputs[1].ki.wVk = static_cast<WORD>(vkCode);
inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;
UINT eventsSent = SendInput(2, inputs, sizeof(INPUT));
if (eventsSent != 2) {
result->Error("SEND_INPUT_FAILED", "Failed to send media key input events");
return;
}
result->Success(flutter::EncodableValue(true));
}
void KeypressSimulatorWindowsPlugin::HandleMethodCall(
@@ -262,6 +306,8 @@ void KeypressSimulatorWindowsPlugin::HandleMethodCall(
SimulateKeyPress(method_call, std::move(result));
} else if (method_call.method_name().compare("simulateMouseClick") == 0) {
SimulateMouseClick(method_call, std::move(result));
} else if (method_call.method_name().compare("simulateMediaKey") == 0) {
SimulateMediaKey(method_call, std::move(result));
} else {
result->NotImplemented();
}

View File

@@ -30,7 +30,9 @@ class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
// Called when a method is called on this plugin's channel from Dart.
void HandleMethodCall(

View File

@@ -1,22 +1,22 @@
import 'dart:async';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:gamepads/gamepads.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
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/keymap/keymap.dart';
import 'package:bike_control/utils/iap/iap_manager.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:gamepads/gamepads.dart';
import 'package:universal_ble/universal_ble.dart';
import 'devices/base_device.dart';
@@ -28,10 +28,12 @@ class Connection {
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>(),
];
@@ -48,6 +50,8 @@ class Connection {
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
Stream<BaseDevice> get connectionStream => _connectionStreams.stream;
final StreamController<BluetoothDevice> _rssiConnectionStreams = StreamController<BluetoothDevice>.broadcast();
Stream<BluetoothDevice> get rssiConnectionStream => _rssiConnectionStreams.stream;
final _lastScanResult = <BleDevice>[];
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
@@ -70,7 +74,8 @@ class Connection {
}
});
} else if (available == AvailabilityState.poweredOff) {
reset();
disconnectAll();
stop();
}
};
UniversalBle.onScanResult = (result) {
@@ -80,20 +85,22 @@ class Connection {
);
if (existingDevice != null && existingDevice.rssi != result.rssi) {
existingDevice.rssi = result.rssi;
_connectionStreams.add(existingDevice); // Notify UI of update
_rssiConnectionStreams.add(existingDevice); // Notify UI of update
}
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
_lastScanResult.add(result);
if (kDebugMode) {
print('Scan result: ${result.name} - ${result.deviceId}');
debugPrint('Scan result: ${result.name} - ${result.deviceId}');
}
final scanResult = BluetoothDevice.fromScanResult(result);
if (scanResult != null) {
_actionStreams.add(LogNotification('Found new device: ${kIsWeb ? scanResult.name : scanResult.runtimeType}'));
_actionStreams.add(
LogNotification('Found new device: ${kIsWeb ? scanResult.toString() : scanResult.runtimeType}'),
);
addDevices([scanResult]);
} else {
final manufacturerData = result.manufacturerDataList;
@@ -120,7 +127,7 @@ class Connection {
// on web, log all characteristic changes for debugging
_actionStreams.add(
LogNotification(
'Characteristic update for device ${device.name}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
'Characteristic update for device ${device.toString()}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
),
);
}
@@ -129,7 +136,7 @@ class Connection {
} catch (e, backtrace) {
_actionStreams.add(
LogNotification(
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
"Error processing characteristic for device ${device.toString()} and char: $characteristicUuid: $e\n$backtrace",
),
);
if (kDebugMode) {
@@ -154,6 +161,9 @@ class Connection {
performScanning();
}
});
if (core.settings.getPhoneSteeringEnabled()) {
toggleGyroscopeSteering(true);
}
}
}
@@ -164,6 +174,10 @@ 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(
@@ -202,14 +216,6 @@ class Connection {
} else {
isScanning.value = false;
}
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
_androidNotificationsSetup = true;
// start foreground service only when app is in foreground
NotificationRequirement.setup().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
Future<void> startMyWhooshServer() {
@@ -248,16 +254,28 @@ class Connection {
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 && !screenshotMode) {
_handlingConnectionQueue = true;
final device = _connectionQueue.removeAt(0);
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.name}'));
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.toString()}'));
_connect(device)
.then((_) {
_handlingConnectionQueue = false;
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.name}'));
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.toString()}'));
if (_connectionQueue.isNotEmpty) {
_handleConnectionQueue();
}
@@ -267,11 +285,11 @@ class Connection {
_handlingConnectionQueue = false;
if (e is TimeoutException) {
_actionStreams.add(
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.name}: Timeout'),
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.toString()}: Timeout'),
);
} else {
_actionStreams.add(
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.name} - $e'),
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.toString()} - $e'),
);
}
if (_connectionQueue.isNotEmpty) {
@@ -287,9 +305,18 @@ class Connection {
_actionStreams.add(data);
});
if (device is BluetoothDevice) {
final connectionStateSubscription = UniversalBle.connectionStream(device.device.deviceId).listen((state) {
final connectionStateSubscription = device.device.connectionStream.listen((state) {
device.isConnected = state;
_connectionStreams.add(device);
core.flutterLocalNotificationsPlugin.show(
1338,
'${device.toString()} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}',
!state ? AppLocalizations.current.tryingToConnectAgain : null,
NotificationDetails(
android: AndroidNotificationDetails('Connection', 'Connection Status'),
iOS: DarwinNotificationDetails(presentAlert: true, presentSound: false),
),
);
if (!device.isConnected) {
disconnect(device, forget: false, persistForget: false);
// try reconnect
@@ -302,22 +329,19 @@ class Connection {
await device.connect();
signalChange(device);
final newButtons = device.availableButtons.filter(
(button) => core.actionHandler.supportedApp?.keymap.getKeyPair(button) == null,
);
for (final button in newButtons) {
core.actionHandler.supportedApp?.keymap.addKeyPair(
KeyPair(
touchPosition: Offset.zero,
buttons: [button],
physicalKey: null,
logicalKey: null,
isLongPress: false,
),
);
}
IAPManager.instance.setAttributes();
core.actionHandler.supportedApp?.keymap.addNewButtons(device.availableButtons);
_streamSubscriptions[device] = actionSubscription;
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
_androidNotificationsSetup = true;
// start foreground service only when app is in foreground
NotificationRequirement.addPersistentNotification().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
} catch (e, backtrace) {
_actionStreams.add(LogNotification("$e\n$backtrace"));
if (kDebugMode) {
@@ -328,31 +352,6 @@ class Connection {
}
}
Future<void> reset() async {
_actionStreams.add(LogNotification('Disconnecting all devices'));
if (core.actionHandler is AndroidActions) {
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
_androidNotificationsSetup = false;
}
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
if (isBtEnabled) {
UniversalBle.stopScan();
}
isScanning.value = false;
for (var device in bluetoothDevices) {
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
UniversalBle.disconnect(device.device.deviceId);
signalChange(device);
}
_gamePadSearchTimer?.cancel();
_lastScanResult.clear();
hasDevices.value = false;
devices.clear();
}
void signalNotification(BaseNotification notification) {
_actionStreams.add(notification);
}
@@ -369,8 +368,8 @@ class Connection {
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}'));
await core.settings.addIgnoredDevice(device.device.deviceId, device.toString());
_actionStreams.add(LogNotification('Device ignored: ${device.toString()}'));
}
if (!forget) {
// allow reconnection
@@ -383,6 +382,16 @@ class Connection {
_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;
@@ -390,4 +399,29 @@ class Connection {
signalChange(device);
}
Future<void> disconnectAll() async {
_actionStreams.add(LogNotification('Disconnecting all devices'));
for (var device in bluetoothDevices) {
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
device.disconnect();
signalChange(device);
devices.remove(device);
}
_gamePadSearchTimer?.cancel();
_lastScanResult.clear();
hasDevices.value = false;
}
Future<void> stop() async {
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
if (isBtEnabled) {
UniversalBle.stopScan();
}
isScanning.value = false;
_androidNotificationsSetup = false;
}
}

View File

@@ -1,12 +1,17 @@
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/main.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/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../../utils/keymap/buttons.dart';
import '../messages/notification.dart';
@@ -31,7 +36,8 @@ abstract class BaseDevice {
@override
bool operator ==(Object other) =>
identical(this, other) || other is BaseDevice && runtimeType == other.runtimeType && name == other.name;
identical(this, other) ||
other is BaseDevice && runtimeType == other.runtimeType && toString() == other.toString();
@override
int get hashCode => name.hashCode;
@@ -47,9 +53,26 @@ abstract class BaseDevice {
Future<void> connect();
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
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([]);
}
}
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked, {bool longPress = false}) async {
try {
await _handleButtonsClickedInternal(buttonsClicked);
await _handleButtonsClickedInternal(buttonsClicked, longPress: longPress);
} catch (e, st) {
actionStreamInternal.add(
LogNotification('Error handling button clicks: $e\n$st'),
@@ -57,7 +80,7 @@ abstract class BaseDevice {
}
}
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked) async {
Future<void> _handleButtonsClickedInternal(List<ControllerButton>? buttonsClicked, {required bool longPress}) async {
if (buttonsClicked == null) {
// ignore, no changes
} else if (buttonsClicked.isEmpty) {
@@ -67,8 +90,9 @@ abstract class BaseDevice {
// Handle release events for long press keys
final buttonsReleased = _previouslyPressedButtons.toList();
final isLongPress =
longPress ||
buttonsReleased.singleOrNull != null &&
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
if (buttonsReleased.isNotEmpty && isLongPress) {
await performRelease(buttonsReleased);
}
@@ -79,15 +103,17 @@ 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 &&
core.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 &&
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
core.actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
if (!isLongPress &&
!(buttonsClicked.singleOrNull == ZwiftButtons.onOffLeft ||
@@ -109,16 +135,46 @@ 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)
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) {
// 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));
}
@@ -126,6 +182,12 @@ abstract class BaseDevice {
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
for (final action in buttonsReleased) {
// 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));
}
@@ -144,6 +206,9 @@ abstract class BaseDevice {
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?
@@ -157,4 +222,26 @@ abstract class BaseDevice {
}
return button;
}
void _showCommandLimitAlert() {
actionStreamInternal.add(
AlertNotification(
LogLevel.LOGLEVEL_ERROR,
_getCommandLimitMessage(),
buttonTitle: AppLocalizations.current.purchase,
onTap: () {
IAPManager.instance.purchaseFullVersion(navigatorKey.currentContext!);
},
),
);
core.flutterLocalNotificationsPlugin.show(
1337,
_getCommandLimitTitle(),
_getCommandLimitMessage(),
NotificationDetails(
android: AndroidNotificationDetails('Limit', 'Limit reached'),
iOS: DarwinNotificationDetails(presentAlert: true),
),
);
}
}

View File

@@ -4,6 +4,7 @@ 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';
@@ -18,8 +19,10 @@ 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:bike_control/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@@ -28,6 +31,7 @@ import 'package:universal_ble/universal_ble.dart';
import 'cycplus/cycplus_bc2.dart';
import 'elite/elite_square.dart';
import 'elite/elite_sterzo.dart';
import 'thinkrider/thinkrider_vs200.dart';
abstract class BluetoothDevice extends BaseDevice {
final BleDevice scanResult;
@@ -52,6 +56,7 @@ abstract class BluetoothDevice extends BaseDevice {
ShimanoDi2Constants.SERVICE_UUID,
ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE,
OpenBikeControlConstants.SERVICE_UUID,
ThinkRiderVs200Constants.SERVICE_UUID,
];
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
@@ -71,7 +76,9 @@ abstract class BluetoothDevice extends BaseDevice {
_ 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('THINK VS') => ThinkRiderVs200(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('SRAM') => SramAxs(scanResult),
_ => null,
};
} else {
@@ -88,15 +95,21 @@ abstract class BluetoothDevice extends BaseDevice {
_ 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('THINK VS') => ThinkRiderVs200(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),
_ when scanResult.services.contains(ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase()) =>
ThinkRiderVs200(scanResult),
// otherwise the service UUIDs will be used
_ => null,
};
@@ -115,28 +128,34 @@ abstract class BluetoothDevice extends BaseDevice {
?.payload;
if (data == null || data.isEmpty) {
return null;
} else {
final type = ZwiftDeviceType.fromManufacturerData(data.first);
device = 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,
};
}
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;
}
if (scanResult.name == 'Zwift Ride' &&
device == null &&
core.connection.controllerDevices.none((d) => d is ZwiftRide)) {
// Fallback for Zwift Ride if nothing else matched => old firmware
if (navigatorKey.currentContext?.mounted ?? false) {
buildToast(
navigatorKey.currentContext!,
title: 'You may need to update your Zwift Ride firmware.',
duration: Duration(seconds: 6),
);
}
}
return device;
}
@override
@@ -147,11 +166,6 @@ abstract class BluetoothDevice extends BaseDevice {
@override
int get hashCode => scanResult.deviceId.hashCode;
@override
String toString() {
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
}
BleDevice get device => scanResult;
@override
@@ -225,7 +239,7 @@ abstract class BluetoothDevice extends BaseDevice {
spacing: 8,
children: [
Text(
device.name?.screenshot ?? runtimeType.toString(),
toString().screenshot ?? runtimeType.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),
@@ -273,113 +287,79 @@ abstract class BluetoothDevice extends BaseDevice {
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: screenshotMode ? 160 : null,
height: screenshotMode ? 70 : null,
child: Card(
filled: true,
fillColor: Theme.of(context).colorScheme.background,
padding: EdgeInsets.all(12),
child: Basic(
title: Text(context.i18n.connection).xSmall,
trailingAlignment: Alignment.centerRight,
trailing: Icon(switch (isConnected) {
true => Icons.bluetooth_connected_outlined,
false => Icons.bluetooth_disabled_outlined,
}),
subtitle: Text(
isConnected ? context.i18n.connected : context.i18n.disconnected,
style: TextStyle(fontSize: 12),
),
),
),
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)
SizedBox(
width: screenshotMode ? 160 : null,
height: screenshotMode ? 70 : null,
child: Card(
filled: true,
fillColor: Theme.of(context).colorScheme.background,
padding: EdgeInsets.all(12),
child: Basic(
title: Text(context.i18n.battery).xSmall,
trailingAlignment: Alignment.centerRight,
trailing: 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,
}),
subtitle: Text(
'$batteryLevel%',
style: TextStyle(fontSize: 12),
),
),
),
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)
SizedBox(
width: screenshotMode ? 160 : null,
height: screenshotMode ? 70 : null,
child: Card(
filled: true,
padding: EdgeInsets.all(12),
fillColor: Theme.of(context).colorScheme.background,
child: Basic(
title: Text(context.i18n.firmware).xSmall,
subtitle: Row(
children: [
Text(
'$firmwareVersion',
style: TextStyle(fontSize: 12),
),
if (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),
),
],
),
trailingAlignment: Alignment.centerRight,
trailing: this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion
? Icon(Icons.warning, color: Theme.of(context).colorScheme.destructive)
: Icon(Icons.text_fields_sharp),
),
),
DeviceInfo(
title: context.i18n.firmware,
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)
SizedBox(
width: screenshotMode ? 160 : null,
height: screenshotMode ? 70 : null,
child: Card(
filled: true,
padding: EdgeInsets.all(12),
fillColor: Theme.of(context).colorScheme.background,
child: Basic(
title: Text(context.i18n.signal).xSmall,
trailingAlignment: Alignment.centerRight,
trailing: Icon(
switch (rssi!) {
>= -50 => Icons.signal_cellular_4_bar,
>= -60 => Icons.signal_cellular_alt_2_bar,
>= -70 => Icons.signal_cellular_alt_1_bar,
_ => Icons.signal_cellular_alt,
},
size: 18,
),
subtitle: Text(
'$rssi dBm',
style: TextStyle(fontSize: 12),
),
),
),
StreamBuilder(
stream: core.connection.rssiConnectionStream
.where((device) => device == this)
.map((event) => event.rssi),
builder: (context, rssiValue) {
return DeviceInfo(
title: context.i18n.signal,
icon: switch (rssiValue.data ?? 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);
}
}
}
}
}

View File

@@ -1,13 +1,13 @@
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';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:gamepads/gamepads.dart';
class GamepadDevice extends BaseDevice {
final String id;
@@ -68,7 +68,7 @@ class GamepadDevice extends BaseDevice {
spacing: 8,
children: [
Text(
name.screenshot,
toString().screenshot,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),

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

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

View File

@@ -1,7 +1,7 @@
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';
import 'package:flutter/material.dart';
class HidDevice extends BaseDevice {
HidDevice(super.name) : super(availableButtons: []);
@@ -15,7 +15,7 @@ class HidDevice extends BaseDevice {
Widget showInformation(BuildContext context) {
return Row(
children: [
Expanded(child: Text(name)),
Expanded(child: Text(toString())),
PopupMenuButton(
itemBuilder: (c) => [
PopupMenuItem(

View File

@@ -16,9 +16,11 @@ class WhooshLink extends TrainerConnection {
Socket? _socket;
ServerSocket? _server;
static const String connectionTitle = 'MyWhoosh Link';
WhooshLink()
: super(
title: 'MyWhoosh Link',
title: connectionTitle,
supportedActions: [
InGameAction.shiftUp,
InGameAction.shiftDown,

View File

@@ -1,9 +1,12 @@
import 'dart:io';
import 'package:bike_control/bluetooth/ble.dart';
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.pbenum.dart';
import 'package:bike_control/bluetooth/messages/notification.dart' show AlertNotification, LogNotification;
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
@@ -13,8 +16,6 @@ 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);
@@ -24,9 +25,11 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
late GATTCharacteristic _buttonCharacteristic;
static const String connectionTitle = 'OpenBikeControl BLE Emulator';
OpenBikeControlBluetoothEmulator()
: super(
title: 'OpenBikeControl BLE Emulator',
title: connectionTitle,
supportedActions: InGameAction.values,
);
@@ -77,6 +80,12 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
await _peripheralManager.respondReadRequestWithValue(
eventArgs.request,
value: Uint8List.fromList([100]),
);
return;
default:
print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}');
}
@@ -114,9 +123,9 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
);
print('Parsed App Info: $appInfo');
core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo'));
} catch (e) {
print('Error parsing App Info: $e');
core.connection.signalNotification(LogNotification('Error parsing App Info ${bytesToHex(value)}: $e'));
}
break;
default:

View File

@@ -18,13 +18,15 @@ 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: 'OpenBikeControl mDNS Emulator',
title: connectionTitle,
supportedActions: InGameAction.values,
);
@@ -50,7 +52,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
throw 'Could not find network interface';
}
_createTcpServer();
await _createTcpServer();
if (kDebugMode) {
enableLogging(LogTopic.calls);
@@ -134,14 +136,18 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
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;
try {
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}'),
);
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
);
} catch (e) {
core.connection.signalNotification(LogNotification('Failed to parse app info: $e'));
}
break;
default:
print('Unknown message type: $messageType');

View File

@@ -8,6 +8,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:dartx/dartx.dart';
@@ -17,7 +18,7 @@ class ProtocolParseException implements Exception {
ProtocolParseException(this.message, [this.raw]);
@override
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${raw!.length}' : ''}';
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${bytesToReadableHex(raw!)}' : ''}';
}
class OpenBikeProtocolParser {
@@ -28,20 +29,19 @@ class OpenBikeProtocolParser {
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),
0x10: ControllerButton('Up', identifier: 0x10, action: InGameAction.up),
0x11: ControllerButton('Down', identifier: 0x11, action: InGameAction.down),
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),
0x18: ControllerButton('Steer Left', identifier: 0x18, action: InGameAction.steerLeft),
0x19: ControllerButton('Steer Right', identifier: 0x19, action: InGameAction.steerRight),
// 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),
0x20: ControllerButton('Emote', identifier: 0x20, action: InGameAction.emote),
0x21: ControllerButton('Push to Talk', identifier: 0x21),
// Training Controls (0x30-0x3F)
0x30: ControllerButton('ERG Up', identifier: 0x30, action: InGameAction.increaseResistance),
0x31: ControllerButton('ERG Down', identifier: 0x31, action: InGameAction.decreaseResistance),

View File

@@ -30,7 +30,7 @@ class ShimanoDi2 extends BluetoothDevice {
bool _isInitialized = false;
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) {
final channels = bytes.sublist(1);
@@ -67,8 +67,7 @@ class ShimanoDi2 extends BluetoothDevice {
});
if (clickedButtons.isNotEmpty) {
handleButtonsClicked(clickedButtons);
handleButtonsClicked([]);
await handleButtonsClickedWithoutLongPressSupport(clickedButtons);
}
}
return Future.value();

View File

@@ -0,0 +1,172 @@
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(
"Don't forget to turn off the function of the button you want to use in the SRAM AXS app!\n"
"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";
}

View File

@@ -0,0 +1,89 @@
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 ThinkRiderVs200 extends BluetoothDevice {
ThinkRiderVs200(super.scanResult)
: super(
availableButtons: ThinkRiderVs200Buttons.values,
isBeta: true,
);
@override
Future<void> handleServices(List<BleService> services) async {
// Only subscribe to service 0xFEA0
final service = services.firstWhere(
(e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase(),
orElse: () => throw Exception('Service not found: ${ThinkRiderVs200Constants.SERVICE_UUID}'),
);
final characteristic = service.characteristics.firstWhere(
(e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase(),
orElse: () => throw Exception('Characteristic not found: ${ThinkRiderVs200Constants.CHARACTERISTIC_UUID}'),
);
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
}
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
if (characteristic.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase()) {
final hexValue = _bytesToHex(bytes).toLowerCase();
// Log all received values while in beta
if (isBeta) {
actionStreamInternal.add(LogNotification('VS200 received: $hexValue'));
}
// Check for specific byte patterns
if (hexValue == ThinkRiderVs200Constants.SHIFT_UP_PATTERN) {
// Plus button pressed
actionStreamInternal.add(LogNotification('Shift Up detected: $hexValue'));
handleButtonsClickedWithoutLongPressSupport([ThinkRiderVs200Buttons.shiftUp]);
} else if (hexValue == ThinkRiderVs200Constants.SHIFT_DOWN_PATTERN) {
// Minus button pressed
actionStreamInternal.add(LogNotification('Shift Down detected: $hexValue'));
handleButtonsClickedWithoutLongPressSupport([ThinkRiderVs200Buttons.shiftDown]);
}
}
return Future.value();
}
String _bytesToHex(List<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
}
class ThinkRiderVs200Constants {
// Service and characteristic UUIDs based on the nRF Connect screenshot
static const String SERVICE_UUID = "0000fea0-0000-1000-8000-00805f9b34fb";
static const String CHARACTERISTIC_UUID = "0000fea1-0000-1000-8000-00805f9b34fb";
// Byte patterns for button detection
static const String SHIFT_UP_PATTERN = "f3050301fc";
static const String SHIFT_DOWN_PATTERN = "f3050300fb";
}
class ThinkRiderVs200Buttons {
static const ControllerButton shiftUp = ControllerButton(
'shiftUp',
action: InGameAction.shiftUp,
icon: Icons.add,
);
static const ControllerButton shiftDown = ControllerButton(
'shiftDown',
action: InGameAction.shiftDown,
icon: Icons.remove,
);
static const List<ControllerButton> values = [
shiftUp,
shiftDown,
];
}

View File

@@ -1,7 +1,7 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:flutter/material.dart';
class ZwiftConstants {
static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1";
@@ -101,7 +101,11 @@ class ZwiftButtons {
// 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: null, color: Colors.deepOrangeAccent);
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);

View File

@@ -1,8 +1,5 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:nsd/nsd.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/protocol/zp.pbenum.dart';
@@ -14,17 +11,22 @@ 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: 'Zwift Network Emulator',
title: connectionTitle,
supportedActions: [
InGameAction.shiftUp,
InGameAction.shiftDown,
@@ -61,7 +63,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
throw 'Could not find network interface';
}
_createTcpServer();
await _createTcpServer();
if (kDebugMode) {
enableLogging(LogTopic.calls);
@@ -79,6 +81,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
'ble-service-uuids': Uint8List.fromList('FC82'.codeUnits),
'mac-address': Uint8List.fromList('50-50-25-6C-66-9C'.codeUnits),
'serial-number': Uint8List.fromList('244700181'.codeUnits),
'manufacturer-data': Uint8List.fromList('094A0BAAAA'.codeUnits),
},
),
);
@@ -378,7 +381,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
_write(_socket!, zero);
}
if (kDebugMode) {
print('Sent action ${keyPair.inGameAction!.title} to Zwift Emulator');
print('Sent action $isKeyUp vs $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator');
}
return Success('Sent action: ${keyPair.inGameAction!.title}');
}
@@ -462,8 +465,8 @@ extension on List<int> {
}
}
String bytesToHex(List<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
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) {

View File

@@ -1,6 +1,3 @@
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';
@@ -9,6 +6,9 @@ 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';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class ZwiftClickV2 extends ZwiftRide {
ZwiftClickV2(super.scanResult)
@@ -39,6 +39,11 @@ class ZwiftClickV2 extends ZwiftRide {
@override
bool get canVibrate => false;
@override
String toString() {
return "$name V2";
}
@override
Future<void> setupHandshake() async {
super.setupHandshake();
@@ -65,51 +70,58 @@ class ZwiftClickV2 extends ZwiftRide {
children: [
super.showInformation(context),
if (isConnected && _noLongerSendsEvents)
if (isConnected)
if (core.settings.getShowZwiftClickV2ReconnectWarning())
Warning(
Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
Warning(
children: [
Expanded(
child: Text(
AppLocalizations.of(context).clickV2Instructions,
).xSmall,
),
IconButton.link(
icon: Icon(Icons.close),
Text(
'Important Setup Information',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.destructive,
),
).small,
Text(
AppLocalizations.of(context).clickV2Instructions,
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).xSmall,
if (kDebugMode)
GhostButton(
onPressed: () {
sendCommand(Opcode.RESET, null);
},
child: Text('Reset now'),
),
Button.secondary(
onPressed: () {
core.settings.setShowZwiftClickV2ReconnectWarning(false);
setState(() {});
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
);
},
leading: const Icon(Icons.help_outline_outlined),
child: Text(context.i18n.instructions),
),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
GhostButton(
onPressed: () {
sendCommand(Opcode.RESET, null);
},
child: Text('Reset now'),
Align(
alignment: Alignment.topRight,
child: IconButton.link(
icon: Icon(
Icons.close,
color: Theme.of(context).colorScheme.destructive,
),
OutlineButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
),
);
},
leading: const Icon(Icons.open_in_new),
child: Text(context.i18n.troubleshootingGuide),
),
],
onPressed: () {
core.settings.setShowZwiftClickV2ReconnectWarning(false);
setState(() {});
},
),
),
],
)
@@ -123,11 +135,10 @@ class ZwiftClickV2 extends ZwiftRide {
LinkButton(
child: Text(context.i18n.troubleshootingGuide),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
),
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
);
},
),

View File

@@ -1,14 +1,16 @@
import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
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/i18n_extension.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:shadcn_flutter/shadcn_flutter.dart';
import 'package:universal_ble/universal_ble.dart';
abstract class ZwiftDevice extends BluetoothDevice {
@@ -87,10 +89,10 @@ abstract class ZwiftDevice extends BluetoothDevice {
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
if (kDebugMode) {
if (kDebugMode && false) {
actionStreamInternal.add(
LogNotification(
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
"Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
),
);
}
@@ -152,10 +154,10 @@ abstract class ZwiftDevice extends BluetoothDevice {
}
@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 == null || _lastButtonsClicked?.contentEquals(buttonsClicked ?? []) == false) {
super.handleButtonsClicked(buttonsClicked);
super.handleButtonsClicked(buttonsClicked, longPress: longPress);
}
_lastButtonsClicked = buttonsClicked;
}
@@ -191,4 +193,24 @@ abstract class ZwiftDevice extends BluetoothDevice {
withoutResponse: true,
);
}
@override
Widget showInformation(BuildContext context) {
return Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
super.showInformation(context),
if (canVibrate)
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);
},
),
],
);
}
}

View File

@@ -22,6 +22,8 @@ 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;
@@ -32,7 +34,7 @@ class ZwiftEmulator extends TrainerConnection {
ZwiftEmulator()
: super(
title: 'Zwift BLE Emulator',
title: connectionTitle,
supportedActions: [
InGameAction.shiftUp,
InGameAction.shiftDown,
@@ -306,7 +308,7 @@ class ZwiftEmulator extends TrainerConnection {
Future<void> _sendKeepAlive() async {
await Future.delayed(const Duration(seconds: 5));
if (isConnected.value) {
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();
@@ -459,4 +461,15 @@ class ZwiftEmulator extends TrainerConnection {
}
return null;
}
void cleanup() {
_peripheralManager.stopAdvertising();
_peripheralManager.removeAllServices();
_isServiceAdded = false;
_isSubscribedToEvents = false;
_central = null;
isConnected.value = false;
isStarted.value = false;
_isLoading = false;
}
}

View File

@@ -1,11 +1,8 @@
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/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';
import 'package:flutter/foundation.dart';
class ZwiftPlay extends ZwiftDevice {
ZwiftPlay(super.scanResult)
@@ -62,23 +59,4 @@ class ZwiftPlay extends ZwiftDevice {
@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);
},
),
],
);
}
}

View File

@@ -1,9 +1,9 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
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:flutter/foundation.dart';
class BaseNotification {}
@@ -68,8 +68,10 @@ class ActionNotification extends BaseNotification {
class AlertNotification extends LogNotification {
final LogLevel level;
final String alertMessage;
final VoidCallback? onTap;
final String? buttonTitle;
AlertNotification(this.level, this.alertMessage) : super(alertMessage);
AlertNotification(this.level, this.alertMessage, {this.onTap, this.buttonTitle}) : super(alertMessage);
@override
String toString() {

View File

@@ -1,8 +1,5 @@
import 'dart:io';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
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';
@@ -12,6 +9,9 @@ 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:permission_handler/permission_handler.dart';
import '../utils/keymap/keymap.dart';
@@ -26,9 +26,11 @@ class RemotePairing extends TrainerConnection {
Central? _central;
GATTCharacteristic? _inputReport;
static const String connectionTitle = 'Remote Control';
RemotePairing()
: super(
title: 'Remote Control',
title: connectionTitle,
supportedActions: InGameAction.values,
);

View File

@@ -1,7 +1,7 @@
{
"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",
"accessibilityReasonControl": "• Um Ihnen die Steuerung von Apps wie MyWhoosh, TrainingPeaks 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.",
@@ -10,16 +10,19 @@
"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",
"adjust": "Anpassen",
"adjustControllerButtons": "Controller-Tasten anpassen",
"afterDate": "Nach dem {date}",
"allow": "Erlauben",
"allowAccessibilityService": "Barrierefreiheitsdienst zulassen",
"allowBluetoothConnections": "Bluetooth-Verbindungen zulassen",
"allowBluetoothScan": "Bluetooth-Scan zulassen",
"allowLocationForBluetooth": "Standortzugriff erlauben, damit Bluetooth-Scan funktioniert",
"allowPersistentNotification": "Dauerhafte Benachrichtigung zulassen",
"allowPersistentNotification": "Benachrichtigungen zulassen",
"allowsRunningInBackground": "Ermöglicht es BikeControl, im Hintergrund weiterzulaufen.",
"alreadyBoughtTheApp": "App bereits gekauft? Dann musst Du BikeControl nicht erneut kaufen. Aus technischen Gründen lässt sich leider nicht feststellen, ob die App früher bereits erworben wurde. \n\nGebe Deine Play Store-Kauf-ID (z.B. GPA.3356-1337-1338-1339) ein, um die Vollversion freizuschalten. Falls Du diese nicht finden kannst, kontaktiere mich bitte direkt.",
"alreadyBoughtTheAppPreviously": "App zuvor bereits gekauft?",
"appIdActions": "{appId} Aktionen",
"@appIdActions": {
"placeholders": {
@@ -28,42 +31,9 @@
}
}
},
"appNameOnTargetName": "{appName} auf {targetName}",
"@appNameOnTargetName": {
"placeholders": {
"appName": {
"type": "String"
},
"targetName": {
"type": "String"
}
}
},
"appStartError": "Beim Starten der App ist ein Fehler aufgetreten. Bitte kontaktiere den Support.\n{error}",
"@appStartError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"attachLogFile": "Bitte füge auch die Datei „ {logPath} “ bei, falls vorhanden.\nBitte diese Informationen nicht entfernen.",
"@attachLogFile": {
"placeholders": {
"logPath": {
"type": "String"
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "Im letzten Schritt wählen Sie die Verbindungsmethode aus für {trainerApp}",
"battery": "Batterie",
"bikeControlPlatform": "BikeControl {platform}",
"@bikeControlPlatform": {
"placeholders": {
"platform": {
"type": "String"
}
}
},
"beforeDate": "Vor dem {date}",
"bluetoothAdvertiseAccess": "Bluetooth-Zugriff",
"bluetoothTurnedOn": "Bluetooth ist eingeschaltet",
"browserNotSupported": "Dieser Browser unterstützt kein Web-Bluetooth und die Plattform wird nicht unterstützt :(",
@@ -73,9 +43,11 @@
"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.",
"choosePreferredConnectionMethod": "Wähle Deine bevorzugte Verbindungsmethode.",
"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.",
"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 das Gerät vor jeder Trainings-Session in der Zwift-App verbinden.\nAnsonsten 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",
@@ -94,7 +66,7 @@
}
},
"connection": "Verbindung",
"continueAction": "Weitermachen",
"continueAction": "Weiter",
"controlAppUsingModes": "Steuere {appName} über {modes}",
"@controlAppUsingModes": {
"placeholders": {
@@ -106,7 +78,9 @@
}
}
},
"controllerConnectedClickButton": "Super! Dein Controller ist verbunden. Drücke eine Taste auf deinem Controller, um fortzufahren.",
"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.",
@@ -125,7 +99,6 @@
}
}
},
"custom": "Benutzerdefiniert",
"customizeControllerButtons": "Controller-Tasten anpassen für {appName}",
"@customizeControllerButtons": {
"placeholders": {
@@ -135,6 +108,8 @@
}
},
"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.",
@@ -165,7 +140,7 @@
"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}.",
"enableKeyboardMouseControl": "BikeControl sendet Maus- oder Tastaturkommandos an {appName}",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
@@ -176,6 +151,7 @@
"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)",
@@ -194,6 +170,8 @@
},
"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",
@@ -238,7 +216,9 @@
}
}
},
"letsGetYouSetUp": "Starten wir die Einrichtung",
"license": "Lizenz",
"licenseStatus": "Lizenzstatus",
"loadScreenshotForPlacement": "Lade einen Screenshot aus dem Spiel zur Platzierung hoch.",
"logViewer": "Protokollanzeige",
"logs": "Protokolle",
@@ -249,6 +229,7 @@
"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",
@@ -261,12 +242,11 @@
"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.",
"myWhooshLinkDescriptionRemote": "Du kannst dich über das Netzwerk mit MyWhoosh verbinden, indem du die „Link”-Verbindung nutzt. Die MyWhoosh Link-Begleit-App darf dabei NICHT gleichzeitig laufen.",
"myWhooshLinkDescriptionLocal": "Direkte Verbindung zu MyWhoosh über die „Link-Methode. Beachte die Anleitung, um sicherzustellen, dass eine Verbindung möglich ist. Die MyWhoosh Link-App darf nicht gleichzeitig aktiv sein.",
"myWhooshLinkInfo": "Schau mal im Abschnitt zur Fehlerbehebung nach, wenn du Probleme hast. Eine deutlich zuverlässigere Verbindungsmethode kommt bald!",
"nameChangeNotice": "SwiftControl heißt jetzt BikeControl! Es ist Teil des OpenBikeControl-Projekts, das sich für offene Standards bei intelligenten Fahrradtrainern einsetzt und erschwingliche Hardware-Controller entwickelt!",
"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",
@@ -279,17 +259,20 @@
}
},
"next": "Nächste",
"no": "Nein",
"noActionAssigned": "Keine Maßnahmen zugewiesen",
"noButtonAssigned": "Für Ihr angeschlossenes Gerät ist keine Taste 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.",
"noPredefinedActionsAvailable": "Keine vordefinierten Aktionen verfügbar",
"noTrainerSelected": "Kein Trainer ausgewählt",
"notConnected": "Nicht verbunden",
"notificationDescription": "Dadurch bleibt die App im Hintergrund aktiv.",
"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.",
@@ -304,7 +287,7 @@
"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 kann, aktiviere bitte die folgenden Berechtigungen:",
"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": {
@@ -352,15 +335,7 @@
},
"profileImportedSuccessfully": "Profil erfolgreich importiert",
"profileName": "Profilname",
"provideFeedback": "Feedback geben",
"recommendDownloadBikeControl": "Wir empfehlen dringend, BikeControl auf dieses {platform} -Gerät herunterzuladen und zu verwenden.",
"@recommendDownloadBikeControl": {
"placeholders": {
"platform": {
"type": "String"
}
}
},
"purchase": "Kaufen",
"recommendedConnectionMethods": "Empfohlene Verbindungsmethoden",
"removeFromIgnoredList": "Aus der Ignorierliste entfernen",
"rename": "Umbenennen",
@@ -368,6 +343,7 @@
"requirement": "Anforderung",
"reset": "Zurücksetzen",
"restart": "Neustart",
"restorePurchaseInfo": "Klicke auf den Knopf oben und anschließend auf „Kauf wiederherstellen“. Bei Problemen kontaktiere mich bitte direkt.",
"runAppOnPlatformRemotely": "{appName} auf {platform} laufen lassen und es von diesem Gerät aus fernsteuern via {preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {
@@ -394,16 +370,6 @@
"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",
"selectOtherDeviceWhereAppRuns": "Wähle das andere Gerät aus, auf dem {appName} läuft auf",
"@selectOtherDeviceWhereAppRuns": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"selectTargetDevice": "Zielgerät auswählen",
"selectTargetDeviceDescription": "Wähle das Zielgerät aus, auf dem die Trainer-App ausgeführt werden soll.",
"selectTargetWhereAppRuns": "Ziel auswählen, auf dem {appName} läuft",
"@selectTargetWhereAppRuns": {
"placeholders": {
@@ -412,53 +378,38 @@
}
}
},
"selectThisDeviceWarning": "Wähle „Dieses Gerät“, es sei denn, Du möchtest ein anderes {platform} -Gerät steuern. Sicher?",
"@selectThisDeviceWarning": {
"placeholders": {
"platform": {
"type": "String"
}
}
},
"selectTrainerApp": "Trainer-App auswählen",
"selectTrainerAppAndTarget": "Trainer-App und Zielgerät auswählen",
"selectTrainerAppPlaceholder": "Trainer-App auswählen",
"setting": "Einstellung",
"setupComplete": "Einrichtung abgeschlossen!",
"setupTrainer": "Trainer einrichten",
"share": "Teilen",
"showDonation": "Zeige Deine Wertschätzung durch eine Spende.",
"showSupportedControllers": "Unterstützte Controller anzeigen",
"showTroubleshootingGuide": "Anleitung zur Fehlerbehebung anzeigen",
"sideloaded": "(per Sideloading)",
"signal": "Signal",
"simulateButtons": "Tasten simulieren",
"simulateButtons": "Trainersteuerung",
"simulateKeyboardShortcut": "Tastenkombination simulieren",
"simulateMediaKey": "Medientaste simulieren",
"simulateTouch": "Berührung simulieren",
"skip": "Überspringen",
"stop": "Stoppen",
"targetAndroid": "Android-Gerät",
"targetIOS": "iPhone / iPad / Apple TV",
"targetMacOS": "Mac",
"supportedActions": "Unterstützte Aktionen",
"targetOtherDevice": "Anderes Gerät",
"targetThisDevice": "Dieses Gerät",
"targetWindows": "Windows-PC",
"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",
"trainerSetup": "Trainer-Setup: {appName} auf {targetName}",
"@trainerSetup": {
"placeholders": {
"appName": {
"type": "String"
},
"targetName": {
"type": "String"
}
}
},
"troubleshootingGuide": "Leitfaden zur Fehlerbehebung",
"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": "Fragen und Antworten",
"tryingToConnectAgain": "Verbinde erneut...",
"unassignAction": "Zuweisung aufheben",
"unlockFullVersion": "Vollversion freischalten",
"unlockingNotPossible": "Eine Freischaltung ist derzeit noch nicht möglich, daher ist die App vorerst uneingeschränkt nutzbar!",
"update": "Aktualisieren",
"useCustomKeymapForButton": "Verwende eine benutzerdefinierte Tastaturbelegung, um die",
"version": "Version {version}",
@@ -469,7 +420,6 @@
}
}
},
"videoInstructions": "Videoanleitung",
"viewDetailedInstructions": "Detaillierte Anweisungen ansehen",
"volumeDown": "Lautstärke verringern",
"volumeUp": "Lautstärke erhöhen",
@@ -484,6 +434,7 @@
},
"whatsNew": "Was ist neu",
"whyPermissionNeeded": "Wozu wird diese Berechtigung benötigt?",
"yes": "Ja",
"zwiftControllerAction": "Zwift Controller-Aktion",
"zwiftControllerDescription": "Ermöglicht es BikeControl, als Zwift-kompatibler Controller zu fungieren."
}

View File

@@ -1,462 +1,440 @@
{
"@@locale": "en",
"controllers": "Controllers",
"trainer": "Trainer",
"configuration": "Configuration",
"logs": "Logs",
"connectControllers": "Connect Controllers",
"connectedControllers": "Connected Controllers",
"scanningForDevices": "Scanning for devices... Make sure they are powered on and in range and not connected to another device.",
"scan": "SCAN",
"enablePermissions": "Enable Permissions",
"permissionsRequired": "In order for BikeControl to search for nearby devices, please enable the following permissions:",
"showTroubleshootingGuide": "Show Troubleshooting Guide",
"showSupportedControllers": "Show Supported Controllers",
"troubleshootingGuide": "Troubleshooting Guide",
"enableMediaKeyDetection": "Enable Media Key Detection",
"mediaKeyDetectionTooltip": "Enable this option to allow BikeControl to detect bluetooth remotes.\nIn order to do so BikeControl needs to act as a media player.",
"nameChangeNotice": "SwiftControl is now BikeControl!\nIt is part of the OpenBikeControl project, advocating for open standards in smart bike trainers - and building affordable hardware controllers!",
"moreInformation": "More Information",
"manageIgnoredDevices": "Manage Ignored Devices",
"ignoredDevices": "Ignored Devices",
"noIgnoredDevices": "No ignored devices.",
"removeFromIgnoredList": "Remove from ignored list",
"close": "Close",
"connectToTrainerApp": "Connect to Trainer app",
"trainerSetup": "Trainer setup: {appName} on {targetName}",
"@trainerSetup": {
"placeholders": {
"appName": { "type": "String" },
"targetName": { "type": "String" }
}
},
"setupTrainer": "Setup Trainer",
"adjust": "Adjust",
"selectTrainerApp": "Select Trainer App",
"selectTrainerAppPlaceholder": "Select Trainer app",
"selectTargetDevice": "Select Target device",
"selectTargetWhereAppRuns": "Select Target where {appName} runs on",
"@selectTargetWhereAppRuns": {
"placeholders": {
"appName": { "type": "String" }
}
},
"selectOtherDeviceWhereAppRuns": "Select the other device where {appName} runs on",
"@selectOtherDeviceWhereAppRuns": {
"placeholders": {
"appName": { "type": "String" }
}
},
"targetThisDevice": "This Device",
"targetOtherDevice": "Other Device",
"targetIOS": "iPhone / iPad / Apple TV",
"targetAndroid": "Android Device",
"targetMacOS": "Mac",
"targetWindows": "Windows PC",
"runAppOnThisDevice": "Run {appName} on this device.",
"@runAppOnThisDevice": {
"placeholders": {
"appName": { "type": "String" }
}
},
"runAppOnPlatformRemotely": "Run {appName} on {platform} and control it remotely from this device{preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {
"appName": { "type": "String" },
"platform": { "type": "String" },
"preferredConnection": { "type": "String" }
}
},
"platformRestrictionOtherDevicesOnly": "Due to platform restrictions only controlling {appName} on other devices is supported.",
"@platformRestrictionOtherDevicesOnly": {
"placeholders": {
"appName": { "type": "String" }
}
},
"platformRestrictionNotSupported": "Due to platform restrictions this scenario is not supported.",
"selectThisDeviceWarning": "Select 'This device' unless you want to control another {platform} device. Are you sure?",
"@selectThisDeviceWarning": {
"placeholders": {
"platform": { "type": "String" }
}
},
"recommendDownloadBikeControl": "We highly recommended to download and use BikeControl on that {platform} device.",
"@recommendDownloadBikeControl": {
"placeholders": {
"platform": { "type": "String" }
}
},
"errorStartingOpenBikeControlServer": "Error starting OpenBikeControl server.",
"errorStartingOpenBikeControlBluetoothServer": "Error starting OpenBikeControl Bluetooth server.",
"enableAutoRotation": "Enable auto-rotation on your device to make sure the app works correctly.",
"miuiDeviceDetected": "MIUI Device Detected",
"miuiWarningDescription": "Your device is running MIUI, which is known to aggressively kill background services and accessibility services.",
"miuiEnsureProperWorking": "To ensure BikeControl works properly:",
"miuiDisableBatteryOptimization": "• Disable battery optimization for BikeControl",
"miuiEnableAutostart": "• Enable autostart for BikeControl",
"miuiLockInRecentApps": "• Lock the app in recent apps",
"viewDetailedInstructions": "View Detailed Instructions",
"recommendedConnectionMethods": "Recommended Connection Methods",
"otherConnectionMethods": "Other Connection Methods",
"connectUsingBluetooth": "Connect using Bluetooth",
"connectedTo": "Connected to {appId}",
"@connectedTo": {
"placeholders": {
"appId": { "type": "String" }
}
},
"chooseBikeControlInConnectionScreen": "Choose BikeControl in the connection screen.",
"letsAppConnectOverBluetooth": "Lets {appName} connect to BikeControl over Bluetooth.",
"@letsAppConnectOverBluetooth": {
"placeholders": {
"appName": { "type": "String" }
}
},
"connectDirectlyOverNetwork": "Connect directly over Network",
"letsAppConnectOverNetwork": "Lets {appName} connect directly over the Network. Choose BikeControl in the connection screen.",
"@letsAppConnectOverNetwork": {
"placeholders": {
"appName": { "type": "String" }
}
},
"connectUsingMyWhooshLink": "Connect using MyWhoosh \"Link\"",
"myWhooshLinkConnected": "MyWhoosh \"Link\" connected",
"checkMyWhooshConnectionScreen": "Check the connection screen in MyWhoosh to see if \"Link\" is connected.",
"myWhooshLinkDescriptionRemote": "Allows you to connect to MyWhoosh over the network, using the \"Link\" connection. The MyWhoosh Link companion app must NOT be running at the same time.",
"myWhooshLinkDescriptionLocal": "Optional - allows you to do some additional features such as Emotes and turn directions. The MyWhoosh Link companion app must NOT be running at the same time.",
"errorStartingMyWhooshLink": "Error starting MyWhoosh Link server. Please make sure the \"MyWhoosh Link\" app is not already running on this device.",
"enableZwiftControllerBluetooth": "Enable Zwift Controller (Bluetooth)",
"enableZwiftControllerNetwork": "Enable Zwift Controller (Network)",
"zwiftControllerDescription": "Enables BikeControl to act as a Zwift-compatible controller.",
"connected": "Connected",
"waitingForConnectionKickrBike": "Waiting for connection. Choose KICKR BIKE PRO in {appName}'s controller pairing menu.",
"@waitingForConnectionKickrBike": {
"placeholders": {
"appName": { "type": "String" }
}
},
"controlAppUsingModes": "Control {appName} using {modes}",
"@controlAppUsingModes": {
"placeholders": {
"appName": { "type": "String" },
"modes": { "type": "String" }
}
},
"enableKeyboardMouseControl": "Enable keyboard and mouse control for better interaction with {appName}.",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": { "type": "String" }
}
},
"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, TrainingPeaks, 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",
"enablePairingProcess": "Enable Pairing Process",
"pairingDescription": "Pairing allows full customizability, but may not work on all devices.",
"pairingInstructionsIOS": "On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.",
"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" }
}
},
"adjustControllerButtons": "Adjust Controller Buttons",
"customizeControllerButtons": "Customize Controller buttons for {appName}",
"@customizeControllerButtons": {
"placeholders": {
"appName": { "type": "String" }
}
},
"selectKeymap": "Select Keymap",
"createNewKeymap": "Create new keymap",
"customizeKeymapHint": "Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)",
"connectControllerToPreview": "Connect a controller device to preview and customize the keymap.",
"enableVibrationFeedback": "Enable vibration feedback when shifting gears",
"deviceButton": "{deviceName} button",
"@deviceButton": {
"placeholders": {
"deviceName": { "type": "String" }
}
},
"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",
"noButtonAssigned": "No button assigned for your connected device",
"noActionAssigned": "No action assigned",
"openBikeControlActions": "OpenBikeControl actions",
"adjustControllerButtons": "Adjust Controller Buttons",
"afterDate": "After {date}",
"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",
"alreadyBoughtTheApp": "Already bought the app? You don't need to pay for BikeControl, again. Due to technical difficulties, it's not possible to determine if the app was bought already.\n\nEnter your Play Store purchase ID (e.g. GPA.3356-1337-1338-1339) to unlock the full version. If you can't find it, please get in touch with me directly.",
"alreadyBoughtTheAppPreviously": "Already bought the app previously?",
"appIdActions": "{appId} actions",
"@appIdActions": {
"placeholders": {
"appId": { "type": "String" }
"appId": {
"type": "String"
}
}
},
"myWhooshDirectConnectAction": "MyWhoosh Direct Connect Action",
"zwiftControllerAction": "Zwift Controller Action",
"custom": "Custom",
"predefinedAction": "Predefined {appName} action",
"@predefinedAction": {
"placeholders": {
"appName": { "type": "String" }
}
},
"noPredefinedActionsAvailable": "No predefined actions available",
"simulateKeyboardShortcut": "Simulate Keyboard shortcut",
"simulateTouch": "Simulate Touch",
"simulateMediaKey": "Simulate Media key",
"setting": "Setting",
"longPressMode": "Long Press Mode (vs. repeating)",
"unassignAction": "Unassign action",
"playPause": "Play/Pause",
"stop": "Stop",
"previous": "Previous",
"next": "Next",
"volumeUp": "Volume Up",
"volumeDown": "Volume Down",
"createdNewCustomProfile": "Created a new custom profile: {profileName}",
"@createdNewCustomProfile": {
"placeholders": {
"profileName": { "type": "String" }
}
},
"manageProfile": "Manage Profile",
"rename": "Rename",
"duplicate": "Duplicate",
"importAction": "Import",
"exportAction": "Export",
"delete": "Delete",
"asAFinalStepYoullChooseHowToConnectTo": "As a final step you'll choose how to connect to {trainerApp}.",
"battery": "Battery",
"beforeDate": "Before {date}",
"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"
}
}
},
"controllerConnectedClickButton": "Great! Your controller is connected. Click a button on your controller to continue.",
"controllers": "Controllers",
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Could not perform {button}: No keymap set",
"create": "Create",
"newCustomProfile": "New Custom Profile",
"profileName": "Profile name",
"renameProfile": "Rename Profile",
"createNewKeymap": "Create new keymap",
"createNewProfileByDuplicating": "Create new custom profile by duplicating \"{profileName}\"",
"@createNewProfileByDuplicating": {
"placeholders": {
"profileName": { "type": "String" }
"profileName": {
"type": "String"
}
}
},
"newProfileName": "New Profile Name",
"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" }
"profileName": {
"type": "String"
}
}
},
"importProfile": "Import Profile",
"pasteExportedJsonData": "Paste the exported JSON data below:",
"jsonData": "JSON Data",
"profileImportedSuccessfully": "Profile imported successfully",
"failedToImportProfile": "Failed to import profile. Invalid format.",
"profileExportedToClipboard": "Profile \"{profileName}\" exported to clipboard",
"@profileExportedToClipboard": {
"deny": "Deny",
"deviceButton": "{deviceName} button",
"@deviceButton": {
"placeholders": {
"profileName": { "type": "String" }
"deviceName": {
"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" }
}
},
"ok": "OK",
"waiting": "Waiting...",
"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",
"loadScreenshotForPlacement": "Load in-game screenshot for placement",
"save": "Save",
"chooseAnotherScreenshot": "Choose another screenshot",
"reset": "Reset",
"dragToReposition": "Drag to reposition",
"longPress": "long\npress",
"videoInstructions": "Video Instructions",
"theFollowingPermissionsRequired": "The following permissions are required:",
"granted": "Granted",
"grant": "Grant",
"choosePreferredConnectionMethod": "Choose your preferred connection method",
"showDonation": "Show your appreciation by donating",
"donateViaCreditCard": "via Credit Card, Google Pay, Apple Pay and others",
"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",
"leaveAReview": "Leave a Review",
"provideFeedback": "Provide Feedback",
"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": "BikeControl will send mouse or keyboard actions to control {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" }
"version": {
"type": "String"
}
}
},
"attachLogFile": "Please also attach the file {logPath}, if it exists.\nPlease don't remove this information, it helps me to assist you better.",
"@attachLogFile": {
"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": {
"logPath": { "type": "String" }
"version": {
"type": "String"
}
}
},
"simulateButtons": "Simulate buttons",
"continueAction": "Continue",
"changelog": "Changelog",
"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"
}
}
},
"letsGetYouSetUp": "Let's get you set up!",
"license": "License",
"whatsNew": "What's New",
"version": "Version {version}",
"@version": {
"placeholders": {
"version": { "type": "String" }
}
},
"gotIt": "Got it!",
"licenseStatus": "License Status",
"loadScreenshotForPlacement": "Load in-game screenshot for placement",
"logViewer": "Log Viewer",
"share": "Share",
"logsHaveBeenCopiedToClipboard": "Logs have been copied to clipboard",
"logs": "Logs",
"logsAreAlsoAt": "Logs are also at",
"pathCopiedToClipboard": "Path has been copied to clipboard",
"appStartError": "There was an error starting the App. Please contact support:\n{error}",
"@appStartError": {
"placeholders": {
"error": { "type": "String" }
}
},
"sideloaded": "(sideloaded)",
"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. Check the instructions to ensure a connection is possible. The \"MyWhoosh Link\" app must not be active 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" }
"version": {
"type": "String"
}
}
},
"update": "Update",
"download": "Download",
"forceCloseToUpdate": "Force-close the app to use the new version",
"restart": "Restart",
"failedToUpdate": "Failed to update: {error}",
"@failedToUpdate": {
"next": "Next",
"no": "No",
"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": {
"error": { "type": "String" }
"targetName": {
"type": "String"
}
}
},
"connection": "Connection",
"disconnected": "Disconnected",
"battery": "Battery",
"firmware": "Firmware",
"latestVersion": "latest: {version}",
"@latestVersion": {
"placeholders": {
"version": { "type": "String" }
}
},
"signal": "Signal",
"accessibilityServicePermissionRequired": "Accessibility Service Permission Required",
"accessibilityServiceExplanation": "BikeControl needs to use Android's AccessibilityService API to function properly.",
"whyPermissionNeeded": "Why is this permission needed?",
"accessibilityReasonTouch": "• To simulate touch gestures on your screen for controlling trainer apps",
"accessibilityReasonWindow": "• To detect which training app window is currently active",
"accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices",
"howBikeControlUsesPermission": "How does BikeControl use this permission?",
"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",
"accessibilityDisclaimer": "BikeControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.",
"mustChooseAllowOrDeny": "You must choose to either Allow or Deny this permission to continue.",
"deny": "Deny",
"allow": "Allow",
"allowAccessibilityService": "Allow Accessibility Service",
"accessibilityDescription": "BikeControl needs accessibility permission to control your training apps.",
"allowBluetoothScan": "Allow Bluetooth Scan",
"allowLocationForBluetooth": "Allow Location so Bluetooth scan works",
"allowBluetoothConnections": "Allow Bluetooth Connections",
"allowPersistentNotification": "Allow persistent Notification",
"notificationDescription": "This keeps the app alive in background",
"allowsRunningInBackground": "Allows BikeControl to keep running in background",
"disconnectDevices": "Disconnect Devices",
"keyboardAccess": "Keyboard access",
"enableKeyboardAccessMessage": "Enable keyboard access in the following screen for BikeControl. If you don't see BikeControl, please add it manually.",
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
"bluetoothTurnedOn": "Bluetooth turned on",
"enableBluetooth": "Enable Bluetooth",
"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" }
"platform": {
"type": "String"
}
}
},
"browserNotSupported": "This Browser does not support Web Bluetooth and platform is not supported :(",
"selectTrainerAppAndTarget": "Select Trainer App & Target Device",
"selectTargetDeviceDescription": "Select your Target Device where you want to run your trainer app on",
"requirement": "Requirement",
"needHelpClickHelp": "Need help? Click on the",
"needHelpDontHesitate": "button on top and don't hesitate to contact us.",
"touchSimulationForegroundMessage": "To simulate touches the app needs to stay in the foreground.",
"useCustomKeymapForButton": "Use a custom keymap to support the",
"button": "button.",
"openBikeControlConnection": " e.g. by using OpenBikeControl connection",
"myWhooshDirectConnection": " e.g. by using MyWhoosh Direct Connect",
"appNameOnTargetName": "{appName} on {targetName}",
"@appNameOnTargetName": {
"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" },
"targetName": { "type": "String" }
"appName": {
"type": "String"
}
}
},
"bikeControlPlatform": "BikeControl {platform}",
"@bikeControlPlatform": {
"placeholders": {
"platform": { "type": "String" }
}
},
"clickV2Instructions": "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 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",
"playPause": "Play/Pause",
"pleaseSelectAConnectionMethodFirst": "Please select a connection method in the Trainer settings, first.",
"noConnectionMethodSelected": "No Connection Method selected",
"noControllerConnected": "None connected",
"notConnected": "Not connected",
"noTrainerSelected": "No Trainer selected",
"instructions": "Instructions",
"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.",
"myWhooshLinkInfo": "Please check the troubleshooting section if you encounter any issues. A much more reliable connection method is coming soon!",
"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."
}
"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",
"restorePurchaseInfo": "Click on the button above, then on \"Restore Purchase\". Please contact me directly if you have any issues.",
"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",
"setupComplete": "Setup Complete!",
"setupTrainer": "Setup Trainer",
"share": "Share",
"showDonation": "Show your appreciation by donating",
"showSupportedControllers": "Show Supported Controllers",
"signal": "Signal",
"simulateButtons": "Trainer Controls",
"simulateKeyboardShortcut": "Simulate Keyboard shortcut",
"simulateMediaKey": "Simulate Media key",
"simulateTouch": "Simulate Touch",
"skip": "Skip",
"stop": "Stop",
"supportedActions": "Supported Actions",
"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": "Questions & Answers",
"tryingToConnectAgain": "Trying to connect again...",
"unassignAction": "Unassign action",
"unlockFullVersion": "Unlock Full Version",
"unlockingNotPossible": "Unlocking is currently not yet possible, so enjoy unlimited usage for the time being!",
"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?",
"yes": "Yes",
"zwiftControllerAction": "Zwift Controller Action",
"zwiftControllerDescription": "Enables BikeControl to act as a Zwift-compatible controller."
}

View File

@@ -1,7 +1,7 @@
{
"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.",
"accessibilityReasonControl": "• Pour vous permettre de contrôler des applications telles que MyWhoosh, TrainingPeaks 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.",
@@ -10,16 +10,19 @@
"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",
"adjust": "Ajuster",
"adjustControllerButtons": "Ajuster les boutons de la manette",
"afterDate": "Après {date}",
"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 persistantes",
"allowPersistentNotification": "Autoriser les notifications",
"allowsRunningInBackground": "Permet à BikeControl de continuer à fonctionner en arrière-plan",
"alreadyBoughtTheApp": "Vous avez déjà acheté l'application ? Vous n'avez pas besoin de payer BikeControl une seconde fois. En raison de difficultés techniques, il est impossible de déterminer si l'application a déjà été achetée. \n\nSaisissez votre identifiant d'achat Play Store (par exemple : GPA.3356-1337-1338-1339) pour débloquer la version complète. Si vous ne le trouvez pas, veuillez me contacter directement.",
"alreadyBoughtTheAppPreviously": "Vous avez déjà acheté l'application ?",
"appIdActions": "{appId} actions",
"@appIdActions": {
"placeholders": {
@@ -28,42 +31,9 @@
}
}
},
"appNameOnTargetName": "{appName} sur {targetName}",
"@appNameOnTargetName": {
"placeholders": {
"appName": {
"type": "String"
},
"targetName": {
"type": "String"
}
}
},
"appStartError": "Une erreur s'est produite lors du démarrage de l'application. Veuillez contacter le service d'assistance :\n{error}",
"@appStartError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"attachLogFile": "Veuillez également joindre le fichier {logPath}, s'il existe.\nVeuillez ne pas supprimer ces informations, elles m'aident à mieux vous aider.",
"@attachLogFile": {
"placeholders": {
"logPath": {
"type": "String"
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "Dernière étape : vous choisirez comment vous connecter à {trainerApp} .",
"battery": "Batterie",
"bikeControlPlatform": "BikeControl {platform}",
"@bikeControlPlatform": {
"placeholders": {
"platform": {
"type": "String"
}
}
},
"beforeDate": "Avant {date}",
"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 :(",
@@ -73,9 +43,11 @@
"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.",
"choosePreferredConnectionMethod": "Choisissez votre méthode de connexion préférée",
"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",
@@ -106,7 +78,9 @@
}
}
},
"controllerConnectedClickButton": "Super ! Votre manette est connectée. Cliquez sur un bouton de votre manette pour continuer.",
"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} ».",
@@ -125,7 +99,6 @@
}
}
},
"custom": "Coutume",
"customizeControllerButtons": "Personnalisez les boutons de la manette pour {appName}",
"@customizeControllerButtons": {
"placeholders": {
@@ -135,6 +108,8 @@
}
},
"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.",
@@ -165,7 +140,7 @@
"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}.",
"enableKeyboardMouseControl": "BikeControl enverra des actions de souris ou de clavier pour contrôler {appName} .",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
@@ -176,6 +151,7 @@
"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)",
@@ -194,6 +170,8 @@
},
"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",
@@ -238,7 +216,9 @@
}
}
},
"letsGetYouSetUp": "On va vous installer !",
"license": "Licence",
"licenseStatus": "Statut de la licence",
"loadScreenshotForPlacement": "Charger une capture d'écran du jeu pour le placement",
"logViewer": "Visionneuse de journaux",
"logs": "Journaux",
@@ -249,6 +229,7 @@
"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.",
@@ -261,12 +242,11 @@
"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.",
"myWhooshLinkDescriptionRemote": "Ça te permet de te connecter à MyWhoosh via le réseau, en utilisant la connexion « Link ». L'appli MyWhoosh Link ne doit PAS être ouverte en même temps.",
"myWhooshLinkDescriptionLocal": "Connectez-vous directement à MyWhoosh via la méthode « Link ». Consultez les instructions pour vérifier la compatibilité de la connexion. Lapplication « MyWhoosh Link » ne doit pas être active simultanément.",
"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 !",
"nameChangeNotice": "SwiftControl devient BikeControl ! Ce logiciel fait partie du projet OpenBikeControl, qui promeut les standards ouverts pour les home trainers connectés et conçoit des contrôleurs matériels abordables !",
"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",
@@ -279,17 +259,20 @@
}
},
"next": "Suivant",
"no": "Non",
"noActionAssigned": "Aucune action assignée",
"noButtonAssigned": "Aucun bouton n'est attribué à votre appareil connecté.",
"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é.",
"noPredefinedActionsAvailable": "Aucune action prédéfinie disponible",
"noTrainerSelected": "Aucun Trainer sélectionné",
"notConnected": "Non connecté",
"notificationDescription": "Cela permet à l'application de rester active en arrière-plan.",
"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.",
@@ -304,7 +287,7 @@
"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é, veuillez activer les autorisations suivantes :",
"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": {
@@ -352,15 +335,7 @@
},
"profileImportedSuccessfully": "Profil importé avec succès",
"profileName": "Nom du profil",
"provideFeedback": "Donnez votre avis",
"recommendDownloadBikeControl": "Nous vous recommandons vivement de télécharger et d'utiliser BikeControl sur cet appareil {platform}.",
"@recommendDownloadBikeControl": {
"placeholders": {
"platform": {
"type": "String"
}
}
},
"purchase": "Achat",
"recommendedConnectionMethods": "Méthodes de connexion recommandées",
"removeFromIgnoredList": "Supprimer de la liste ignorée",
"rename": "Rebaptiser",
@@ -368,6 +343,7 @@
"requirement": "Exigence",
"reset": "Réinitialiser",
"restart": "Redémarrage",
"restorePurchaseInfo": "Cliquez sur le bouton ci-dessus, puis sur « Restaurer lachat ». Veuillez me contacter directement en cas de problème.",
"runAppOnPlatformRemotely": "Exécutez {appName} sur {platform} et contrôlez-le à distance depuis cet appareil{preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {
@@ -394,16 +370,6 @@
"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",
"selectOtherDeviceWhereAppRuns": "Sélectionnez l'autre appareil sur lequel {appName} fonctionne.",
"@selectOtherDeviceWhereAppRuns": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"selectTargetDevice": "Sélectionner l'appareil cible",
"selectTargetDeviceDescription": "Sélectionnez l'appareil cible sur lequel vous souhaitez exécuter votre application d'entraînement.",
"selectTargetWhereAppRuns": "Sélectionnez la cible où {appName} fonctionne sur",
"@selectTargetWhereAppRuns": {
"placeholders": {
@@ -412,53 +378,38 @@
}
}
},
"selectThisDeviceWarning": "Sélectionnez « Cet appareil » sauf si vous souhaitez contrôler un autre appareil {platform}. Êtes-vous sûr ?",
"@selectThisDeviceWarning": {
"placeholders": {
"platform": {
"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",
"setupComplete": "Installation terminée !",
"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",
"sideloaded": "(transféré)",
"signal": "Signal",
"simulateButtons": "Simuler des boutons",
"simulateButtons": "Commandes de l'entraîneur",
"simulateKeyboardShortcut": "Simuler un raccourci clavier",
"simulateMediaKey": "Simuler la touche média",
"simulateTouch": "Simuler le toucher",
"skip": "Sauter",
"stop": "Arrêt",
"targetAndroid": "Appareil Android",
"targetIOS": "iPhone / iPad / Apple TV",
"targetMacOS": "Mac",
"supportedActions": "Actions prises en charge",
"targetOtherDevice": "Autre appareil",
"targetThisDevice": "Cet appareil",
"targetWindows": "PC Windows",
"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",
"trainerSetup": "Configuration du home trainer: {appName} sur {targetName}",
"@trainerSetup": {
"placeholders": {
"appName": {
"type": "String"
},
"targetName": {
"type": "String"
}
}
},
"troubleshootingGuide": "Guide de dépannage",
"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": "Questions et réponses",
"tryingToConnectAgain": "Tentative de reconnexion...",
"unassignAction": "Action de désaffectation",
"unlockFullVersion": "Débloquer la version complète",
"unlockingNotPossible": "Le déverrouillage n'est pas encore possible pour le moment, alors profitez d'une utilisation illimitée pour l'instant !",
"update": "Mise à jour",
"useCustomKeymapForButton": "Utilisez une configuration de touches personnalisée pour prendre en charge",
"version": "Version {version}",
@@ -469,7 +420,6 @@
}
}
},
"videoInstructions": "Instructions vidéo",
"viewDetailedInstructions": "Consultez les instructions détaillées",
"volumeDown": "Baisser le volume",
"volumeUp": "Augmenter le volume",
@@ -484,6 +434,7 @@
},
"whatsNew": "Quoi de neuf",
"whyPermissionNeeded": "Pourquoi cette autorisation est-elle nécessaire ?",
"yes": "Oui",
"zwiftControllerAction": "Action du contrôleur Zwift",
"zwiftControllerDescription": "Permet à BikeControl de fonctionner comme une manette compatible avec Zwift."
}

440
lib/i10n/intl_it.arb Normal file
View File

@@ -0,0 +1,440 @@
{
"accessibilityDescription": "BikeControl necessita dell'autorizzazione di accessibilità per controllare le tue app di allenamento.",
"accessibilityDisclaimer": "BikeControl accederà al tuo schermo solo per eseguire i gesti da te configurati. Non accederà ad altre funzioni di accessibilità o a informazioni personali.",
"accessibilityReasonControl": "• Per consentirti di controllare app come MyWhoosh, TrainingPeaks e altre utilizzando i tuoi dispositivi Zwift",
"accessibilityReasonTouch": "• Per simulare i gesti touch sullo schermo per controllare le app di allenamento",
"accessibilityReasonWindow": "• Per rilevare quale finestra dell'app di allenamento è attualmente attiva",
"accessibilityServiceExplanation": "Per funzionare correttamente, BikeControl deve utilizzare l'API AccessibilityService di Android.",
"accessibilityServiceNotRunning": "Il servizio di accessibilità non è in esecuzione. Seguire le istruzioni su",
"accessibilityServicePermissionRequired": "Autorizzazione al servizio di accessibilità richiesta",
"accessibilityUsageGestures": "• Quando premi i pulsanti sui tuoi dispositivi Zwift Click, Zwift Ride o Zwift Play, BikeControl simula i gesti touch in posizioni specifiche dello schermo",
"accessibilityUsageMonitor": "• L'applicazione monitora quale finestra dell'app di allenamento è attiva per garantire che i gesti vengano inviati all'app corretta",
"accessibilityUsageNoData": "• Nessun dato personale viene raccolto o consultato tramite questo servizio",
"accessories": "Accessori",
"action": "Azione",
"adjustControllerButtons": "Regola i pulsanti del controller",
"afterDate": "Dopo il {date}",
"allow": "Consentire",
"allowAccessibilityService": "Consenti servizio di accessibilità",
"allowBluetoothConnections": "Consenti connessioni Bluetooth",
"allowBluetoothScan": "Consenti scansione Bluetooth",
"allowLocationForBluetooth": "Consentire in modo che la scansione Bluetooth funzioni",
"allowPersistentNotification": "Consenti notifiche",
"allowsRunningInBackground": "Consente a BikeControl di continuare a funzionare in background",
"alreadyBoughtTheApp": "Hai già acquistato l'app? Non devi più pagare per BikeControl. A causa di problemi tecnici, non è possibile determinare se l'app è già stata acquistata.\n\nInserisci il tuo ID di acquisto sul Play Store (ad esempio GPA.3356-1337-1338-1339) per sbloccare la versione completa. Se non riesci a trovarlo, contattami direttamente.",
"alreadyBoughtTheAppPreviously": "Hai già acquistato l'app in precedenza?",
"appIdActions": "{appId} azioni",
"@appIdActions": {
"placeholders": {
"appId": {
"type": "String"
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "Come ultimo passaggio sceglierai come connetterti a {trainerApp} .",
"battery": "Batteria",
"beforeDate": "Prima del {date}",
"bluetoothAdvertiseAccess": "Accesso pubblicitario Bluetooth",
"bluetoothTurnedOn": "Bluetooth attivato",
"browserNotSupported": "Questo browser non supporta il Web Bluetooth e la piattaforma non è supportata :(",
"button": "pulsante.",
"cancel": "Cancella",
"changelog": "Registro delle modifiche",
"checkMyWhooshConnectionScreen": "Controlla la schermata di connessione in MyWhoosh per vedere se è connesso.",
"chooseAnotherScreenshot": "Scegli un altro screenshot",
"chooseBikeControlInConnectionScreen": "Selezionare BikeControl nella schermata di connessione.",
"clickAButtonOnYourController": "Fai clic su un pulsante del controller per modificarne l'azione oppure tocca l'icona di modifica.",
"clickV2EventInfo": "Il tuo Click V2 potrebbe non inviare più eventi tramite i pulsanti. Verifica toccando alcuni pulsanti e verifica se sono visibili in BikeControl.",
"clickV2Instructions": "Per far funzionare al meglio il tuo Zwift Click V2, dovresti collegarlo all'app Zwift prima di ogni sessione di allenamento. In caso contrario, il Click V2 smetterà di funzionare dopo un minuto. \n1. Apri l'app Zwift \n2. Accedi (non richiesto un abbonamento attivo) e apri la schermata di connessione del dispositivo \n3. Collega il tuo trainer, quindi collega lo Zwift Click V2, verifica che i comandi del controller funzionino \n4. Chiudi nuovamente l'app Zwift e riconnettiti a BikeControl",
"close": "Chiudi",
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} comandi rimanenti oggi",
"configuration": "Configurazione",
"connectControllerToPreview": "Collega un dispositivo controller per visualizzare in anteprima e personalizzare la mappa dei tasti.",
"connectControllers": "Connetti i controller",
"connectDirectlyOverNetwork": "Connettiti direttamente tramite la rete",
"connectToTrainerApp": "Connettiti all'app di allenamento",
"connectUsingBluetooth": "Connettiti tramite Bluetooth",
"connectUsingMyWhooshLink": "Connettiti tramite MyWhoosh \"Link\"",
"connected": "Connesso",
"connectedControllers": "Controller connessi",
"connectedTo": "Connesso a{appId}",
"@connectedTo": {
"placeholders": {
"appId": {
"type": "String"
}
}
},
"connection": "Connessione",
"continueAction": "Continua",
"controlAppUsingModes": "Controllare{appName} usando{modes}",
"@controlAppUsingModes": {
"placeholders": {
"appName": {
"type": "String"
},
"modes": {
"type": "String"
}
}
},
"controllerConnectedClickButton": "Ottimo! Il tuo controller è connesso. Clicca su un pulsante del controller per continuare.",
"controllers": "Controller",
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Non è stato possibile eseguire{button} : Nessuna mappa dei tasti impostata",
"create": "Crea",
"createNewKeymap": "Crea una nuova mappa dei tasti",
"createNewProfileByDuplicating": "Crea un nuovo profilo personalizzato duplicando \"{profileName}\"",
"@createNewProfileByDuplicating": {
"placeholders": {
"profileName": {
"type": "String"
}
}
},
"createdNewCustomProfile": "Creato un nuovo profilo personalizzato:{profileName}",
"@createdNewCustomProfile": {
"placeholders": {
"profileName": {
"type": "String"
}
}
},
"customizeControllerButtons": "Personalizza i pulsanti del controller per{appName}",
"@customizeControllerButtons": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"customizeKeymapHint": "Personalizza la mappa dei tasti, se riscontri problemi (ad esempio, output della tastiera errato o posizionamenti del tocco non allineati)",
"dailyCommandLimitReachedNotification": "Limite giornaliero di comandi raggiunto per oggi. Esegui l'upgrade per sbloccare la versione completa con comandi illimitati.",
"dailyLimitReached": "Limite giornaliero raggiunto ({dailyCommandCount} /{dailyCommandLimit} usato)",
"delete": "Cancella",
"deleteProfile": "Elimina profilo",
"deleteProfileConfirmation": "Sei sicuro di voler eliminare \"{profileName}\" ? Questa azione non può essere annullata.",
"@deleteProfileConfirmation": {
"placeholders": {
"profileName": {
"type": "String"
}
}
},
"deny": "Nega",
"deviceButton": "{deviceName}pulsante",
"@deviceButton": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"disconnectDevices": "Dispositivi disconnessi",
"disconnected": "Disconnesso",
"donateByBuyingFromPlayStore": "acquistando l'app dal Play Store",
"donateViaCreditCard": "tramite carta di credito, Google Pay, Apple Pay e altri",
"donateViaPaypal": "tramite PayPal",
"download": "Download",
"dragToReposition": "Trascina per riposizionare",
"duplicate": "Duplica",
"enableAutoRotation": "Abilita la rotazione automatica sul tuo dispositivo per assicurarti che l'app funzioni correttamente.",
"enableBluetooth": "Abilita Bluetooth",
"enableKeyboardAccessMessage": "Abilita l'accesso tramite tastiera nella schermata seguente per BikeControl. Se BikeControl non è presente, aggiungilo manualmente.",
"enableKeyboardMouseControl": "BikeControl invierà azioni del mouse o della tastiera per controllare {appName} .",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"enableMediaKeyDetection": "Abilita rilevamento tasti multimediali",
"enablePairingProcess": "Abilita processo di associazione",
"enablePermissions": "Abilita autorizzazioni",
"enableSteeringWithPhone": "Abilita i sensori dei telefoni per abilitare ad esempio lo sterzo",
"enableVibrationFeedback": "Abilita il feedback delle vibrazioni durante il cambio marcia",
"enableZwiftControllerBluetooth": "Abilita il controller Zwift (Bluetooth)",
"enableZwiftControllerNetwork": "Abilita Zwift Controller (rete)",
"errorStartingMyWhooshLink": "Errore durante l'avvio del server MyWhoosh Link. Assicurati che l'app \"MyWhoosh Link\" non sia già in esecuzione su questo dispositivo.",
"errorStartingOpenBikeControlBluetoothServer": "Errore durante l'avvio del server Bluetooth OpenBikeControl.",
"errorStartingOpenBikeControlServer": "Errore durante l'avvio del server OpenBikeControl.",
"exportAction": "Esporta",
"failedToImportProfile": "Impossibile importare il profilo. Formato non valido.",
"failedToUpdate": "Aggiornamento non riuscito:{error}",
"@failedToUpdate": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"firmware": "Firmware",
"forceCloseToUpdate": "Forza la chiusura dell'app per utilizzare la nuova versione",
"fullVersion": "Versione completa",
"fullVersionDescription": "La versione completa include: \n- Comandi illimitati al giorno \n- Accesso a tutti gli aggiornamenti futuri \n- Nessun abbonamento! Solo un canone una tantum :)",
"getSupport": "Ottieni supporto",
"gotIt": "Fatto!",
"grant": "Concedi",
"granted": "Concesso",
"helpRequested": "Aiuto richiesto per BikeControl v{version}",
"@helpRequested": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"howBikeControlUsesPermission": "In che modo BikeControl utilizza questa autorizzazione?",
"ignoredDevices": "Dispositivi ignorati",
"importAction": "Importa",
"importProfile": "Importa profilo",
"instructions": "Istruzioni",
"jsonData": "Dati JSON",
"keyboardAccess": "Accesso tramite tastiera",
"latestVersion": "ultima:{version}",
"@latestVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"leaveAReview": "Lascia una recensione",
"letsAppConnectOverBluetooth": "Lascia che {appName} si connetta a BikeControl tramite Bluetooth.",
"@letsAppConnectOverBluetooth": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"letsAppConnectOverNetwork": "Lascia che {appName} si connetta direttamente tramite la rete. Seleziona BikeControl nella schermata di connessione.",
"@letsAppConnectOverNetwork": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"letsGetYouSetUp": "Ti aiuteremo a prepararti!",
"license": "Licenza",
"licenseStatus": "Stato della licenza",
"loadScreenshotForPlacement": "Carica lo screenshot del gioco per il posizionamento",
"logViewer": "Visualizzatore di registri",
"logs": "Registri",
"logsAreAlsoAt": "I registri sono anche a",
"logsHaveBeenCopiedToClipboard": "I registri sono stati copiati negli appunti",
"longPress": "pressione prolungata",
"longPressMode": "Modalità pressione prolungata (rispetto alla ripetizione)",
"mailSupportExplanation": "Fornire supporto individuale via email è un impegno notevole per me. \n\nVi invito a utilizzare Reddit, Facebook o GitHub per domande e problemi, in modo che l'intera comunità possa trarne beneficio.",
"manageIgnoredDevices": "Gestisci dispositivi ignorati",
"manageProfile": "Gestisci profilo",
"manualyControllingButton": "Controlla {trainerApp} manualmente!",
"mediaKeyDetectionTooltip": "Abilita questa opzione per consentire a BikeControl di rilevare i telecomandi Bluetooth. \nPer fare ciò, BikeControl deve funzionare come lettore multimediale.",
"miuiDeviceDetected": "Dispositivo MIUI rilevato",
"miuiDisableBatteryOptimization": "• Disattivare l'ottimizzazione della batteria per BikeControl",
"miuiEnableAutostart": "• Abilita l'avvio automatico per BikeControl",
"miuiEnsureProperWorking": "Per garantire il corretto funzionamento di BikeControl:",
"miuiLockInRecentApps": "• Blocca l'app nelle app recenti",
"miuiWarningDescription": "Il tuo dispositivo utilizza MIUI, che è noto per disattivare in modo aggressivo i servizi in background e i servizi di accessibilità.",
"moreInformation": "Ulteriori informazioni",
"mustChooseAllowOrDeny": "Per continuare, devi scegliere se consentire o negare questa autorizzazione.",
"myWhooshDirectConnectAction": "Azione MyWhoosh \"Link\"",
"myWhooshDirectConnection": " ad esempio utilizzando MyWhoosh \"Link\"",
"myWhooshLinkConnected": "MyWhoosh \"Link\" connesso",
"myWhooshLinkDescriptionLocal": "Connettiti direttamente a MyWhoosh tramite il metodo \"Link\". Controlla le istruzioni per assicurarti che la connessione sia possibile. L'app \"MyWhoosh Link\" non deve essere attiva contemporaneamente.",
"myWhooshLinkInfo": "In caso di problemi, consulta la sezione relativa alla risoluzione dei problemi. Presto sarà disponibile un metodo di connessione molto più affidabile!",
"needHelpClickHelp": "Hai bisogno di aiuto? Clicca sul",
"needHelpDontHesitate": "pulsante in alto e non esitare a contattarci.",
"newConnectionMethodAnnouncement": "{trainerApp}supporterà presto metodi di connessione molto migliori e affidabili: restate sintonizzati per gli aggiornamenti!",
"newCustomProfile": "Nuovo profilo personalizzato",
"newProfileName": "Nuovo nome del profilo",
"newVersionAvailable": "Nuova versione disponibile",
"newVersionAvailableWithVersion": "Nuova versione disponibile:{version}",
"@newVersionAvailableWithVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"next": "Successivo",
"no": "No",
"noActionAssigned": "Nessuna azione assegnata",
"noActionAssignedForButton": "Non è stato possibile eseguire{button} : Nessuna azione assegnata",
"noConnectionMethodIsConnectedOrActive": "Nessun metodo di connessione è connesso o attivo.",
"noConnectionMethodSelected": "Nessun metodo di connessione selezionato",
"noControllerConnected": "Nessuno connesso",
"noControllerUseCompanionMode": "Non hai un controller? Usa la Companion Mode",
"noIgnoredDevices": "Nessun dispositivo ignorato.",
"noTrainerSelected": "Nessun Trainer selezionato",
"notConnected": "Non connesso",
"notificationDescription": "In questo modo l'app rimane attiva in background e ti aggiorna quando cambia la connessione ai tuoi dispositivi.",
"ok": "Ok",
"openBikeControlActions": "Azioni OpenBikeControl",
"openBikeControlAnnouncement": "Ottime notizie -{trainerApp} supporta il protocollo OpenBikeControl, così avrai la migliore esperienza possibile!",
"openBikeControlConnection": " ad esempio utilizzando la connessione OpenBikeControl",
"otherConnectionMethods": "Altri metodi di connessione",
"pairingDescription": "L'associazione consente la personalizzazione completa, ma potrebbe non funzionare su tutti i dispositivi.",
"pairingInstructions": "Sul tuo{targetName} Accedi alle impostazioni Bluetooth e cerca BikeControl o il nome del tuo dispositivo. L'associazione è necessaria se vuoi utilizzare la funzione di controllo remoto.",
"@pairingInstructions": {
"placeholders": {
"targetName": {
"type": "String"
}
}
},
"pairingInstructionsIOS": "Sul tuo iPad, vai su Impostazioni > Accessibilità > Tocco > Assistenza Tocco > Dispositivi di puntamento > Dispositivi e associa il tuo dispositivo. Assicurati che Assistenza Tocco sia abilitato.",
"pasteExportedJsonData": "Incolla i dati JSON esportati qui sotto:",
"pathCopiedToClipboard": "Il percorso è stato copiato negli appunti",
"permissionsRequired": "Per consentire a BikeControl di cercare i dispositivi nelle vicinanze e di aggiornarti quando cambia la connessione, abilita le seguenti autorizzazioni:",
"platformNotSupported": "Questo{platform} non è supportato :(",
"@platformNotSupported": {
"placeholders": {
"platform": {
"type": "String"
}
}
},
"platformRestrictionNotSupported": "A causa delle restrizioni della piattaforma, questo scenario non è supportato.",
"platformRestrictionOtherDevicesOnly": "A causa delle restrizioni della piattaforma, solo il controllo{appName} su altri dispositivi è supportato.",
"@platformRestrictionOtherDevicesOnly": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"playPause": "Riproduci/Pausa",
"pleaseSelectAConnectionMethodFirst": "Per prima cosa seleziona un metodo di connessione nelle impostazioni del Trainer.",
"predefinedAction": "Predefinito{appName} azione",
"@predefinedAction": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"pressButtonOnClickDevice": "Premi un pulsante sul tuo dispositivo Click",
"pressKeyToAssign": "Premi un tasto sulla tastiera per assegnarlo a{buttonName}",
"@pressKeyToAssign": {
"placeholders": {
"buttonName": {
"type": "String"
}
}
},
"previous": "Precedente",
"profileExportedToClipboard": "Profilo \"{profileName}\" esportato negli appunti",
"@profileExportedToClipboard": {
"placeholders": {
"profileName": {
"type": "String"
}
}
},
"profileImportedSuccessfully": "Profilo importato con successo",
"profileName": "Nome del profilo",
"purchase": "Acquista",
"recommendedConnectionMethods": "Metodi di connessione consigliati",
"removeFromIgnoredList": "Rimuovi dall'elenco degli ignorati",
"rename": "Rinomina",
"renameProfile": "Rinomina profilo",
"requirement": "Requisiti",
"reset": "Reset",
"restart": "Riavvia",
"restorePurchaseInfo": "Clicca sul pulsante qui sopra, poi su \"Ripristina acquisto\". In caso di problemi, contattami direttamente.",
"runAppOnPlatformRemotely": "Avvia {appName} su{platform} e controllarlo da remoto da questo dispositivo{preferredConnection} .",
"@runAppOnPlatformRemotely": {
"placeholders": {
"appName": {
"type": "String"
},
"platform": {
"type": "String"
},
"preferredConnection": {
"type": "String"
}
}
},
"runAppOnThisDevice": "Avvia {appName} su questo dispositivo.",
"@runAppOnThisDevice": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"save": "Salva",
"scan": "Scansione",
"scanningForDevices": "Ricerca dispositivi in corso... Assicurarsi che siano accesi e nel raggio d'azione e che non siano connessi a un altro dispositivo.",
"selectKeymap": "Seleziona la mappa dei tasti",
"selectTargetWhereAppRuns": "Seleziona la destinazione dove{appName} è in esecuzione",
"@selectTargetWhereAppRuns": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"selectTrainerApp": "Seleziona l'app Trainer",
"selectTrainerAppAndTarget": "Seleziona l'app Trainer e il dispositivo di destinazione",
"selectTrainerAppPlaceholder": "Seleziona l'app Trainer",
"setting": "Impostazioni",
"setupComplete": "Installazione completata!",
"setupTrainer": "Impostazioni Trainer",
"share": "Condividi",
"showDonation": "Mostra il tuo apprezzamento donando",
"showSupportedControllers": "Mostra controller supportati",
"signal": "Segnale",
"simulateButtons": "Controlli del Trainer",
"simulateKeyboardShortcut": "Simula scorciatoia da tastiera",
"simulateMediaKey": "Simula il tasto multimediale",
"simulateTouch": "Simula il tocco",
"skip": "Saltare",
"stop": "Stop",
"supportedActions": "Azioni supportate",
"targetOtherDevice": "Altro dispositivo",
"targetThisDevice": "Questo dispositivo",
"theFollowingPermissionsRequired": "Sono richieste le seguenti autorizzazioni:",
"touchAreaInstructions": "1. Crea uno screenshot in-game della tua app (ad esempio all'interno di MyWhoosh) in orientamento orizzontale \n2. Carica lo screenshot con il pulsante qui sotto \n3. L'app viene automaticamente impostata in orientamento orizzontale per una mappatura accurata \n4. Premi un pulsante sul tuo dispositivo Click per creare un'area touch \n5. Trascina le aree touch nella posizione desiderata sullo screenshot \n6. Salva e chiudi questa schermata",
"touchSimulationForegroundMessage": "Per simulare i tocchi, l'app deve rimanere in primo piano.",
"trainer": "Trainer",
"trialDaysRemaining": "{trialDaysRemaining}giorni rimanenti",
"trialExpired": "Prova scaduta. Comandi limitati a{dailyCommandLimit} al giorno.",
"trialPeriodActive": "Periodo di prova attivo -{trialDaysRemaining} giorni rimanenti",
"trialPeriodDescription": "Goditi comandi illimitati durante il periodo di prova. Dopo la prova, i comandi saranno limitati a{dailyCommandLimit} al giorno.",
"troubleshootingGuide": "Domande e risposte",
"tryingToConnectAgain": "Sto provando a connettermi di nuovo...",
"unassignAction": "Annulla assegnazione azione",
"unlockFullVersion": "Sblocca la versione completa",
"unlockingNotPossible": "Al momento non è ancora possibile sbloccarlo, quindi per il momento goditi un utilizzo illimitato!",
"update": "Aggiorna",
"useCustomKeymapForButton": "Utilizzare una mappa dei tasti personalizzata per supportare",
"version": "Versione{version}",
"@version": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"viewDetailedInstructions": "Visualizza le istruzioni dettagliate",
"volumeDown": "Abbassa il volume",
"volumeUp": "Aumenta il volume",
"waiting": "In attesa...",
"waitingForConnectionKickrBike": "In attesa di connessione. Scegli KICKR BIKE PRO in{appName} menu di associazione del controller.",
"@waitingForConnectionKickrBike": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"whatsNew": "Novità",
"whyPermissionNeeded": "Perché è necessaria questa autorizzazione?",
"yes": "SÌ",
"zwiftControllerAction": "Azione del controller Zwift",
"zwiftControllerDescription": "Consente a BikeControl di funzionare come controller compatibile con Zwift."
}

440
lib/i10n/intl_pl.arb Normal file
View File

@@ -0,0 +1,440 @@
{
"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, TrainingPeaks 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",
"afterDate": "Po {date}",
"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",
"alreadyBoughtTheApp": "Kupiłeś już aplikację? Nie musisz płacić za BikeControl. Z powodu problemów technicznych nie można ustalić, czy aplikacja została już zakupiona. \n\nWprowadź swój identyfikator zakupu w Sklepie Play (np. GPA.3356-1337-1338-1339), aby odblokować pełną wersję. Jeśli nie możesz jej znaleźć, skontaktuj się ze mną bezpośrednio.",
"alreadyBoughtTheAppPreviously": "Już wcześniej kupiłeś aplikację?",
"appIdActions": "{appId} działania",
"@appIdActions": {
"placeholders": {
"appId": {
"type": "String"
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "W ostatnim kroku wybierzesz sposób połączenia {trainerApp} .",
"battery": "Bateria",
"beforeDate": "Zanim {date}",
"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": "Dziennik zmian",
"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"
}
}
},
"controllerConnectedClickButton": "Świetnie! Twój kontroler jest połączony. Kliknij przycisk na kontrolerze, aby kontynuować.",
"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": "BikeControl będzie wysyłał akcje myszy lub klawiatury do kontrolera {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"
}
}
},
"letsGetYouSetUp": "Przygotujmy Cię!",
"license": "Licencja",
"licenseStatus": "Status licencji",
"loadScreenshotForPlacement": "Prześlij zrzut ekranu z gry w celu ustalenia miejsca",
"logViewer": "Podgląd 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ą metody „Link”. Sprawdź instrukcje, aby upewnić się, że połączenie jest możliwe. Aplikacja „MyWhoosh Link” nie może być jednocześnie aktywna.",
"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 skontaktuj się z nami.",
"newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będzie wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądź 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",
"no": "Nie",
"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 trenażera",
"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 trenażera.",
"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",
"restorePurchaseInfo": "Kliknij przycisk powyżej, a następnie „Przywróć zakup”. W razie jakichkolwiek problemów skontaktuj się ze mną bezpośrednio.",
"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",
"setupComplete": "Konfiguracja ukończona!",
"setupTrainer": "Konfiguracja trenażera",
"share": "Udostępnij",
"showDonation": "Wyraź swoją wdzięczność poprzez darowiznę",
"showSupportedControllers": "Pokaż wspierane kontrolery",
"signal": "Sygnał",
"simulateButtons": "Sterowanie trenażerem",
"simulateKeyboardShortcut": "Symuluj skrót klawiaturowy",
"simulateMediaKey": "Symuluj przycisk multimedialny",
"simulateTouch": "Symuluj dotyk",
"skip": "Pominąć",
"stop": "Stop",
"supportedActions": "Obsługiwane działania",
"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": "Pytania i odpowiedzi",
"tryingToConnectAgain": "Próba ponownego połączenia...",
"unassignAction": "Anuluj przypisanie akcji",
"unlockFullVersion": "Odblokuj pełną wersję",
"unlockingNotPossible": "Odblokowanie nie jest obecnie możliwe, więc ciesz się nieograniczonym użytkowaniem!",
"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?",
"yes": "Tak",
"zwiftControllerAction": "Akcja kontrolera Zwift",
"zwiftControllerDescription": "Umożliwia BikeControl działanie jako kontroler kompatybilny ze Zwift."
}

View File

@@ -2,7 +2,9 @@ import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/pages/onboarding.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';
@@ -10,8 +12,6 @@ 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_localizations/flutter_localizations.dart'
show GlobalMaterialLocalizations, GlobalWidgetsLocalizations;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'pages/navigation.dart';
@@ -104,7 +104,7 @@ Future<void> _persistCrash({
..writeln('Error: $error')
..writeln('Stack: ${stack ?? 'no stack'}')
..writeln('Info: ${information ?? ''}')
..writeln(debugText())
..writeln(await debugText())
..writeln()
..writeln();
@@ -120,7 +120,7 @@ Future<void> _persistCrash({
}
await file.writeAsString(crashData.toString(), mode: FileMode.append);
core.connection.lastLogEntries.add((date: DateTime.now(), entry: 'App crashed: $error'));
core.connection.signalNotification(LogNotification('App crashed: $error'));
} catch (_) {
// Avoid throwing from the crash logger
}
@@ -164,10 +164,18 @@ void initializeActions(ConnectionType connectionType) {
core.actionHandler.init(core.settings.getKeyMap());
}
class BikeControlApp extends StatelessWidget {
class BikeControlApp extends StatefulWidget {
final Widget? customChild;
final BCPage page;
final String? error;
const BikeControlApp({super.key, this.error, this.page = BCPage.devices});
const BikeControlApp({super.key, this.error, this.page = BCPage.devices, this.customChild});
@override
State<BikeControlApp> createState() => _BikeControlAppState();
}
class _BikeControlAppState extends State<BikeControlApp> {
BCPage? _showPage;
@override
Widget build(BuildContext context) {
@@ -178,8 +186,8 @@ class BikeControlApp extends StatelessWidget {
menuHandler: PopoverOverlayHandler(),
popoverHandler: PopoverOverlayHandler(),
localizationsDelegates: [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
...ShadcnLocalizations.localizationsDelegates,
OtherLocalizationsDelegate(),
AppLocalizations.delegate,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
@@ -197,23 +205,75 @@ class BikeControlApp extends StatelessWidget {
),
),
//themeMode: ThemeMode.dark,
home: error != null
home: widget.error != null
? Center(
child: Text(
'There was an error starting the App. Please contact support:\n$error',
'There was an error starting the App. Please contact support:\n${widget.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: [
Navigation(page: page),
Positioned.fill(child: Testbed()),
],
child: _Starter(
child: Stack(
children: [
widget.customChild ??
(AnimatedSwitcher(
duration: Duration(milliseconds: 600),
child: core.settings.getShowOnboarding()
? OnboardingPage(
onComplete: () {
setState(() {
_showPage = BCPage.trainer;
});
},
)
: Navigation(page: _showPage ?? widget.page),
)),
Positioned.fill(child: Testbed()),
],
),
),
),
);
}
}
class _Starter extends StatefulWidget {
final Widget child;
const _Starter({super.key, required this.child});
@override
State<_Starter> createState() => _StarterState();
}
class _StarterState extends State<_Starter> {
@override
void initState() {
super.initState();
core.connection.initialize();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class OtherLocalizationsDelegate extends LocalizationsDelegate<ShadcnLocalizations> {
const OtherLocalizationsDelegate();
@override
bool isSupported(Locale locale) =>
AppLocalizations.delegate.supportedLocales.map((e) => e.languageCode).contains(locale.languageCode);
@override
Future<ShadcnLocalizations> load(Locale locale) async {
return SynchronousFuture<ShadcnLocalizations>(lookupShadcnLocalizations(Locale('en')));
}
@override
bool shouldReload(covariant LocalizationsDelegate<ShadcnLocalizations> old) => false;
}

View File

@@ -1,5 +1,9 @@
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';
@@ -16,26 +20,69 @@ 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});
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 keyPair = widget.keyPair;
final trainerApp = core.settings.getTrainerApp();
final actionsWithInGameAction = trainerApp?.keymap.keyPairs
@@ -53,7 +100,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
padding: const EdgeInsets.only(right: 26.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
spacing: 8,
children: [
SizedBox(height: 16),
Row(
@@ -63,7 +110,15 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
spacing: 8,
children: [
Text('Editing').h3,
ButtonWidget(button: widget.keyPair.buttons.first),
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),
@@ -85,137 +140,29 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
),
if (core.logic.showObpActions) ...[
ColoredTitle(text: context.i18n.openBikeControlActions),
Builder(
builder: (context) => SelectableCard(
icon: Icons.link,
title: Text(
core.logic.obpConnectedApp == null
? 'Please connect to ${core.settings.getTrainerApp()?.name}, first.'
: context.i18n.appIdActions(core.logic.obpConnectedApp!.appId),
),
isActive: core.logic.obpConnectedApp != null && keyPair.inGameAction != null,
onPressed: core.logic.obpConnectedApp == null
? null
: () {
showDropdown(
builder: (c) => DropdownMenu(
children: core.logic.obpConnectedApp!.supportedActions
.map(
(action) => MenuButton(
child: Text(action.name),
onPressed: (_) {
keyPair.touchPosition = Offset.zero;
keyPair.physicalKey = null;
keyPair.logicalKey = null;
keyPair.inGameAction = action;
keyPair.inGameActionValue = null;
widget.onUpdate();
setState(() {});
},
),
)
.toList(),
),
context: context,
);
},
),
),
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),
Builder(
builder: (context) => SelectableCard(
icon: Icons.link,
title: Text(context.i18n.myWhooshDirectConnectAction),
isActive:
keyPair.inGameAction != null &&
core.whooshLink.supportedActions.contains(keyPair.inGameAction),
value: [keyPair.inGameAction.toString(), ?keyPair.inGameActionValue?.toString()].join(' '),
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: core.whooshLink.supportedActions.map(
(ingame) {
return MenuButton(
subMenu: ingame.possibleValues
?.map(
(value) => MenuButton(
child: Text(value.toString()),
onPressed: (_) {
keyPair.inGameAction = ingame;
keyPair.inGameActionValue = value;
widget.onUpdate();
setState(() {});
},
),
)
.toList(),
child: Text(ingame.toString()),
onPressed: (_) {
keyPair.inGameAction = ingame;
keyPair.inGameActionValue = null;
widget.onUpdate();
setState(() {});
},
);
},
).toList(),
),
);
},
),
),
..._buildTrainerConnectionActions(core.whooshLink.supportedActions),
],
if (core.logic.isZwiftBleEnabled || core.logic.isZwiftMdnsEnabled) ...[
SizedBox(height: 8),
ColoredTitle(text: context.i18n.zwiftControllerAction),
Builder(
builder: (context) => SelectableCard(
icon: Icons.link,
title: Text(context.i18n.zwiftControllerAction),
isActive:
keyPair.inGameAction != null &&
core.zwiftEmulator.supportedActions.contains(keyPair.inGameAction),
value: [keyPair.inGameAction.toString(), ?keyPair.inGameActionValue?.toString()].join(' '),
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: core.zwiftEmulator.supportedActions.map(
(ingame) {
return MenuButton(
subMenu: ingame.possibleValues
?.map(
(value) => MenuButton(
child: Text(value.toString()),
onPressed: (_) {
keyPair.inGameAction = ingame;
keyPair.inGameActionValue = value;
widget.onUpdate();
setState(() {});
},
),
)
.toList(),
child: Text(ingame.toString()),
onPressed: (_) {
keyPair.inGameAction = ingame;
keyPair.inGameActionValue = null;
widget.onUpdate();
setState(() {});
},
);
},
).toList(),
),
);
},
),
),
..._buildTrainerConnectionActions(core.zwiftEmulator.supportedActions),
],
if (core.logic.showLocalRemoteOptions) ...[
@@ -233,38 +180,31 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
builder: (c) => DropdownMenu(
children: actionsWithInGameAction!.map((keyPairAction) {
return MenuButton(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(_formatActionDescription(keyPairAction).split(' = ').first),
Text(
_formatActionDescription(keyPairAction).split(' = ').last,
style: TextStyle(fontSize: 12, color: Colors.gray),
),
],
),
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);
_keyPair.physicalKey = keyPairAction.physicalKey;
_keyPair.logicalKey = keyPairAction.logicalKey;
_keyPair.modifiers = List.of(keyPairAction.modifiers);
} else {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
keyPair.modifiers = [];
_keyPair.physicalKey = null;
_keyPair.logicalKey = null;
_keyPair.modifiers = [];
}
if (core.actionHandler.supportedModes.contains(SupportedMode.touch)) {
keyPair.touchPosition = keyPairAction.touchPosition;
_keyPair.touchPosition = keyPairAction.touchPosition;
} else {
keyPair.touchPosition = Offset.zero;
_keyPair.touchPosition = Offset.zero;
}
keyPair.isLongPress = keyPairAction.isLongPress;
keyPair.inGameAction = keyPairAction.inGameAction;
keyPair.inGameActionValue = keyPairAction.inGameActionValue;
_keyPair.isLongPress = keyPairAction.isLongPress;
_keyPair.inGameAction = keyPairAction.inGameAction;
_keyPair.inGameActionValue = keyPairAction.inGameActionValue;
setState(() {});
},
child: Text(keyPairAction.toString()),
);
}).toList(),
),
@@ -275,17 +215,17 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
],
if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard))
SelectableCard(
icon: Icons.keyboard_alt_outlined,
icon: RadixIcons.keyboard,
title: Text(context.i18n.simulateKeyboardShortcut),
isActive: keyPair.physicalKey != null && !keyPair.isSpecialKey,
value: 'Key: $keyPair',
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,
keyPair: _keyPair,
),
);
setState(() {});
@@ -295,20 +235,19 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
if (core.actionHandler.supportedModes.contains(SupportedMode.touch))
SelectableCard(
title: Text(context.i18n.simulateTouch),
icon: Icons.touch_app_outlined,
isActive: keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero,
value:
'Coordinates: X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}',
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);
if (_keyPair.touchPosition == Offset.zero) {
_keyPair.touchPosition = Offset(50, 50);
}
keyPair.physicalKey = null;
keyPair.logicalKey = null;
_keyPair.physicalKey = null;
_keyPair.logicalKey = null;
await Navigator.of(context).push<bool?>(
MaterialPageRoute(
builder: (c) => TouchAreaSetupPage(
keyPair: keyPair,
keyPair: _keyPair,
),
),
);
@@ -321,59 +260,63 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
Builder(
builder: (context) => SelectableCard(
icon: Icons.music_note_outlined,
isActive: keyPair.isSpecialKey,
isActive: _keyPair.isSpecialKey,
title: Text(context.i18n.simulateMediaKey),
value: keyPair.toString(),
value: _keyPair.toString(),
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: [
MenuButton(
leading: Icon(Icons.play_arrow_outlined),
onPressed: (c) {
_keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
_keyPair.logicalKey = null;
setState(() {});
widget.onUpdate();
},
child: Text(context.i18n.playPause),
),
MenuButton(
leading: Icon(Icons.stop_outlined),
onPressed: (c) {
keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
keyPair.logicalKey = null;
_keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
_keyPair.logicalKey = null;
setState(() {});
widget.onUpdate();
},
),
MenuButton(
child: Text(context.i18n.stop),
),
MenuButton(
leading: Icon(Icons.skip_previous_outlined),
onPressed: (c) {
keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
keyPair.logicalKey = null;
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
_keyPair.logicalKey = null;
setState(() {});
widget.onUpdate();
},
),
MenuButton(
child: Text(context.i18n.previous),
),
MenuButton(
leading: Icon(Icons.skip_next_outlined),
onPressed: (c) {
keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
keyPair.logicalKey = null;
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext;
_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(
leading: Icon(Icons.volume_up_outlined),
onPressed: (c) {
keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
keyPair.logicalKey = null;
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
_keyPair.logicalKey = null;
setState(() {});
widget.onUpdate();
@@ -381,10 +324,11 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
child: Text(context.i18n.volumeUp),
),
MenuButton(
leading: Icon(Icons.volume_down_outlined),
child: Text(context.i18n.volumeDown),
onPressed: (c) {
keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
keyPair.logicalKey = null;
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
_keyPair.logicalKey = null;
setState(() {});
widget.onUpdate();
@@ -406,11 +350,11 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
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()
_keyPair.inGameAction != null &&
(_keyPair.inGameAction == InGameAction.headwindSpeed ||
_keyPair.inGameAction == InGameAction.headwindHeartRateMode),
value: _keyPair.inGameAction != null
? '${_keyPair.inGameAction} ${_keyPair.inGameActionValue ?? ""}'.trim()
: null,
onPressed: () {
showDropdown(
@@ -423,8 +367,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
(value) => MenuButton(
child: Text('Set Speed to $value%'),
onPressed: (_) {
keyPair.inGameAction = InGameAction.headwindSpeed;
keyPair.inGameActionValue = value;
_keyPair.inGameAction = InGameAction.headwindSpeed;
_keyPair.inGameActionValue = value;
widget.onUpdate();
setState(() {});
},
@@ -436,8 +380,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
MenuButton(
child: Text('Set to Heart Rate Mode'),
onPressed: (_) {
keyPair.inGameAction = InGameAction.headwindHeartRateMode;
keyPair.inGameActionValue = null;
_keyPair.inGameAction = InGameAction.headwindHeartRateMode;
_keyPair.inGameActionValue = null;
widget.onUpdate();
setState(() {});
},
@@ -453,11 +397,11 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
SizedBox(height: 8),
ColoredTitle(text: context.i18n.setting),
SelectableCard(
icon: keyPair.isLongPress ? Icons.check_box : Icons.check_box_outline_blank,
icon: _keyPair.isLongPress ? Icons.check_box : Icons.check_box_outline_blank,
title: Text(context.i18n.longPressMode),
isActive: keyPair.isLongPress,
isActive: _keyPair.isLongPress,
onPressed: () {
keyPair.isLongPress = !keyPair.isLongPress;
_keyPair.isLongPress = !_keyPair.isLongPress;
widget.onUpdate();
setState(() {});
},
@@ -465,13 +409,13 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
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;
_keyPair.isLongPress = false;
_keyPair.physicalKey = null;
_keyPair.logicalKey = null;
_keyPair.modifiers = [];
_keyPair.touchPosition = Offset.zero;
_keyPair.inGameAction = null;
_keyPair.inGameActionValue = null;
widget.onUpdate();
setState(() {});
},
@@ -486,33 +430,56 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
);
}
String _formatActionDescription(KeyPair keyPairAction) {
final parts = <String>[];
if (keyPairAction.inGameAction != null) {
parts.add(keyPairAction.inGameAction!.toString());
if (keyPairAction.inGameActionValue != null) {
parts.add('(${keyPairAction.inGameActionValue})');
}
}
// Use KeyPair's toString() which formats the key with modifiers (e.g., "Ctrl+Alt+R")
final keyLabel = keyPairAction.toString();
if (keyLabel != 'Not assigned') {
parts.add('Key: $keyLabel');
}
if (keyPairAction.touchPosition != Offset.zero) {
parts.add(
'Touch: ${keyPairAction.touchPosition.dx.toInt()}, ${keyPairAction.touchPosition.dy.toInt()}',
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())
: action.alternativeTitle != null
? Text(action.alternativeTitle!)
: 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(() {});
}
},
);
},
);
}
if (keyPairAction.isLongPress) {
parts.add('[Long Press]');
}
return parts.isNotEmpty ? [parts.first, ' = ', parts.skip(1).join('')].join() : 'Action';
}).toList();
}
}
@@ -557,7 +524,7 @@ class SelectableCard extends StatelessWidget {
hoverColor: Theme.of(context).colorScheme.card,
),
onPressed: onPressed,
alignment: Alignment.centerLeft,
alignment: Alignment.topLeft,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@@ -1,12 +1,28 @@
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';
@@ -65,7 +81,12 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
'm',
];
static const Duration _keyPressDuration = Duration(milliseconds: 100);
static const Duration _keyPressDuration = Duration(milliseconds: 200);
InGameAction? _pressedAction;
DateTime? _lastDown;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
@@ -78,6 +99,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
@override
void dispose() {
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@@ -86,7 +108,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
// If no saved hotkeys, initialize with defaults
if (savedHotkeys.isEmpty) {
final connectedTrainers = core.logic.connectedTrainerConnections;
final connectedTrainers = core.logic.enabledTrainerConnections;
final allActions = <InGameAction>[];
for (final connection in connectedTrainers) {
@@ -126,6 +148,9 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
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));
@@ -137,11 +162,17 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
_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;
@@ -149,7 +180,9 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
@override
Widget build(BuildContext context) {
final connectedTrainers = core.logic.connectedTrainerConnections;
final connectedTrainers = core.logic.enabledTrainerConnections;
final isMobile = MediaQuery.sizeOf(context).width < 600;
return Focus(
focusNode: _focusNode,
@@ -169,7 +202,9 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
),
],
child: Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -178,9 +213,28 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
if (connectedTrainers.isEmpty)
Warning(
children: [
Text('No connected trainers found. Connect a trainer to simulate button presses.'),
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;
@@ -188,58 +242,93 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
final actionGroups = {
if (supportedActions.contains(InGameAction.shiftUp) &&
supportedActions.contains(InGameAction.shiftDown))
'Shifting': [InGameAction.shiftUp, InGameAction.shiftDown],
'Shifting': [InGameAction.shiftDown, InGameAction.shiftUp],
'Other': supportedActions
.where((action) => action != InGameAction.shiftUp && action != InGameAction.shiftDown)
.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,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
for (final group in actionGroups.entries) ...[
Text(group.key).bold,
Wrap(
spacing: 12,
runSpacing: 12,
children: group.value.map(
(action) {
final hotkey = _hotkeys[action];
return PrimaryButton(
size: ButtonSize(1.6),
leading: hotkey != null
? KeyWidget(
label: hotkey.toUpperCase(),
)
: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(action.title),
if (action.alternativeTitle != null)
Text(
action.alternativeTitle!,
style: TextStyle(fontSize: 12, color: Colors.gray),
),
],
),
onPressed: () {},
onTapDown: (c) async {
_sendKey(context, down: true, action: action, connection: connection);
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,
),
),
],
),
);
},
onTapUp: (c) async {
_sendKey(context, down: false, action: action, connection: connection);
).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),
).toList(),
),
SizedBox(height: 12),
],
],
],
),
),
];
},
@@ -295,12 +384,70 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
);
}
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(
@@ -330,6 +477,16 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
);
return;
} else {
if (!down && _lastDown != null && action.isLongPress) {
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: [],
@@ -340,6 +497,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
isKeyDown: down,
isKeyUp: !down,
);
await IAPManager.instance.incrementCommandCount();
if (result is! Success) {
buildToast(context, title: result.message);
}

View File

@@ -1,5 +1,7 @@
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';
@@ -14,8 +16,9 @@ import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class ConfigurationPage extends StatefulWidget {
final bool onboardingMode;
final VoidCallback onUpdate;
const ConfigurationPage({super.key, required this.onUpdate});
const ConfigurationPage({super.key, required this.onUpdate, this.onboardingMode = false});
@override
State<ConfigurationPage> createState() => _ConfigurationPageState();
@@ -26,24 +29,10 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
Widget build(BuildContext context) {
return Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(text: '${context.i18n.needHelpClickHelp} '),
WidgetSpan(
child: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Icon(Icons.help_outline),
),
),
TextSpan(text: ' ${context.i18n.needHelpDontHesitate}'),
],
),
).small.muted,
SizedBox(height: 4),
ColoredTitle(text: context.i18n.setupTrainer),
Card(
fillColor: Theme.of(context).colorScheme.background,
@@ -52,7 +41,6 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
borderColor: Theme.of(context).colorScheme.border,
child: Builder(
builder: (context) {
final isMobile = MediaQuery.sizeOf(context).width < 600;
return StatefulBuilder(
builder: (c, setState) => Column(
spacing: 8,
@@ -125,38 +113,44 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
},
),
if (core.settings.getTrainerApp() != null) ...[
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true)
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true &&
!screenshotMode &&
!widget.onboardingMode)
Text(
'Great news - ${core.settings.getTrainerApp()!.name} supports the OpenBikeControl Protocol, so you\'ll the best possible experience!',
AppLocalizations.of(context).openBikeControlAnnouncement(core.settings.getTrainerApp()!.name),
).xSmall,
SizedBox(height: 8),
SizedBox(height: 0),
Text(
context.i18n.selectTargetWhereAppRuns(
screenshotMode ? 'Trainer app' : core.settings.getTrainerApp()?.name ?? 'the Trainer app',
),
).small,
Flex(
direction: isMobile ? Axis.vertical : Axis.horizontal,
Row(
spacing: 8,
children: [Target.thisDevice, Target.otherDevice]
.map(
(target) => SelectableCard(
title: Text(target.getTitle(context)),
icon: target.icon,
isActive: target == core.settings.getLastTarget(),
subtitle: !target.isCompatible
? Text(context.i18n.platformRestrictionNotSupported)
: null,
onPressed: !target.isCompatible
? null
: () async {
await _setTarget(context, target);
setState(() {});
widget.onUpdate();
},
(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();
},
),
),
)
.map((e) => !isMobile ? Expanded(child: e) : e)
.toList(),
),
],
@@ -172,6 +166,21 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
],
),
],
if (core.settings.getTrainerApp()?.star == true && !screenshotMode && !widget.onboardingMode)
Row(
spacing: 8,
children: [
Icon(Icons.star),
Expanded(
child: Text(
AppLocalizations.of(
context,
).newConnectionMethodAnnouncement(core.settings.getTrainerApp()!.name),
style: TextStyle(fontWeight: FontWeight.bold),
).xSmall,
),
],
),
],
),
);

View File

@@ -1,18 +1,20 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.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/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});
final bool isMobile;
const CustomizePage({super.key, required this.isMobile});
@override
State<CustomizePage> createState() => _CustomizeState();
@@ -21,17 +23,17 @@ class CustomizePage extends StatefulWidget {
class _CustomizeState extends State<CustomizePage> {
@override
Widget build(BuildContext context) {
final canVibrate = core.connection.bluetoothDevices.any(
(device) => device.isConnected && device is ZwiftDevice && device.canVibrate,
);
return SingleChildScrollView(
padding: EdgeInsets.all(16),
padding: EdgeInsets.only(bottom: widget.isMobile ? 146 : 16, left: 16, right: 16, top: 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),
@@ -43,79 +45,86 @@ class _CustomizeState extends State<CustomizePage> {
),
),
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,
Row(
spacing: 8,
children: [
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 300),
child: Select<SupportedApp?>(
value: core.actionHandler.supportedApp,
popup: SelectPopup(
items: SelectItemList(
children: [
Expanded(child: Text(a.name)),
if (a is CustomApp) BetaPill(text: 'CUSTOM'),
..._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),
],
),
),
],
),
),
),
SelectItemButton(
value: CustomApp(profileName: 'New'),
child: Row(
spacing: 6,
).call,
itemBuilder: (c, app) => Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.add, color: Theme.of(context).colorScheme.mutedForeground),
Expanded(child: Text(context.i18n.createNewKeymap).normal.muted),
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(() {});
}
},
),
],
),
),
).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'),
],
),
/*DropdownMenuEntry(
value: CustomApp(profileName: 'New'),
label: 'Create new keymap',
labelWidget: Text('Create new keymap'),
leadingIcon: Icon(Icons.add),
),*/
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);
KeymapManager().getManageProfileDialog(
context,
core.actionHandler.supportedApp is CustomApp ? core.actionHandler.supportedApp?.name : null,
onDone: () {
setState(() {});
}
} else {
core.actionHandler.supportedApp = 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,

View File

@@ -1,39 +1,36 @@
import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.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/pages/button_simulator.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/ignored_devices_dialog.dart';
import 'package:bike_control/widgets/scan.dart';
import 'package:bike_control/widgets/ui/colored_title.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:bike_control/widgets/ui/warning.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../bluetooth/devices/base_device.dart';
import '../widgets/ignored_devices_dialog.dart';
class DevicePage extends StatefulWidget {
final bool isMobile;
final VoidCallback onUpdate;
const DevicePage({super.key, required this.onUpdate});
const DevicePage({super.key, required this.onUpdate, required this.isMobile});
@override
State<DevicePage> createState() => _DevicePageState();
}
class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
class _DevicePageState extends State<DevicePage> {
late StreamSubscription<BaseDevice> _connectionStateSubscription;
bool _showNameChangeWarning = false;
@override
void initState() {
super.initState();
_showNameChangeWarning = !core.settings.knowsAboutNameChange();
_connectionStateSubscription = core.connection.connectionStream.listen((state) async {
setState(() {});
});
@@ -49,58 +46,37 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
Widget build(BuildContext context) {
return Scrollbar(
child: SingleChildScrollView(
padding: EdgeInsets.all(16),
primary: true,
padding: EdgeInsets.only(bottom: widget.isMobile ? 166 : 16, left: 16, right: 16, top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
if (_showNameChangeWarning && !screenshotMode)
Warning(
important: false,
children: [
Text(context.i18n.nameChangeNotice),
SizedBox(height: 8),
TextButton(
onPressed: () {
setState(() {
_showNameChangeWarning = false;
});
launchUrlString('https://openbikecontrol.org');
},
child: Text(context.i18n.moreInformation),
),
],
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ColoredTitle(
text: core.connection.controllerDevices.isEmpty
? context.i18n.connectControllers
: context.i18n.connectedControllers,
),
ValueListenableBuilder(
valueListenable: IAPManager.instance.isPurchased,
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: false),
),
if (core.connection.controllerDevices.isEmpty || kIsWeb) ScanWidget(),
...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),
),
),
if (core.connection.accessories.isNotEmpty) ...[
if (core.connection.controllerDevices.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ColoredTitle(
text: 'Accessories',
),
child: ColoredTitle(text: context.i18n.connectControllers),
),
...core.connection.accessories.map(
(device) => Card(
// leave it in for the extra scanning options
ScanWidget(),
Gap(12),
if (core.connection.controllerDevices.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ColoredTitle(text: context.i18n.connectedControllers),
),
Gap(12),
...core.connection.controllerDevices.map(
(device) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Card(
filled: true,
fillColor: Theme.of(context).brightness == Brightness.dark
? Theme.of(context).colorScheme.card
@@ -108,24 +84,102 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
child: device.showInformation(context),
),
),
),
Gap(12),
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) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: 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),
),
),
),
],
if (core.settings.getIgnoredDevices().isNotEmpty)
OutlineButton(
child: Text(context.i18n.manageIgnoredDevices),
onPressed: () async {
await showDialog(
context: context,
builder: (context) => IgnoredDevicesDialog(),
);
setState(() {});
},
),
Gap(12),
if (!screenshotMode)
Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlineButton(
onPressed: () {
launchUrlString(
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices',
);
},
leading: Icon(Icons.gamepad_outlined),
child: Text(context.i18n.showSupportedControllers),
),
if (core.settings.getIgnoredDevices().isNotEmpty)
OutlineButton(
leading: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.destructive,
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(horizontal: 6),
margin: EdgeInsets.only(right: 4),
child: Text(
core.settings.getIgnoredDevices().length.toString(),
style: TextStyle(
color: Theme.of(context).colorScheme.primaryForeground,
),
),
),
onPressed: () async {
await showDialog(
context: context,
builder: (context) => IgnoredDevicesDialog(),
);
setState(() {});
},
child: Text(context.i18n.manageIgnoredDevices),
),
if (core.connection.controllerDevices.isEmpty)
PrimaryButton(
leading: Icon(Icons.computer_outlined),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => ButtonSimulator(),
),
);
},
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: "${AppLocalizations.of(context).noControllerUseCompanionMode.split("?").first}?\n",
),
TextSpan(
text: AppLocalizations.of(context).noControllerUseCompanionMode.split("? ").last,
style: TextStyle(color: Theme.of(context).colorScheme.muted, fontSize: 12),
),
],
),
),
),
],
),
Gap(12),
SizedBox(),
if (core.connection.controllerDevices.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 8,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
PrimaryButton(
child: Text(context.i18n.connectToTrainerApp),
@@ -134,37 +188,6 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
},
),
],
)
else
PrimaryButton(
child: Text(
'No Controller? Control ${core.settings.getTrainerApp()?.name ?? 'your trainer'} manually!',
),
onPressed: () {
if (core.settings.getTrainerApp() == null) {
buildToast(
context,
level: LogLevel.LOGLEVEL_WARNING,
title: context.i18n.selectTrainerApp,
);
widget.onUpdate();
} else if (core.logic.connectedTrainerConnections.isEmpty) {
buildToast(
context,
level: LogLevel.LOGLEVEL_WARNING,
title:
'Please connect to ${core.settings.getTrainerApp()?.name ?? 'your trainer'} with ${core.logic.trainerConnections.joinToString(transform: (t) => t.title, separator: ' or ')}, first.',
);
widget.onUpdate();
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => ButtonSimulator(),
),
);
}
},
),
],
),

View File

@@ -1,10 +1,8 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart' show BackButton;
import 'package:bike_control/widgets/ui/gradient_text.dart';
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 {
@@ -40,51 +38,36 @@ class _ChangelogPageState extends State<MarkdownPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
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);
},
return _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: GradientText(group.title).bold),
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(),
),
),
)
.toList(),
),
);
);
}
void _parseMarkdown(String md) {

View File

@@ -1,10 +1,5 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/pages/customize.dart';
@@ -16,6 +11,12 @@ import 'package:bike_control/widgets/logviewer.dart';
import 'package:bike_control/widgets/menu.dart';
import 'package:bike_control/widgets/title.dart';
import 'package:bike_control/widgets/ui/colors.dart';
import 'package:bike_control/widgets/ui/help_button.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../widgets/changelog_dialog.dart';
@@ -51,13 +52,19 @@ class _NavigationState extends State<Navigation> {
bool _isMobile = false;
late BCPage _selectedPage;
final Map<BCPage, Key> _pageKeys = {
BCPage.devices: Key('devices_page'),
BCPage.trainer: Key('trainer_page'),
BCPage.customization: Key('customization_page'),
BCPage.logs: Key('logs_page'),
};
@override
void initState() {
super.initState();
_selectedPage = widget.page;
core.connection.initialize();
core.logic.startEnabledConnectionMethod();
core.connection.actionStream.listen((_) {
@@ -127,26 +134,43 @@ class _NavigationState extends State<Navigation> {
Widget build(BuildContext context) {
return Scaffold(
headers: [
AppBar(
padding:
const EdgeInsets.only(top: 12, bottom: 8, left: 12, right: 12) *
(screenshotMode ? 2 : Theme.of(context).scaling),
title: AppTitle(),
backgroundColor: Theme.of(context).colorScheme.background,
trailing: buildMenuButtons(
context,
_isMobile
? () {
setState(() {
_selectedPage = BCPage.logs;
});
}
: null,
),
Stack(
children: [
AppBar(
padding:
const EdgeInsets.only(top: 12, bottom: 8, left: 12, right: 12) *
(screenshotMode ? 2 : Theme.of(context).scaling),
title: AppTitle(),
backgroundColor: Theme.of(context).colorScheme.background,
trailing: buildMenuButtons(
context,
_selectedPage,
_isMobile
? () {
setState(() {
_selectedPage = BCPage.logs;
});
}
: null,
),
),
if (!_isMobile)
Container(
alignment: Alignment.topCenter,
child: HelpButton(isMobile: false),
),
],
),
Divider(),
],
footers: _isMobile ? [Divider(), _buildNavigationBar()] : [],
footers: _isMobile
? [
if (_isMobile) Center(child: HelpButton(isMobile: true)),
Divider(),
_buildNavigationBar(),
]
: [],
floatingFooter: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -155,19 +179,25 @@ class _NavigationState extends State<Navigation> {
VerticalDivider(),
],
Expanded(
child: Container(
alignment: Alignment.topLeft,
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: switch (_selectedPage) {
BCPage.devices => DevicePage(
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: switch (_selectedPage) {
BCPage.devices => Align(
alignment: Alignment.topLeft,
key: _pageKeys[BCPage.devices],
child: DevicePage(
isMobile: _isMobile,
onUpdate: () {
setState(() {
_selectedPage = BCPage.trainer;
});
},
),
BCPage.trainer => TrainerPage(
),
BCPage.trainer => Align(
alignment: Alignment.topLeft,
key: _pageKeys[BCPage.trainer],
child: TrainerPage(
onUpdate: () {
setState(() {});
},
@@ -176,11 +206,21 @@ class _NavigationState extends State<Navigation> {
_selectedPage = BCPage.customization;
});
},
isMobile: _isMobile,
),
BCPage.customization => CustomizePage(),
BCPage.logs => LogViewer(),
},
),
),
BCPage.customization => Align(
alignment: Alignment.topLeft,
key: _pageKeys[BCPage.customization],
child: CustomizePage(isMobile: _isMobile),
),
BCPage.logs => Padding(
padding: EdgeInsets.only(bottom: _isMobile ? 146 : 16, left: 16, right: 16, top: 16),
child: LogViewer(
key: _pageKeys[BCPage.logs],
),
),
},
),
),
],
@@ -249,7 +289,7 @@ class _NavigationState extends State<Navigation> {
reverseDuration: Duration(seconds: 1),
start: 10,
end: 12,
mode: RepeatMode.pingPong,
mode: LoopingMode.pingPong,
builder: (context, value, child) {
return Container(
width: value,

331
lib/pages/onboarding.dart Normal file
View File

@@ -0,0 +1,331 @@
import 'dart:async';
import 'package:bike_control/bluetooth/devices/base_device.dart';
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/requirements/platform.dart';
import 'package:bike_control/widgets/scan.dart';
import 'package:bike_control/widgets/title.dart';
import 'package:bike_control/widgets/ui/help_button.dart';
import 'package:bike_control/widgets/ui/permissions_list.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../utils/i18n_extension.dart';
import '../widgets/ui/colored_title.dart';
import 'configuration.dart';
class OnboardingPage extends StatefulWidget {
final VoidCallback onComplete;
const OnboardingPage({super.key, required this.onComplete});
@override
State<OnboardingPage> createState() => _OnboardingPageState();
}
enum _OnboardingStep {
permissions,
connect,
trainer,
finish,
}
class _OnboardingPageState extends State<OnboardingPage> {
var _currentStep = _OnboardingStep.permissions;
bool _isMobile = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_isMobile = MediaQuery.sizeOf(context).width < 600;
}
@override
Widget build(BuildContext context) {
return Scaffold(
loadingProgress: _OnboardingStep.values.indexOf(_currentStep) / (_OnboardingStep.values.length - 1),
headers: [
AppBar(
backgroundColor: Theme.of(context).colorScheme.primaryForeground,
leading: [
Image.asset('icon.png', height: 40),
SizedBox(width: 10),
AppTitle(),
],
trailing: [
Button(
style: ButtonStyle.outline(size: ButtonSize.small),
child: Text(AppLocalizations.of(context).skip),
onPressed: () {
core.settings.setShowOnboarding(false);
widget.onComplete();
},
),
],
),
Divider(),
],
floatingFooter: true,
footers: [
Center(
child: HelpButton(
isMobile: true,
),
),
],
child: Center(
child: Container(
alignment: Alignment.topCenter,
constraints: !_isMobile ? BoxConstraints(maxWidth: 500) : null,
child: SingleChildScrollView(
padding: EdgeInsets.only(top: !_isMobile ? 42 : 22.0, bottom: !_isMobile ? 42 : 68.0, left: 16, right: 16),
child: AnimatedSwitcher(
duration: Duration(milliseconds: 600),
child: switch (_currentStep) {
_OnboardingStep.permissions => _PermissionsOnboardingStep(
onComplete: () {
setState(() {
_currentStep = _OnboardingStep.connect;
});
},
),
_OnboardingStep.connect => _ConnectOnboardingStep(
onComplete: () {
setState(() {
_currentStep = _OnboardingStep.trainer;
});
},
),
_OnboardingStep.trainer => _TrainerOnboardingStep(
onComplete: () {
setState(() {
_currentStep = _OnboardingStep.finish;
});
},
),
_OnboardingStep.finish => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 12,
children: [
SizedBox(height: 30),
Icon(Icons.check_circle, size: 58, color: Colors.green),
ColoredTitle(text: AppLocalizations.of(context).setupComplete),
Text(
AppLocalizations.of(
context,
).asAFinalStepYoullChooseHowToConnectTo(core.settings.getTrainerApp()?.name ?? 'your trainer'),
textAlign: TextAlign.center,
).small.muted,
SizedBox(height: 30),
PrimaryButton(
leading: Icon(Icons.check),
onPressed: () {
core.settings.setShowOnboarding(false);
widget.onComplete();
},
child: Text(context.i18n.continueAction),
),
],
),
},
),
),
),
),
);
}
}
class _PermissionsOnboardingStep extends StatefulWidget {
final VoidCallback onComplete;
const _PermissionsOnboardingStep({super.key, required this.onComplete});
@override
State<_PermissionsOnboardingStep> createState() => _PermissionsOnboardingStepState();
}
class _PermissionsOnboardingStepState extends State<_PermissionsOnboardingStep> {
void _checkRequirements() {
core.permissions.getScanRequirements().then((permissions) {
if (!mounted) return;
setState(() {
_needsPermissions = permissions;
});
if (permissions.isEmpty) {
widget.onComplete();
}
});
}
List<PlatformRequirement>? _needsPermissions;
@override
void initState() {
super.initState();
_checkRequirements();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(height: 8),
Text(AppLocalizations.of(context).letsGetYouSetUp).h3,
if (_needsPermissions != null && _needsPermissions!.isNotEmpty)
PermissionList(
requirements: _needsPermissions!,
onDone: () {
widget.onComplete();
},
),
],
);
}
}
class _ConnectOnboardingStep extends StatefulWidget {
final VoidCallback onComplete;
const _ConnectOnboardingStep({super.key, required this.onComplete});
@override
State<_ConnectOnboardingStep> createState() => _ConnectOnboardingStepState();
}
class _ConnectOnboardingStepState extends State<_ConnectOnboardingStep> {
late StreamSubscription<BaseDevice> _connectionStateSubscription;
late StreamSubscription<BaseNotification> _actionSubscription;
@override
void initState() {
super.initState();
_actionSubscription = core.connection.actionStream.listen((data) async {
setState(() {});
if (data is ButtonNotification) {
widget.onComplete();
}
});
_connectionStateSubscription = core.connection.connectionStream.listen((state) async {
setState(() {});
});
}
@override
void dispose() {
_connectionStateSubscription.cancel();
_actionSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
ColoredTitle(text: context.i18n.connectControllers),
if (core.connection.controllerDevices.isEmpty) ...[
ScanWidget(),
OutlineButton(
onPressed: () {
launchUrlString('https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices');
},
leading: Icon(Icons.gamepad_outlined),
child: Text(context.i18n.showSupportedControllers),
),
PrimaryButton(
leading: Icon(Icons.computer_outlined),
onPressed: () {
widget.onComplete();
},
child: Text.rich(
TextSpan(
children: [
TextSpan(text: "${AppLocalizations.of(context).noControllerUseCompanionMode.split("?").first}?\n"),
TextSpan(
text: AppLocalizations.of(context).noControllerUseCompanionMode.split("? ").last,
style: TextStyle(color: Theme.of(context).colorScheme.muted, fontSize: 12),
),
],
),
),
),
] else ...[
if (core.connection.controllerDevices.any((d) => d.isConnected && d is ZwiftDevice))
RepeatedAnimationBuilder<double>(
duration: Duration(seconds: 1),
start: 0.5,
end: 1.0,
curve: Curves.easeInOut,
mode: LoopingMode.pingPong,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Text(
AppLocalizations.of(context).controllerConnectedClickButton,
).small,
);
},
),
SizedBox(),
...core.connection.controllerDevices.map(
(device) => device.showInformation(context),
),
if (core.connection.controllerDevices.any((d) => d.isConnected))
PrimaryButton(
leading: Icon(Icons.check),
onPressed: () {
widget.onComplete();
},
child: Text(context.i18n.continueAction),
),
SizedBox(),
],
],
);
}
}
class _TrainerOnboardingStep extends StatefulWidget {
final VoidCallback onComplete;
const _TrainerOnboardingStep({super.key, required this.onComplete});
@override
State<_TrainerOnboardingStep> createState() => _TrainerOnboardingStepState();
}
class _TrainerOnboardingStepState extends State<_TrainerOnboardingStep> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
SizedBox(),
ConfigurationPage(
onboardingMode: true,
onUpdate: () {
setState(() {});
},
),
if (core.settings.getTrainerApp() != null) SizedBox(height: 20),
if (core.settings.getTrainerApp() != null)
PrimaryButton(
leading: Icon(Icons.check),
onPressed: () {
widget.onComplete();
},
child: Text(context.i18n.continueAction),
),
],
);
}
}

View File

@@ -8,8 +8,6 @@ import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/widgets/keymap_explanation.dart';
import 'package:bike_control/widgets/testbed.dart';
import 'package:bike_control/widgets/ui/button_widget.dart';
import 'package:bike_control/widgets/ui/colors.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
@@ -18,7 +16,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../utils/actions/base_actions.dart';
import '../utils/keymap/keymap.dart';
final touchAreaSize = 42.0;
@@ -141,7 +138,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
// figure out notch height for e.g. macOS. On Windows the display size is not available (0,0).
final differenceInHeight = (flutterView.display.size.height > 0 && !Platform.isIOS)
final differenceInHeight = (!Platform.isWindows && flutterView.display.size.height > 0 && !Platform.isIOS)
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
: 0.0;
@@ -393,53 +390,24 @@ class KeypairExplanation extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (withKey)
Row(
children: keyPair.buttons.map((b) => ButtonWidget(button: b, big: true)).toList(),
)
else
Icon(keyPair.icon),
if (keyPair.inGameAction != null && core.logic.emulatorEnabled)
KeyWidget(
label: [
keyPair.inGameAction!.title,
if (keyPair.inGameActionValue != null) '${keyPair.inGameActionValue}',
].joinToString(separator: ': '),
)
else if (keyPair.isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media))
KeyWidget(
label: switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
PhysicalKeyboardKey.mediaStop => 'Stop',
PhysicalKeyboardKey.mediaTrackPrevious => 'Previous',
PhysicalKeyboardKey.mediaTrackNext => 'Next',
PhysicalKeyboardKey.audioVolumeUp => 'Volume Up',
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
_ => 'Unknown',
},
)
else if (keyPair.physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
KeyWidget(
label: keyPair.toString(),
),
] else ...[
if (!withKey && keyPair.touchPosition != Offset.zero && core.logic.showLocalRemoteOptions)
KeyWidget(label: 'X:${keyPair.touchPosition.dx.toInt()}, Y:${keyPair.touchPosition.dy.toInt()}'),
],
if (keyPair.isLongPress) Text(context.i18n.longPress, style: TextStyle(fontSize: 10)),
],
return Basic(
leading: withKey
? Row(
children: keyPair.buttons.map((b) => ButtonWidget(button: b, big: true)).toList(),
)
: Icon(keyPair.icon),
leadingAlignment: Alignment.centerLeft,
contentSpacing: 10,
subtitle: keyPair.isLongPress ? Text(context.i18n.longPress.replaceAll('\n', ' ')).muted.xSmall : null,
title: Text(keyPair.toString()),
);
}
}
class KeyWidget extends StatelessWidget {
final String label;
const KeyWidget({super.key, required this.label});
final bool invert;
const KeyWidget({super.key, required this.label, this.invert = false});
@override
Widget build(BuildContext context) {
@@ -448,7 +416,7 @@ class KeyWidget extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
constraints: BoxConstraints(minWidth: 30),
decoration: BoxDecoration(
color: BKColor.main,
color: invert ? Colors.white : Colors.black,
border: Border.all(color: Theme.of(context).colorScheme.border, width: 2),
borderRadius: BorderRadius.circular(4),
),
@@ -458,7 +426,7 @@ class KeyWidget extends StatelessWidget {
style: TextStyle(
fontFamily: screenshotMode ? null : 'monospace',
fontSize: 12,
color: Colors.white,
color: invert ? Colors.black : Colors.white,
),
),
),

View File

@@ -1,28 +1,36 @@
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/pages/button_simulator.dart';
import 'package:bike_control/pages/configuration.dart';
import 'package:bike_control/pages/navigation.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/requirements/multi.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/widgets/apps/local_tile.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/iap_status_widget.dart';
import 'package:bike_control/widgets/pair_widget.dart';
import 'package:bike_control/widgets/ui/colored_title.dart';
import 'package:bike_control/widgets/ui/toast.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 'package:wakelock_plus/wakelock_plus.dart';
import '../bluetooth/devices/zwift/protocol/zp.pbenum.dart';
import '../utils/keymap/apps/supported_app.dart';
class TrainerPage extends StatefulWidget {
final bool isMobile;
final VoidCallback onUpdate;
final VoidCallback goToNextPage;
const TrainerPage({super.key, required this.onUpdate, required this.goToNextPage});
const TrainerPage({super.key, required this.onUpdate, required this.goToNextPage, required this.isMobile});
@override
State<TrainerPage> createState() => _TrainerPageState();
@@ -83,90 +91,205 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
final showLocalAsOther =
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showLocalControl;
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) &&
core.logic.showLocalControl &&
!core.settings.getLocalEnabled();
final showWhooshLinkAsOther =
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showMyWhooshLink;
final recommendedTiles = [
if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(),
if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(),
if (core.logic.showZwiftMsdnEmulator)
ZwiftMdnsTile(
onUpdate: () {
core.connection.signalNotification(
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
);
},
),
if (core.logic.showZwiftBleEmulator)
ZwiftTile(
onUpdate: () {
if (mounted) {
core.connection.signalNotification(
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
);
setState(() {});
}
},
),
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
];
final otherTiles = [
if (core.logic.showRemote) RemotePairingWidget(),
if (showLocalAsOther) LocalTile(),
if (showWhooshLinkAsOther) MyWhooshLinkTile(),
];
return Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.all(16),
padding: EdgeInsets.only(bottom: widget.isMobile ? 166 : 16, left: 16, right: 16, top: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
ConfigurationPage(
onUpdate: () {
setState(() {});
widget.onUpdate();
if (_scrollController.position.pixels != _scrollController.position.maxScrollExtent &&
core.settings.getLastTarget() == Target.otherDevice) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.animateTo(
_scrollController.offset + 300,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
});
}
},
ValueListenableBuilder(
valueListenable: IAPManager.instance.isPurchased,
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: true),
),
if (core.settings.getTrainerApp() != null) ...[
SizedBox(height: 8),
if (core.logic.hasRecommendedConnectionMethods)
ColoredTitle(text: context.i18n.recommendedConnectionMethods),
if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(),
if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(),
if (core.logic.showZwiftMsdnEmulator)
ZwiftMdnsTile(
onUpdate: () {
core.connection.signalNotification(
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
);
},
),
if (core.logic.showZwiftBleEmulator)
ZwiftTile(
onUpdate: () {
core.connection.signalNotification(
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
);
setState(() {});
},
),
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
if (core.logic.showRemote || showLocalAsOther || showWhooshLinkAsOther) ...[
SizedBox(height: 16),
Accordion(
items: [
AccordionItem(
trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)),
content: Column(
children: [
if (core.logic.showRemote) RemotePairingWidget(),
if (showLocalAsOther) LocalTile(),
if (showWhooshLinkAsOther) MyWhooshLinkTile(),
],
SizedBox(
width: double.infinity,
child: Accordion(
items: [
AccordionItem(
trigger: AccordionTrigger(
child: IgnorePointer(
child: Row(
spacing: 12,
children: [
Flexible(
child: Select<SupportedApp>(
itemBuilder: (c, app) => Row(
spacing: 4,
children: [
Expanded(child: 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 (core.settings.getLastTarget() != null) ...[
if (!widget.isMobile) Icon(core.settings.getLastTarget()!.icon),
Text(core.settings.getLastTarget()!.getTitle(context)),
],
],
),
),
),
],
),
content: ConfigurationPage(
onUpdate: () {
setState(() {});
widget.onUpdate();
},
),
),
],
),
),
if (core.settings.getTrainerApp() != null) ...[
Gap(22),
if (recommendedTiles.isNotEmpty) ...[
ColoredTitle(text: context.i18n.recommendedConnectionMethods),
Gap(12),
],
SizedBox(),
Row(
mainAxisAlignment: MainAxisAlignment.end,
for (final grouped in recommendedTiles.chunked(widget.isMobile ? 1 : 2)) ...[
IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: grouped.map((tile) => Expanded(child: tile)).toList(),
),
),
),
],
Gap(12),
if (otherTiles.isNotEmpty) ...[
SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: Accordion(
items: [
AccordionItem(
trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)),
content: Column(
children: [
for (final grouped in otherTiles.chunked(widget.isMobile ? 1 : 2)) ...[
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: IntrinsicHeight(
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: grouped.map((tile) => Expanded(child: tile)).toList(),
),
),
),
],
],
),
),
],
),
),
],
Gap(12),
SizedBox(height: 4),
Flex(
direction: widget.isMobile ? Axis.vertical : Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
PrimaryButton(
child: Text(context.i18n.adjustControllerButtons),
leading: Icon(Icons.computer_outlined),
child: Text(
AppLocalizations.of(
context,
).manualyControllingButton(core.settings.getTrainerApp()?.name ?? 'your trainer'),
),
onPressed: () {
if (core.settings.getTrainerApp() == null) {
buildToast(
context,
level: LogLevel.LOGLEVEL_WARNING,
title: context.i18n.selectTrainerApp,
);
widget.onUpdate();
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => ButtonSimulator(),
),
);
}
},
),
PrimaryButton(
leading: Icon(BCPage.customization.icon),
onPressed: () {
widget.goToNextPage();
},
child: Text(context.i18n.adjustControllerButtons),
),
],
),

View File

@@ -1,11 +1,13 @@
import 'dart:async';
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/widgets/keymap_explanation.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import '../keymap/apps/supported_app.dart';
import '../single_line_exception.dart';
@@ -14,6 +16,7 @@ class AndroidActions extends BaseActions {
WindowEvent? windowInfo;
final accessibilityHandler = Accessibility();
StreamSubscription<void>? _keymapUpdateSubscription;
AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]});
@@ -26,11 +29,22 @@ class AndroidActions extends BaseActions {
}
});
// Update handled keys list when keymap changes
updateHandledKeys();
// Listen to keymap changes and update handled keys
_keymapUpdateSubscription?.cancel();
_keymapUpdateSubscription = supportedApp?.keymap.updateStream.listen((_) {
updateHandledKeys();
});
hidKeyPressed().listen((keyPressed) async {
final hidDevice = HidDevice(keyPressed.source);
final button = hidDevice.getOrAddButton(keyPressed.hidKey, () => ControllerButton(keyPressed.hidKey));
var availableDevice = core.connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
var availableDevice = core.connection.controllerDevices.firstOrNullWhere(
(e) => e.toString() == hidDevice.toString(),
);
if (availableDevice == null) {
core.connection.addDevices([hidDevice]);
availableDevice = hidDevice;
@@ -47,6 +61,7 @@ class AndroidActions extends BaseActions {
Future<ActionResult> performAction(ControllerButton button, {required bool isKeyDown, required bool isKeyUp}) async {
final superResult = await super.performAction(button, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
if (superResult is! NotHandled) {
// Increment command count after successful execution
return superResult;
}
final keyPair = supportedApp!.keymap.getKeyPair(button)!;
@@ -59,6 +74,8 @@ class AndroidActions extends BaseActions {
PhysicalKeyboardKey.audioVolumeDown => MediaAction.volumeDown,
_ => throw SingleLineException("No action for key: ${keyPair.physicalKey}"),
});
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
return Success("Key pressed: ${keyPair.toString()}");
}
@@ -69,6 +86,8 @@ class AndroidActions extends BaseActions {
} on PlatformException catch (e) {
return Error("Accessibility Service not working. Follow instructions at https://dontkillmyapp.com/");
}
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
return Success(
"Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
? "click"
@@ -77,10 +96,28 @@ class AndroidActions extends BaseActions {
: "up"}",
);
}
return NotHandled('No action assigned for ${button.toString().splitByUpperCase()}');
return NotHandled('No action assigned for ${button.name}');
}
void ignoreHidDevices() {
accessibilityHandler.ignoreHidDevices();
}
void updateHandledKeys() {
if (supportedApp == null) {
accessibilityHandler.setHandledKeys([]);
return;
}
// Get all keys from the keymap that have a mapping defined
final handledKeys = supportedApp!.keymap.keyPairs
.filter((keyPair) => !keyPair.hasNoAction)
.expand((keyPair) => keyPair.buttons)
.filter((e) => e.action == null && e.icon == null)
.map((button) => button.name)
.toSet()
.toList();
accessibilityHandler.setHandledKeys(handledKeys);
}
}

View File

@@ -2,11 +2,13 @@ import 'dart:io';
import 'dart:math';
import 'package:accessibility/accessibility.dart';
import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.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/core.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/keymap_explanation.dart';
@@ -52,23 +54,8 @@ abstract class BaseActions {
debugPrint('Supported app: ${supportedApp?.name ?? "None"}');
if (supportedApp != null) {
final allButtons = core.connection.devices.map((e) => e.availableButtons).flatten().distinct();
final newButtons = allButtons.filter(
(button) => supportedApp.keymap.getKeyPair(button) == null,
);
for (final button in newButtons) {
supportedApp.keymap.addKeyPair(
KeyPair(
touchPosition: Offset.zero,
buttons: [button],
inGameAction: button.action,
physicalKey: null,
logicalKey: null,
isLongPress: false,
),
);
}
final allButtons = core.connection.devices.map((e) => e.availableButtons).flatten().distinct().toList();
supportedApp.keymap.addNewButtons(allButtons);
}
}
@@ -120,19 +107,23 @@ abstract class BaseActions {
Future<ActionResult> performAction(ControllerButton button, {required bool isKeyDown, required bool isKeyUp}) async {
if (supportedApp == null) {
return Error("Could not perform ${button.name.splitByUpperCase()}: No keymap set");
return Error(
AppLocalizations.current.couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet(button.name.splitByUpperCase()),
);
}
final keyPair = supportedApp!.keymap.getKeyPair(button);
if (core.logic.hasNoConnectionMethod) {
return Error(AppLocalizations.current.pleaseSelectAConnectionMethodFirst);
if (GyroscopeSteeringButtons.values.contains(button)) {
return Ignored('Too many messages from gyroscope steering');
} else {
return Error(AppLocalizations.current.pleaseSelectAConnectionMethodFirst);
}
} else if (!(await core.logic.isTrainerConnected())) {
return Error('No connection method is connected or active.');
} else if (keyPair == null) {
return Error("Could not perform ${button.name.splitByUpperCase()}: No action assigned");
} else if (keyPair.hasNoAction) {
return Error('No action assigned for ${button.toString().splitByUpperCase()}');
return Error(AppLocalizations.current.noConnectionMethodIsConnectedOrActive);
} else if (keyPair == null || keyPair.hasNoAction) {
return Error(AppLocalizations.current.noActionAssignedForButton(button.name.splitByUpperCase()));
}
// Handle Headwind actions
@@ -143,12 +134,17 @@ abstract class BaseActions {
return Error('No Headwind connected');
}
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
return await headwind.handleKeypair(keyPair, isKeyDown: isKeyDown);
}
final directConnectHandled = await _handleDirectConnect(keyPair, button, isKeyUp: isKeyUp, isKeyDown: isKeyDown);
if (directConnectHandled is NotHandled && directConnectHandled.message.isNotEmpty) {
core.connection.signalNotification(LogNotification(directConnectHandled.message));
} else if (directConnectHandled is! NotHandled) {
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
}
return directConnectHandled;
}
@@ -180,11 +176,11 @@ abstract class BaseActions {
class StubActions extends BaseActions {
StubActions({super.supportedModes = const []});
final List<ControllerButton> performedActions = [];
final List<(ControllerButton button, bool isDown, bool isUp)> performedActions = [];
@override
Future<ActionResult> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
performedActions.add(button);
return Future.value(Success('${button.name.splitByUpperCase()} clicked'));
performedActions.add((button, isKeyDown, isKeyUp));
return Future.value(Ignored('${button.name.splitByUpperCase()} clicked'));
}
}

View File

@@ -1,9 +1,11 @@
import 'dart:ui';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class DesktopActions extends BaseActions {
DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]});
@@ -19,10 +21,41 @@ class DesktopActions extends BaseActions {
final keyPair = supportedApp!.keymap.getKeyPair(button)!;
if (core.settings.getLocalEnabled()) {
// Handle media keys
if (keyPair.isSpecialKey) {
try {
await keyPressSimulator.simulateMediaKey(keyPair.physicalKey!);
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
return Success('Media key pressed: $keyPair');
} catch (e) {
return Error('Failed to simulate media key: $e');
}
}
if (keyPair.physicalKey != null) {
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
if (keyPair.logicalKey != null && navigatorKey.currentContext?.mounted == true) {
final label = keyPair.logicalKey!.keyLabel;
final keyName = label.isNotEmpty ? label : keyPair.logicalKey!.debugName ?? 'Key';
buildToast(
navigatorKey.currentContext!,
location: ToastLocation.bottomLeft,
title:
'${isKeyDown
? ""
: isKeyUp
? ""
: ""} $keyName',
);
}
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
return Success('Key clicked: $keyPair');
} else if (isKeyDown) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
@@ -34,6 +67,8 @@ class DesktopActions extends BaseActions {
} else {
final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: null);
if (point != Offset.zero) {
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateMouseClickDown(point);
// slight move to register clicks on some apps, see issue #116

View File

@@ -55,7 +55,9 @@ class Core {
class Permissions {
Future<List<PlatformRequirement>> getScanRequirements() async {
final List<PlatformRequirement> list;
if (kIsWeb) {
if (screenshotMode) {
list = [];
} else if (kIsWeb) {
final availablity = await UniversalBle.getBluetoothAvailabilityState();
if (availablity == AvailabilityState.unsupported) {
list = [UnsupportedPlatform()];
@@ -63,14 +65,19 @@ class Permissions {
list = [BluetoothTurnedOn()];
}
} else if (Platform.isMacOS) {
list = [BluetoothTurnedOn()];
list = [
BluetoothTurnedOn(),
if (core.settings.getShowOnboarding()) NotificationRequirement(),
];
} else if (Platform.isIOS) {
list = [
BluetoothTurnedOn(),
NotificationRequirement(),
];
} else if (Platform.isWindows) {
list = [
BluetoothTurnedOn(),
NotificationRequirement(),
];
} else if (Platform.isAndroid) {
final deviceInfoPlugin = DeviceInfoPlugin();
@@ -101,6 +108,8 @@ class Permissions {
return [
BluetoothTurnedOn(),
if (Platform.isAndroid) ...[
BluetoothScanRequirement(),
BluetoothConnectRequirement(),
BluetoothAdvertiseRequirement(),
],
];
@@ -231,6 +240,15 @@ class CoreLogic {
if (isRemoteControlEnabled) core.remotePairing,
].filter((e) => e.isConnected.value).toList();
List<TrainerConnection> get enabledTrainerConnections => [
if (isMyWhooshLinkEnabled) core.whooshLink,
if (isObpMdnsEnabled) core.obpMdnsEmulator,
if (isObpBleEnabled) core.obpBluetoothEmulator,
if (isZwiftBleEnabled) core.zwiftEmulator,
if (isZwiftMdnsEnabled) core.zwiftMdnsEmulator,
if (isRemoteControlEnabled) core.remotePairing,
];
List<TrainerConnection> get trainerConnections => [
if (showMyWhooshLink) core.whooshLink,
if (showObpMdnsEmulator) core.obpMdnsEmulator,
@@ -394,12 +412,13 @@ class MediaKeyHandler {
() => ControllerButton(keyPressed),
);
var availableDevice = core.connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
var availableDevice = core.connection.controllerDevices.firstOrNullWhere(
(e) => e.toString() == hidDevice.toString(),
);
if (availableDevice == null) {
core.connection.addDevices([hidDevice]);
availableDevice = hidDevice;
}
availableDevice.handleButtonsClicked([button]);
availableDevice.handleButtonsClicked([]);
availableDevice.handleButtonsClickedWithoutLongPressSupport([button]);
}
}

View File

@@ -0,0 +1,222 @@
import 'dart:io';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/iap/iap_service.dart';
import 'package:bike_control/utils/iap/revenuecat_service.dart';
import 'package:bike_control/utils/iap/windows_iap_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Unified IAP manager that handles platform-specific IAP services
class IAPManager {
static IAPManager? _instance;
static IAPManager get instance {
_instance ??= IAPManager._();
return _instance!;
}
static int dailyCommandLimit = 15;
IAPService? _iapService;
RevenueCatService? _revenueCatService;
WindowsIAPService? _windowsIapService;
ValueNotifier<bool> isPurchased = ValueNotifier<bool>(false);
IAPManager._();
/// Initialize the IAP manager
Future<void> initialize() async {
final prefs = FlutterSecureStorage(aOptions: AndroidOptions());
if (kIsWeb || screenshotMode) {
// Web doesn't support IAP
return;
}
try {
if (Platform.isWindows) {
// Keep Windows using the existing windows_iap implementation
_windowsIapService = WindowsIAPService(prefs);
await _windowsIapService!.initialize();
} else if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
// Use RevenueCat for supported platforms when API key is available
_revenueCatService = RevenueCatService(
prefs,
isPurchasedNotifier: isPurchased,
getDailyCommandLimit: () => dailyCommandLimit,
setDailyCommandLimit: (limit) => dailyCommandLimit = limit,
);
await _revenueCatService!.initialize();
} else {
// Fall back to legacy IAP service
debugPrint('Using legacy IAP service (no RevenueCat key)');
_iapService = IAPService(prefs);
await _iapService!.initialize();
}
} catch (e) {
debugPrint('Error initializing IAP manager: $e');
}
}
/// Check if the trial period has started
bool get hasTrialStarted {
if (_revenueCatService != null) {
return _revenueCatService!.hasTrialStarted;
} else if (_iapService != null) {
return _iapService!.hasTrialStarted;
} else if (_windowsIapService != null) {
return _windowsIapService!.hasTrialStarted;
}
return false;
}
/// Start the trial period
Future<void> startTrial() async {
if (_revenueCatService != null) {
await _revenueCatService!.startTrial();
} else if (_iapService != null) {
await _iapService!.startTrial();
}
}
/// Get the number of days remaining in the trial
int get trialDaysRemaining {
if (_revenueCatService != null) {
return _revenueCatService!.trialDaysRemaining;
} else if (_iapService != null) {
return _iapService!.trialDaysRemaining;
} else if (_windowsIapService != null) {
return _windowsIapService!.trialDaysRemaining;
}
return 0;
}
/// Check if the trial has expired
bool get isTrialExpired {
if (_revenueCatService != null) {
return _revenueCatService!.isTrialExpired;
} else if (_iapService != null) {
return _iapService!.isTrialExpired;
} else if (_windowsIapService != null) {
return _windowsIapService!.isTrialExpired;
}
return false;
}
/// Check if the user can execute a command
bool get canExecuteCommand {
// If IAP is not initialized or not available, allow commands
if (_revenueCatService == null && _iapService == null && _windowsIapService == null) return true;
if (_revenueCatService != null) {
return _revenueCatService!.canExecuteCommand;
} else if (_iapService != null) {
return _iapService!.canExecuteCommand;
} else if (_windowsIapService != null) {
return _windowsIapService!.canExecuteCommand;
}
return true; // Default to true for platforms without IAP
}
/// Get the number of commands remaining today (for free tier after trial)
int get commandsRemainingToday {
if (_revenueCatService != null) {
return _revenueCatService!.commandsRemainingToday;
} else if (_iapService != null) {
return _iapService!.commandsRemainingToday;
} else if (_windowsIapService != null) {
return _windowsIapService!.commandsRemainingToday;
}
return -1; // Unlimited
}
/// Get the daily command count
int get dailyCommandCount {
if (_revenueCatService != null) {
return _revenueCatService!.dailyCommandCount;
} else if (_iapService != null) {
return _iapService!.dailyCommandCount;
} else if (_windowsIapService != null) {
return _windowsIapService!.dailyCommandCount;
}
return 0;
}
/// Increment the daily command count
Future<void> incrementCommandCount() async {
if (_revenueCatService != null) {
await _revenueCatService!.incrementCommandCount();
} else if (_iapService != null) {
await _iapService!.incrementCommandCount();
} else if (_windowsIapService != null) {
await _windowsIapService!.incrementCommandCount();
}
}
/// Get a status message for the user
String getStatusMessage() {
/// Get a status message for the user
if (kIsWeb) {
return "Web";
} else if (IAPManager.instance.isPurchased.value) {
return AppLocalizations.current.fullVersion;
} else if (!hasTrialStarted) {
return '${_revenueCatService?.trialDaysRemaining ?? _iapService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available';
} else if (!isTrialExpired) {
return AppLocalizations.current.trialDaysRemaining(trialDaysRemaining);
} else {
return AppLocalizations.current.commandsRemainingToday(commandsRemainingToday, dailyCommandLimit);
}
}
/// Purchase the full version
Future<void> purchaseFullVersion(BuildContext context) async {
if (_revenueCatService != null) {
return await _revenueCatService!.purchaseFullVersion(context);
} else if (_iapService != null) {
return await _iapService!.purchaseFullVersion();
} else if (_windowsIapService != null) {
return await _windowsIapService!.purchaseFullVersion();
}
}
/// Restore previous purchases
Future<void> restorePurchases() async {
if (_revenueCatService != null) {
await _revenueCatService!.restorePurchases();
} else if (_iapService != null) {
await _iapService!.restorePurchases();
}
// Windows doesn't have a separate restore mechanism in the stub
}
/// Check if RevenueCat is being used
bool get isUsingRevenueCat => _revenueCatService != null;
/// Dispose the manager
void dispose() {
_revenueCatService?.dispose();
_iapService?.dispose();
_windowsIapService?.dispose();
}
Future<void> reset(bool fullReset) async {
isPurchased.value = false;
_windowsIapService?.reset();
await _revenueCatService?.reset(fullReset);
await _iapService?.reset(fullReset);
}
Future<void> redeem(String purchaseId) async {
if (_revenueCatService != null) {
await _revenueCatService!.redeem(purchaseId);
} else if (_iapService != null) {
await _iapService!.redeem();
}
}
void setAttributes() {
_revenueCatService?.setAttributes();
}
}

View File

@@ -0,0 +1,449 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:intl/intl.dart';
import 'package:ios_receipt/ios_receipt.dart';
import 'package:version/version.dart';
/// Service to handle in-app purchase functionality and trial period management
class IAPService {
static const int trialDays = 5;
static const String _trialStartDateKey = 'iap_trial_start_date';
static const String _purchaseStatusKey = 'iap_purchase_status';
static const String _dailyCommandCountKey = 'iap_daily_command_count';
static const String _lastCommandDateKey = 'iap_last_command_date';
static const String _lastPurchaseCheckKey = 'iap_last_purchase_check';
static const String _hasPurchasedKey = 'iap_has_purchased';
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
final FlutterSecureStorage _prefs;
StreamSubscription<List<PurchaseDetails>>? _subscription;
bool _isInitialized = false;
String? _trialStartDate;
String? _lastCommandDate;
int? _dailyCommandCount;
IAPService(this._prefs);
/// Initialize the IAP service
Future<void> initialize() async {
if (_isInitialized) return;
try {
// Skip IAP initialization on web
if (kIsWeb) {
debugPrint('IAP not supported on web');
_isInitialized = true;
return;
}
// Check if IAP is available on this platform
final available = await _inAppPurchase.isAvailable();
if (!available) {
debugPrint('IAP not available on this platform -');
// Set as purchased to allow unlimited access when IAP is not available
IAPManager.instance.isPurchased.value = false;
_isInitialized = true;
return;
}
// Listen for purchase updates
_subscription = _inAppPurchase.purchaseStream.listen(
_onPurchaseUpdate,
onDone: () => _subscription?.cancel(),
onError: (error) {
debugPrint('IAP Error: $error');
core.connection.signalNotification(
LogNotification('There was an error with in-app purchases: ${error.toString()}'),
);
// On error, default to allowing access
IAPManager.instance.isPurchased.value = false;
},
);
_trialStartDate = await _prefs.read(key: _trialStartDateKey);
core.connection.signalNotification(LogNotification('Trial start date: $_trialStartDate => $trialDaysRemaining'));
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
final commandCount = await _prefs.read(key: _dailyCommandCountKey) ?? '0';
_dailyCommandCount = int.tryParse(commandCount);
// Check if already purchased
await _checkExistingPurchase();
_isInitialized = true;
if (!isTrialExpired && Platform.isAndroid) {
IAPManager.dailyCommandLimit = 80;
}
} catch (e, s) {
recordError(e, s, context: 'Initializing IAP Service');
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'There was an error checking purchase status: ${e.toString()}'),
);
debugPrint('Failed to initialize IAP: $e');
// On initialization failure, default to allowing access
IAPManager.instance.isPurchased.value = false;
_isInitialized = true;
}
}
/// Check if the user has already purchased the app
Future<void> _checkExistingPurchase() async {
// First check if we have a stored purchase status
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
final lastPurchaseCheck = await _prefs.read(key: _lastPurchaseCheckKey);
final hasPurchased = await _prefs.read(key: _hasPurchasedKey);
String todayDate = DateFormat('yyyy-MM-dd').format(DateTime.now());
if (storedStatus == "true") {
if (Platform.isAndroid) {
if (lastPurchaseCheck == todayDate || hasPurchased == null) {
// hasPurchased means it was redeemed manually, so we skip the daily check
IAPManager.instance.isPurchased.value = true;
}
} else {
IAPManager.instance.isPurchased.value = true;
}
return;
}
await _prefs.write(key: _lastPurchaseCheckKey, value: todayDate);
// Platform-specific checks for existing paid app purchases
if (Platform.isIOS || Platform.isMacOS) {
// On iOS/macOS, check if the app was previously purchased (has a receipt)
await _checkAppleReceipt();
} else if (Platform.isAndroid) {
// On Android, check if user had the paid version before
await _checkAndroidPreviousPurchase();
}
// Also check for IAP purchase
if (!IAPManager.instance.isPurchased.value) {
await restorePurchases();
}
}
/// Check for Apple receipt (iOS/macOS)
Future<void> _checkAppleReceipt() async {
try {
final receiptContent = await IosReceipt.getAppleReceipt();
if (receiptContent != null) {
debugPrint('Existing Apple user detected - validating receipt $receiptContent');
var sharedSecret =
Platform.environment['VERIFYING_SHARED_SECRET'] ?? const String.fromEnvironment("VERIFYING_SHARED_SECRET");
if (sharedSecret.isEmpty) {
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Shared Secret is empty'));
}
core.connection.signalNotification(
LogNotification('Using shared secret: ${sharedSecret.characters.take(10).join()}'),
);
await validateReceipt(
base64Receipt: receiptContent,
sharedSecret: sharedSecret,
);
} else {
debugPrint('No Apple receipt found');
}
} catch (e) {
core.connection.signalNotification(LogNotification('There was an error checking Apple receipt: ${e.toString()}'));
debugPrint('Error checking Apple receipt: $e');
}
}
Future<void> validateReceipt({
required String base64Receipt,
required String sharedSecret,
bool isSandbox = false,
}) async {
final Uri url = Uri.parse(
isSandbox ? 'https://sandbox.itunes.apple.com/verifyReceipt' : 'https://buy.itunes.apple.com/verifyReceipt',
);
final Map<String, dynamic> requestData = {
'receipt-data': base64Receipt,
'password': sharedSecret,
'exclude-old-transactions': false,
};
final HttpClient client = HttpClient();
try {
final HttpClientRequest request = await client.postUrl(url);
request.headers.set(
HttpHeaders.contentTypeHeader,
'application/json',
);
request.add(utf8.encode(jsonEncode(requestData)));
final HttpClientResponse response = await request.close();
final String responseBody = await response.transform(utf8.decoder).join();
final Map<String, dynamic> json = jsonDecode(responseBody) as Map<String, dynamic>;
if (json['status'] == 21007) {
// Receipt is from sandbox, retry with sandbox URL
debugPrint('Receipt is from sandbox, retrying with sandbox URL');
return validateReceipt(
base64Receipt: base64Receipt,
sharedSecret: sharedSecret,
isSandbox: true,
);
} else if (json['status'] != 0) {
core.connection.signalNotification(
LogNotification('Apple receipt validation failed with status: ${json['status']}'),
);
return;
}
final purchasedVersion = json['receipt']["original_application_version"];
core.connection.signalNotification(
LogNotification('Apple receipt validated for version: $purchasedVersion'),
);
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 0;
IAPManager.instance.isPurchased.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
if (IAPManager.instance.isPurchased.value) {
debugPrint('Apple receipt validation successful - granting full access');
await _prefs.write(key: _purchaseStatusKey, value: "true");
} else {
debugPrint('Apple receipt validation failed - no full access');
}
} catch (e) {
rethrow;
} finally {
client.close();
}
}
/// Check if Android user had the paid app before
Future<void> _checkAndroidPreviousPurchase() async {
try {
// On Android, we use the last seen version to determine if they had the paid version
// IMPORTANT: This assumes the app is currently paid and this update will be released
// while the app is still paid. Only users who downloaded the paid version will have
// a last_seen_version. After changing the app to free, new users won't have this set.
final lastSeenVersion = core.settings.getLastSeenVersion();
core.connection.signalNotification(LogNotification('Android last seen version: $lastSeenVersion'));
if (lastSeenVersion != null && lastSeenVersion.isNotEmpty) {
Version lastVersion = Version.parse(lastSeenVersion);
// If they had a previous version, they're an existing paid user
IAPManager.instance.isPurchased.value = lastVersion < Version(4, 2, 0);
if (IAPManager.instance.isPurchased.value) {
await _prefs.write(key: _purchaseStatusKey, value: "true");
}
debugPrint('Existing Android user detected - granting full access');
}
} catch (e, s) {
debugPrint('Error checking Android previous purchase: $e');
recordError(e, s, context: 'Checking Android previous purchase');
}
}
/// Restore previous purchases
Future<void> restorePurchases() async {
try {
await _inAppPurchase.restorePurchases();
// The purchase stream will be called with restored purchases
} catch (e, s) {
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'There was an error restoring purchases: ${e.toString()}'),
);
recordError(e, s, context: 'Restore Purchases');
debugPrint('Error restoring purchases: $e');
}
}
/// Handle purchase updates
Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) async {
for (final purchase in purchaseDetailsList) {
core.connection.signalNotification(
LogNotification('Purchase found: ${purchase.purchaseID} ${purchase.productID} - ${purchase.status}'),
);
if (purchase.status == PurchaseStatus.purchased || purchase.status == PurchaseStatus.restored) {
IAPManager.instance.isPurchased.value = true;
await _prefs.write(key: _hasPurchasedKey, value: "true");
await _prefs.write(key: _purchaseStatusKey, value: IAPManager.instance.isPurchased.value.toString());
debugPrint('Purchase successful or restored');
}
// Complete the purchase
if (purchase.pendingCompletePurchase) {
_inAppPurchase.completePurchase(purchase);
}
}
}
/// Purchase the full version
Future<void> purchaseFullVersion() async {
try {
if (!_isInitialized) {
await initialize();
}
final available = await _inAppPurchase.isAvailable();
if (!available) {
debugPrint('IAP not available');
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP is not available on this platform.'),
);
return;
}
final productId = 'full_access_unlock';
// Query product details
final response = await _inAppPurchase.queryProductDetails({productId});
if (response.error != null) {
debugPrint('Error querying products: ${response.error}');
if (response.error!.code == 'storekit_no_response') {
_trialStartDate = DateTime.now().toIso8601String();
core.connection.signalNotification(
AlertNotification(
LogLevel.LOGLEVEL_WARNING,
'Unlock will be available, soon! Trial days have been extended.',
),
);
} else {
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP issue: ${response.error!.toString()}'),
);
}
return;
}
if (response.productDetails.isEmpty) {
debugPrint('Product not found: $productId');
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP issue: Product not found.'));
return;
}
final product = response.productDetails.first;
final purchaseParam = PurchaseParam(productDetails: product);
await _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
await restorePurchases();
} catch (e, s) {
debugPrint('Error purchasing: $e');
recordError(e, s, context: 'Error purchasing');
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'There was an error during purchasing: ${e.toString()}'),
);
}
}
/// Check if the trial period has started
bool get hasTrialStarted {
return _trialStartDate != null;
}
/// Start the trial period
Future<void> startTrial() async {
if (!hasTrialStarted) {
await _prefs.write(key: _trialStartDateKey, value: DateTime.now().toIso8601String());
}
}
/// Get the number of days remaining in the trial
int get trialDaysRemaining {
if (IAPManager.instance.isPurchased.value) return 0;
final trialStart = _trialStartDate;
if (trialStart == null) return trialDays;
final startDate = DateTime.parse(trialStart);
final now = DateTime.now();
final daysPassed = now.difference(startDate).inDays;
final remaining = trialDays - daysPassed;
return remaining > 0 ? remaining : 0;
}
/// Check if the trial has expired
bool get isTrialExpired {
return (!IAPManager.instance.isPurchased.value && hasTrialStarted && trialDaysRemaining <= 0);
}
/// Get the number of commands executed today
int get dailyCommandCount {
final lastDate = _lastCommandDate;
final today = DateTime.now().toIso8601String().split('T')[0];
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 0;
}
return _dailyCommandCount ?? 0;
}
/// Increment the daily command count
Future<void> incrementCommandCount() async {
final today = DateTime.now().toIso8601String().split('T')[0];
final lastDate = await _prefs.read(key: _lastCommandDateKey);
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 1;
await _prefs.write(key: _lastCommandDateKey, value: today);
await _prefs.write(key: _dailyCommandCountKey, value: '1');
} else {
final count = _dailyCommandCount ?? 0;
_dailyCommandCount = count + 1;
await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString());
}
}
/// Check if the user can execute a command
bool get canExecuteCommand {
if (IAPManager.instance.isPurchased.value) return true;
if (!isTrialExpired && !Platform.isAndroid) return true;
return dailyCommandCount < IAPManager.dailyCommandLimit;
}
/// Get the number of commands remaining today (for free tier after trial)
int get commandsRemainingToday {
if (IAPManager.instance.isPurchased.value || (!isTrialExpired && !Platform.isAndroid)) return -1; // Unlimited
final remaining = IAPManager.dailyCommandLimit - dailyCommandCount;
return remaining > 0 ? remaining : 0; // Never return negative
}
/// Dispose the service
void dispose() {
_subscription?.cancel();
}
Future<void> reset(bool fullReset) async {
if (fullReset) {
await _prefs.deleteAll();
} else {
await _prefs.delete(key: _purchaseStatusKey);
_isInitialized = false;
await initialize();
}
}
Future<void> redeem() async {
IAPManager.instance.isPurchased.value = true;
await _prefs.write(key: _purchaseStatusKey, value: IAPManager.instance.isPurchased.value.toString());
}
}

View File

@@ -0,0 +1,384 @@
import 'dart:async';
import 'dart:io';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart' as zp;
import 'package:bike_control/bluetooth/messages/notification.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/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:version/version.dart';
/// RevenueCat-based IAP service for iOS, macOS, and Android
class RevenueCatService {
static const int trialDays = 5;
static const String _trialStartDateKey = 'iap_trial_start_date';
static const String _purchaseStatusKey = 'iap_purchase_status';
static const String _dailyCommandCountKey = 'iap_daily_command_count';
static const String _lastCommandDateKey = 'iap_last_command_date';
static const String _syncedPurchasesKey = 'iap_synced_purchases';
// RevenueCat entitlement identifier
static const String fullVersionEntitlement = 'Full Version';
final FlutterSecureStorage _prefs;
final ValueNotifier<bool> isPurchasedNotifier;
final int Function() getDailyCommandLimit;
final void Function(int limit) setDailyCommandLimit;
static const _isAndroidWorking = false;
bool _isInitialized = false;
String? _trialStartDate;
String? _lastCommandDate;
int? _dailyCommandCount;
StreamSubscription<CustomerInfo>? _customerInfoSubscription;
RevenueCatService(
this._prefs, {
required this.isPurchasedNotifier,
required this.getDailyCommandLimit,
required this.setDailyCommandLimit,
});
/// Initialize the RevenueCat service
Future<void> initialize() async {
if (_isInitialized) return;
try {
// Skip RevenueCat initialization on web or unsupported platforms
if (kIsWeb) {
debugPrint('RevenueCat not supported on web');
_isInitialized = true;
return;
}
// Get API key from environment variable
final String apiKey;
if (Platform.isAndroid) {
apiKey =
Platform.environment['REVENUECAT_API_KEY_ANDROID'] ??
const String.fromEnvironment('REVENUECAT_API_KEY_ANDROID', defaultValue: '');
} else if (Platform.isIOS || Platform.isMacOS) {
apiKey =
Platform.environment['REVENUECAT_API_KEY_IOS'] ??
const String.fromEnvironment('REVENUECAT_API_KEY_IOS', defaultValue: '');
} else {
apiKey = '';
}
if (apiKey.isEmpty) {
debugPrint('RevenueCat API key not found in environment');
core.connection.signalNotification(
LogNotification('RevenueCat API key not configured'),
);
isPurchasedNotifier.value = false;
_isInitialized = true;
return;
}
// Configure RevenueCat
final configuration = PurchasesConfiguration(apiKey);
// Enable debug logs in debug mode
if (kDebugMode) {
await Purchases.setLogLevel(LogLevel.debug);
}
await Purchases.configure(configuration);
debugPrint('RevenueCat initialized successfully');
core.connection.signalNotification(
LogNotification('RevenueCat initialized'),
);
// Listen for customer info updates
Purchases.addCustomerInfoUpdateListener((customerInfo) {
_handleCustomerInfoUpdate(customerInfo);
});
_trialStartDate = await _prefs.read(key: _trialStartDateKey);
core.connection.signalNotification(
LogNotification('Trial start date: $_trialStartDate => $trialDaysRemaining'),
);
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
final commandCount = await _prefs.read(key: _dailyCommandCountKey) ?? '0';
_dailyCommandCount = int.tryParse(commandCount);
// Check existing purchase status
await _checkExistingPurchase();
_isInitialized = true;
if (Platform.isAndroid && !isPurchasedNotifier.value && !_isAndroidWorking) {
setDailyCommandLimit(10000);
} else if (!isTrialExpired && Platform.isAndroid) {
setDailyCommandLimit(80);
}
} catch (e, s) {
recordError(e, s, context: 'Initializing RevenueCat Service');
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error initializing RevenueCat. Please check your configuration.',
),
);
debugPrint('Failed to initialize RevenueCat: $e');
isPurchasedNotifier.value = false;
_isInitialized = true;
}
}
/// Check if the user has an active entitlement
Future<void> _checkExistingPurchase() async {
try {
final storedStatus = await _prefs.read(key: _syncedPurchasesKey);
if (storedStatus != "true") {
await _prefs.write(key: _syncedPurchasesKey, value: "true");
await Purchases.syncPurchases();
}
// Check current entitlement status from RevenueCat
final customerInfo = await Purchases.getCustomerInfo();
await _handleCustomerInfoUpdate(customerInfo);
} catch (e, s) {
debugPrint('Error checking existing purchase: $e');
recordError(e, s, context: 'Checking existing purchase');
}
}
/// Handle customer info updates from RevenueCat
Future<bool> _handleCustomerInfoUpdate(CustomerInfo customerInfo) async {
final hasEntitlement = customerInfo.entitlements.active.containsKey(fullVersionEntitlement);
final userId = await Purchases.appUserID;
core.connection.signalNotification(LogNotification('User ID: $userId at ${customerInfo.requestDate}'));
core.connection.signalNotification(LogNotification('Full Version entitlement: $hasEntitlement'));
if (!hasEntitlement) {
// purchased before IAP migration
if (Platform.isAndroid) {
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
if (storedStatus == "true") {
core.connection.signalNotification(LogNotification('Setting full version based on stored status'));
await Purchases.setAttributes({_purchaseStatusKey: "true"});
isPurchasedNotifier.value = true;
}
} else {
final purchasedVersion = customerInfo.originalApplicationVersion;
core.connection.signalNotification(LogNotification('Apple receipt validated for version: $purchasedVersion'));
if (purchasedVersion != null && purchasedVersion.contains(".")) {
final parsedVersion = Version.parse(purchasedVersion);
isPurchasedNotifier.value = parsedVersion < Version(4, 2, 0);
} else {
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 1337;
isPurchasedNotifier.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
}
}
} else {
isPurchasedNotifier.value = hasEntitlement;
}
return isPurchasedNotifier.value;
}
/// Present the RevenueCat paywall
Future<void> presentPaywall() async {
try {
if (!_isInitialized) {
await initialize();
}
final paywallResult = await RevenueCatUI.presentPaywall(displayCloseButton: true);
debugPrint('Paywall result: $paywallResult');
// The customer info listener will handle the purchase update
} catch (e, s) {
debugPrint('Error presenting paywall: $e');
recordError(e, s, context: 'Presenting paywall');
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error displaying the paywall. Please try again.',
),
);
}
}
/// Restore previous purchases
Future<void> restorePurchases() async {
try {
final customerInfo = await Purchases.restorePurchases();
final result = await _handleCustomerInfoUpdate(customerInfo);
if (result) {
core.connection.signalNotification(
AlertNotification(zp.LogLevel.LOGLEVEL_INFO, 'Purchase restored'),
);
}
} catch (e, s) {
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error restoring purchases. Please try again.',
),
);
recordError(e, s, context: 'Restore Purchases');
debugPrint('Error restoring purchases: $e');
}
}
/// Purchase the full version (use paywall instead)
Future<void> purchaseFullVersion(BuildContext context) async {
// Direct the user to the paywall for a better experience
if (Platform.isAndroid && !_isAndroidWorking) {
buildToast(
context,
title: AppLocalizations.of(context).unlockingNotPossible,
duration: Duration(seconds: 5),
);
setDailyCommandLimit(10000);
} else if (Platform.isMacOS) {
try {
final offerings = await Purchases.getOfferings();
final purchaseParams = PurchaseParams.package(offerings.current!.availablePackages.first);
PurchaseResult result = await Purchases.purchase(purchaseParams);
core.connection.signalNotification(
LogNotification('Purchase result: $result'),
);
} on PlatformException catch (e) {
var errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode != PurchasesErrorCode.purchaseCancelledError) {
buildToast(context, title: e.message);
}
}
} else {
await presentPaywall();
}
}
/// Check if the trial period has started
bool get hasTrialStarted {
return _trialStartDate != null;
}
/// Start the trial period
Future<void> startTrial() async {
if (!hasTrialStarted) {
await _prefs.write(key: _trialStartDateKey, value: DateTime.now().toIso8601String());
}
}
/// Get the number of days remaining in the trial
int get trialDaysRemaining {
if (isPurchasedNotifier.value) return 0;
final trialStart = _trialStartDate;
if (trialStart == null) return trialDays;
final startDate = DateTime.parse(trialStart);
final now = DateTime.now();
final daysPassed = now.difference(startDate).inDays;
final remaining = trialDays - daysPassed;
return remaining > 0 ? remaining : 0;
}
/// Check if the trial has expired
bool get isTrialExpired {
return (!isPurchasedNotifier.value && hasTrialStarted && trialDaysRemaining <= 0);
}
/// Get the number of commands executed today
int get dailyCommandCount {
final lastDate = _lastCommandDate;
final today = DateTime.now().toIso8601String().split('T')[0];
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 0;
}
return _dailyCommandCount ?? 0;
}
/// Increment the daily command count
Future<void> incrementCommandCount() async {
final today = DateTime.now().toIso8601String().split('T')[0];
final lastDate = await _prefs.read(key: _lastCommandDateKey);
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 1;
await _prefs.write(key: _lastCommandDateKey, value: today);
await _prefs.write(key: _dailyCommandCountKey, value: '1');
} else {
final count = _dailyCommandCount ?? 0;
_dailyCommandCount = count + 1;
await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString());
}
}
/// Check if the user can execute a command
bool get canExecuteCommand {
if (isPurchasedNotifier.value) return true;
if (!isTrialExpired && !Platform.isAndroid) return true;
return dailyCommandCount < getDailyCommandLimit();
}
/// Get the number of commands remaining today (for free tier after trial)
int get commandsRemainingToday {
if (isPurchasedNotifier.value || (!isTrialExpired && !Platform.isAndroid)) return -1; // Unlimited
final remaining = getDailyCommandLimit() - dailyCommandCount;
return remaining > 0 ? remaining : 0; // Never return negative
}
/// Dispose the service
void dispose() {
_customerInfoSubscription?.cancel();
}
Future<void> reset(bool fullReset) async {
if (fullReset) {
await _prefs.deleteAll();
} else {
await _prefs.delete(key: _purchaseStatusKey);
_isInitialized = false;
Purchases.invalidateCustomerInfoCache();
await initialize();
_checkExistingPurchase();
}
}
Future<void> redeem(String purchaseId) async {
await Purchases.setAttributes({"purchase_id": purchaseId});
core.connection.signalNotification(LogNotification('Redeemed purchase ID: $purchaseId'));
Purchases.invalidateCustomerInfoCache();
_checkExistingPurchase();
isPurchasedNotifier.value = true;
}
Future<void> setAttributes() async {
// attributes are fully anonymous
await Purchases.setAttributes({
"bikecontrol_trainer": core.settings.getTrainerApp()?.name ?? '-',
"bikecontrol_target": core.settings.getLastTarget()?.name ?? '-',
if (core.connection.controllerDevices.isNotEmpty)
'bikecontrol_controllers': core.connection.controllerDevices.joinToString(
transform: (d) => d.toString(),
separator: ',',
),
'bikecontrol_keymap': core.settings.getKeyMap()?.name ?? '-',
});
}
}

View File

@@ -0,0 +1,170 @@
import 'dart:async';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/utils/windows_store_environment.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:windows_iap/windows_iap.dart';
/// Windows-specific IAP service
/// Note: This is a stub implementation. For actual Windows Store integration,
/// you would need to use the Windows Store APIs through platform channels.
class WindowsIAPService {
static const String productId = '9NP42GS03Z26';
static const int trialDays = 7;
static const int dailyCommandLimit = 15;
static const String _purchaseStatusKey = 'iap_purchase_status_2';
static const String _dailyCommandCountKey = 'iap_daily_command_count';
static const String _lastCommandDateKey = 'iap_last_command_date';
final FlutterSecureStorage _prefs;
bool _isInitialized = false;
String? _lastCommandDate;
int? _dailyCommandCount;
final _windowsIapPlugin = WindowsIap();
WindowsIAPService(this._prefs);
/// Initialize the Windows IAP service
Future<void> initialize() async {
if (_isInitialized) return;
try {
// Check if already purchased
await _checkExistingPurchase();
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
_dailyCommandCount = int.tryParse(await _prefs.read(key: _dailyCommandCountKey) ?? '0');
_isInitialized = true;
} catch (e, s) {
recordError(e, s, context: 'Initializing');
debugPrint('Failed to initialize Windows IAP: $e');
_isInitialized = true;
}
}
/// Check if the user has already purchased the app
Future<void> _checkExistingPurchase() async {
// First check if we have a stored purchase status
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
core.connection.signalNotification(LogNotification('Is purchased status: $storedStatus'));
if (storedStatus == "true") {
IAPManager.instance.isPurchased.value = true;
return;
}
final trial = await _windowsIapPlugin.getTrialStatusAndRemainingDays();
core.connection.signalNotification(LogNotification('Trial status: $trial'));
final trialEndDate = trial.remainingDays;
if (trial.isTrial && trialEndDate.isNotEmpty && !trialEndDate.contains("?")) {
try {
trialDaysRemaining = DateTime.parse(trialEndDate).difference(DateTime.now()).inDays;
} catch (e) {
core.connection.signalNotification(LogNotification('Error parsing trial end date: $e'));
trialDaysRemaining = 0;
}
} else {
final isStorePackaged = await WindowsStoreEnvironment.isPackaged();
trial.isActive = isStorePackaged;
trialDaysRemaining = 0;
}
if (trial.isActive && !trial.isTrial && trialDaysRemaining <= 0) {
IAPManager.instance.isPurchased.value = true;
await _prefs.write(key: _purchaseStatusKey, value: "true");
} else {
IAPManager.instance.isPurchased.value = false;
}
}
/// Purchase the full version
/// TODO: Implement actual Windows Store purchase flow
Future<void> purchaseFullVersion() async {
try {
final status = await _windowsIapPlugin.makePurchase(productId);
if (status == StorePurchaseStatus.succeeded || status == StorePurchaseStatus.alreadyPurchased) {
IAPManager.instance.isPurchased.value = true;
buildToast(
navigatorKey.currentContext!,
title: 'Purchase Successful',
subtitle: 'Thank you for your purchase! You now have unlimited access.',
);
}
} catch (e, s) {
recordError(e, s, context: 'Purchasing on Windows');
debugPrint('Error purchasing on Windows: $e');
}
}
/// Check if the trial period has started
bool get hasTrialStarted => trialDaysRemaining >= 0;
/// Get the number of days remaining in the trial
int trialDaysRemaining = 0;
/// Check if the trial has expired
bool get isTrialExpired {
return !IAPManager.instance.isPurchased.value && hasTrialStarted && trialDaysRemaining <= 0;
}
/// Get the number of commands executed today
int get dailyCommandCount {
final lastDate = _lastCommandDate;
final today = DateTime.now().toIso8601String().split('T')[0];
if (lastDate != today) {
// Reset counter for new day
return 0;
}
return _dailyCommandCount ?? 0;
}
/// Increment the daily command count
Future<void> incrementCommandCount() async {
final today = DateTime.now().toIso8601String().split('T')[0];
final lastDate = _lastCommandDate;
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 1;
await _prefs.write(key: _lastCommandDateKey, value: today);
await _prefs.write(key: _dailyCommandCountKey, value: "1");
} else {
final count = _dailyCommandCount ?? 0;
_dailyCommandCount = count + 1;
await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString());
}
}
/// Check if the user can execute a command
bool get canExecuteCommand {
if (IAPManager.instance.isPurchased.value) return true;
if (!isTrialExpired) return true;
return dailyCommandCount < dailyCommandLimit;
}
/// Get the number of commands remaining today (for free tier after trial)
int get commandsRemainingToday {
if (IAPManager.instance.isPurchased.value || !isTrialExpired) return -1; // Unlimited
final remaining = dailyCommandLimit - dailyCommandCount;
return remaining > 0 ? remaining : 0; // Never return negative
}
/// Dispose the service
void dispose() {
// Nothing to dispose for Windows
}
void reset() {
_prefs.deleteAll();
}
}

View File

@@ -15,6 +15,7 @@ class MyWhoosh extends SupportedApp {
compatibleTargets: Target.values,
supportsZwiftEmulation: false,
supportsOpenBikeProtocol: screenshotMode,
star: true,
keymap: Keymap(
keyPairs: [
...ControllerButton.values

View File

@@ -6,7 +6,7 @@ import '../keymap.dart';
class OpenBikeControl extends SupportedApp {
OpenBikeControl()
: super(
name: 'OpenBikeControl compatible app',
name: 'OpenBikeControl Compatible',
packageName: "org.openbikecontrol",
compatibleTargets: Target.values,
supportsZwiftEmulation: false,

View File

@@ -1,12 +1,12 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/requirements/multi.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../keymap.dart';
@@ -17,6 +17,7 @@ class Rouvy extends SupportedApp {
packageName: "eu.virtualtraining.rouvy.android",
compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values,
supportsZwiftEmulation: !kIsWeb && Platform.isAndroid,
star: true,
keymap: Keymap(
keyPairs: [
// https://support.rouvy.com/hc/de/articles/32452137189393-Virtuelles-Schalten#h_01K5GMVG4KVYZ0Y6W7RBRZC9MA

View File

@@ -16,6 +16,7 @@ abstract class SupportedApp {
final Keymap keymap;
final bool supportsZwiftEmulation;
final bool supportsOpenBikeProtocol;
final bool star;
const SupportedApp({
required this.name,
@@ -24,6 +25,7 @@ abstract class SupportedApp {
required this.compatibleTargets,
required this.supportsZwiftEmulation,
this.supportsOpenBikeProtocol = false,
this.star = false,
});
static final List<SupportedApp> supportedApps = [

View File

@@ -1,24 +1,25 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:bike_control/bluetooth/devices/elite/elite_square.dart';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/requirements/multi.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../keymap.dart';
class TrainingPeaks extends SupportedApp {
TrainingPeaks()
: super(
name: 'TrainingPeaks Virtual / IndieVelo',
name: 'TrainingPeaks Virtual',
packageName: "com.indieVelo.client",
compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values,
supportsZwiftEmulation: false,
supportsOpenBikeProtocol: false,
star: true,
keymap: Keymap(
keyPairs: [
// Explicit controller-button mappings with updated touch coordinates

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