Compare commits

...

97 Commits

Author SHA1 Message Date
Jonas Bark
a89ffc7ffd integrate shorebird 2025-10-12 12:12:46 +02:00
Jonas Bark
4e75270e49 Merge branch 'ios' 2025-10-12 11:33:57 +02:00
Jonas Bark
e08a1dc183 version++ 2025-10-12 11:33:51 +02:00
Jonas Bark
8fa31968c0 integrate shorebird 2025-10-12 11:13:54 +02:00
Jonas Bark
27e25978f2 integrate shorebird 2025-10-12 11:06:24 +02:00
Jonas Bark
5a0761ef1a integrate shorebird 2025-10-12 11:05:13 +02:00
Jonas Bark
52c40e6f5c fix a few issues in button customization screen 2025-10-12 10:16:36 +02:00
Jonas Bark
be7a18384c fix desktop actions when device pixel ratio is not 1 2025-10-11 10:44:35 +02:00
Jonas Bark
b4693229d2 github actions change 2025-10-10 21:28:13 +02:00
Jonas Bark
dc28be0657 screenshot mode 2025-10-10 15:20:58 +02:00
Jonas Bark
ce6f33522f version++ 2025-10-10 15:03:37 +02:00
Jonas Bark
200ac9d81e Merge branch 'copilot/fix-2d2954be-782f-43b7-b654-d4aa8263083d' into ios 2025-10-10 15:02:40 +02:00
Jonas Bark
078398daba make the UI look nicer 2025-10-10 14:58:30 +02:00
Jonas Bark
9ac73ec6fc make the UI look nicer 2025-10-10 14:23:14 +02:00
Jonas Bark
a469134d2f UI changes and fixes 2025-10-10 12:26:20 +02:00
Jonas Bark
57690808dd UI changes and fixes 2025-10-10 11:10:12 +02:00
Jonas Bark
4edc8ef10c UI changes and fixes 2025-10-10 11:06:16 +02:00
Jonas Bark
576e66c60c fix merge 2025-10-10 09:42:15 +02:00
Jonas Bark
0e53f225d0 Merge branch 'ios' into copilot/fix-2d2954be-782f-43b7-b654-d4aa8263083d
# Conflicts:
#	lib/pages/touch_area.dart
2025-10-10 09:41:16 +02:00
Jonas Bark
5d656913a8 fix when setting full screen takes long time 2025-10-09 23:14:23 +02:00
Jonas Bark
49cea5f45d fix UI 2025-10-09 23:08:54 +02:00
Jonas Bark
255435e419 fix coordinates 2025-10-09 22:48:09 +02:00
Jonas Bark
1657338640 Merge branch 'ios' into copilot/fix-2d2954be-782f-43b7-b654-d4aa8263083d 2025-10-09 22:41:33 +02:00
Jonas Bark
eb66731784 dev fix 2025-10-09 22:41:26 +02:00
Jonas Bark
07c9abc87b fix simulate button 2025-10-09 22:41:15 +02:00
Jonas Bark
f5e8bad1ae initial fixes 2025-10-09 22:37:07 +02:00
Jonas Bark
38e9533bfa Merge branch 'ios' into copilot/fix-2d2954be-782f-43b7-b654-d4aa8263083d 2025-10-09 22:17:28 +02:00
copilot-swe-agent[bot]
2cd0273382 Remove screenSize parameter from encode/decode methods
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-09 20:06:11 +00:00
copilot-swe-agent[bot]
d62d572387 Implement full migration to percentage-based keymap coordinates
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-09 19:46:16 +00:00
copilot-swe-agent[bot]
b65fe57c68 Add comprehensive tests for import/export and percentage-based keymaps
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-09 19:33:56 +00:00
copilot-swe-agent[bot]
0e5f6ef2dd Add import/export functionality for keymap profiles
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-09 19:32:46 +00:00
copilot-swe-agent[bot]
45112ccfcf Implement percentage-based keymap storage for better device compatibility
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-09 19:30:34 +00:00
copilot-swe-agent[bot]
d26e937066 Remove documentation file and refactor to use Settings methods instead of exposing prefs
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-09 19:26:22 +00:00
Jonas Bark
bb1bb42214 build Android for Play Store 2025-10-09 21:21:51 +02:00
Jonas Bark
07c16dcbe2 build Android for Play Store 2025-10-09 20:46:18 +02:00
Jonas Bark
1b4f5613ac build Android for Play Store 2025-10-09 20:43:07 +02:00
jonasbark
3315bcd73e Revise download section in README.md
iOS + Mac App Store links
2025-10-09 16:05:55 +02:00
Jonas Bark
87f33b9a15 rebuild 2025-10-09 15:55:15 +02:00
Jonas Bark
c06d364344 rebuild 2025-10-09 15:28:01 +02:00
Jonas Bark
cbab56c17b rebuild 2025-10-09 15:10:57 +02:00
Jonas Bark
585c78c232 rebuild 2025-10-09 15:06:38 +02:00
Jonas Bark
e569b20b9f zommable customize keymap screen 2025-10-09 14:53:54 +02:00
Jonas Bark
590e18ee43 rebuild 2025-10-09 14:20:06 +02:00
Jonas Bark
a8edd09eae rebuild 2025-10-09 14:10:40 +02:00
Jonas Bark
f3dae6fb48 rebuild 2025-10-09 13:52:18 +02:00
Jonas Bark
b4672c7f39 rebuild 2025-10-09 13:39:28 +02:00
Jonas Bark
e60a7b61a8 rebuild 2025-10-09 13:39:05 +02:00
Jonas Bark
e443e5ab0d rebuild 2025-10-09 13:34:58 +02:00
Jonas Bark
29f773d212 rebuild 2025-10-09 13:30:25 +02:00
Jonas Bark
86d09450b0 rebuild 2025-10-09 13:25:28 +02:00
jonasbark
c081da9545 Merge pull request #91 from jmoro/main
feature: add analog paddle support for Zwift Ride
2025-10-09 12:53:20 +02:00
Jonas Bark
4d0f447b25 rebuild 2025-10-09 12:52:28 +02:00
Jonas Bark
9cc7c1b123 rebuild 2025-10-09 12:46:33 +02:00
Jonas Bark
354742a545 rebuild 2025-10-09 12:44:42 +02:00
Jonas Bark
b64fbfb6e4 rebuild 2025-10-09 12:40:12 +02:00
Jonas Bark
3a2ff5c8d2 rebuild 2025-10-09 12:31:57 +02:00
Jonas Bark
a5a4d9e0c2 rebuild 2025-10-09 12:19:35 +02:00
Jonas Bark
cfeef1621a rebuild 2025-10-09 12:18:48 +02:00
Jonas Bark
2e25b09bdf rebuild 2025-10-09 12:15:22 +02:00
Jonas Bark
5ba70376e6 rebuild 2025-10-09 12:05:26 +02:00
Jonas Bark
7c07d6ecf8 rebuild 2025-10-09 12:02:42 +02:00
Jonas Bark
2788ecc32e rebuild 2025-10-09 11:56:18 +02:00
Jonas Bark
26dc9e93b3 rebuild 2025-10-09 11:52:58 +02:00
Jonas Bark
14bf6c9ac3 rebuild 2025-10-09 11:25:39 +02:00
Jonas Bark
1db9669ed2 rebuild 2025-10-09 10:53:37 +02:00
Jonas Bark
c466e6dfa3 rebuild 2025-10-09 10:51:59 +02:00
Jonas Bark
1c00921ee1 - keep iOS app alive when in background
- keep app active on iOS to keep the remote control happy
- reconnect when this was ignored
2025-10-09 10:45:02 +02:00
Jonas Bark
df432542b5 - keep iOS app alive when in background
- keep app active on iOS to keep the remote control happy
- reconnect when this was ignored
2025-10-09 09:51:47 +02:00
Jonas Bark
fe989750e7 app store compliance 2025-10-08 20:56:40 +02:00
Jonas Bark
e008dea61e fix BT connection issues 2025-10-08 13:08:40 +02:00
Jonas Bark
7a8c7c963b add troubleshooting guide 2025-10-08 12:04:57 +02:00
Jonas Bark
0ecf285a95 adjust readme, changelog 2025-10-08 11:26:17 +02:00
Jonas Bark
b14500351f android works okish 2025-10-08 11:10:26 +02:00
Jonas Bark
97693e25b8 zwift ride does not need encryption 2025-10-08 11:02:02 +02:00
Jonas Bark
12d573bc55 refactoring to allow remote handling on non iOS devices 2025-10-08 10:34:53 +02:00
Javier Moro Sotelo
68562aaec9 fixup! fixup! feature: add analog paddle support for Zwift Ride 2025-10-08 09:56:49 +02:00
Javier Moro Sotelo
2c7e714856 fixup! feature: add analog paddle support for Zwift Ride 2025-10-08 09:34:40 +02:00
Jonas Bark
a7183cc519 remove donate button from iOS 2025-10-08 09:24:43 +02:00
Jonas Bark
bfffb2856d try it on Android #1 2025-10-08 09:18:53 +02:00
Javier Moro Sotelo
d2be747fc1 feature: add analog paddle support for Zwift Ride
Implement analog paddle detection for Zwift Ride with Protocol Buffer parsing.
Paddles (Location 0=left, 1=right) trigger when pressure exceeds threshold and
are user-configurable via keymap settings.

Includes comprehensive test suite and reusable Protocol Buffer parser utilities
for handling non-standard embedded analog data.

Fixes #21

Signed-off-by: Javier Moro Sotelo <810976+jmoro@users.noreply.github.com>
2025-10-08 08:13:40 +02:00
Jonas Bark
7fb44d2782 use relative coordinates on iOS touch targets 2025-10-07 21:52:41 +02:00
Jonas Bark
d7b46205fa make macOS work #7 2025-10-07 20:42:12 +02:00
Jonas Bark
0e0835c2f7 make macOS work #7 2025-10-07 20:08:46 +02:00
Jonas Bark
e81d6cb86f make it work #7 2025-10-07 19:22:38 +02:00
Jonas Bark
8eef01437c make it work #6 2025-10-07 18:33:00 +02:00
Jonas Bark
0d446ee293 make it work #5 2025-10-07 17:47:05 +02:00
Jonas Bark
c0afe1792e make it work #4 2025-10-07 16:53:19 +02:00
Jonas Bark
11fdcad57d make it work #3 2025-10-07 16:41:53 +02:00
Jonas Bark
2ac94907e8 make it work #2 2025-10-06 22:43:36 +02:00
Jonas Bark
f7669b2bbc make it work 2025-10-06 21:41:37 +02:00
Jonas Bark
89d200243b Revert "try flutter_ble_peripheral"
This reverts commit 013b078a44.
2025-10-06 19:28:43 +02:00
Jonas Bark
013b078a44 try flutter_ble_peripheral 2025-10-06 19:27:52 +02:00
Jonas Bark
06aefdedc2 try bluetooth_low_energy 2025-10-06 18:46:46 +02:00
copilot-swe-agent[bot]
4071a12c11 Add documentation for custom profiles feature
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-05 10:28:38 +00:00
copilot-swe-agent[bot]
83cdb6efd7 Add comprehensive tests for custom profile functionality
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-05 10:26:52 +00:00
copilot-swe-agent[bot]
040c0d3027 Implement multiple custom mapping profiles feature
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-05 10:26:20 +00:00
copilot-swe-agent[bot]
a44d4d62d0 Initial plan 2025-10-05 10:19:54 +00:00
91 changed files with 3222 additions and 1391 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- ios
paths:
- '.github/workflows/**'
- 'lib/**'
@@ -11,6 +12,10 @@ on:
- 'keypress_simulator/**'
- 'pubspec.yaml'
env:
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
FLUTTER_VERSION: 3.35.5
jobs:
build:
name: Build & Release
@@ -29,15 +34,27 @@ jobs:
- name: Install certificates
env:
DEVELOPER_ID_APPLICATION_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64_MAC }}
DEVELOPER_ID_INSTALLER_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_INSTALLER_P12_BASE64_MAC }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
APPSTORE_PROFILE_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_IOS_BASE64 }}
APPSTORE_PROFILE_MACOS_BASE64: ${{ secrets.APPSTORE_PROFILE_MACOS_BASE64 }}
APPSTORE_PROFILE_DEV_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_DEV_IOS_BASE64 }}
run: |
# create variables
DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_application_certificate.p12
DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_installer_certificate.p12
PP_PATH_IOS=$RUNNER_TEMP/build_pp_ios.mobileprovision
PP_PATH_IOS_DEV=$RUNNER_TEMP/build_pp_ios_dev.mobileprovision
PP_PATH_MACOS=$RUNNER_TEMP/build_pp_macos.provisionprofile
KEYCHAIN_PATH=$RUNNER_TEMP/pg-signing.keychain-db
# import certificate and provisioning profile from secrets
echo -n "$DEVELOPER_ID_APPLICATION_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH
echo -n "$DEVELOPER_ID_INSTALLER_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH
echo -n "$APPSTORE_PROFILE_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS
echo -n "$APPSTORE_PROFILE_DEV_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS_DEV
echo -n "$APPSTORE_PROFILE_MACOS_BASE64" | base64 --decode -o $PP_PATH_MACOS
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
@@ -47,32 +64,28 @@ jobs:
# import certificate to keychain
security import $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security import $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH_IOS ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH_IOS_DEV ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles
#2 Setup Java
- name: Set Up Java
uses: actions/setup-java@v3.12.0
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:
distribution: 'oracle'
java-version: '17'
cache: true
#3 Setup Flutter
- name: Set Up Flutter
uses: subosito/flutter-action@v2
- name: 🚀 Shorebird Release macOS
uses: shorebirdtech/shorebird-release@v1
with:
channel: 'stable'
#4 Install Dependencies
- name: Install Dependencies
run: flutter pub get
#8 Build app ( macos Build )
- name: Build App
run: flutter build macos --release
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: macos
- name: Code Signing
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --options runtime SwiftControl.app -v
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v
working-directory: build/macos/Build/Products/Release
env:
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
@@ -82,16 +95,25 @@ jobs:
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
- name: Build APK
run: flutter build apk --release
- name: 🚀 Shorebird Release Android
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: android
args: "--artifact=apk"
- name: Build Bundle
run: flutter build appbundle --release
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build Web
if: github.ref == 'refs/heads/main'
run: flutter build web --release --base-href "/swiftcontrol/"
- name: Handle archives
if: github.ref == 'refs/heads/main'
run: |
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
cd build/macos/Build/Products/Release/
@@ -99,6 +121,7 @@ jobs:
#9 Upload Artifacts
- name: Upload Artifacts
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: Releases
@@ -108,6 +131,7 @@ jobs:
#10 Extract Version
- name: Extract version from pubspec.yaml
if: github.ref == 'refs/heads/main'
id: extract_version
run: |
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
@@ -115,6 +139,7 @@ jobs:
#11 Check if Tag Exists
- name: Check if Tag Exists
if: github.ref == 'refs/heads/main'
id: check_tag
run: |
if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then
@@ -125,7 +150,7 @@ jobs:
#12 Modify Tag if it Exists
- name: Modify Tag
if: env.TAG_EXISTS == 'true'
if: env.TAG_EXISTS == 'true' && github.ref == 'refs/heads/main'
id: modify_tag
run: |
new_version="${{ env.VERSION }}-build-${{ github.run_number }}"
@@ -133,22 +158,25 @@ jobs:
#13 Create Release
- name: Create Release
if: github.ref == 'refs/heads/main'
uses: ncipollo/release-action@v1
with:
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
allowUpdates: true
prerelease: ${{ endsWith(env.VERSION, '1337') }}
body: "You can also download the Android version from the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"
bodyFile: scripts/RELEASE_NOTES.md
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
- name: Upload static files as artifact
if: github.ref == 'refs/heads/main'
id: deployment
uses: actions/upload-pages-artifact@v3
with:
path: build/web
- name: Web Deploy
if: github.ref == 'refs/heads/main'
uses: actions/deploy-pages@v4
- name: Extract latest changelog
@@ -156,7 +184,22 @@ jobs:
run: |
chmod +x scripts/get_latest_changelog.sh
mkdir -p whatsnew
./scripts/get_latest_changelog.sh > whatsnew/whatsnew-en-US
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
- name: 🚀 Shorebird Release iOS
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: ios
args: "--export-options-plist ios/ExportOptions.plist"
- name: Prepare App Store authentication key
env:
API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }}
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
run: |
mkdir -p ./private_keys;
printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8";
- name: Upload to Play Store
# only upload when env.VERSION does not end with 1337, which is our indicator for beta releases
@@ -169,8 +212,25 @@ jobs:
track: production
whatsNewDirectory: whatsnew
- name: Upload to macOS App Store
env:
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
run: |
productbuild --component "build/macos/Build/Products/Release/SwiftControl.app" /Applications "SwiftControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
xcrun altool --upload-app -f SwiftControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
- name: Upload to iOS App Store
env:
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
run: |
xcrun altool --upload-app -f build/ios/ipa/swift_play.ipa -t ios --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
windows:
needs: build
if: github.ref == 'refs/heads/main'
name: Build & Release on Windows
runs-on: windows-latest
@@ -186,18 +246,16 @@ jobs:
distribution: 'oracle'
java-version: '17'
#3 Setup Flutter
- name: Set Up Flutter
uses: subosito/flutter-action@v2
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:
channel: 'stable'
cache: true
#4 Install Dependencies
- name: Install Dependencies
run: flutter pub get
- name: Build App
run: flutter build windows
- name: 🚀 Shorebird Release Windows
uses: shorebirdtech/shorebird-release@v1
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: windows
- name: Zip directory (Windows)
shell: pwsh
@@ -248,5 +306,6 @@ jobs:
with:
allowUpdates: true
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip"
bodyFile: scripts/RELEASE_NOTES.md
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}

View File

@@ -1,3 +1,13 @@
### 3.0.3 (2025-10-12)
- SwiftControl now supports iOS!
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations but...:
- You can now use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
- your phone (Android/iOS) runs SwiftControl and connects to your Click devices
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
- Ride: analog paddles are now supported thanks to contributor @jmoro
- you can now zoom in and out in the Keymap customization screen
### 2.6.3 (2025-10-01)
- fix a few issues with the new touch placement feature
- add a workaround for Zwift Click V2 which resets the device when button events are no longer sent

View File

@@ -11,8 +11,6 @@ With SwiftControl you can **control your favorite trainer app** using your Zwift
- control music on your device
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
**Android AccessibilityService Usage**: On Android, SwiftControl uses the AccessibilityService API to simulate touch gestures on your screen, allowing your Zwift devices to control training apps. This service only monitors which app window is active and performs touch gestures at the locations you configure. No personal data is accessed or collected.
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
@@ -21,20 +19,21 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
## Downloads
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a>
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a>
Get the latest version for free for Windows, macOS and Android here: https://github.com/jonasbark/swiftcontrol/releases
Get the latest version for Windows here: https://github.com/jonasbark/swiftcontrol/releases
## Supported Apps
- MyWhoosh
- indieVelo / Training Peaks
- Biketerra.com
- any other:
- Android: you can customize simulated touch points of all your buttons in the app
- Desktop: you can customize keyboard shortcuts and mouse clicks in the app
- any other! Customize touch points or keyboard shortcuts to your liking
## Supported Devices
- Zwift Click
- Zwift Click v2 (mostly, see #68)
- Zwift Click v2 (mostly, see issue #68)
- Zwift Ride
- Zwift Play
@@ -47,22 +46,27 @@ Get the latest version for free for Windows, macOS and Android here: https://git
- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).
- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70).
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
- NOT SUPPORTED: iOS (iPhone, iPad) as Apple does not provide any way to simulate touches or keyboard events
- iOS (iPhone, iPad)
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you can use it to remotely control MyWhoosh and similar on e.g. an iPad.
## Troubleshooting
- Your Zwift device is found but connection does not work properly? You may need to update the firmware in Zwift Companion app.
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
## How does it work?
The app connects to your Zwift device automatically. It does not connect to your trainer itself.
- When using Android: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
- When using macOS or Windows a keyboard or mouse click is used to trigger the action.
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
- you can also create your own Keymaps for any other app
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
- your phone (Android/iOS) runs SwiftControl and connects to your Zwift devices
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
## Alternatives
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app)
- [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.
## Donate
Please consider donating to support the development of this app :)

22
TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,22 @@
## Click device cannot be found
You may need to update the firmware in Zwift Companion app.
## Click device does not send any data
You may need to update the firmware in Zwift Companion app.
## My Click v2 disconnects after a minute
Check [this](https://github.com/jonasbark/swiftcontrol/issues/68) discussion.
To make your Click V2 work best you should connect it in the Zwift app once each day.
If you don't do that SwiftControl will need to reconnect every minute.
1. Open Zwift app (not the Companion)
2. Log in (subscription not required) and open the device connection screen
3. Connect your Trainer, then connect the Click V2
4. Close the Zwift app again and connect again in SwiftControl
## Remote control is not working - nothing happens
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
- Try restarting the pairing process in SwiftControl
- try restarting Bluetooth on your phone and on the device you want to control
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.

View File

@@ -23,8 +23,10 @@ linter:
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
- require_trailing_commas
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
formatter:
page_width: 120
trailing_commas: preserve

View File

@@ -1,7 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- Allow Bluetooth -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<!-- New Bluetooth permissions in Android 12
https://developer.android.com/about/versions/12/features/bluetooth-permissions -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
@@ -16,7 +18,7 @@
<!-- legacy for Android 9 or lower -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" tools:replace="android:maxSdkVersion" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- to check if you have the latest version -->

View File

@@ -9,9 +9,9 @@ flutter_launcher_icons:
# adaptive_icon_foreground: "assets/icon/foreground.png"
# adaptive_icon_monochrome: "assets/icon/monochrome.png"
ios: false
ios: true
# image_path_ios: "assets/icon/icon.png"
remove_alpha_channel_ios: true
remove_alpha_ios: true
# image_path_ios_dark_transparent: "assets/icon/icon_dark.png"
# image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png"
# desaturate_tinted_to_grayscale_ios: true

29
ios/ExportOptions.plist Normal file
View File

@@ -0,0 +1,29 @@
<?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>destination</key>
<string>export</string>
<key>generateAppStoreInformation</key>
<false/>
<key>manageAppVersionAndBuildNumber</key>
<true/>
<key>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>manual</string>
<key>provisioningProfiles</key>
<dict>
<key>de.jonasbark.swiftcontrol.darwin</key>
<string>ios app store</string>
</dict>
<key>stripSwiftSymbols</key>
<true/>
<key>teamID</key>
<string>UZRHKPVWN9</string>
<key>testFlightInternalTestingOnly</key>
<false/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>

View File

@@ -1,4 +1,7 @@
PODS:
- bluetooth_low_energy_darwin (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@@ -10,6 +13,10 @@ PODS:
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- restart (1.0.0):
- Flutter
- restart_app (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -18,19 +25,27 @@ PODS:
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- bluetooth_low_energy_darwin (from `.symlinks/plugins/bluetooth_low_energy_darwin/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- restart (from `.symlinks/plugins/restart/ios`)
- restart_app (from `.symlinks/plugins/restart_app/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`)
EXTERNAL SOURCES:
bluetooth_low_energy_darwin:
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
@@ -43,23 +58,33 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
restart:
:path: ".symlinks/plugins/restart/ios"
restart_app:
:path: ".symlinks/plugins/restart_app/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
universal_ble:
:path: ".symlinks/plugins/universal_ble/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
bluetooth_low_energy_darwin: 764d8d1ae5abefbcdb839e812b4b25c0061fcf8b
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
restart: b5fe16e6e038f0024b2f3af43768e9d2a1557554
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e

View File

@@ -97,7 +97,6 @@
8AA6D129479129F106E2298A /* Pods-RunnerTests.release.xcconfig */,
EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -488,16 +487,19 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7BL8RUV2K6;
DEVELOPMENT_TEAM = UZRHKPVWN9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftPlay;
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -558,7 +560,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -615,7 +617,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@@ -671,16 +673,19 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7BL8RUV2K6;
DEVELOPMENT_TEAM = UZRHKPVWN9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftPlay;
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -694,16 +699,19 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 7BL8RUV2K6;
DEVELOPMENT_TEAM = UZRHKPVWN9;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftPlay;
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";

View File

@@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 880 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -2,6 +2,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>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -24,6 +26,17 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>SwiftControl uses Bluetooth to connect to accessories.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-peripheral</string>
<string>bluetooth-central</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -41,11 +54,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>SwiftControl</string>
</dict>
</plist>

View File

@@ -186,6 +186,10 @@ class Connection {
devices.clear();
}
void signalNotification(BaseNotification notification) {
_actionStreams.add(notification);
}
void signalChange(BaseDevice baseDevice) {
_connectionStreams.add(baseDevice);
}

View File

@@ -27,11 +27,10 @@ abstract class BaseDevice {
final zapEncryption = ZapCrypto(LocalKeyProvider());
bool isConnected = false;
bool _isInited = false;
int? batteryLevel;
String? firmwareVersion;
bool supportsEncryption = true;
bool supportsEncryption = false;
BleCharacteristic? syncRxCharacteristic;
Timer? _longPressTimer;
@@ -366,7 +365,6 @@ abstract class BaseDevice {
}
Future<void> disconnect() async {
_isInited = false;
_longPressTimer?.cancel();
// Release any held keys in long press mode
if (actionHandler is DesktopActions) {

View File

@@ -14,6 +14,11 @@ import '../messages/notification.dart';
import '../protocol/zp.pb.dart';
class ZwiftRide extends BaseDevice {
/// Minimum absolute analog value (0-100) required to trigger paddle button press.
/// Values below this threshold are ignored to prevent accidental triggers from
/// analog drift or light touches.
static const int analogPaddleThreshold = 25;
ZwiftRide(super.scanResult)
: super(
availableButtons: [
@@ -84,9 +89,7 @@ class ZwiftRide extends BaseDevice {
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once per day. Resetting the device now.',
),
);
if (!kDebugMode) {
sendCommand(Opcode.RESET, null);
}
sendCommand(Opcode.RESET, null);
}
switch (opcode) {
@@ -200,7 +203,11 @@ class ZwiftRide extends BaseDevice {
@override
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
final RideNotification clickNotification = RideNotification(message);
final RideNotification clickNotification = RideNotification(
message,
analogPaddleThreshold: analogPaddleThreshold,
);
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
_lastControllerNotification = clickNotification;

View File

@@ -1,6 +1,7 @@
import 'dart:typed_data';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
@@ -34,10 +35,14 @@ enum _RideButtonMask {
class RideNotification extends BaseNotification {
late List<ZwiftButton> buttonsClicked;
late List<ZwiftButton> analogButtons;
RideNotification(Uint8List message) {
RideNotification(Uint8List message, {required int analogPaddleThreshold}) {
final status = RideKeyPadStatus.fromBuffer(message);
// Debug: Log all button mask detections (moved to ZwiftRide.processClickNotification)
// Process DIGITAL buttons separately
buttonsClicked = [
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationLeft,
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationRight,
@@ -58,22 +63,37 @@ class RideNotification extends BaseNotification {
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffRight,
];
for (final analogue in status.analogButtons.groupStatus) {
if (analogue.analogValue.abs() == 100) {
if (analogue.location == RideAnalogLocation.LEFT) {
buttonsClicked.add(ZwiftButton.paddleLeft);
} else if (analogue.location == RideAnalogLocation.RIGHT) {
buttonsClicked.add(ZwiftButton.paddleRight);
} else if (analogue.location == RideAnalogLocation.DOWN || analogue.location == RideAnalogLocation.UP) {
// TODO what is this even?
// Process ANALOG inputs separately - now properly separated from digital
// All analog paddles (L0-L3) appear in field 3 as repeated RideAnalogKeyPress
analogButtons = [];
try {
for (final paddle in status.analogPaddles) {
if (paddle.hasLocation() && paddle.hasAnalogValue()) {
if (paddle.analogValue.abs() >= analogPaddleThreshold) {
final button = switch (paddle.location.value) {
0 => ZwiftButton.paddleLeft, // L0 = left paddle
1 => ZwiftButton.paddleRight, // L1 = right paddle
_ => null, // L2, L3 unused
};
if (button != null) {
buttonsClicked.add(button);
analogButtons.add(button);
}
}
}
}
} catch (e) {
if (kDebugMode) {
print('Error parsing analog paddle data: $e');
}
}
}
@override
String toString() {
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name.splitByUpperCase())}';
final digitalButtons = buttonsClicked.where((b) => !analogButtons.contains(b)).toList();
return 'Digital: ${digitalButtons.joinToString(transform: (e) => e.name.splitByUpperCase())} | Analog: ${analogButtons.joinToString(transform: (e) => e.name.splitByUpperCase())}';
}
@override

View File

@@ -480,62 +480,19 @@ class RideAnalogKeyPress extends $pb.GeneratedMessage {
void clearAnalogValue() => clearField(2);
}
class RideAnalogKeyGroup extends $pb.GeneratedMessage {
factory RideAnalogKeyGroup({
$core.Iterable<RideAnalogKeyPress>? groupStatus,
}) {
final $result = create();
if (groupStatus != null) {
$result.groupStatus.addAll(groupStatus);
}
return $result;
}
RideAnalogKeyGroup._() : super();
factory RideAnalogKeyGroup.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
factory RideAnalogKeyGroup.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RideAnalogKeyGroup', package: const $pb.PackageName(_omitMessageNames ? '' : 'de.jonasbark'), createEmptyInstance: create)
..pc<RideAnalogKeyPress>(1, _omitFieldNames ? '' : 'GroupStatus', $pb.PbFieldType.PM, protoName: 'GroupStatus', subBuilder: RideAnalogKeyPress.create)
..hasRequiredFields = false
;
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
'Will be removed in next major version')
RideAnalogKeyGroup clone() => RideAnalogKeyGroup()..mergeFromMessage(this);
@$core.Deprecated(
'Using this can add significant overhead to your binary. '
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
'Will be removed in next major version')
RideAnalogKeyGroup copyWith(void Function(RideAnalogKeyGroup) updates) => super.copyWith((message) => updates(message as RideAnalogKeyGroup)) as RideAnalogKeyGroup;
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static RideAnalogKeyGroup create() => RideAnalogKeyGroup._();
RideAnalogKeyGroup createEmptyInstance() => create();
static $pb.PbList<RideAnalogKeyGroup> createRepeated() => $pb.PbList<RideAnalogKeyGroup>();
@$core.pragma('dart2js:noInline')
static RideAnalogKeyGroup getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<RideAnalogKeyGroup>(create);
static RideAnalogKeyGroup? _defaultInstance;
@$pb.TagNumber(1)
$core.List<RideAnalogKeyPress> get groupStatus => $_getList(0);
}
/// The command code prepending this message is 0x23
/// All analog paddles (L0-L3) appear as repeated RideAnalogKeyPress in field 3
class RideKeyPadStatus extends $pb.GeneratedMessage {
factory RideKeyPadStatus({
$core.int? buttonMap,
RideAnalogKeyGroup? analogButtons,
$core.Iterable<RideAnalogKeyPress>? analogPaddles,
}) {
final $result = create();
if (buttonMap != null) {
$result.buttonMap = buttonMap;
}
if (analogButtons != null) {
$result.analogButtons = analogButtons;
if (analogPaddles != null) {
$result.analogPaddles.addAll(analogPaddles);
}
return $result;
}
@@ -545,7 +502,7 @@ class RideKeyPadStatus extends $pb.GeneratedMessage {
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RideKeyPadStatus', package: const $pb.PackageName(_omitMessageNames ? '' : 'de.jonasbark'), createEmptyInstance: create)
..a<$core.int>(1, _omitFieldNames ? '' : 'ButtonMap', $pb.PbFieldType.OU3, protoName: 'ButtonMap')
..aOM<RideAnalogKeyGroup>(2, _omitFieldNames ? '' : 'AnalogButtons', protoName: 'AnalogButtons', subBuilder: RideAnalogKeyGroup.create)
..pc<RideAnalogKeyPress>(3, _omitFieldNames ? '' : 'AnalogPaddles', $pb.PbFieldType.PM, protoName: 'AnalogPaddles', subBuilder: RideAnalogKeyPress.create)
..hasRequiredFields = false
;
@@ -579,16 +536,8 @@ class RideKeyPadStatus extends $pb.GeneratedMessage {
@$pb.TagNumber(1)
void clearButtonMap() => clearField(1);
@$pb.TagNumber(2)
RideAnalogKeyGroup get analogButtons => $_getN(1);
@$pb.TagNumber(2)
set analogButtons(RideAnalogKeyGroup v) { setField(2, v); }
@$pb.TagNumber(2)
$core.bool hasAnalogButtons() => $_has(1);
@$pb.TagNumber(2)
void clearAnalogButtons() => clearField(2);
@$pb.TagNumber(2)
RideAnalogKeyGroup ensureAnalogButtons() => $_ensure(1);
@$pb.TagNumber(3)
$core.List<RideAnalogKeyPress> get analogPaddles => $_getList(1);
}
/// ------------------ Zwift Click messages

View File

@@ -170,33 +170,20 @@ final $typed_data.Uint8List rideAnalogKeyPressDescriptor = $convert.base64Decode
'lkZUFuYWxvZ0xvY2F0aW9uUghMb2NhdGlvbhIgCgtBbmFsb2dWYWx1ZRgCIAEoEVILQW5hbG9n'
'VmFsdWU=');
@$core.Deprecated('Use rideAnalogKeyGroupDescriptor instead')
const RideAnalogKeyGroup$json = {
'1': 'RideAnalogKeyGroup',
'2': [
{'1': 'GroupStatus', '3': 1, '4': 3, '5': 11, '6': '.de.jonasbark.RideAnalogKeyPress', '10': 'GroupStatus'},
],
};
/// Descriptor for `RideAnalogKeyGroup`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List rideAnalogKeyGroupDescriptor = $convert.base64Decode(
'ChJSaWRlQW5hbG9nS2V5R3JvdXASQgoLR3JvdXBTdGF0dXMYASADKAsyIC5kZS5qb25hc2Jhcm'
'suUmlkZUFuYWxvZ0tleVByZXNzUgtHcm91cFN0YXR1cw==');
@$core.Deprecated('Use rideKeyPadStatusDescriptor instead')
const RideKeyPadStatus$json = {
'1': 'RideKeyPadStatus',
'2': [
{'1': 'ButtonMap', '3': 1, '4': 1, '5': 13, '10': 'ButtonMap'},
{'1': 'AnalogButtons', '3': 2, '4': 1, '5': 11, '6': '.de.jonasbark.RideAnalogKeyGroup', '10': 'AnalogButtons'},
{'1': 'AnalogPaddles', '3': 3, '4': 3, '5': 11, '6': '.de.jonasbark.RideAnalogKeyPress', '10': 'AnalogPaddles'},
],
};
/// Descriptor for `RideKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List rideKeyPadStatusDescriptor = $convert.base64Decode(
'ChBSaWRlS2V5UGFkU3RhdHVzEhwKCUJ1dHRvbk1hcBgBIAEoDVIJQnV0dG9uTWFwEkYKDUFuYW'
'xvZ0J1dHRvbnMYAiABKAsyIC5kZS5qb25hc2JhcmsuUmlkZUFuYWxvZ0tleUdyb3VwUg1BbmFs'
'b2dCdXR0b25z');
'xvZ1BhZGRsZXMYAyADKAsyIC5kZS5qb25hc2JhcmsuUmlkZUFuYWxvZ0tleVByZXNzUg1BbmFs'
'b2dQYWRkbGVz');
@$core.Deprecated('Use clickKeyPadStatusDescriptor instead')
const ClickKeyPadStatus$json = {

View File

@@ -79,14 +79,11 @@ message RideAnalogKeyPress {
optional sint32 AnalogValue = 2;
}
message RideAnalogKeyGroup {
repeated RideAnalogKeyPress GroupStatus = 1;
}
// The command code prepending this message is 0x23
// All analog paddles (L0-L3) appear as repeated RideAnalogKeyPress in field 3
message RideKeyPadStatus {
optional uint32 ButtonMap = 1;
optional RideAnalogKeyGroup AnalogButtons = 2;
repeated RideAnalogKeyPress AnalogPaddles = 3; // Field 3 contains all paddles
}
//------------------ Zwift Click messages

View File

@@ -8,6 +8,7 @@ import 'package:swift_control/pages/requirements.dart';
import 'package:swift_control/theme.dart';
import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/settings/settings.dart';
import 'package:window_manager/window_manager.dart';
@@ -15,26 +16,41 @@ import 'bluetooth/connection.dart';
import 'utils/actions/base_actions.dart';
final connection = Connection();
late final BaseActions actionHandler;
late BaseActions actionHandler;
final accessibilityHandler = Accessibility();
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final settings = Settings();
const screenshotMode = false;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (kIsWeb) {
actionHandler = StubActions();
} else if (Platform.isAndroid || Platform.isIOS) {
actionHandler = AndroidActions();
} else {
actionHandler = DesktopActions();
initializeActions(true);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
windowManager.setSize(Size(1280, 800));
}
runApp(const SwiftPlayApp());
}
Future<void> initializeActions(bool local) async {
if (kIsWeb) {
actionHandler = StubActions();
} else if (Platform.isAndroid) {
if (local) {
actionHandler = AndroidActions();
} else {
actionHandler = RemoteActions();
}
} else if (Platform.isIOS) {
actionHandler = RemoteActions();
} else {
actionHandler = DesktopActions();
}
}
class SwiftPlayApp extends StatelessWidget {
const SwiftPlayApp({super.key});

View File

@@ -1,98 +0,0 @@
import 'package:flutter/material.dart';
import 'package:swift_control/utils/changelog.dart';
class ChangelogPage extends StatefulWidget {
const ChangelogPage({super.key});
@override
State<ChangelogPage> createState() => _ChangelogPageState();
}
class _ChangelogPageState extends State<ChangelogPage> {
List<ChangelogEntry>? _entries;
String? _error;
@override
void initState() {
super.initState();
_loadChangelog();
}
Future<void> _loadChangelog() async {
try {
final entries = await ChangelogParser.parse();
setState(() {
_entries = entries;
});
} catch (e) {
setState(() {
_error = 'Failed to load changelog: $e';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Changelog'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: _error != null
? Center(child: Text(_error!))
: _entries == null
? Center(child: CircularProgressIndicator())
: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: _entries!.length,
itemBuilder: (context, index) {
final entry = _entries![index];
return Card(
margin: EdgeInsets.only(bottom: 16),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Version ${entry.version}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
entry.date,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
SizedBox(height: 12),
...entry.changes.map(
(change) => Padding(
padding: EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('', style: TextStyle(fontSize: 16)),
Expanded(
child: Text(
change,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
),
),
],
),
),
);
},
),
);
}
}

View File

@@ -4,17 +4,26 @@ import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
import 'package:swift_control/bluetooth/protocol/zp.pb.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/markdown.dart';
import 'package:swift_control/pages/touch_area.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/loading_widget.dart';
import 'package:swift_control/widgets/logviewer.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:swift_control/widgets/testbed.dart';
import 'package:swift_control/widgets/title.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../bluetooth/devices/base_device.dart';
import '../utils/actions/remote.dart';
import '../utils/keymap/apps/custom_app.dart';
import '../utils/keymap/apps/supported_app.dart';
import '../utils/requirements/remote.dart';
import '../widgets/menu.dart';
class DevicePage extends StatefulWidget {
@@ -24,14 +33,50 @@ class DevicePage extends StatefulWidget {
State<DevicePage> createState() => _DevicePageState();
}
class _DevicePageState extends State<DevicePage> {
class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
late StreamSubscription<BaseDevice> _connectionStateSubscription;
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
List<SupportedApp> _getAllApps() {
final baseApps = SupportedApp.supportedApps.where((app) => app is! CustomApp).toList();
final customProfiles = settings.getCustomAppProfiles();
final customApps = customProfiles.map((profile) {
final customApp = CustomApp(profileName: profile);
final savedKeymap = settings.getCustomAppKeymap(profile);
if (savedKeymap != null) {
customApp.decodeKeymap(savedKeymap);
}
return customApp;
}).toList();
// If no custom profiles exist, add the default "Custom" one
if (customApps.isEmpty) {
customApps.add(CustomApp());
}
return [...baseApps, ...customApps];
}
@override
void initState() {
super.initState();
// keep screen on - this is required for iOS to keep the bluetooth connection alive
WakelockPlus.enable();
WidgetsBinding.instance.addObserver(this);
if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS) {
WidgetsBinding.instance.addPostFrameCallback((_) {
// show snackbar to inform user that the app needs to stay in foreground
_snackBarMessengerKey.currentState?.showSnackBar(
SnackBar(
content: Text('To keep working properly the app needs to stay in the foreground.'),
duration: Duration(seconds: 5),
),
);
});
}
_connectionStateSubscription = connection.connectionStream.listen((state) async {
setState(() {});
});
@@ -39,15 +84,37 @@ class _DevicePageState extends State<DevicePage> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_connectionStateSubscription.cancel();
controller.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && actionHandler is RemoteActions && Platform.isIOS) {
final requirement = RemoteRequirement();
requirement.reconnect();
_snackBarMessengerKey.currentState?.showSnackBar(
SnackBar(
content: Text('To keep working properly the app needs to stay in the foreground.'),
duration: Duration(seconds: 5),
),
);
}
}
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
@override
Widget build(BuildContext context) {
final canVibrate = connection.devices.any(
(device) => (device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') && device.isConnected,
);
final paddingMultiplicator = actionHandler is DesktopActions ? 2.5 : 1.0;
return ScaffoldMessenger(
key: _snackBarMessengerKey,
child: PopScope(
@@ -63,156 +130,324 @@ class _DevicePageState extends State<DevicePage> {
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(8.0),
padding: EdgeInsets.only(
top: 16,
left: 8.0 * paddingMultiplicator,
right: 8 * paddingMultiplicator,
bottom: 8,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
children: [
Text('Connected Devices:', style: Theme.of(context).textTheme.titleMedium),
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
),
Card(
child: Padding(
padding: EdgeInsets.only(
left: 16.0,
right: 16,
top: 16,
bottom: actionHandler is RemoteActions ? 0 : 12,
),
padding: const EdgeInsets.all(8),
child: Text(
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that SwiftControl will need to reconnect every minute.
child: Column(
children: [
if (connection.devices.isEmpty)
Text('No devices connected. Go back and connect a device to get started.'),
...connection.devices.map(
(device) => Row(
children: [
Text(
device.device.name?.screenshot ?? device.runtimeType.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
if (device.batteryLevel != null) ...[
Icon(switch (device.batteryLevel!) {
>= 80 => Icons.battery_full,
>= 60 => Icons.battery_6_bar,
>= 50 => Icons.battery_5_bar,
>= 25 => Icons.battery_4_bar,
>= 10 => Icons.battery_2_bar,
_ => Icons.battery_alert,
}),
Text('${device.batteryLevel}%'),
if (device.firmwareVersion != null) Text(' - Firmware: ${device.firmwareVersion}'),
],
],
),
),
if (actionHandler is RemoteActions)
Row(
children: [
Text(
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
),
LoadingWidget(
futureCallback: () async {
final requirement = RemoteRequirement();
await requirement.reconnect();
},
renderChild: (isLoading, tap) => TextButton(
onPressed: tap,
child: isLoading ? SmallProgressIndicator() : Text('Reconnect'),
),
),
],
),
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
Container(
margin: EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that SwiftControl will need to reconnect every minute.
1. Open Zwift app
2. Log in (subscription not required) and open the device connection screen
3. Connect your Trainer, then connect the Zwift Click V2
4. Close the Zwift app again and connect again in SwiftControl''',
),
Row(
children: [
TextButton(
onPressed: () {
connection.devices.whereType<ZwiftClickV2>().forEach(
(device) => device.sendCommand(Opcode.RESET, null),
);
},
child: Text('Reset now'),
),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
),
);
},
child: Text('Troubleshooting'),
),
],
),
],
),
),
],
),
),
Text(
connection.devices.joinToString(
separator: '\n',
transform: (it) {
return """${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}
${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}
${it.firmwareVersion != null ? ' - Firmware Version: ${it.firmwareVersion}' : ''}""".trim();
},
),
),
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
if (!kIsWeb)
Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flex(
if (!kIsWeb) ...[
SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Customize', style: Theme.of(context).textTheme.titleMedium),
),
Card(
child: Padding(
padding: EdgeInsets.only(
left: 16.0,
right: 16,
top: 16,
bottom: canVibrate ? 0 : 12,
),
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
direction: MediaQuery.sizeOf(context).width > 600 ? Axis.horizontal : Axis.vertical,
spacing: 8,
children: [
DropdownMenu<SupportedApp>(
controller: controller,
dropdownMenuEntries:
SupportedApp.supportedApps
.map((app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name))
.toList(),
label: Text('Select Keymap / app'),
onSelected: (app) async {
if (app == null) {
return;
}
controller.text = app.name ?? '';
actionHandler.supportedApp = app;
await settings.setApp(app);
setState(() {});
if (app is! CustomApp && !kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text(
'Customize the keymap if you experience any issues (e.g. wrong keyboard output)',
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
spacing: 8,
children: [
Expanded(
child: DropdownMenu<SupportedApp?>(
controller: controller,
dropdownMenuEntries: [
..._getAllApps().map(
(app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name),
),
DropdownMenuEntry(
value: CustomApp(profileName: 'New'),
label: 'Create new keymap',
labelWidget: Text('Create new keymap'),
leadingIcon: Icon(Icons.add),
),
],
label: Text('Select Keymap / app'),
onSelected: (app) async {
if (app == null) {
return;
} else if (app.name == 'New') {
final profileName = await _showNewProfileDialog();
if (profileName != null && profileName.isNotEmpty) {
final customApp = CustomApp(profileName: profileName);
actionHandler.supportedApp = customApp;
await settings.setApp(customApp);
controller.text = profileName;
setState(() {});
}
} else {
controller.text = app.name ?? '';
actionHandler.supportedApp = app;
await settings.setApp(app);
setState(() {});
if (app is! CustomApp &&
!kIsWeb &&
(Platform.isMacOS || Platform.isWindows)) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text(
'Customize the keymap if you experience any issues (e.g. wrong keyboard output)',
),
),
);
}
}
},
initialSelection: actionHandler.supportedApp,
hintText: 'Select your Keymap',
),
),
Row(
children: [
if (actionHandler.supportedApp != null)
ElevatedButton.icon(
onPressed: () async {
if (actionHandler.supportedApp is! CustomApp) {
await _duplicate(actionHandler.supportedApp!.name);
}
final result = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
if (result == true && actionHandler.supportedApp is CustomApp) {
await settings.setApp(actionHandler.supportedApp!);
}
setState(() {});
},
icon: Icon(Icons.edit),
label: Text('Edit'),
),
IconButton(
onPressed: () async {
final currentProfile = actionHandler.supportedApp?.name;
final action = await _showManageProfileDialog(currentProfile);
if (action != null) {
if (action == 'rename') {
final newName = await _showRenameProfileDialog(currentProfile!);
if (newName != null && newName.isNotEmpty && newName != currentProfile) {
await settings.duplicateCustomAppProfile(currentProfile, newName);
await settings.deleteCustomAppProfile(currentProfile);
final customApp = CustomApp(profileName: newName);
final savedKeymap = settings.getCustomAppKeymap(newName);
if (savedKeymap != null) {
customApp.decodeKeymap(savedKeymap);
}
actionHandler.supportedApp = customApp;
await settings.setApp(customApp);
controller.text = newName;
setState(() {});
}
} else if (action == 'duplicate') {
_duplicate(currentProfile!);
} else if (action == 'delete') {
final confirmed = await _showDeleteConfirmDialog(currentProfile!);
if (confirmed == true) {
await settings.deleteCustomAppProfile(currentProfile);
controller.text = '';
setState(() {});
}
} else if (action == 'import') {
final jsonData = await _showImportDialog();
if (jsonData != null && jsonData.isNotEmpty) {
final success = await settings.importCustomAppProfile(jsonData);
if (mounted) {
if (success) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Profile imported successfully'),
duration: Duration(seconds: 5),
),
);
setState(() {});
} else {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Failed to import profile. Invalid format.'),
duration: Duration(seconds: 5),
backgroundColor: Colors.red,
),
);
}
}
}
} else if (action == 'export') {
final currentProfile =
(actionHandler.supportedApp as CustomApp).profileName;
final jsonData = settings.exportCustomAppProfile(currentProfile);
if (jsonData != null) {
await Clipboard.setData(ClipboardData(text: jsonData));
if (mounted) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text('Profile "$currentProfile" exported to clipboard'),
duration: Duration(seconds: 5),
),
);
}
}
}
}
},
icon: Icon(Icons.more_vert),
),
);
}
},
initialSelection: actionHandler.supportedApp,
hintText: 'Select your Keymap',
],
),
],
),
if (actionHandler.supportedApp != null)
ElevatedButton(
onPressed: () async {
if (actionHandler.supportedApp! is! CustomApp) {
final customApp = CustomApp();
final connectedDevice = connection.devices.firstOrNull;
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
pair.buttons
.filter(
(button) => connectedDevice?.availableButtons.contains(button) == true,
)
.forEachIndexed((button, indexB) {
customApp.setKey(
button,
physicalKey: pair.physicalKey!,
logicalKey: pair.logicalKey,
isLongPress: pair.isLongPress,
touchPosition:
pair.touchPosition != Offset.zero
? pair.touchPosition
: Offset(((indexB + 1)) * 100, 200 + (index * 100)),
);
});
});
actionHandler.supportedApp = customApp;
await settings.setApp(customApp);
}
final result = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
if (result == true && actionHandler.supportedApp is CustomApp) {
await settings.setApp(actionHandler.supportedApp!);
}
KeymapExplanation(
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
keymap: actionHandler.supportedApp!.keymap,
onUpdate: () {
setState(() {});
controller.text = actionHandler.supportedApp?.name ?? '';
},
),
if (canVibrate) ...[
SwitchListTile(
title: Text('Enable vibration feedback when shifting gears'),
value: settings.getVibrationEnabled(),
contentPadding: EdgeInsets.zero,
onChanged: (value) async {
await settings.setVibrationEnabled(value);
setState(() {});
},
child: Text('Customize Keymap'),
),
],
],
),
if (actionHandler.supportedApp != null)
KeymapExplanation(
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
keymap: actionHandler.supportedApp!.keymap,
onUpdate: () {
setState(() {});
controller.text = actionHandler.supportedApp?.name ?? '';
},
),
if (connection.devices.any(
(device) =>
(device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') &&
device.isConnected,
))
SwitchListTile(
title: Text('Vibration on Shift'),
subtitle: Text('Enable vibration feedback when shifting gears'),
value: settings.getVibrationEnabled(),
onChanged: (value) async {
await settings.setVibrationEnabled(value);
setState(() {});
},
),
if (kDebugMode &&
connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
ElevatedButton(
onPressed: () {
(connection.devices.first as ZwiftClickV2).test();
},
child: Text('Test'),
),
],
),
),
],
SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Logs', style: Theme.of(context).textTheme.titleMedium),
),
LogViewer(),
],
),
@@ -224,4 +459,208 @@ ${it.firmwareVersion != null ? ' - Firmware Version: ${it.firmwareVersion}' : ''
),
);
}
Future<String?> _showNewProfileDialog() async {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('New Custom Profile'),
content: TextField(
controller: controller,
decoration: InputDecoration(labelText: 'Profile Name', hintText: 'e.g., Workout, Race, Event'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Create')),
],
),
);
}
Future<String?> _showManageProfileDialog(String? currentProfile) async {
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Manage Profile: ${currentProfile ?? ''}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (currentProfile != null && actionHandler.supportedApp is CustomApp)
ListTile(
leading: Icon(Icons.edit),
title: Text('Rename'),
onTap: () => Navigator.pop(context, 'rename'),
),
if (currentProfile != null)
ListTile(
leading: Icon(Icons.copy),
title: Text('Duplicate'),
onTap: () => Navigator.pop(context, 'duplicate'),
),
ListTile(
leading: Icon(Icons.file_upload),
title: Text('Import'),
onTap: () => Navigator.pop(context, 'import'),
),
if (currentProfile != null)
ListTile(
leading: Icon(Icons.share),
title: Text('Export'),
onTap: () => Navigator.pop(context, 'export'),
),
if (currentProfile != null)
ListTile(
leading: Icon(Icons.delete, color: Theme.of(context).colorScheme.error),
title: Text('Delete', style: TextStyle(color: Theme.of(context).colorScheme.error)),
onTap: () => Navigator.pop(context, 'delete'),
),
],
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel'))],
),
);
}
Future<String?> _showRenameProfileDialog(String currentName) async {
final controller = TextEditingController(text: currentName);
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Rename Profile'),
content: TextField(
controller: controller,
decoration: InputDecoration(labelText: 'Profile Name'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Rename')),
],
),
);
}
Future<String?> _showDuplicateProfileDialog(String currentName) async {
final controller = TextEditingController(text: '$currentName (Copy)');
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Duplicate Profile'),
content: TextField(
controller: controller,
decoration: InputDecoration(labelText: 'New Profile Name'),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Duplicate')),
],
),
);
}
Future<bool?> _showDeleteConfirmDialog(String profileName) async {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete Profile'),
content: Text('Are you sure you want to delete "$profileName"? This action cannot be undone.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('Delete'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
Future<String?> _showImportDialog() async {
final controller = TextEditingController();
// Try to get data from clipboard
try {
final clipboardData = await Clipboard.getData('text/plain');
if (clipboardData?.text != null) {
controller.text = clipboardData!.text!;
}
} catch (e) {
// Ignore clipboard errors
}
return showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Import Profile'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Paste the exported JSON data below:'),
SizedBox(height: 16),
TextField(
controller: controller,
decoration: InputDecoration(labelText: 'JSON Data', border: OutlineInputBorder()),
maxLines: 5,
autofocus: true,
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Import')),
],
),
);
}
Future<void> _duplicate(String currentProfile) async {
final newName = await _showDuplicateProfileDialog(currentProfile);
if (newName != null && newName.isNotEmpty) {
if (actionHandler.supportedApp is CustomApp) {
await settings.duplicateCustomAppProfile(currentProfile, newName);
final customApp = CustomApp(profileName: newName);
final savedKeymap = settings.getCustomAppKeymap(newName);
if (savedKeymap != null) {
customApp.decodeKeymap(savedKeymap);
}
actionHandler.supportedApp = customApp;
await settings.setApp(customApp);
controller.text = newName;
setState(() {});
} else {
final customApp = CustomApp(profileName: newName);
final connectedDevice = connection.devices.firstOrNull;
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
pair.buttons.filter((button) => connectedDevice?.availableButtons.contains(button) == true).forEachIndexed((
button,
indexB,
) {
customApp.setKey(
button,
physicalKey: pair.physicalKey,
logicalKey: pair.logicalKey,
isLongPress: pair.isLongPress,
touchPosition: pair.touchPosition != Offset.zero
? pair.touchPosition
: Offset(((indexB + 1)) * 10, 20 + (index * 10)),
);
});
});
actionHandler.supportedApp = customApp;
await settings.setApp(customApp);
controller.text = newName;
setState(() {});
}
}
}
}
extension Screenshot on String {
String get screenshot => screenshotMode ? replaceAll('Zwift ', '') : this;
}

84
lib/pages/markdown.dart Normal file
View File

@@ -0,0 +1,84 @@
import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_md/flutter_md.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher_string.dart';
class MarkdownPage extends StatefulWidget {
final String assetPath;
const MarkdownPage({super.key, required this.assetPath});
@override
State<MarkdownPage> createState() => _ChangelogPageState();
}
class _ChangelogPageState extends State<MarkdownPage> {
Markdown? _markdown;
String? _error;
@override
void initState() {
super.initState();
_loadChangelog();
}
Future<void> _loadChangelog() async {
try {
final md = await rootBundle.loadString(widget.assetPath);
setState(() {
_markdown = Markdown.fromString(md);
});
// load latest version
final response = await http.get(
Uri.parse('https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/${widget.assetPath}'),
);
if (response.statusCode == 200) {
final latestMd = response.body;
if (latestMd != md) {
setState(() {
_markdown = Markdown.fromString(md);
});
}
}
} catch (e) {
setState(() {
_error = 'Failed to load changelog: $e';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.assetPath.replaceAll('.md', '').toLowerCase().capitalize),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body:
_error != null
? Center(child: Text(_error!))
: _markdown == null
? Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: MarkdownWidget(
markdown: _markdown!,
theme: MarkdownThemeData(
textStyle: TextStyle(fontSize: 14.0, color: Colors.black87),
onLinkTap: (title, url) {
launchUrlString(url);
},
),
),
),
],
),
),
);
}
}

View File

@@ -21,6 +21,7 @@ class RequirementsPage extends StatefulWidget {
class _RequirementsPageState extends State<RequirementsPage> with WidgetsBindingObserver {
int _currentStep = 0;
var _local = true;
List<PlatformRequirement> _requirements = [];
@@ -29,6 +30,8 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
super.initState();
WidgetsBinding.instance.addObserver(this);
_local = kIsWeb || !Platform.isIOS;
// call after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
settings.init().then((_) {
@@ -56,11 +59,11 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
final lastSeenVersion = settings.getLastSeenVersion();
if (mounted) {
await ChangelogDialog.showIfNeeded(context, currentVersion, lastSeenVersion);
}
// Update last seen version
await settings.setLastSeenVersion(currentVersion);
} catch (e) {
@@ -89,38 +92,53 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: buildMenuButtons(),
),
body:
_requirements.isEmpty
? Center(child: CircularProgressIndicator())
: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0),
child: Text(
'Please complete the following requirements to make the app work correctly:',
style: Theme.of(context).textTheme.titleMedium,
),
),
Expanded(
body: _requirements.isEmpty
? Center(child: CircularProgressIndicator())
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
SwitchListTile.adaptive(
value: _local,
title: Text('Trainer app is running on this device'),
subtitle: Text('Turn off if you want to control another device, e.g. your tablet'),
onChanged: (local) {
if (kIsWeb || Platform.isIOS) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('This platform only supports controlling trainer apps on other devices'),
),
);
} else {
initializeActions(local);
setState(() {
_local = local;
_reloadRequirements();
});
}
},
),
Expanded(
child: Card(
margin: EdgeInsets.symmetric(horizontal: 16),
child: Stepper(
currentStep: _currentStep,
connectorColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
),
onStepContinue:
_currentStep < _requirements.length
? () {
setState(() {
_currentStep += 1;
});
}
: null,
onStepContinue: _currentStep < _requirements.length
? () {
setState(() {
_currentStep += 1;
});
}
: null,
onStepTapped: (step) {
if (_requirements[step].status) {
return;
}
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
if (hasEarlierIncomplete) {
if (hasEarlierIncomplete && !kDebugMode) {
return;
}
setState(() {
@@ -128,44 +146,49 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
});
},
controlsBuilder: (context, details) => Container(),
steps:
_requirements
.mapIndexed(
(index, req) => Step(
title: Text(req.name),
content: Container(
padding: const EdgeInsets.symmetric(vertical: 16.0),
alignment: Alignment.centerLeft,
child:
(index == _currentStep
? req.build(context, () {
steps: _requirements
.mapIndexed(
(index, req) => Step(
title: Text(req.name, style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: req.description != null ? Text(req.description!) : null,
content: Container(
padding: const EdgeInsets.only(top: 16.0),
alignment: Alignment.centerLeft,
child:
(index == _currentStep
? req.build(context, () {
_reloadRequirements();
})
: null) ??
ElevatedButton(
onPressed: req.status
? null
: () => _callRequirement(req, context, () {
_reloadRequirements();
})
: null) ??
ElevatedButton(
onPressed: req.status ? null : () => _callRequirement(req),
child: Text(req.name),
),
),
state: req.status ? StepState.complete : StepState.indexed,
),
)
.toList(),
}),
child: Text(req.name),
),
),
state: req.status ? StepState.complete : StepState.indexed,
),
)
.toList(),
),
),
],
),
),
],
),
);
}
void _callRequirement(PlatformRequirement req) {
req.call().then((_) {
void _callRequirement(PlatformRequirement req, BuildContext context, VoidCallback onUpdate) {
req.call(context, onUpdate).then((_) {
_reloadRequirements();
});
}
void _reloadRequirements() {
getRequirements().then((req) {
getRequirements(_local).then((req) {
_requirements = req;
_currentStep = req.indexWhere((req) => !req.status);
if (mounted) {

View File

@@ -1,8 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/markdown.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../widgets/logviewer.dart';
@@ -47,6 +47,8 @@ class _ScanWidgetState extends State<ScanWidget> {
return Container(
constraints: BoxConstraints(minHeight: 200),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ValueListenableBuilder(
valueListenable: connection.isScanning,
@@ -54,14 +56,17 @@ class _ScanWidgetState extends State<ScanWidget> {
if (isScanning) {
return Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Scanning for devices... Make sure they are powered on and in range and not connected to another device.',
),
TextButton(
onPressed: () {
launchUrlString(
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-platforms',
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
child: const Text("Show Troubleshooting Guide"),
@@ -83,7 +88,7 @@ class _ScanWidgetState extends State<ScanWidget> {
}
},
),
if (kDebugMode) SizedBox(height: 500, child: LogViewer()),
if (kDebugMode && false) SizedBox(height: 500, child: LogViewer()),
],
),
);

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -9,6 +9,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/menu.dart';
import 'package:swift_control/widgets/testbed.dart';
import 'package:window_manager/window_manager.dart';
@@ -16,6 +17,7 @@ import '../bluetooth/messages/click_notification.dart';
import '../bluetooth/messages/notification.dart';
import '../bluetooth/messages/play_notification.dart';
import '../bluetooth/messages/ride_notification.dart';
import '../utils/actions/base_actions.dart';
import '../utils/keymap/apps/custom_app.dart';
import '../utils/keymap/buttons.dart';
import '../utils/keymap/keymap.dart';
@@ -34,14 +36,37 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
File? _backgroundImage;
late StreamSubscription<BaseNotification> _actionSubscription;
ZwiftButton? _pressedButton;
final TransformationController _transformationController = TransformationController();
late Rect _imageRect;
Future<void> _pickScreenshot() async {
final picker = ImagePicker();
final result = await picker.pickImage(source: ImageSource.gallery);
if (result != null) {
setState(() {
_backgroundImage = File(result.path);
});
final image = File(result.path);
// need to decode image to get its size so we can have a percentage mapping
final decodedImage = await decodeImageFromList(image.readAsBytesSync());
// calculate image rectangle in the current screen, given it's boxfit contain
final screenSize = MediaQuery.sizeOf(context);
final imageAspectRatio = decodedImage.width / decodedImage.height;
final screenAspectRatio = screenSize.width / screenSize.height;
if (imageAspectRatio > screenAspectRatio) {
// image is wider than screen
final width = screenSize.width;
final height = width / imageAspectRatio;
final top = (screenSize.height - height) / 2;
_imageRect = Rect.fromLTWH(0, top, width, height);
} else {
// image is taller than screen
final height = screenSize.height;
final width = height * imageAspectRatio;
final left = (screenSize.width - width) / 2;
_imageRect = Rect.fromLTWH(left, 0, width, height);
}
_backgroundImage = image;
setState(() {});
}
}
@@ -71,13 +96,15 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
void initState() {
super.initState();
// initialize _imageRect by using Flutter view size
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
final size = flutterView.physicalSize / flutterView.devicePixelRatio;
_imageRect = Rect.fromLTWH(0, 0, size.width, size.height);
// Force landscape orientation during keymap editing
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []).then((_) {
// this will make sure the buttons are placed correctly after the transition
setState(() {});
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []);
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
windowManager.setFullScreen(true);
}
@@ -100,9 +127,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
final KeyPair keyPair;
actionHandler.supportedApp!.keymap.keyPairs.add(
keyPair = KeyPair(
touchPosition: context.size!
.center(Offset.zero)
.translate(actionHandler.supportedApp!.keymap.keyPairs.length * 40, 0),
touchPosition: Offset((actionHandler.supportedApp!.keymap.keyPairs.length + 1) * 10, 10),
buttons: [_pressedButton!],
physicalKey: null,
logicalKey: null,
@@ -122,270 +147,350 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
});
}
List<Widget> _buildDraggableArea({
required Offset position,
Widget _buildDraggableArea({
required bool enableTouch,
required void Function(Offset newPosition) onPositionChanged,
required Color color,
required KeyPair keyPair,
}) {
// map the percentage position to the image rect
final relativeX = min(100.0, keyPair.touchPosition.dx) / 100.0;
final relativeY = min(100.0, keyPair.touchPosition.dy) / 100.0;
//print('Relative position: $relativeX, $relativeY');
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)
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
: 0.0;
final differenceInHeight = (flutterView.display.size.height > 0 && !Platform.isIOS)
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
: 0.0;
if (kDebugMode && false) {
print('Position: $position');
print('Display Size: ${flutterView.display.size}');
print('View size: ${flutterView.physicalSize}');
print('Difference: $differenceInHeight');
}
final isOnTheRightEdge = position.dx > (MediaQuery.sizeOf(context).width - 250);
final label = KeypairExplanation(withKey: true, keyPair: keyPair);
//final isOnTheRightEdge = position.dx > (MediaQuery.sizeOf(context).width - 250);
final iconSize = 40.0;
final icon = Container(
decoration: BoxDecoration(
color: color.withOpacity(0.4),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
(!keyPair.isSpecialKey && keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero)
? Icons.add
: Icons.drag_indicator_outlined,
size: iconSize,
shadows: [
Shadow(color: Colors.white, offset: Offset(1, 1)),
Shadow(color: Colors.white, offset: Offset(-1, -1)),
Shadow(color: Colors.white, offset: Offset(-1, 1)),
Shadow(color: Colors.white, offset: Offset(-1, 1)),
Shadow(color: Colors.white, offset: Offset(1, -1)),
],
),
final Offset position = Offset(
_imageRect.left + relativeX * _imageRect.width - iconSize / 2,
_imageRect.top + relativeY * _imageRect.height - differenceInHeight - iconSize / 2,
);
return [
Positioned(
left: position.dx,
top: position.dy - differenceInHeight,
child: FractionalTranslation(
translation: Offset(isOnTheRightEdge ? -1.0 : 0.0, 0),
child: PopupMenuButton<PhysicalKeyboardKey>(
enabled: enableTouch,
tooltip: 'Drag to reposition. Tap to edit.',
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(
leading: Icon(Icons.keyboard_alt_outlined),
title: const Text('Simulate Keyboard shortcut'),
),
onTap: () async {
await showDialog<void>(
context: context,
barrierDismissible: false, // enable Escape key
builder:
(c) => HotKeyListenerDialog(
customApp: actionHandler.supportedApp! as CustomApp,
keyPair: keyPair,
),
);
setState(() {});
},
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)),
onTap: () {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
setState(() {});
},
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
onTap: () {
keyPair.isLongPress = !keyPair.isLongPress;
setState(() {});
},
child: CheckboxListTile(
value: keyPair.isLongPress,
onChanged: (value) {
keyPair.isLongPress = value ?? false;
setState(() {});
Navigator.of(context).pop();
},
title: const Text('Long Press Mode (vs. repeating)'),
),
),
PopupMenuDivider(),
PopupMenuItem(
child: PopupMenuButton<PhysicalKeyboardKey>(
padding: EdgeInsets.zero,
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaPlayPause,
child: const Text('Media: Play/Pause'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaStop,
child: const Text('Media: Stop'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackPrevious,
child: const Text('Media: Previous'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackNext,
child: const Text('Media: Next'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeUp,
child: const Text('Media: Volume Up'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeDown,
child: const Text('Media: Volume Down'),
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
final actions = [
if (actionHandler.supportedModes.contains(SupportedMode.keyboard))
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(
leading: Icon(Icons.keyboard_alt_outlined),
title: const Text('Simulate Keyboard shortcut'),
trailing: keyPair.physicalKey != null ? Checkbox(value: true, onChanged: null) : null,
),
onTap: () async {
await showDialog<void>(
context: context,
barrierDismissible: false, // enable Escape key
builder: (c) =>
HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair),
);
setState(() {});
},
),
if (actionHandler.supportedModes.contains(SupportedMode.touch))
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(
title: const Text('Simulate Touch'),
leading: Icon(Icons.touch_app_outlined),
trailing: keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero
? Checkbox(value: true, onChanged: null)
: null,
),
onTap: () {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
setState(() {});
},
),
setState(() {});
},
child: ListTile(
leading: Icon(Icons.music_note_outlined),
trailing: Icon(Icons.arrow_right),
title: Text('Simulate Media key'),
),
),
),
PopupMenuDivider(),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Delete Keymap'), leading: Icon(Icons.delete, color: Colors.red)),
onTap: () {
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
setState(() {});
},
),
if (actionHandler.supportedModes.contains(SupportedMode.media))
PopupMenuItem<PhysicalKeyboardKey>(
child: PopupMenuButton<PhysicalKeyboardKey>(
padding: EdgeInsets.zero,
itemBuilder: (context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaPlayPause,
child: const Text('Media: Play/Pause'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaStop,
child: const Text('Media: Stop'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackPrevious,
child: const Text('Media: Previous'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackNext,
child: const Text('Media: Next'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeUp,
child: const Text('Media: Volume Up'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeDown,
child: const Text('Media: Volume Down'),
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: ListTile(
leading: Icon(Icons.music_note_outlined),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (keyPair.isSpecialKey) Checkbox(value: true, onChanged: null),
Icon(Icons.arrow_right),
],
),
title: Text('Simulate Media key'),
),
),
),
];
final icon = Container(
constraints: BoxConstraints(minHeight: iconSize, minWidth: iconSize),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (keyPair.buttons.singleOrNull?.color == null)
Container(
decoration: BoxDecoration(
color: color.withOpacity(0.4),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
width: iconSize,
height: iconSize,
child: Icon(
keyPair.icon,
size: iconSize - 12,
shadows: [
Shadow(color: Colors.white, offset: Offset(1, 1)),
Shadow(color: Colors.white, offset: Offset(-1, -1)),
Shadow(color: Colors.white, offset: Offset(-1, 1)),
Shadow(color: Colors.white, offset: Offset(-1, 1)),
Shadow(color: Colors.white, offset: Offset(1, -1)),
],
),
),
PopupMenuButton<PhysicalKeyboardKey>(
enabled: enableTouch,
itemBuilder: (context) => [
if (actions.length > 1) ...actions,
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
onTap: () {
keyPair.isLongPress = !keyPair.isLongPress;
setState(() {});
},
child: CheckboxListTile(
value: keyPair.isLongPress,
onChanged: (value) {
keyPair.isLongPress = value ?? false;
setState(() {});
Navigator.of(context).pop();
},
title: const Text('Long Press Mode (vs. repeating)'),
),
),
PopupMenuDivider(),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(
title: const Text('Delete Keymap'),
leading: Icon(Icons.delete, color: Colors.red),
),
onTap: () {
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
setState(() {});
},
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: label,
child: Row(
children: [
KeypairExplanation(withKey: true, keyPair: keyPair),
Icon(Icons.more_vert),
],
),
),
),
],
),
);
Positioned(
left: position.dx - iconSize / 2,
top: position.dy - differenceInHeight - iconSize / 2,
return Positioned(
left: position.dx,
top: position.dy,
child: Tooltip(
message: 'Drag to reposition',
child: Draggable(
feedback: Material(color: Colors.transparent, child: icon),
dragAnchorStrategy: (widget, context, position) {
final scale = _transformationController.value.getMaxScaleOnAxis();
final RenderBox renderObject = context.findRenderObject() as RenderBox;
return renderObject.globalToLocal(position).scale(scale, scale);
},
feedback: Material(
color: Colors.transparent,
child: icon,
),
childWhenDragging: const SizedBox.shrink(),
onDraggableCanceled: (velo, offset) {
onDragEnd: (details) {
// otherwise simulated touch will move it
if (velo.pixelsPerSecond.distance > 0) {
final fixedPosition = offset + Offset(iconSize / 2, differenceInHeight + iconSize / 2);
setState(() => onPositionChanged(fixedPosition));
if (details.velocity.pixelsPerSecond.distance > 0) {
final matrix = Matrix4.inverted(_transformationController.value);
final height = 0;
final sceneY = details.offset.dy - height;
final viewportPoint = MatrixUtils.transformPoint(
matrix,
Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2),
);
setState(() => onPositionChanged(viewportPoint));
}
},
child: icon,
),
),
];
);
}
@override
Widget build(BuildContext context) {
final isDesktop = Platform.isWindows || Platform.isLinux || Platform.isMacOS;
final devicePixelRatio = isDesktop ? 1.0 : MediaQuery.devicePixelRatioOf(context);
return Scaffold(
body: Stack(
children: [
if (_backgroundImage != null)
Positioned.fill(child: Opacity(opacity: 0.5, child: Image.file(_backgroundImage!, fit: BoxFit.contain)))
else
Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Text('''1. Create an in-game screenshot of your app (e.g. within MyWhoosh) in landscape orientation
body: LayoutBuilder(
builder: (context, constraints) {
if (_backgroundImage == null && constraints.biggest != _imageRect.size) {
_imageRect = Rect.fromLTWH(0, 0, constraints.maxWidth, constraints.maxHeight);
}
return InteractiveViewer(
transformationController: _transformationController,
child: Stack(
children: [
if (_backgroundImage != null)
Positioned.fill(
child: Opacity(
opacity: 0.5,
child: Image.file(
_backgroundImage!,
fit: BoxFit.contain,
),
),
),
// draw _imageRect for debugging
if (kDebugMode)
Positioned(
left: _imageRect.left,
top: _imageRect.top,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.green, width: 2),
),
child: SizedBox.fromSize(size: _imageRect.size),
),
),
...?actionHandler.supportedApp?.keymap.keyPairs.map((keyPair) {
return _buildDraggableArea(
enableTouch: true,
keyPair: keyPair,
onPositionChanged: (newPos) {
// convert to percentage
final relativeX = ((newPos.dx - _imageRect.left) / _imageRect.width).clamp(0.0, 1.0);
final relativeY = ((newPos.dy - _imageRect.top) / _imageRect.height).clamp(0.0, 1.0);
keyPair.touchPosition = Offset(relativeX * 100.0, relativeY * 100.0);
setState(() {});
},
color: Colors.red,
);
}),
Positioned.fill(child: Testbed()),
if (_backgroundImage == null)
Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
IgnorePointer(
child: Text(
'''1. Create an in-game screenshot of your app (e.g. within MyWhoosh) in landscape orientation
2. Load the screenshot with the button below
3. The app is automatically set to landscape orientation for accurate mapping
4. Press a button on your Zwift device to create a touch area
4. Press a button on your Click device to create a touch area
5. Drag the touch areas to the desired position on the screenshot
6. Save and close this screen'''),
ElevatedButton(
onPressed: () {
_pickScreenshot();
},
child: Text('Load in-game screenshot for placement'),
6. Save and close this screen''',
),
),
ElevatedButton(
onPressed: () {
_pickScreenshot();
},
child: Text('Load in-game screenshot for placement'),
),
],
),
),
],
),
),
),
// Touch Areas
...?actionHandler.supportedApp?.keymap.keyPairs
.map(
(keyPair) => _buildDraggableArea(
enableTouch: true,
position: Offset(
keyPair.touchPosition.dx / devicePixelRatio,
keyPair.touchPosition.dy / devicePixelRatio,
),
keyPair: keyPair,
onPositionChanged: (newPos) {
final converted = newPos * devicePixelRatio;
keyPair.touchPosition = converted;
setState(() {});
},
color: Colors.red,
),
)
.flatten(),
Positioned.fill(child: Testbed()),
Positioned(
top: 40,
right: 20,
child: Row(
spacing: 8,
children: [
ElevatedButton.icon(onPressed: _saveAndClose, icon: const Icon(Icons.save), label: const Text("Save")),
PopupMenuButton(
itemBuilder:
(c) => [
PopupMenuItem(
child: Text('Reset'),
onTap: () {
actionHandler.supportedApp?.keymap.reset();
setState(() {});
},
),
],
icon: Icon(Icons.more_vert),
Positioned(
top: 40,
right: 20,
child: Row(
spacing: 8,
children: [
ElevatedButton.icon(
onPressed: _saveAndClose,
icon: const Icon(Icons.save),
label: const Text("Save"),
),
PopupMenuButton(
itemBuilder: (c) => [
PopupMenuItem(
child: Text('Reset'),
onTap: () {
_backgroundImage = null;
actionHandler.supportedApp?.keymap.reset();
setState(() {});
},
),
],
icon: Icon(Icons.more_vert),
),
if (kDebugMode) MenuButton(),
],
),
),
],
),
),
],
);
},
),
);
}
@@ -403,34 +508,29 @@ class KeypairExplanation extends StatelessWidget {
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (withKey) KeyWidget(label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n')),
if (keyPair.physicalKey != null) ...[
Icon(switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaPlayPause ||
PhysicalKeyboardKey.mediaStop ||
PhysicalKeyboardKey.mediaTrackPrevious ||
PhysicalKeyboardKey.mediaTrackNext ||
PhysicalKeyboardKey.audioVolumeUp ||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
_ => Icons.keyboard,
}, size: 16),
if (withKey)
Row(
children: keyPair.buttons.map((b) => ButtonWidget(button: b, big: true)).toList(),
)
else
Icon(keyPair.icon),
if (keyPair.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
KeyWidget(
label: switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => 'Media: Play/Pause',
PhysicalKeyboardKey.mediaStop => 'Media: Stop',
PhysicalKeyboardKey.mediaTrackPrevious => 'Media: Previous',
PhysicalKeyboardKey.mediaTrackNext => 'Media: Next',
PhysicalKeyboardKey.audioVolumeUp => 'Media: Volume Up',
PhysicalKeyboardKey.audioVolumeDown => 'Media: Volume Down',
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
PhysicalKeyboardKey.mediaStop => 'Stop',
PhysicalKeyboardKey.mediaTrackPrevious => 'Previous',
PhysicalKeyboardKey.mediaTrackNext => 'Next',
PhysicalKeyboardKey.audioVolumeUp => 'Volume Up',
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
},
),
if (keyPair.isLongPress) Text('using long press'),
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
] else ...[
Icon(Icons.touch_app, size: 16),
KeyWidget(label: 'X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}'),
if (keyPair.isLongPress) Text('using long press'),
if (!withKey)
KeyWidget(label: 'X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}'),
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
],
],
);

View File

@@ -7,6 +7,9 @@ abstract final class AppTheme {
static ThemeData light = FlexThemeData.light(
// Using FlexColorScheme built-in FlexScheme enum based colors
scheme: FlexScheme.redM3,
primary: Color(0xFF0E74B7),
primaryContainer: Color(0x7C0E9297),
onPrimaryContainer: Colors.black,
// Component theme configurations for light mode.
subThemesData: const FlexSubThemesData(
interactionEffects: true,
@@ -23,27 +26,28 @@ abstract final class AppTheme {
);
// The FlexColorScheme defined dark mode ThemeData.
static ThemeData dark = FlexThemeData.dark(
// Using FlexColorScheme built-in FlexScheme enum based colors.
scheme: FlexScheme.redM3,
// Component theme configurations for dark mode.
subThemesData: const FlexSubThemesData(
interactionEffects: true,
tintedDisabledControls: true,
blendOnColors: true,
useM2StyleDividerInM3: true,
inputDecoratorIsFilled: true,
inputDecoratorBorderType: FlexInputBorderType.outline,
alignedDropdown: true,
navigationRailUseIndicator: true,
),
// Direct ThemeData properties.
visualDensity: FlexColorScheme.comfortablePlatformDensity,
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
).copyWith(
scaffoldBackgroundColor: Color(0xff0b1623),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
),
);
static ThemeData dark =
FlexThemeData.dark(
// Using FlexColorScheme built-in FlexScheme enum based colors.
scheme: FlexScheme.redM3,
// Component theme configurations for dark mode.
subThemesData: const FlexSubThemesData(
interactionEffects: true,
tintedDisabledControls: true,
blendOnColors: true,
useM2StyleDividerInM3: true,
inputDecoratorIsFilled: true,
inputDecoratorBorderType: FlexInputBorderType.outline,
alignedDropdown: true,
navigationRailUseIndicator: true,
),
// Direct ThemeData properties.
visualDensity: FlexColorScheme.comfortablePlatformDensity,
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
).copyWith(
scaffoldBackgroundColor: Color(0xff0b1623),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
),
);
}

View File

@@ -12,6 +12,8 @@ import '../single_line_exception.dart';
class AndroidActions extends BaseActions {
WindowEvent? windowInfo;
AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]});
@override
void init(SupportedApp? supportedApp) {
super.init(supportedApp);
@@ -41,7 +43,7 @@ class AndroidActions extends BaseActions {
return "Key pressed: ${keyPair.toString()}";
}
}
final point = supportedApp!.resolveTouchPosition(action: button, windowInfo: windowInfo);
final point = resolveTouchPosition(action: button, windowInfo: windowInfo);
if (point != Offset.zero) {
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp

View File

@@ -1,18 +1,49 @@
import 'package:accessibility/accessibility.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../keymap/apps/supported_app.dart';
enum SupportedMode { keyboard, touch, media }
abstract class BaseActions {
final List<SupportedMode> supportedModes;
SupportedApp? supportedApp;
BaseActions({required this.supportedModes});
void init(SupportedApp? supportedApp) {
this.supportedApp = supportedApp;
}
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
final keyPair = supportedApp!.keymap.getKeyPair(action);
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
// convert relative position to absolute position based on window info
if (windowInfo != null && windowInfo.right != 0) {
final x = windowInfo.left + (keyPair.touchPosition.dx / 100) * (windowInfo.right - windowInfo.left);
final y = windowInfo.top + (keyPair.touchPosition.dy / 100) * (windowInfo.bottom - windowInfo.top);
return Offset(x, y);
} else {
// TODO support multiple screens
final screenSize =
WidgetsBinding.instance.platformDispatcher.views.first.display.size /
WidgetsBinding.instance.platformDispatcher.views.first.devicePixelRatio;
final x = (keyPair.touchPosition.dx / 100) * screenSize.width;
final y = (keyPair.touchPosition.dy / 100) * screenSize.height;
return Offset(x, y);
}
}
return Offset.zero;
}
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false});
}
class StubActions extends BaseActions {
StubActions({super.supportedModes = const []});
@override
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
return Future.value(action.name);

View File

@@ -4,6 +4,8 @@ import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
class DesktopActions extends BaseActions {
DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]});
// Track keys that are currently held down in long press mode
@override
@@ -31,7 +33,7 @@ class DesktopActions extends BaseActions {
return 'Key released: $keyPair';
}
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
final point = resolveTouchPosition(action: action, windowInfo: null);
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateMouseClickDown(point);
await keyPressSimulator.simulateMouseClickUp(point);

View File

@@ -0,0 +1,84 @@
import 'dart:ui';
import 'package:accessibility/accessibility.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/base_actions.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:universal_ble/universal_ble.dart';
import '../requirements/remote.dart';
class RemoteActions extends BaseActions {
RemoteActions({super.supportedModes = const [SupportedMode.touch]});
@override
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
if (supportedApp == null) {
return 'Supported app is not set';
}
final keyPair = supportedApp!.keymap.getKeyPair(action);
if (keyPair == null) {
return 'Keymap entry not found for action: ${action.toString().splitByUpperCase()}';
}
if (!(actionHandler as RemoteActions).isConnected) {
return 'Not connected to a device';
}
if (keyPair.physicalKey != null && keyPair.touchPosition == Offset.zero) {
return ('Physical key actions are not supported, yet');
} else {
final point = resolveTouchPosition(action: action, windowInfo: null);
final point2 = point; //Offset(100, 99.0);
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
return 'Mouse clicked at: ${point2.dx} ${point2.dy}';
}
}
@override
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
// for remote actions we use the relative position only
final keyPair = supportedApp!.keymap.getKeyPair(action);
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
return keyPair.touchPosition;
}
return Offset.zero;
}
Uint8List absMouseReport(int buttons3bit, int x, int y) {
final b = buttons3bit & 0x07;
final xi = x.clamp(0, 100);
final yi = y.clamp(0, 100);
return Uint8List.fromList([b, xi, yi]);
}
// Send a relative mouse move + button state as 3-byte report: [buttons, dx, dy]
Future<void> sendAbsMouseReport(int buttons, int dx, int dy) async {
final bytes = absMouseReport(buttons, dx, dy);
if (kDebugMode) {
print('Preparing to send abs mouse report: buttons=$buttons, dx=$dx, dy=$dy');
print('Sending abs mouse report: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0'))}');
}
await peripheralManager.notifyCharacteristic(connectedCentral!, connectedCharacteristic!, value: bytes);
}
Central? connectedCentral;
GATTCharacteristic? connectedCharacteristic;
void setConnectedCentral(Central? central, GATTCharacteristic? gattCharacteristic) {
connectedCentral = central;
connectedCharacteristic = gattCharacteristic;
connection.signalChange(ZwiftClick(BleDevice(deviceId: 'deviceId', name: 'name')));
}
bool get isConnected => connectedCentral != null;
}

View File

@@ -1,79 +0,0 @@
import 'package:flutter/services.dart';
class ChangelogEntry {
final String version;
final String date;
final List<String> changes;
ChangelogEntry({
required this.version,
required this.date,
required this.changes,
});
@override
String toString() {
return '### $version ($date)\n${changes.map((c) => '- $c').join('\n')}';
}
}
class ChangelogParser {
static Future<List<ChangelogEntry>> parse() async {
final content = await rootBundle.loadString('CHANGELOG.md');
return parseContent(content);
}
static List<ChangelogEntry> parseContent(String content) {
final entries = <ChangelogEntry>[];
final lines = content.split('\n');
ChangelogEntry? currentEntry;
for (var line in lines) {
// Check if this is a version header (e.g., "### 2.6.0 (2025-09-28)")
if (line.startsWith('### ')) {
// Save previous entry if exists
if (currentEntry != null) {
entries.add(currentEntry);
}
// Parse new entry
final header = line.substring(4).trim();
final match = RegExp(r'^(\S+)\s+\(([^)]+)\)').firstMatch(header);
if (match != null) {
currentEntry = ChangelogEntry(
version: match.group(1)!,
date: match.group(2)!,
changes: [],
);
}
} else if (line.startsWith('- ') && currentEntry != null) {
// Add change to current entry
currentEntry.changes.add(line.substring(2).trim());
} else if (line.startsWith(' - ') && currentEntry != null) {
// Sub-bullet point
currentEntry.changes.add(line.substring(4).trim());
}
}
// Add the last entry
if (currentEntry != null) {
entries.add(currentEntry);
}
return entries;
}
static Future<ChangelogEntry?> getLatestEntry() async {
final entries = await parse();
return entries.isNotEmpty ? entries.first : null;
}
static Future<String?> getLatestEntryForPlayStore() async {
final entry = await getLatestEntry();
if (entry == null) return null;
// Format for Play Store: just the changes, no version header
return entry.changes.join('\n');
}
}

View File

@@ -1,4 +1,3 @@
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
@@ -42,8 +41,3 @@ class Biketerra extends SupportedApp {
),
);
}
extension WindowSize on WindowEvent {
int get width => right - left;
int get height => bottom - top;
}

View File

@@ -1,23 +1,19 @@
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import '../../single_line_exception.dart';
import '../buttons.dart';
import '../keymap.dart';
class CustomApp extends SupportedApp {
CustomApp() : super(name: 'Custom', packageName: "custom", keymap: Keymap.custom);
final String profileName;
@override
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
final keyPair = keymap.getKeyPair(action);
if (keyPair == null || keyPair.touchPosition == Offset.zero) {
throw SingleLineException("No key pair found for action: $action. You may want to adjust the keymap.");
}
return keyPair.touchPosition;
}
CustomApp({this.profileName = 'Custom'})
: super(
name: profileName,
packageName: "custom_$profileName",
keymap: Keymap(keyPairs: []),
);
List<String> encodeKeymap() {
// encode to save in preferences
@@ -40,7 +36,7 @@ class CustomApp extends SupportedApp {
void setKey(
ZwiftButton zwiftButton, {
required PhysicalKeyboardKey physicalKey,
required PhysicalKeyboardKey? physicalKey,
required LogicalKeyboardKey? logicalKey,
bool isLongPress = false,
Offset? touchPosition,

View File

@@ -1,10 +1,7 @@
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import '../../single_line_exception.dart';
import '../buttons.dart';
import '../keymap.dart';
@@ -19,21 +16,27 @@ class MyWhoosh extends SupportedApp {
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
physicalKey: PhysicalKeyboardKey.keyI,
logicalKey: LogicalKeyboardKey.keyI,
touchPosition: Offset(80, 94),
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
physicalKey: PhysicalKeyboardKey.keyK,
logicalKey: LogicalKeyboardKey.keyK,
touchPosition: Offset(98, 94),
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
physicalKey: PhysicalKeyboardKey.keyD,
logicalKey: LogicalKeyboardKey.keyD,
touchPosition: Offset(98, 80),
isLongPress: true,
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
touchPosition: Offset(32, 80),
isLongPress: true,
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
@@ -43,54 +46,4 @@ class MyWhoosh extends SupportedApp {
],
),
);
@override
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
final superPosition = super.resolveTouchPosition(action: action, windowInfo: windowInfo);
if (superPosition != Offset.zero) {
return superPosition;
}
if (windowInfo == null) {
throw SingleLineException("Window size not known - open $this first");
}
// just my personal preference
switch (action) {
case ZwiftButton.y:
accessibilityHandler.controlMedia(MediaAction.volumeUp);
return Offset.zero;
case ZwiftButton.b:
accessibilityHandler.controlMedia(MediaAction.volumeDown);
return Offset.zero;
case ZwiftButton.a:
accessibilityHandler.controlMedia(MediaAction.next);
return Offset.zero;
case ZwiftButton.z:
accessibilityHandler.controlMedia(MediaAction.playPause);
return Offset.zero;
default:
break;
}
return switch (action.action) {
InGameAction.shiftUp => Offset(
windowInfo.right - windowInfo.width * 0.02,
windowInfo.bottom - windowInfo.height * 0.06,
),
InGameAction.shiftDown => Offset(
windowInfo.right - windowInfo.width * 0.20,
windowInfo.bottom - windowInfo.height * 0.06,
),
InGameAction.navigateRight => Offset(
windowInfo.right - windowInfo.width * 0.02,
windowInfo.bottom - windowInfo.height * 0.20,
),
_ => throw SingleLineException("Unsupported action for MyWhoosh: $action"),
};
}
}
extension WindowSize on WindowEvent {
int get width => right - left;
int get height => bottom - top;
}

View File

@@ -1,11 +1,6 @@
import 'dart:ui';
import 'package:accessibility/accessibility.dart';
import 'package:swift_control/utils/keymap/apps/biketerra.dart';
import 'package:swift_control/utils/keymap/apps/training_peaks.dart';
import '../../single_line_exception.dart';
import '../buttons.dart';
import '../keymap.dart';
import 'custom_app.dart';
import 'my_whoosh.dart';
@@ -15,17 +10,6 @@ abstract class SupportedApp {
final String name;
final Keymap keymap;
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
if (this is CustomApp) {
final keyPair = keymap.getKeyPair(action);
if (keyPair == null || keyPair.touchPosition == Offset.zero) {
throw SingleLineException("No key pair found for action: $action");
}
return keyPair.touchPosition;
}
return Offset.zero;
}
const SupportedApp({required this.name, required this.packageName, required this.keymap});
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];

View File

@@ -1,10 +1,7 @@
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/single_line_exception.dart';
import '../keymap.dart';
@@ -20,11 +17,13 @@ class TrainingPeaks extends SupportedApp {
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
physicalKey: PhysicalKeyboardKey.numpadSubtract,
logicalKey: LogicalKeyboardKey.numpadSubtract,
touchPosition: Offset(50 * 1.32, 74),
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
physicalKey: PhysicalKeyboardKey.numpadAdd,
logicalKey: LogicalKeyboardKey.numpadAdd,
touchPosition: Offset(50 * 1.15, 74),
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
@@ -54,20 +53,4 @@ class TrainingPeaks extends SupportedApp {
],
),
);
@override
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
final superPosition = super.resolveTouchPosition(action: action, windowInfo: windowInfo);
if (superPosition != Offset.zero) {
return superPosition;
}
if (windowInfo == null) {
throw SingleLineException("Window size not known - open $this first");
}
return switch (action.action) {
InGameAction.shiftUp => Offset(windowInfo.width / 2 * 1.32, windowInfo.height * 0.74),
InGameAction.shiftDown => Offset(windowInfo.width / 2 * 1.15, windowInfo.height * 0.74),
_ => throw SingleLineException("Unsupported action for IndieVelo: $action"),
};
}
}

View File

@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
enum InGameAction {
shiftUp,
shiftDown,
@@ -15,35 +17,37 @@ enum InGameAction {
enum ZwiftButton {
// left controller
navigationUp._(InGameAction.increaseResistance),
navigationDown._(InGameAction.decreaseResistance),
navigationLeft._(InGameAction.navigateLeft),
navigationRight._(InGameAction.navigateRight),
navigationUp._(InGameAction.increaseResistance, icon: Icons.keyboard_arrow_up, color: Colors.black),
navigationDown._(InGameAction.decreaseResistance, icon: Icons.keyboard_arrow_down, color: Colors.black),
navigationLeft._(InGameAction.navigateLeft, icon: Icons.keyboard_arrow_left, color: Colors.black),
navigationRight._(InGameAction.navigateRight, icon: Icons.keyboard_arrow_right, color: Colors.black),
onOffLeft._(InGameAction.toggleUi),
sideButtonLeft._(InGameAction.shiftDown),
paddleLeft._(InGameAction.shiftDown),
// zwift ride only
shiftUpLeft._(InGameAction.shiftDown),
shiftDownLeft._(InGameAction.shiftDown),
shiftUpLeft._(InGameAction.shiftDown, icon: Icons.minimize_rounded, color: Colors.black),
shiftDownLeft._(InGameAction.shiftDown, icon: Icons.minimize_rounded, color: Colors.black),
powerUpLeft._(InGameAction.shiftDown),
// right controller
a._(null),
b._(null),
z._(null),
y._(null),
a._(null, color: Colors.lightGreen),
b._(null, color: Colors.pinkAccent),
z._(null, color: Colors.deepOrangeAccent),
y._(null, color: Colors.lightBlue),
onOffRight._(InGameAction.toggleUi),
sideButtonRight._(InGameAction.shiftUp),
paddleRight._(InGameAction.shiftUp),
// zwift ride only
shiftUpRight._(InGameAction.shiftUp),
shiftUpRight._(InGameAction.shiftUp, icon: Icons.add, color: Colors.black),
shiftDownRight._(InGameAction.shiftUp),
powerUpRight._(InGameAction.shiftUp);
final InGameAction? action;
const ZwiftButton._(this.action);
final Color? color;
final IconData? icon;
const ZwiftButton._(this.action, {this.color, this.icon});
@override
String toString() {

View File

@@ -1,9 +1,13 @@
import 'dart:convert';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../actions/base_actions.dart';
class Keymap {
static Keymap custom = Keymap(keyPairs: []);
@@ -15,9 +19,8 @@ class Keymap {
String toString() {
return keyPairs.joinToString(
separator: ('\n---------\n'),
transform:
(k) =>
'''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}${k.isLongPress ? '\nLong Press: Enabled' : ''}''',
transform: (k) =>
'''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}${k.isLongPress ? '\nLong Press: Enabled' : ''}''',
);
}
@@ -59,6 +62,21 @@ class KeyPair {
physicalKey == PhysicalKeyboardKey.audioVolumeUp ||
physicalKey == PhysicalKeyboardKey.audioVolumeDown;
IconData get icon {
return switch (physicalKey) {
PhysicalKeyboardKey.mediaPlayPause ||
PhysicalKeyboardKey.mediaStop ||
PhysicalKeyboardKey.mediaTrackPrevious ||
PhysicalKeyboardKey.mediaTrackNext ||
PhysicalKeyboardKey.audioVolumeUp ||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
_ =>
physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)
? Icons.keyboard
: Icons.touch_app,
};
}
@override
String toString() {
return logicalKey?.keyLabel ??
@@ -75,11 +93,12 @@ class KeyPair {
String encode() {
// encode to save in preferences
return jsonEncode({
'actions': buttons.map((e) => e.name).toList(),
'logicalKey': logicalKey?.keyId.toString() ?? '0',
'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
if (logicalKey != null) 'logicalKey': logicalKey?.keyId.toString(),
if (physicalKey != null) 'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
if (touchPosition != Offset.zero) 'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
'isLongPress': isLongPress,
});
}
@@ -87,18 +106,26 @@ class KeyPair {
static KeyPair? decode(String data) {
// decode from preferences
final decoded = jsonDecode(data);
if (decoded['actions'] == null || decoded['logicalKey'] == null || decoded['physicalKey'] == null) {
return null;
}
// Support both percentage-based (new) and pixel-based (old) formats for backward compatibility
final Offset touchPosition = decoded.containsKey('touchPosition')
? Offset(
(decoded['touchPosition']['x'] as num).toDouble(),
(decoded['touchPosition']['y'] as num).toDouble(),
)
: Offset.zero;
return KeyPair(
buttons:
decoded['actions']
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
.toList(),
logicalKey: int.parse(decoded['logicalKey']) != 0 ? LogicalKeyboardKey(int.parse(decoded['logicalKey'])) : null,
physicalKey:
int.parse(decoded['physicalKey']) != 0 ? PhysicalKeyboardKey(int.parse(decoded['physicalKey'])) : null,
touchPosition: Offset(decoded['touchPosition']['x'], decoded['touchPosition']['y']),
buttons: decoded['actions']
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
.toList(),
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
? LogicalKeyboardKey(int.parse(decoded['logicalKey']))
: null,
physicalKey: decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0
? PhysicalKeyboardKey(int.parse(decoded['physicalKey']))
: null,
touchPosition: touchPosition,
isLongPress: decoded['isLongPress'] ?? false,
);
}

View File

@@ -8,11 +8,15 @@ import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
class AccessibilityRequirement extends PlatformRequirement {
AccessibilityRequirement() : super('Allow Accessibility Service');
AccessibilityRequirement()
: super(
'Allow Accessibility Service',
description: 'SwiftControl needs accessibility permission to control your training apps.',
);
@override
Future<void> call() async {
return accessibilityHandler.openPermissions();
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
_showDisclosureDialog(context, onUpdate);
}
@override
@@ -20,31 +24,6 @@ class AccessibilityRequirement extends PlatformRequirement {
status = await accessibilityHandler.hasPermission();
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
if (status) {
return null; // Already granted, no need for disclosure
}
return Container(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'SwiftControl needs accessibility permission to control your training apps.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _showDisclosureDialog(context, onUpdate),
child: const Text('Show Permission Details'),
),
],
),
);
}
Future<void> _showDisclosureDialog(BuildContext context, VoidCallback onUpdate) async {
return showDialog<void>(
context: context,
@@ -72,7 +51,7 @@ class BluetoothScanRequirement extends PlatformRequirement {
BluetoothScanRequirement() : super('Allow Bluetooth Scan');
@override
Future<void> call() async {
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
await Permission.bluetoothScan.request();
}
@@ -87,7 +66,7 @@ class LocationRequirement extends PlatformRequirement {
LocationRequirement() : super('Allow Location so Bluetooth scan works');
@override
Future<void> call() async {
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
await Permission.locationWhenInUse.request();
}
@@ -102,7 +81,7 @@ class BluetoothConnectRequirement extends PlatformRequirement {
BluetoothConnectRequirement() : super('Allow Bluetooth Connections');
@override
Future<void> call() async {
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
await Permission.bluetoothConnect.request();
}
@@ -114,10 +93,11 @@ class BluetoothConnectRequirement extends PlatformRequirement {
}
class NotificationRequirement extends PlatformRequirement {
NotificationRequirement() : super('Allow adding persistent Notification (keeps app alive)');
NotificationRequirement()
: super('Allow persistent Notification', description: 'This keeps the app alive in background');
@override
Future<void> call() async {
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
@@ -170,7 +150,7 @@ class NotificationRequirement extends PlatformRequirement {
await AndroidFlutterLocalNotificationsPlugin().startForegroundService(
1,
channelGroupId,
'Bluetooth keep alive',
'Allows SwiftControl to keep running in background',
foregroundServiceTypes: {AndroidServiceForegroundType.foregroundServiceTypeConnectedDevice},
notificationDetails: AndroidNotificationDetails(
channelGroupId,

View File

@@ -1,15 +1,20 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/scan.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/utils/requirements/remote.dart';
import 'package:universal_ble/universal_ble.dart';
class KeyboardRequirement extends PlatformRequirement {
KeyboardRequirement() : super('Keyboard access');
@override
Future<void> call() async {
await keyPressSimulator.requestAccess();
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
await keyPressSimulator.requestAccess(onlyOpenPrefPane: Platform.isMacOS);
}
@override
@@ -22,14 +27,19 @@ class BluetoothTurnedOn extends PlatformRequirement {
BluetoothTurnedOn() : super('Bluetooth turned on');
@override
Future<void> call() async {
await UniversalBle.enableBluetooth();
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
if (!kIsWeb && Platform.isIOS) {
// on iOS we cannot programmatically enable Bluetooth, just open settings
await peripheralManager.showAppSettings();
} else {
await UniversalBle.enableBluetooth();
}
}
@override
Future<void> getStatus() async {
final currentState = await UniversalBle.getBluetoothAvailabilityState();
status = currentState == AvailabilityState.poweredOn;
status = currentState == AvailabilityState.poweredOn || screenshotMode;
}
}
@@ -39,19 +49,19 @@ class UnsupportedPlatform extends PlatformRequirement {
}
@override
Future<void> call() async {}
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Future<void> getStatus() async {}
}
class BluetoothScanning extends PlatformRequirement {
BluetoothScanning() : super('Finding your Zwift® controller...') {
BluetoothScanning() : super('Finding your Controller...') {
status = false;
}
@override
Future<void> call() async {}
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Future<void> getStatus() async {}

View File

@@ -5,28 +5,32 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/utils/requirements/android.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/remote.dart';
abstract class PlatformRequirement {
String name;
String? description;
late bool status;
PlatformRequirement(this.name);
PlatformRequirement(this.name, {this.description});
Future<void> getStatus();
Future<void> call();
Future<void> call(BuildContext context, VoidCallback onUpdate);
Widget? build(BuildContext context, VoidCallback onUpdate) {
return null;
}
}
Future<List<PlatformRequirement>> getRequirements() async {
Future<List<PlatformRequirement>> getRequirements(bool local) async {
List<PlatformRequirement> list;
if (kIsWeb) {
list = [BluetoothTurnedOn(), BluetoothScanning()];
} else if (Platform.isMacOS || Platform.isIOS) {
} else if (Platform.isMacOS) {
list = [BluetoothTurnedOn(), KeyboardRequirement(), BluetoothScanning()];
} else if (Platform.isIOS) {
list = [BluetoothTurnedOn(), RemoteRequirement(), BluetoothScanning()];
} else if (Platform.isWindows) {
list = [BluetoothTurnedOn(), KeyboardRequirement(), BluetoothScanning()];
} else if (Platform.isAndroid) {
@@ -34,7 +38,7 @@ Future<List<PlatformRequirement>> getRequirements() async {
final deviceInfo = await deviceInfoPlugin.androidInfo;
list = [
BluetoothTurnedOn(),
AccessibilityRequirement(),
if (local) AccessibilityRequirement() else RemoteRequirement(),
NotificationRequirement(),
if (deviceInfo.version.sdkInt <= 30)
LocationRequirement()

View File

@@ -0,0 +1,340 @@
import 'dart:io';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:permission_handler/permission_handler.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import '../../pages/markdown.dart';
final peripheralManager = PeripheralManager();
bool _isAdvertising = false;
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
class RemoteRequirement extends PlatformRequirement {
RemoteRequirement() : super('Connect to your other device');
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
Future<void> reconnect() async {
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
_isServiceAdded = false;
_isAdvertising = false;
(actionHandler as RemoteActions).setConnectedCentral(null, null);
startAdvertising(() {});
}
Future<void> startAdvertising(VoidCallback onUpdate) async {
// Input report characteristic (notify)
final inputReport = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4D'),
permissions: [GATTCharacteristicPermission.read],
properties: [GATTCharacteristicProperty.notify, GATTCharacteristicProperty.read],
descriptors: [
GATTDescriptor.immutable(
// Report Reference: ID=1, Type=Input(1)
uuid: UUID.fromString('2908'),
value: Uint8List.fromList([0x01, 0x01]),
),
],
);
peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
if (!kIsWeb && Platform.isAndroid) {
if (Platform.isAndroid) {
peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
/*(actionHandler as RemoteActions).setConnectedCentral(state.central, inputReport);
//peripheralManager.stopAdvertising();
onUpdate();*/
} else if (state.state == ConnectionState.disconnected) {
(actionHandler as RemoteActions).setConnectedCentral(null, null);
onUpdate();
}
});
}
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
_isAdvertising = false;
onUpdate();
return;
}
}
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn &&
peripheralManager.state != BluetoothLowEnergyState.unknown) {
print('Waiting for peripheral manager to be powered on...');
await Future.delayed(Duration(seconds: 1));
}
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
final reportMapDataAbsolute = Uint8List.fromList([
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Min (1)
0x29, 0x03, // Usage Max (3)
0x15, 0x00, // Logical Min (0)
0x25, 0x01, // Logical Max (1)
0x95, 0x03, // Report Count (3)
0x75, 0x01, // Report Size (1)
0x81, 0x02, // Input (Data,Var,Abs) // buttons
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x03, // Input (Const,Var,Abs) // padding
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x15, 0x00, // Logical Min (0)
0x25, 0x64, // Logical Max (100)
0x75, 0x08, // Report Size (8)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0,
0xC0,
]);
// 1) Build characteristics
final hidInfo = GATTCharacteristic.immutable(
uuid: UUID.fromString('2A4A'),
value: Uint8List.fromList([0x11, 0x01, 0x00, 0x02]),
descriptors: [], // HID v1.11, country=0, flags=2
);
final reportMap = GATTCharacteristic.immutable(
uuid: UUID.fromString('2A4B'),
//properties: [GATTCharacteristicProperty.read],
//permissions: [GATTCharacteristicPermission.read],
value: reportMapDataAbsolute,
descriptors: [
GATTDescriptor.immutable(uuid: UUID.fromString('2908'), value: Uint8List.fromList([0x0, 0x0])),
],
);
final protocolMode = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4E'),
properties: [GATTCharacteristicProperty.read, GATTCharacteristicProperty.writeWithoutResponse],
permissions: [GATTCharacteristicPermission.read, GATTCharacteristicPermission.write],
descriptors: [],
);
final hidControlPoint = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4C'),
properties: [GATTCharacteristicProperty.writeWithoutResponse],
permissions: [GATTCharacteristicPermission.write],
descriptors: [],
);
// Input report characteristic (notify)
final keyboardInputReport = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4D'),
permissions: [GATTCharacteristicPermission.read],
properties: [GATTCharacteristicProperty.notify, GATTCharacteristicProperty.read],
descriptors: [
GATTDescriptor.immutable(
// Report Reference: ID=1, Type=Input(1)
uuid: UUID.fromString('2908'),
value: Uint8List.fromList([0x02, 0x01]),
),
],
);
final outputReport = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4D'),
permissions: [GATTCharacteristicPermission.read, GATTCharacteristicPermission.write],
properties: [
GATTCharacteristicProperty.read,
GATTCharacteristicProperty.write,
GATTCharacteristicProperty.writeWithoutResponse,
],
descriptors: [
GATTDescriptor.immutable(
// Report Reference: ID=1, Type=Input(1)
uuid: UUID.fromString('2908'),
value: Uint8List.fromList([0x02, 0x02]),
),
],
);
// 2) HID service
final hidService = GATTService(
uuid: UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB'),
isPrimary: true,
characteristics: [
hidInfo,
reportMap,
protocolMode,
outputReport,
hidControlPoint,
keyboardInputReport,
inputReport,
],
includedServices: [],
);
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((char) {
print('Read request for characteristic: ${char}');
// You can respond to read requests here if needed
});
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
if (char.characteristic.uuid == inputReport.uuid) {
if (char.state) {
(actionHandler as RemoteActions).setConnectedCentral(char.central, char.characteristic);
} else {
(actionHandler as RemoteActions).setConnectedCentral(null, null);
}
onUpdate();
}
print(
'Notify state changed for characteristic: ${char.characteristic.uuid} vs ${char.characteristic.uuid == inputReport.uuid}: ${char.state}',
);
});
}
await peripheralManager.addService(hidService);
// 3) Optional Battery service
await peripheralManager.addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
characteristics: [
GATTCharacteristic.immutable(
uuid: UUID.fromString('2A19'),
value: Uint8List.fromList([100]),
descriptors: [],
),
],
includedServices: [],
),
);
_isServiceAdded = true;
}
final advertisement = Advertisement(
name:
'SwiftControl ${Platform.isIOS
? 'iOS'
: Platform.isAndroid
? 'Android'
: ''}',
serviceUUIDs: [UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB')],
);
/*pm.connectionStateChanged.forEach((state) {
print('Peripheral connection state: $state');
});*/
print('Starting advertising with HID service...');
await peripheralManager.startAdvertising(advertisement);
_isAdvertising = true;
onUpdate();
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return _PairWidget(onUpdate: onUpdate, requirement: this);
}
@override
Future<void> getStatus() async {
status = (actionHandler as RemoteActions).isConnected || screenshotMode;
}
}
class _PairWidget extends StatefulWidget {
final RemoteRequirement requirement;
final VoidCallback onUpdate;
const _PairWidget({super.key, required this.onUpdate, required this.requirement});
@override
State<_PairWidget> createState() => _PairWidgetState();
}
class _PairWidgetState extends State<_PairWidget> {
@override
void initState() {
super.initState();
// after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
toggle();
});
}
@override
Widget build(BuildContext context) {
return Column(
spacing: 10,
children: [
Row(
spacing: 10,
children: [
ElevatedButton(
onPressed: () async {
await toggle();
},
child: Text(_isAdvertising ? 'Stop Pairing' : 'Start Pairing'),
),
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
if (kDebugMode && !screenshotMode)
ElevatedButton(
onPressed: () {
(actionHandler as RemoteActions).sendAbsMouseReport(0, 90, 90);
(actionHandler as RemoteActions).sendAbsMouseReport(1, 90, 90);
(actionHandler as RemoteActions).sendAbsMouseReport(0, 90, 90);
},
child: Text('Test'),
),
],
),
if (_isAdvertising) ...[
Text(
'If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
),
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));
},
child: Text('Check the troubleshooting guide'),
),
],
],
);
}
Future<void> toggle() async {
if (_isAdvertising) {
await peripheralManager.stopAdvertising();
_isAdvertising = false;
(actionHandler as RemoteActions).setConnectedCentral(null, null);
widget.onUpdate();
_isLoading = false;
setState(() {});
} else {
_isLoading = true;
setState(() {});
await widget.requirement.startAdvertising(widget.onUpdate);
_isLoading = false;
setState(() {});
}
}
}

View File

@@ -1,4 +1,7 @@
import 'dart:convert';
import 'package:dartx/dartx.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
@@ -6,57 +9,178 @@ import '../../main.dart';
import '../keymap/apps/custom_app.dart';
class Settings {
late final SharedPreferences _prefs;
late final SharedPreferences prefs;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
prefs = await SharedPreferences.getInstance();
try {
final appSetting = _prefs.getStringList("customapp");
if (appSetting != null) {
final customApp = CustomApp();
customApp.decodeKeymap(appSetting);
// Get screen size for migrations
Size? screenSize;
try {
final view = WidgetsBinding.instance.platformDispatcher.views.first;
screenSize = view.physicalSize / view.devicePixelRatio;
} catch (e) {
screenSize = null;
}
final appName = _prefs.getString('app');
// Handle migration from old "customapp" key to new "customapp_Custom" key
if (prefs.containsKey('customapp') && !prefs.containsKey('customapp_Custom')) {
final oldCustomApp = prefs.getStringList('customapp');
if (oldCustomApp != null) {
// Migrate pixel-based to percentage-based if screen size available
if (screenSize != null) {
final migratedData = await _migrateToPercentageBased(oldCustomApp, screenSize);
await prefs.setStringList('customapp_Custom', migratedData);
} else {
await prefs.setStringList('customapp_Custom', oldCustomApp);
}
await prefs.remove('customapp');
}
}
final appName = prefs.getString('app');
if (appName == null) {
return;
}
final app = SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
actionHandler.init(app);
// Check if it's a custom app with a profile name
if (appName.startsWith('Custom') || prefs.containsKey('customapp_$appName')) {
final customApp = CustomApp(profileName: appName);
final appSetting = prefs.getStringList('customapp_$appName');
if (appSetting != null) {
customApp.decodeKeymap(appSetting);
}
actionHandler.init(customApp);
} else {
final app = SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
actionHandler.init(app);
}
} catch (e) {
// couldn't decode, reset
await _prefs.clear();
await prefs.clear();
rethrow;
}
}
Future<void> reset() async {
await _prefs.clear();
await prefs.clear();
actionHandler.init(null);
}
Future<void> setApp(SupportedApp app) async {
if (app is CustomApp) {
await _prefs.setStringList("customapp", app.encodeKeymap());
await prefs.setStringList('customapp_${app.profileName}', app.encodeKeymap());
}
await prefs.setString('app', app.name);
}
List<String> getCustomAppProfiles() {
// Get all keys starting with 'customapp_'
final keys = prefs.getKeys().where((key) => key.startsWith('customapp_')).toList();
return keys.map((key) => key.replaceFirst('customapp_', '')).toList();
}
List<String>? getCustomAppKeymap(String profileName) {
return prefs.getStringList('customapp_$profileName');
}
Future<void> deleteCustomAppProfile(String profileName) async {
await prefs.remove('customapp_$profileName');
// If the current app is the one being deleted, reset
if (prefs.getString('app') == profileName) {
actionHandler.init(null);
await prefs.remove('app');
}
}
Future<void> duplicateCustomAppProfile(String sourceProfileName, String newProfileName) async {
final sourceData = prefs.getStringList('customapp_$sourceProfileName');
if (sourceData != null) {
await prefs.setStringList('customapp_$newProfileName', sourceData);
}
}
String? exportCustomAppProfile(String profileName) {
final data = prefs.getStringList('customapp_$profileName');
if (data == null) return null;
var encoder = JsonEncoder.withIndent(" ");
return encoder.convert({
'version': 1,
'profileName': profileName,
'keymap': data.map((e) => jsonDecode(e)).toList(),
});
}
Future<bool> importCustomAppProfile(String jsonData, {String? newProfileName}) async {
try {
final decoded = jsonDecode(jsonData);
// Validate the structure
if (decoded['version'] == null || decoded['keymap'] == null) {
return false;
}
final profileName = newProfileName ?? decoded['profileName'] ?? 'Imported';
final keymap = (decoded['keymap'] as List).map((e) => jsonEncode(e)).toList().cast<String>();
await prefs.setStringList('customapp_$profileName', keymap);
return true;
} catch (e) {
print(e);
return false;
}
await _prefs.setString('app', app.name);
}
String? getLastSeenVersion() {
return _prefs.getString('last_seen_version');
return prefs.getString('last_seen_version');
}
Future<void> setLastSeenVersion(String version) async {
await _prefs.setString('last_seen_version', version);
await prefs.setString('last_seen_version', version);
}
bool getVibrationEnabled() {
return _prefs.getBool('vibration_enabled') ?? true;
return prefs.getBool('vibration_enabled') ?? true;
}
Future<void> setVibrationEnabled(bool enabled) async {
await _prefs.setBool('vibration_enabled', enabled);
await prefs.setBool('vibration_enabled', enabled);
}
Future<List<String>> _migrateToPercentageBased(List<String> keymapData, Size screenSize) async {
final migratedData = <String>[];
final needMigrations = keymapData.associateWith((encodedKeyPair) {
final decoded = jsonDecode(encodedKeyPair);
final touchPosData = decoded['touchPosition'];
// Convert pixel-based to percentage-based
final x = (touchPosData['x'] as num).toDouble();
final y = (touchPosData['y'] as num).toDouble();
return x > 100.0 || y > 100.0;
});
for (final entry in needMigrations.entries) {
if (entry.value) {
final decoded = jsonDecode(entry.key);
final touchPosData = decoded['touchPosition'];
// Convert pixel-based to percentage-based
final x = (touchPosData['x'] as num).toDouble();
final y = (touchPosData['y'] as num).toDouble();
final newX = (x / screenSize.width).clamp(0.0, 1.0) * 100.0;
final newY = (y / screenSize.height).clamp(0.0, 1.0) * 100.0;
// Update the JSON structure
decoded['touchPosition'] = {'x': newX, 'y': newY};
migratedData.add(jsonEncode(decoded));
} else {
migratedData.add(entry.key);
}
}
return migratedData;
}
}

View File

@@ -1,13 +1,15 @@
import 'package:flutter/material.dart';
import 'package:swift_control/utils/changelog.dart';
import 'package:flutter/services.dart';
import 'package:flutter_md/flutter_md.dart';
class ChangelogDialog extends StatelessWidget {
final ChangelogEntry entry;
final Markdown entry;
const ChangelogDialog({super.key, required this.entry});
@override
Widget build(BuildContext context) {
final latestVersion = Markdown(blocks: entry.blocks.skip(1).take(2).toList(), markdown: entry.markdown);
return AlertDialog(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -16,28 +18,14 @@ class ChangelogDialog extends StatelessWidget {
Text('What\'s New'),
SizedBox(height: 4),
Text(
'Version ${entry.version}',
'Version ${entry.blocks.first.text}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.normal),
),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children:
entry.changes
.map(
(change) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('', style: TextStyle(fontSize: 16)),
Expanded(child: Text(change, style: Theme.of(context).textTheme.bodyMedium)),
],
),
)
.toList(),
),
content: Container(
constraints: BoxConstraints(minWidth: 460),
child: MarkdownWidget(markdown: latestVersion),
),
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Got it!'))],
);
@@ -47,9 +35,14 @@ class ChangelogDialog extends StatelessWidget {
// Show dialog if this is a new version
if (lastSeenVersion != currentVersion) {
try {
final entry = await ChangelogParser.getLatestEntry();
if (entry != null && context.mounted) {
showDialog(context: context, builder: (context) => ChangelogDialog(entry: entry));
final entry = await rootBundle.loadString('CHANGELOG.md');
if (context.mounted) {
final markdown = Markdown.fromString(entry);
showDialog(
context: context,
useRootNavigator: true,
builder: (context) => ChangelogDialog(entry: markdown),
);
}
} catch (e) {
print('Failed to load changelog for dialog: $e');

View File

@@ -86,7 +86,7 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
return AlertDialog(
content:
_pressedButton == null
? Text('Press a button on your Zwift device')
? Text('Press a button on your Click device')
: KeyboardListener(
focusNode: _focusNode,
autofocus: true,

View File

@@ -1,9 +1,12 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/device.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
import '../pages/touch_area.dart';
import '../utils/actions/base_actions.dart';
class KeymapExplanation extends StatelessWidget {
final Keymap keymap;
@@ -15,14 +18,18 @@ class KeymapExplanation extends StatelessWidget {
final connectedDevice = connection.devices.firstOrNull;
final availableKeypairs = keymap.keyPairs.filter(
(e) => connectedDevice?.availableButtons.containsAny(e.buttons) == true,
(e) => connectedDevice?.availableButtons.containsAny(e.buttons) ?? true,
);
final keyboardGroups = availableKeypairs
.filter((e) => e.physicalKey != null)
.filter((e) => e.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard))
.groupBy((element) => '${element.physicalKey?.usbHidUsage}-${element.isLongPress}');
final touchGroups = availableKeypairs
.filter((e) => e.physicalKey == null && e.touchPosition != Offset.zero)
.filter(
(e) =>
(e.physicalKey == null || !actionHandler.supportedModes.contains(SupportedMode.keyboard)) &&
e.touchPosition != Offset.zero,
)
.groupBy((element) => '${element.touchPosition.dx}-${element.touchPosition.dy}-${element.isLongPress}');
return Column(
@@ -34,14 +41,22 @@ class KeymapExplanation extends StatelessWidget {
Text('No key mappings found. Please customize the keymap.')
else
Table(
border: TableBorder.all(color: Theme.of(context).colorScheme.primaryContainer),
border: TableBorder.symmetric(
borderRadius: BorderRadius.circular(9),
inside: BorderSide(
color: Theme.of(context).colorScheme.primaryContainer,
),
outside: BorderSide(
color: Theme.of(context).colorScheme.primaryContainer,
),
),
children: [
TableRow(
children: [
Padding(
padding: const EdgeInsets.all(6),
child: Text(
'Button on your ${connectedDevice?.device.name ?? connectedDevice?.runtimeType}',
'Button on your ${connectedDevice?.device.name?.screenshot ?? connectedDevice?.runtimeType ?? 'device'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
@@ -65,12 +80,15 @@ class KeymapExplanation extends StatelessWidget {
children: [
for (final keyPair in pair.value)
for (final button in keyPair.buttons)
if (connectedDevice?.availableButtons.contains(button) == true)
IntrinsicWidth(child: KeyWidget(label: button.name)),
if (connectedDevice?.availableButtons.contains(button) ?? true)
IntrinsicWidth(child: ButtonWidget(button: button)),
],
),
),
Padding(padding: const EdgeInsets.all(6), child: KeypairExplanation(keyPair: pair.value.first)),
Padding(
padding: const EdgeInsets.all(6),
child: KeypairExplanation(keyPair: pair.value.first),
),
],
),
],
@@ -79,17 +97,21 @@ class KeymapExplanation extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.all(6),
child: Row(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final keyPair in pair.value)
for (final button in keyPair.buttons)
if (connectedDevice?.availableButtons.contains(button) == true)
KeyWidget(label: button.name),
if (connectedDevice?.availableButtons.contains(button) ?? true)
ButtonWidget(button: button),
],
),
),
Padding(padding: const EdgeInsets.all(6), child: KeypairExplanation(keyPair: pair.value.first)),
Padding(
padding: const EdgeInsets.all(6),
child: KeypairExplanation(keyPair: pair.value.first),
),
],
),
],
@@ -130,6 +152,48 @@ class KeyWidget extends StatelessWidget {
}
}
class ButtonWidget extends StatelessWidget {
final ZwiftButton button;
final bool big;
const ButtonWidget({super.key, required this.button, this.big = false});
@override
Widget build(BuildContext context) {
return IntrinsicWidth(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
constraints: BoxConstraints(
minWidth: big && button.color != null ? 40 : 30,
minHeight: big && button.color != null ? 40 : 0,
),
decoration: BoxDecoration(
border: Border.all(color: button.color != null ? Colors.black : Theme.of(context).colorScheme.primary),
shape: button.color != null || button.icon != null ? BoxShape.circle : BoxShape.rectangle,
borderRadius: button.color != null || button.icon != null ? null : BorderRadius.circular(4),
color: button.color ?? Theme.of(context).colorScheme.primaryContainer,
),
child: Center(
child: button.icon != null
? Icon(
button.icon,
color: Colors.white,
size: big && button.color != null ? null : 14,
)
: Text(
button.name.splitByUpperCase(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: big && button.color != null ? 20 : 12,
fontWeight: button.color != null ? FontWeight.bold : null,
color: button.color != null ? Colors.white : Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
),
);
}
}
extension SplitByUppercase on String {
String splitByUpperCase() {
return replaceAllMapped(RegExp(r'([a-z])([A-Z])'), (match) => '${match.group(1)} ${match.group(2)}').capitalize();

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
typedef RenderLoadCallback = Widget Function();
typedef OnErrorCallback = void Function(BuildContext context, dynamic error);
typedef OnLoadCallback = void Function(bool isLoading);
typedef RenderChildCallback = Widget Function(bool isLoading, VoidCallback? onTap);
typedef FutureCallback = Future Function();
enum LoadingState { Error, Loading, Success }
class LoadingWidget extends StatefulWidget {
const LoadingWidget({
super.key,
this.renderLoad,
this.renderChild,
this.onErrorCallback,
this.futureCallback,
this.onLoadCallback,
});
final RenderLoadCallback? renderLoad;
final RenderChildCallback? renderChild;
final OnErrorCallback? onErrorCallback;
final OnLoadCallback? onLoadCallback;
final FutureCallback? futureCallback;
@override
State<StatefulWidget> createState() => LoadingWidgetState();
}
class LoadingWidgetState extends State<LoadingWidget> {
var _loadingState = LoadingState.Success;
dynamic _error;
Future<void> reloadState() {
return _initState();
}
Future<void> _initState() async {
if (!mounted) {
return;
}
if (widget.onLoadCallback != null) {
widget.onLoadCallback!(true);
}
setState(() {
_loadingState = LoadingState.Loading;
});
try {
await widget.futureCallback!();
if (!mounted) {
return;
}
if (widget.onLoadCallback != null) {
widget.onLoadCallback!(false);
}
setState(() {
_loadingState = LoadingState.Success;
});
} catch (e) {
if (widget.onLoadCallback != null) {
widget.onLoadCallback!(false);
}
debugPrint(e.toString());
if (mounted) {
setState(() {
_error = e;
_loadingState = LoadingState.Error;
if (widget.onErrorCallback != null) {
widget.onErrorCallback!(context, _error);
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_error.toString())));
}
});
}
}
}
@override
Widget build(BuildContext context) {
if (_loadingState == LoadingState.Loading && widget.renderLoad != null) {
return widget.renderLoad!();
}
final isLoading = _loadingState == LoadingState.Loading;
return widget.renderChild!(isLoading, isLoading ? null : () => reloadState());
}
}

View File

@@ -29,12 +29,14 @@ class _LogviewerState extends State<LogViewer> {
_actions.add((date: DateTime.now(), entry: data.toString()));
_actions = _actions.takeLast(60).toList();
});
// scroll to the bottom
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 60),
curve: Curves.easeInOut,
);
if (_scrollController.hasClients) {
// scroll to the bottom
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 60),
curve: Curves.easeInOut,
);
}
}
});
}
@@ -48,48 +50,56 @@ class _LogviewerState extends State<LogViewer> {
@override
Widget build(BuildContext context) {
return SelectionArea(
child: GestureDetector(
onLongPress: () {
setState(() {
_actions = [];
});
},
child: ListView(
physics: const NeverScrollableScrollPhysics(),
controller: _scrollController,
shrinkWrap: true,
reverse: true,
children:
_actions
.map(
(action) => Text.rich(
TextSpan(
children: [
TextSpan(
text: action.date.toString().split(" ").last,
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: "monospace",
fontFamilyFallback: <String>["Courier"],
return _actions.isEmpty
? Container()
: SafeArea(
child: Card(
child: Padding(
padding: const EdgeInsets.all(8),
child: SelectionArea(
child: GestureDetector(
onLongPress: () {
setState(() {
_actions = [];
});
},
child: ListView(
physics: const NeverScrollableScrollPhysics(),
controller: _scrollController,
shrinkWrap: true,
reverse: true,
children: _actions
.map(
(action) => Text.rich(
TextSpan(
children: [
TextSpan(
text: action.date.toString().split(" ").last,
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: "monospace",
fontFamilyFallback: <String>["Courier"],
),
),
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
],
),
),
),
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
],
),
)
.toList(),
),
)
.toList(),
),
),
);
),
),
),
),
);
}
}

View File

@@ -3,48 +3,54 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/markdown.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/widgets/title.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../pages/changelog.dart';
import '../pages/device.dart';
List<Widget> buildMenuButtons() {
return [
PopupMenuButton(
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Text('via Credit Card, Google Pay, Apple Pay and others'),
onTap: () {
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
final link = switch (currency.currencyName) {
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
};
launchUrlString(link);
},
),
if (!kIsWeb && Platform.isAndroid && !isFromPlayStore)
if (kIsWeb || (!Platform.isIOS && !Platform.isMacOS)) ...[
PopupMenuButton(
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Text('by buying the app from Play Store'),
child: Text('via Credit Card, Google Pay, Apple Pay and others'),
onTap: () {
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
final link = switch (currency.currencyName) {
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
};
launchUrlString(link);
},
),
PopupMenuItem(
child: Text('via PayPal'),
onTap: () {
launchUrlString('https://paypal.me/boni');
},
),
];
},
icon: Text('Donate ♥', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
),
SizedBox(width: 8),
if (!kIsWeb && Platform.isAndroid && isFromPlayStore == false)
PopupMenuItem(
child: Text('by buying the app from Play Store'),
onTap: () {
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
},
),
PopupMenuItem(
child: Text('via PayPal'),
onTap: () {
launchUrlString('https://paypal.me/boni');
},
),
];
},
icon: Text(
'Donate ♥',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
),
SizedBox(width: 8),
],
const MenuButton(),
SizedBox(width: 8),
];
@@ -56,61 +62,71 @@ class MenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton(
itemBuilder:
(c) => [
if (kDebugMode) ...[
PopupMenuItem(
child: PopupMenuButton(
child: Text("Simulate buttons"),
itemBuilder: (_) {
return ZwiftButton.values
.map(
(e) => PopupMenuItem(
child: Text(e.name),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.performAction(e);
});
},
),
)
.toList();
},
),
),
PopupMenuItem(
child: Text('Continue'),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
},
),
PopupMenuItem(
child: Text('Reset'),
onTap: () async {
await settings.reset();
},
),
PopupMenuItem(child: PopupMenuDivider()),
],
PopupMenuItem(
child: Text('Changelog'),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => ChangelogPage()));
itemBuilder: (c) => [
if (kDebugMode) ...[
PopupMenuItem(
child: PopupMenuButton(
child: Text("Simulate buttons"),
itemBuilder: (_) {
return ZwiftButton.values
.map(
(e) => PopupMenuItem(
child: Text(e.name),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
connection.signalNotification(
RideNotification(Uint8List(0), analogPaddleThreshold: 25)..buttonsClicked = [e],
);
});
},
),
)
.toList();
},
),
PopupMenuItem(
child: Text('Feedback'),
onTap: () {
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
),
PopupMenuItem(
child: Text('License'),
onTap: () {
showLicensePage(context: context);
},
),
],
),
PopupMenuItem(
child: Text('Continue'),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
},
),
PopupMenuItem(
child: Text('Reset'),
onTap: () async {
await settings.reset();
},
),
PopupMenuItem(child: PopupMenuDivider()),
],
PopupMenuItem(
child: Text('Changelog'),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
},
),
PopupMenuItem(
child: Text('Troubleshooting Guide'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
),
PopupMenuItem(
child: Text('Feedback'),
onTap: () {
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
),
PopupMenuItem(
child: Text('License'),
onTap: () {
showLicensePage(context: context);
},
),
],
);
}
}

85
lib/widgets/title.dart Normal file → Executable file
View File

@@ -7,12 +7,16 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:in_app_update/in_app_update.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:restart/restart.dart';
import 'package:restart_app/restart_app.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:version/version.dart';
String? _latestVersionUrlValue;
PackageInfo? _packageInfoValue;
bool isFromPlayStore = true;
bool? isFromPlayStore;
class AppTitle extends StatefulWidget {
const AppTitle({super.key});
@@ -22,28 +26,33 @@ class AppTitle extends StatefulWidget {
}
class _AppTitleState extends State<AppTitle> {
final updater = ShorebirdUpdater();
Patch? _shorebirdPatch;
Future<String?> _getLatestVersionUrlIfNewer() async {
final response = await http.get(Uri.parse('https://api.github.com/repos/jonasbark/swiftcontrol/releases/latest'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final tagName = data['tag_name'] as String;
final prerelase = data['prerelease'] as bool;
final latestVersion = tagName.split('+').first;
final currentVersion = 'v${_packageInfoValue!.version}';
final latestVersion = Version.parse(tagName.split('+').first.replaceAll('v', ''));
final currentVersion = Version.parse(_packageInfoValue!.version);
// +1337 releases are considered beta
if (latestVersion != currentVersion && !prerelase) {
if (latestVersion > currentVersion && !prerelase) {
final assets = data['assets'] as List;
if (Platform.isAndroid) {
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
return apkUrl;
} else if (Platform.isMacOS) {
final dmgUrl =
assets.firstOrNullWhere((asset) => asset['name'].endsWith('.macos.zip'))['browser_download_url'];
final dmgUrl = assets.firstOrNullWhere(
(asset) => asset['name'].endsWith('.macos.zip'),
)['browser_download_url'];
return dmgUrl;
} else if (Platform.isWindows) {
final appImageUrl =
assets.firstOrNullWhere((asset) => asset['name'].endsWith('.windows.zip'))['browser_download_url'];
final appImageUrl = assets.firstOrNullWhere(
(asset) => asset['name'].endsWith('.windows.zip'),
)['browser_download_url'];
return appImageUrl;
}
}
@@ -54,6 +63,15 @@ class _AppTitleState extends State<AppTitle> {
@override
void initState() {
super.initState();
if (updater.isAvailable) {
updater.readCurrentPatch().then((patch) {
setState(() {
_shorebirdPatch = patch;
});
});
}
if (_packageInfoValue == null) {
PackageInfo.fromPlatform().then((value) {
setState(() {
@@ -67,6 +85,28 @@ class _AppTitleState extends State<AppTitle> {
}
void _checkForUpdate() async {
if (updater.isAvailable) {
final updateStatus = await updater.checkForUpdate();
if (updateStatus == UpdateStatus.outdated) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('New version available'),
duration: Duration(seconds: 1337),
action: SnackBarAction(
label: 'Update',
onPressed: () {
updater.update().then((value) {
_showShorebirdRestartSnackbar();
});
},
),
),
);
} else if (updateStatus == UpdateStatus.restartRequired) {
_showShorebirdRestartSnackbar();
}
return null;
}
if (!kIsWeb && Platform.isAndroid) {
try {
final appUpdateInfo = await InAppUpdate.checkForUpdate();
@@ -84,13 +124,15 @@ class _AppTitleState extends State<AppTitle> {
),
);
}
isFromPlayStore = true;
return null;
} on Exception catch (e) {
isFromPlayStore = false;
print('Failed to check for update: $e');
}
setState(() {});
}
if (_latestVersionUrlValue == null && !kIsWeb) {
if (_latestVersionUrlValue == null && !kIsWeb && !Platform.isIOS) {
final url = await _getLatestVersionUrlIfNewer();
if (url != null && mounted && !kDebugMode) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -114,10 +156,10 @@ class _AppTitleState extends State<AppTitle> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SwiftControl'),
Text('SwiftControl', style: TextStyle(fontWeight: FontWeight.bold)),
if (_packageInfoValue != null)
Text(
'v${_packageInfoValue!.version}',
'v${_packageInfoValue!.version}${_shorebirdPatch != null ? '+${_shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
)
else
@@ -125,4 +167,25 @@ class _AppTitleState extends State<AppTitle> {
],
);
}
void _showShorebirdRestartSnackbar() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Restart the app to use the new version'),
duration: Duration(seconds: 10),
action: Platform.isIOS || Platform.isAndroid
? SnackBarAction(
label: 'Restart',
onPressed: () {
if (Platform.isAndroid) {
restart();
} else if (Platform.isIOS) {
Restart.restartApp();
}
},
)
: null,
),
);
}
}

View File

@@ -6,12 +6,16 @@
#include "generated_plugin_registrant.h"
#include <bluetooth_low_energy_linux/bluetooth_low_energy_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) bluetooth_low_energy_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "BluetoothLowEnergyLinuxPlugin");
bluetooth_low_energy_linux_plugin_register_with_registrar(bluetooth_low_energy_linux_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
bluetooth_low_energy_linux
file_selector_linux
screen_retriever_linux
url_launcher_linux

View File

@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import bluetooth_low_energy_darwin
import device_info_plus
import file_selector_macos
import flutter_local_notifications
@@ -14,9 +15,11 @@ import screen_retriever_macos
import shared_preferences_foundation
import universal_ble
import url_launcher_macos
import wakelock_plus
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
BluetoothLowEnergyDarwinPlugin.register(with: registry.registrar(forPlugin: "BluetoothLowEnergyDarwinPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
@@ -26,5 +29,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}

View File

@@ -1,4 +1,7 @@
PODS:
- bluetooth_low_energy_darwin (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
@@ -20,10 +23,13 @@ PODS:
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- wakelock_plus (0.0.1):
- FlutterMacOS
- window_manager (0.5.0):
- FlutterMacOS
DEPENDENCIES:
- bluetooth_low_energy_darwin (from `Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
@@ -34,9 +40,12 @@ DEPENDENCIES:
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
EXTERNAL SOURCES:
bluetooth_low_energy_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_selector_macos:
@@ -57,10 +66,13 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
wakelock_plus:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
window_manager:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
bluetooth_low_energy_darwin: 764d8d1ae5abefbcdb839e812b4b25c0061fcf8b
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
@@ -71,6 +83,7 @@ SPEC CHECKSUMS:
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
wakelock_plus: 9d63063ffb7af1c215209769067c57103bde719d
window_manager: e25faf20d88283a0d46e7b1a759d07261ca27575
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009

28
macos/Runner.xcodeproj/project.pbxproj Normal file → Executable file
View File

@@ -571,19 +571,22 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
"DEVELOPMENT_TEAM[sdk=macosx*]" = UZRHKPVWN9;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "mac app store";
SWIFT_VERSION = 5.0;
};
name = Profile;
@@ -709,18 +712,18 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
DEVELOPMENT_TEAM = UZRHKPVWN9;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -735,19 +738,22 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
"DEVELOPMENT_TEAM[sdk=macosx*]" = UZRHKPVWN9;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "mac app store";
SWIFT_VERSION = 5.0;
};
name = Release;

13
macos/Runner/Info.plist Normal file → Executable file
View File

@@ -2,12 +2,21 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-peripheral</string>
<string>bluetooth-central</string>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.healthcare-fitness</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
@@ -23,12 +32,14 @@
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>We need BT access because it's a BT App.</string>
<string>SwiftControl requires Bluetooth to connect to your devices.</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAccessibilityUsageDescription</key>
<string>SwiftControl needs to send keys to your trainer app.</string>
</dict>
</plist>

6
macos/Runner/Release.entitlements Normal file → Executable file
View File

@@ -2,9 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

133
pubspec.lock Normal file → Executable file
View File

@@ -32,6 +32,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
bluetooth_low_energy:
dependency: "direct main"
description:
name: bluetooth_low_energy
sha256: "5dec5831412c7d82b77df878dd3e08a82132426d2fb4c5d7c98c9a8cd0ed79e0"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
bluetooth_low_energy_android:
dependency: transitive
description:
name: bluetooth_low_energy_android
sha256: "32c0f84f88770845e3189e04b0ddf4780dc8743fd7a8ade60b99b6cb414b8a89"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
bluetooth_low_energy_darwin:
dependency: transitive
description:
name: bluetooth_low_energy_darwin
sha256: fbbe3be175cb54093884a84f6f0826d6e8a2a2e29dfeae9b367d5e8e9ee1db38
url: "https://pub.dev"
source: hosted
version: "6.1.0"
bluetooth_low_energy_linux:
dependency: transitive
description:
name: bluetooth_low_energy_linux
sha256: a5c740f445dc8d2e940767fa94ed3bb24c32e77bc962a67ab23cb1f218180705
url: "https://pub.dev"
source: hosted
version: "6.1.0"
bluetooth_low_energy_platform_interface:
dependency: transitive
description:
name: bluetooth_low_energy_platform_interface
sha256: dd76c0f8e31dcfb984059b03e73cb2998c29cffd17425f4ce946365b63abb3dc
url: "https://pub.dev"
source: hosted
version: "6.1.0"
bluetooth_low_energy_windows:
dependency: transitive
description:
name: bluetooth_low_energy_windows
sha256: "7a651259f7bc3ae2bb042c21e15e1e4f88acea57da1f69b3165f239124724791"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
bluez:
dependency: transitive
description:
@@ -277,6 +325,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_md:
dependency: "direct main"
description:
name: flutter_md
sha256: b5a67ae49135f7a76a0cc6f938ee3e8754e71d8448b97cf99c11512877f1d055
url: "https://pub.dev"
source: hosted
version: "0.0.7"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -319,6 +375,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
hybrid_logging:
dependency: transitive
description:
name: hybrid_logging
sha256: "54248d52ce68c14702a42fbc4083bac5c6be30f6afad8a41be4bbadd197b8af5"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
image:
dependency: transitive
description:
@@ -659,6 +723,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.2.0"
restart:
dependency: "direct main"
description:
name: restart
sha256: "70c12d26900b7c451b99eb012dea7b0d5f229f93e705c3915df41c5ddd0b97cd"
url: "https://pub.dev"
source: hosted
version: "1.0.0+1"
restart_app:
dependency: "direct main"
description:
name: restart_app
sha256: "00d5ec3e9de871cedbe552fc41e615b042b5ec654385e090e0983f6d02f655ed"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
screen_retriever:
dependency: transitive
description:
@@ -711,10 +791,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
sha256: "0b0f98d535319cb5cdd4f65783c2a54ee6d417a2f093dbb18be3e36e4c3d181f"
url: "https://pub.dev"
source: hosted
version: "2.4.13"
version: "2.4.14"
shared_preferences_foundation:
dependency: transitive
description:
@@ -755,6 +835,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shorebird_code_push:
dependency: "direct main"
description:
name: shorebird_code_push
sha256: "82203f39a66c78548da944dbe4079c2aa2a60fa5bc1105ed707b144c94f04349"
url: "https://pub.dev"
source: hosted
version: "2.0.5"
sky_engine:
dependency: transitive
description: flutter
@@ -843,10 +931,11 @@ packages:
universal_ble:
dependency: "direct main"
description:
name: universal_ble
sha256: "6a5c6c1fb295015934a5aef3dc751ae7e00721535275f8478bfe74db77b238c5"
url: "https://pub.dev"
source: hosted
path: "."
ref: HEAD
resolved-ref: "22b713600511ef5adff60aa70b941ada99ba2890"
url: "https://github.com/jonasbark/universal_ble.git"
source: git
version: "0.21.1"
url_launcher:
dependency: "direct main"
@@ -860,10 +949,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "199bc33e746088546a39cc5f36bac5a278c5e53b40cb3196f99e7345fdcfae6b"
sha256: c0fb544b9ac7efa10254efaf00a951615c362d1ea1877472f8f6c0fa00fcf15b
url: "https://pub.dev"
source: hosted
version: "6.3.22"
version: "6.3.23"
url_launcher_ios:
dependency: transitive
description:
@@ -920,6 +1009,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version:
dependency: "direct main"
description:
name: version
sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
vm_service:
dependency: transitive
description:
@@ -928,6 +1025,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
wakelock_plus:
dependency: "direct main"
description:
name: wakelock_plus
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
web:
dependency: transitive
description:
@@ -940,10 +1053,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.14.0"
version: "5.15.0"
win32_registry:
dependency: transitive
description:

24
pubspec.yaml Normal file → Executable file
View File

@@ -1,10 +1,10 @@
name: swift_control
description: "SwiftControl - Control your virtual riding"
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 2.6.4+10
version: 3.0.3+26
environment:
sdk: ^3.7.0
sdk: ^3.9.0
dependencies:
flutter:
@@ -14,7 +14,11 @@ dependencies:
flutter_local_notifications: ^19.4.1
universal_ble: ^0.21.1
intl: any
version: ^3.0.0
bluetooth_low_energy: ^6.1.0
wakelock_plus: ^1.4.0
protobuf: ^4.2.0
flutter_md: ^0.0.7
permission_handler: ^12.0.1
dartx: any
image_picker: ^1.1.2
@@ -25,12 +29,22 @@ dependencies:
path: keypress_simulator/packages/keypress_simulator
shared_preferences: ^2.5.3
flex_color_scheme: ^8.3.0
package_info_plus: ^9.0.0
in_app_update: ^4.2.5
accessibility:
path: accessibility
# shorebird related
shorebird_code_push: ^2.0.5
restart_app: ^1.3.2
restart: ^1.0.0+1
package_info_plus: ^9.0.0
in_app_update: ^4.2.5
http: ^1.3.0
dependency_overrides:
universal_ble:
git:
url: https://github.com/jonasbark/universal_ble.git
dev_dependencies:
flutter_test:
sdk: flutter
@@ -42,3 +56,5 @@ flutter:
uses-material-design: true
assets:
- CHANGELOG.md
- TROUBLESHOOTING.md
- shorebird.yaml

6
scripts/RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,6 @@
I recommend downloading from the official stores:
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a>
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a>

2
shorebird.yaml Normal file
View File

@@ -0,0 +1,2 @@
app_id: 21d821f0-6795-4fca-aeb1-5a5dbe2d2b62
auto_update: false

View File

@@ -1,69 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_control/utils/changelog.dart';
void main() {
group('ChangelogParser', () {
test('parses changelog entries correctly', () {
const testContent = '''
### 2.6.0 (2025-09-28)
- Fix crashes on some Android devices
- refactor touch placements: show touches on screen
- show firmware version of connected device
### 2.5.0 (2025-09-25)
- Improve usability
- SwiftControl is now available via the Play Store
- SwiftControl will continue to be available to download for free on GitHub
''';
final entries = ChangelogParser.parseContent(testContent);
expect(entries.length, 2);
expect(entries[0].version, '2.6.0');
expect(entries[0].date, '2025-09-28');
expect(entries[0].changes.length, 3);
expect(entries[0].changes[0], 'Fix crashes on some Android devices');
expect(entries[1].version, '2.5.0');
expect(entries[1].date, '2025-09-25');
expect(entries[1].changes.length, 3);
expect(entries[1].changes[0], 'Improve usability');
expect(entries[1].changes[1], 'SwiftControl is now available via the Play Store');
expect(entries[1].changes[2], 'SwiftControl will continue to be available to download for free on GitHub');
});
test('handles empty content', () {
const testContent = '';
final entries = ChangelogParser.parseContent(testContent);
expect(entries.length, 0);
});
test('handles single entry', () {
const testContent = '''
### 1.0.0 (2025-01-01)
- Initial release
''';
final entries = ChangelogParser.parseContent(testContent);
expect(entries.length, 1);
expect(entries[0].version, '1.0.0');
expect(entries[0].changes.length, 1);
expect(entries[0].changes[0], 'Initial release');
});
test('ChangelogEntry toString formats correctly', () {
final entry = ChangelogEntry(
version: '1.0.0',
date: '2025-01-01',
changes: ['Change 1', 'Change 2'],
);
final result = entry.toString();
expect(result, contains('### 1.0.0 (2025-01-01)'));
expect(result, contains('- Change 1'));
expect(result, contains('- Change 2'));
});
});
}

View File

@@ -0,0 +1,145 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
import 'package:swift_control/utils/settings/settings.dart';
void main() {
group('Custom Profile Tests', () {
late Settings settings;
setUp(() async {
// Initialize SharedPreferences with in-memory storage for testing
SharedPreferences.setMockInitialValues({});
settings = Settings();
await settings.init();
});
test('Should create custom app with default profile name', () {
final customApp = CustomApp();
expect(customApp.profileName, 'Custom');
expect(customApp.name, 'Custom');
});
test('Should create custom app with custom profile name', () {
final customApp = CustomApp(profileName: 'Workout');
expect(customApp.profileName, 'Workout');
expect(customApp.name, 'Workout');
});
test('Should save and retrieve custom profile', () async {
final customApp = CustomApp(profileName: 'Race');
await settings.setApp(customApp);
final profiles = settings.getCustomAppProfiles();
expect(profiles.contains('Race'), true);
});
test('Should list multiple custom profiles', () async {
final workout = CustomApp(profileName: 'Workout');
final race = CustomApp(profileName: 'Race');
final event = CustomApp(profileName: 'Event');
await settings.setApp(workout);
await settings.setApp(race);
await settings.setApp(event);
final profiles = settings.getCustomAppProfiles();
expect(profiles.contains('Workout'), true);
expect(profiles.contains('Race'), true);
expect(profiles.contains('Event'), true);
expect(profiles.length, 3);
});
test('Should duplicate custom profile', () async {
final original = CustomApp(profileName: 'Original');
await settings.setApp(original);
await settings.duplicateCustomAppProfile('Original', 'Copy');
final profiles = settings.getCustomAppProfiles();
expect(profiles.contains('Original'), true);
expect(profiles.contains('Copy'), true);
expect(profiles.length, 2);
});
test('Should delete custom profile', () async {
final customApp = CustomApp(profileName: 'ToDelete');
await settings.setApp(customApp);
var profiles = settings.getCustomAppProfiles();
expect(profiles.contains('ToDelete'), true);
await settings.deleteCustomAppProfile('ToDelete');
profiles = settings.getCustomAppProfiles();
expect(profiles.contains('ToDelete'), false);
});
test('Should migrate old custom keymap to new format', () async {
// Simulate old storage format
SharedPreferences.setMockInitialValues({
'customapp': ['test_data'],
'app': 'Custom',
});
final newSettings = Settings();
await newSettings.init();
// Check that migration happened
expect(newSettings.prefs.containsKey('customapp'), false);
expect(newSettings.prefs.containsKey('customapp_Custom'), true);
});
test('Should not duplicate migration if already migrated', () async {
SharedPreferences.setMockInitialValues({
'customapp': ['old_data'],
'customapp_Custom': ['new_data'],
'app': 'Custom',
});
final newSettings = Settings();
await newSettings.init();
// Old key should still exist because new key already existed
expect(newSettings.getCustomAppKeymap('customapp'), null);
final customKeymap = newSettings.getCustomAppKeymap('Custom');
expect(customKeymap, isNotNull);
});
test('Should export custom profile as JSON', () async {
final customApp = CustomApp(profileName: 'TestProfile');
await settings.setApp(customApp);
final jsonData = settings.exportCustomAppProfile('TestProfile');
expect(jsonData, isNotNull);
expect(jsonData, contains('version'));
expect(jsonData, contains('profileName'));
expect(jsonData, contains('keymap'));
});
test('Should import custom profile from JSON', () async {
// First export a profile
final customApp = CustomApp(profileName: 'ExportTest');
await settings.setApp(customApp);
final jsonData = settings.exportCustomAppProfile('ExportTest');
// Import with a new name
final success = await settings.importCustomAppProfile(jsonData!, newProfileName: 'ImportTest');
expect(success, true);
final profiles = settings.getCustomAppProfiles();
expect(profiles.contains('ImportTest'), true);
});
test('Should fail to import invalid JSON', () async {
final success = await settings.importCustomAppProfile('invalid json');
expect(success, false);
});
test('Should fail to import JSON with missing fields', () async {
final invalidJson = '{"version": 1}';
final success = await settings.importCustomAppProfile(invalidJson);
expect(success, false);
});
});
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
void main() {
group('Percentage-based Keymap Tests', () {
test('Should encode touch position as percentage using fallback screen size', () {
final keyPair = KeyPair(
buttons: [ZwiftButton.paddleRight],
physicalKey: null,
logicalKey: null,
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
);
final encoded = keyPair.encode();
expect(encoded, contains('x_percent'));
expect(encoded, contains('y_percent'));
// Should use fallback screen size of 1920x1080
expect(encoded, contains('0.5')); // 960/1920 and 540/1080 = 0.5
});
test('Should encode touch position as percentages with fallback when screen size not available', () {
final keyPair = KeyPair(
buttons: [ZwiftButton.paddleRight],
physicalKey: null,
logicalKey: null,
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
);
final encoded = keyPair.encode();
expect(encoded, contains('x_percent'));
expect(encoded, contains('y_percent'));
// Should use fallback screen size of 1920x1080
expect(encoded, contains('0.5')); // 960/1920 and 540/1080 = 0.5
});
test('Should decode percentage-based touch position correctly', () {
final encoded =
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x_percent":0.5,"y_percent":0.5},"isLongPress":false}';
final keyPair = KeyPair.decode(encoded);
expect(keyPair, isNotNull);
// Since no real screen is available in tests, it should return Offset.zero or use fallback
expect(keyPair!.touchPosition, isNotNull);
});
test('Should decode pixel-based touch position correctly (backward compatibility)', () {
final encoded =
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x":300,"y":600},"isLongPress":false}';
final keyPair = KeyPair.decode(encoded);
expect(keyPair, isNotNull);
expect(keyPair!.touchPosition.dx, 300);
expect(keyPair.touchPosition.dy, 600);
});
test('Should handle zero touch position correctly', () {
final keyPair = KeyPair(
buttons: [ZwiftButton.paddleRight],
physicalKey: PhysicalKeyboardKey.keyA,
logicalKey: LogicalKeyboardKey.keyA,
touchPosition: Offset.zero,
);
final encoded = keyPair.encode();
// Should encode as percentages even when position is zero
expect(encoded, contains('x_percent'));
expect(encoded, contains('y_percent'));
expect(encoded, contains('0.0'));
});
test('Should encode and decode with fallback screen size', () {
final keyPair = KeyPair(
buttons: [ZwiftButton.paddleRight],
physicalKey: null,
logicalKey: null,
touchPosition: Offset(480, 270), // 25% of 1920x1080
);
// Encode (will use fallback screen size)
final encoded = keyPair.encode();
// Decode (will also use fallback or available screen size)
final decoded = KeyPair.decode(encoded);
expect(decoded, isNotNull);
expect(decoded!.touchPosition, isNotNull);
});
test('Should handle decoding when no screen size available', () {
final encoded =
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x_percent":0.5,"y_percent":0.5},"isLongPress":false}';
final keyPair = KeyPair.decode(encoded);
expect(keyPair, isNotNull);
// When no screen size is available, it may return Offset.zero as fallback
expect(keyPair!.touchPosition, isNotNull);
});
});
}

View File

@@ -0,0 +1,210 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
void main() {
group('Zwift Ride Analog Paddle - ZigZag Encoding Tests', () {
test('Should correctly decode positive ZigZag values', () {
// Test ZigZag decoding algorithm: (n >>> 1) ^ -(n & 1)
const threshold = ZwiftRide.analogPaddleThreshold;
expect(_zigzagDecode(0), 0); // 0 -> 0
expect(_zigzagDecode(2), 1); // 2 -> 1
expect(_zigzagDecode(4), 2); // 4 -> 2
expect(_zigzagDecode(threshold * 2), threshold); // threshold value
expect(_zigzagDecode(200), 100); // 200 -> 100 (max positive)
});
test('Should correctly decode negative ZigZag values', () {
const threshold = ZwiftRide.analogPaddleThreshold;
expect(_zigzagDecode(1), -1); // 1 -> -1
expect(_zigzagDecode(3), -2); // 3 -> -2
expect(_zigzagDecode(threshold * 2 - 1), -threshold); // negative threshold
expect(_zigzagDecode(199), -100); // 199 -> -100 (max negative)
});
test('Should handle boundary values correctly', () {
const threshold = ZwiftRide.analogPaddleThreshold;
// Test values at the detection threshold
expect(_zigzagDecode(threshold * 2).abs(), threshold);
expect(_zigzagDecode(threshold * 2 - 1).abs(), threshold);
// Test maximum paddle values (±100)
expect(_zigzagDecode(200), 100);
expect(_zigzagDecode(199), -100);
});
});
group('Zwift Ride Analog Paddle - Protocol Buffer Varint Decoding', () {
test('Should decode single-byte varint values', () {
// Values 0-127 fit in a single byte
final buffer1 = Uint8List.fromList([0x00]); // 0
expect(_decodeVarint(buffer1, 0).$1, 0);
expect(_decodeVarint(buffer1, 0).$2, 1); // Consumed 1 byte
final buffer2 = Uint8List.fromList([0x0A]); // 10
expect(_decodeVarint(buffer2, 0).$1, 10);
final buffer3 = Uint8List.fromList([0x7F]); // 127
expect(_decodeVarint(buffer3, 0).$1, 127);
});
test('Should decode multi-byte varint values', () {
// Values >= 128 require multiple bytes
final buffer1 = Uint8List.fromList([0xC7, 0x01]); // ZigZag encoded -100 (199)
expect(_decodeVarint(buffer1, 0).$1, 199);
expect(_decodeVarint(buffer1, 0).$2, 2); // Consumed 2 bytes
final buffer2 = Uint8List.fromList([0xC8, 0x01]); // ZigZag encoded 100 (200)
expect(_decodeVarint(buffer2, 0).$1, 200);
expect(_decodeVarint(buffer2, 0).$2, 2);
});
test('Should handle varint decoding with offset', () {
// Test decoding from a specific offset in the buffer
final buffer = Uint8List.fromList([0xFF, 0xFF, 0xC8, 0x01]); // Garbage + 200
expect(_decodeVarint(buffer, 2).$1, 200);
expect(_decodeVarint(buffer, 2).$2, 2);
});
});
group('Zwift Ride Analog Paddle - Protocol Buffer Wire Type Parsing', () {
test('Should correctly extract field number and wire type from tag', () {
// Tag format: (field_number << 3) | wire_type
// Field 1, wire type 0 (varint)
final tag1 = 0x08; // 1 << 3 | 0
expect(tag1 >> 3, 1); // field number
expect(tag1 & 0x7, 0); // wire type
// Field 2, wire type 0 (varint)
final tag2 = 0x10; // 2 << 3 | 0
expect(tag2 >> 3, 2);
expect(tag2 & 0x7, 0);
// Field 3, wire type 2 (length-delimited)
final tag3 = 0x1a; // 3 << 3 | 2
expect(tag3 >> 3, 3);
expect(tag3 & 0x7, 2);
});
test('Should identify location and value field tags', () {
const locationTag = 0x08; // Field 1 (location), wire type 0
const valueTag = 0x10; // Field 2 (value), wire type 0
const nestedMessageTag = 0x1a; // Field 3 (nested), wire type 2
expect(locationTag >> 3, 1);
expect(valueTag >> 3, 2);
expect(nestedMessageTag >> 3, 3);
expect(nestedMessageTag & 0x7, 2); // Length-delimited
});
});
group('Zwift Ride Analog Paddle - Real-world Scenarios', () {
test('Threshold value should trigger paddle detection', () {
const threshold = ZwiftRide.analogPaddleThreshold;
// At threshold: ZigZag encoding of threshold
final zigzagValue = threshold * 2;
final decodedValue = _zigzagDecode(zigzagValue);
expect(decodedValue, threshold);
expect(decodedValue.abs() >= threshold, isTrue);
});
test('Below threshold value should not trigger paddle detection', () {
const threshold = ZwiftRide.analogPaddleThreshold;
// Below threshold: value = threshold - 1
final zigzagValue = (threshold - 1) * 2;
final decodedValue = _zigzagDecode(zigzagValue);
expect(decodedValue, threshold - 1);
expect(decodedValue.abs() >= threshold, isFalse);
});
test('Maximum paddle press (-100) should trigger left paddle', () {
const threshold = ZwiftRide.analogPaddleThreshold;
// Max left: value = -100, ZigZag = 199 = 0xC7 0x01
final zigzagValue = 199;
final decodedValue = _zigzagDecode(zigzagValue);
expect(decodedValue, -100);
expect(decodedValue.abs() >= threshold, isTrue);
});
test('Maximum paddle press (100) should trigger right paddle', () {
const threshold = ZwiftRide.analogPaddleThreshold;
// Max right: value = 100, ZigZag = 200 = 0xC8 0x01
final zigzagValue = 200;
final decodedValue = _zigzagDecode(zigzagValue);
expect(decodedValue, 100);
expect(decodedValue.abs() >= threshold, isTrue);
});
test('Paddle location mapping is correct', () {
// Location 0 = left paddle
// Location 1 = right paddle
const leftPaddleLocation = 0;
const rightPaddleLocation = 1;
expect(leftPaddleLocation, 0);
expect(rightPaddleLocation, 1);
});
test('Analog paddle threshold constant is accessible', () {
expect(ZwiftRide.analogPaddleThreshold, 25);
});
});
group('Zwift Ride Analog Paddle - Message Structure Documentation', () {
test('0x1a marker identifies analog message sections', () {
const analogSectionMarker = 0x1a;
// Field 3 << 3 | wire type 2 = 3 << 3 | 2 = 26 = 0x1a
expect(analogSectionMarker, 0x1a);
expect(analogSectionMarker >> 3, 3); // Field number
expect(analogSectionMarker & 0x7, 2); // Wire type (length-delimited)
});
test('Message offset 7 skips header and button map', () {
// Offset breakdown:
// [0]: Message type (0x23 for controller notification)
// [1]: Button map field tag (0x05)
// [2-6]: Button map (5 bytes)
// [7]: Start of analog data
const messageTypeOffset = 0;
const buttonMapTagOffset = 1;
const buttonMapOffset = 2;
const buttonMapSize = 5;
const analogDataOffset = 7;
expect(analogDataOffset, buttonMapOffset + buttonMapSize);
});
});
}
/// Helper function to test ZigZag decoding algorithm.
/// ZigZag encoding maps signed integers to unsigned integers:
/// 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3, 2 -> 4, etc.
int _zigzagDecode(int n) {
return (n >>> 1) ^ -(n & 1);
}
/// Helper function to decode a Protocol Buffer varint from a buffer.
/// Returns a record of (value, bytesConsumed).
(int, int) _decodeVarint(Uint8List buffer, int offset) {
var value = 0;
var shift = 0;
var bytesRead = 0;
while (offset + bytesRead < buffer.length) {
final byte = buffer[offset + bytesRead];
value |= (byte & 0x7f) << shift;
bytesRead++;
if ((byte & 0x80) == 0) {
// MSB is 0, we're done
break;
}
shift += 7;
}
return (value, bytesRead);
}

View File

@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <bluetooth_low_energy_windows/bluetooth_low_energy_windows_plugin_c_api.h>
#include <file_selector_windows/file_selector_windows.h>
#include <keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
@@ -15,6 +16,8 @@
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
BluetoothLowEnergyWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("BluetoothLowEnergyWindowsPluginCApi"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
bluetooth_low_energy_windows
file_selector_windows
keypress_simulator_windows
permission_handler_windows