Merge branch 'ios' into copilot/fix-2d2954be-782f-43b7-b654-d4aa8263083d

This commit is contained in:
Jonas Bark
2025-10-09 22:17:28 +02:00
72 changed files with 1773 additions and 886 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
- ios
paths:
- '.github/workflows/**'
- 'lib/**'
@@ -29,15 +30,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,20 +60,20 @@ 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
#2 Setup Java
- name: Set Up Java
uses: actions/setup-java@v3.12.0
with:
distribution: 'oracle'
java-version: '17'
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
#3 Setup Flutter
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
cache: true
channel: 'stable'
#4 Install Dependencies
@@ -68,11 +81,13 @@ jobs:
run: flutter pub get
#8 Build app ( macos Build )
- name: Build App
- name: Build macOS App
if: false
run: flutter build macos --release
- name: Code Signing
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --options runtime SwiftControl.app -v
if: false
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 }}
@@ -83,15 +98,18 @@ jobs:
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
- name: Build APK
if: github.ref == 'refs/heads/main'
run: flutter build apk --release
- name: Build Bundle
run: flutter build appbundle --release
- 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 +117,7 @@ jobs:
#9 Upload Artifacts
- name: Upload Artifacts
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: Releases
@@ -108,6 +127,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 +135,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 +146,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 +154,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"
body: "I recommend downloading the Android version from the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"
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 +180,7 @@ 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: Upload to Play Store
# only upload when env.VERSION does not end with 1337, which is our indicator for beta releases
@@ -169,8 +193,34 @@ jobs:
track: production
whatsNewDirectory: whatsnew
- name: Build iOS app and release
if: false
env:
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }}
run: |
mkdir -p ./private_keys;
printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8";
flutter build ipa --release --export-options-plist=ios/ExportOptions.plist;
xcrun altool --upload-app -f build/ios/ipa/swift_play.ipa -t ios --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
- name: Release macOS app
if: false
env:
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }}
run: |
mkdir -p ./private_keys;
printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8";
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";
windows:
needs: build
if: github.ref == 'refs/heads/main'
name: Build & Release on Windows
runs-on: windows-latest
@@ -248,5 +298,6 @@ jobs:
with:
allowUpdates: true
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip"
body: "I recommend downloading the Android version from the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}

View File

@@ -1,3 +1,13 @@
### 3.0.1 (2025-10-08)
- 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"><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"><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

@@ -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)
@@ -18,8 +21,11 @@ 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`)
@@ -29,8 +35,11 @@ DEPENDENCIES:
- 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:
@@ -49,8 +58,11 @@ EXTERNAL SOURCES:
: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
@@ -60,6 +72,7 @@ SPEC CHECKSUMS:
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

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

@@ -5,6 +5,7 @@ import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
import 'package:swift_control/bluetooth/protocol/zp_vendor.pb.dart';
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:universal_ble/universal_ble.dart';
@@ -14,6 +15,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: [
@@ -200,7 +206,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;
@@ -240,4 +250,5 @@ class ZwiftRide extends BaseDevice {
withoutResponse: true,
);
}
}

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

@@ -9,13 +9,18 @@ import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/touch_area.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 {
@@ -25,7 +30,7 @@ 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);
@@ -54,6 +59,21 @@ class _DevicePageState extends State<DevicePage> {
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(() {});
});
@@ -61,11 +81,27 @@ 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
@@ -112,12 +148,31 @@ class _DevicePageState extends State<DevicePage> {
connection.devices.joinToString(
separator: '\n',
transform: (it) {
return """${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}
return """${it.device.name?.screenshot ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}
${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}
${it.firmwareVersion != null ? ' - Firmware Version: ${it.firmwareVersion}' : ''}""".trim();
},
),
),
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'),
),
),
],
),
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
if (!kIsWeb)
Column(
@@ -543,3 +598,7 @@ ${it.firmwareVersion != null ? ' - Firmware Version: ${it.firmwareVersion}' : ''
);
}
}
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) {
@@ -93,6 +96,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
_requirements.isEmpty
? Center(child: CircularProgressIndicator())
: Column(
spacing: 8,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0),
@@ -101,6 +105,26 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
style: Theme.of(context).textTheme.titleMedium,
),
),
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: Stepper(
currentStep: _currentStep,
@@ -120,7 +144,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
return;
}
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
if (hasEarlierIncomplete) {
if (hasEarlierIncomplete && !kDebugMode) {
return;
}
setState(() {
@@ -134,7 +158,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
(index, req) => Step(
title: Text(req.name),
content: Container(
padding: const EdgeInsets.symmetric(vertical: 16.0),
padding: const EdgeInsets.only(top: 16.0),
alignment: Alignment.centerLeft,
child:
(index == _currentStep
@@ -142,9 +166,22 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
_reloadRequirements();
})
: null) ??
ElevatedButton(
onPressed: req.status ? null : () => _callRequirement(req),
child: Text(req.name),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
if (req.description != null)
Text(req.description!, style: TextStyle(fontSize: 16)),
ElevatedButton(
onPressed:
req.status
? null
: () => _callRequirement(req, context, () {
_reloadRequirements();
}),
child: Text(req.name),
),
],
),
),
state: req.status ? StepState.complete : StepState.indexed,
@@ -158,14 +195,14 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
);
}
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,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
@@ -8,7 +9,9 @@ import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/remote.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';
@@ -34,6 +37,9 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
File? _backgroundImage;
late StreamSubscription<BaseNotification> _actionSubscription;
ZwiftButton? _pressedButton;
final TransformationController _transformationController = TransformationController();
Rect? _imageRect;
Future<void> _pickScreenshot() async {
final picker = ImagePicker();
@@ -41,6 +47,30 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
if (result != null) {
setState(() {
_backgroundImage = File(result.path);
// need to decode image to get its size so we can have a percentage mapping
if (actionHandler is RemoteActions) {
decodeImageFromList(_backgroundImage!.readAsBytesSync()).then((decodedImage) {
// calculate image rectangle in the current screen, given it's boxfit contain
final screenSize = MediaQuery.sizeOf(context);
final imageAspectRatio = decodedImage.width / decodedImage.height;
final screenAspectRatio = screenSize.width / screenSize.height;
if (imageAspectRatio > screenAspectRatio) {
// image is wider than screen
final width = screenSize.width;
final height = width / imageAspectRatio;
final top = (screenSize.height - height) / 2;
_imageRect = Rect.fromLTWH(0, top, width, height);
} else {
// image is taller than screen
final height = screenSize.height;
final width = height * imageAspectRatio;
final left = (screenSize.width - width) / 2;
_imageRect = Rect.fromLTWH(left, 0, width, height);
}
setState(() {});
});
}
});
}
}
@@ -100,9 +130,12 @@ 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:
_imageRect != null
? Offset((actionHandler.supportedApp!.keymap.keyPairs.length + 1) * 10, 10)
: context.size!
.center(Offset.zero)
.translate(actionHandler.supportedApp!.keymap.keyPairs.length * 40, 0),
buttons: [_pressedButton!],
physicalKey: null,
logicalKey: null,
@@ -122,7 +155,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
});
}
List<Widget> _buildDraggableArea({
Widget _buildDraggableArea({
required Offset position,
required bool enableTouch,
required void Function(Offset newPosition) onPositionChanged,
@@ -133,7 +166,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
// 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 > 0 && !Platform.isIOS)
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
: 0.0;
@@ -144,167 +177,175 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
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)),
],
),
);
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;
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(() {});
},
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: label,
),
final draggable = [
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) => [
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;
Positioned(
left: position.dx - iconSize / 2,
top: position.dy - differenceInHeight - iconSize / 2,
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(() {});
},
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: Row(children: [KeypairExplanation(withKey: true, keyPair: keyPair), Icon(Icons.more_vert)]),
),
];
final icon = Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: draggable,
);
return Positioned(
left: position.dx - iconSize / 2,
top: position.dy - differenceInHeight - iconSize / 2,
child: Tooltip(
message: 'Drag to reposition',
child: Draggable(
dragAnchorStrategy: (widget, context, position) {
final scale = _transformationController.value.getMaxScaleOnAxis();
final RenderBox renderObject = context.findRenderObject() as RenderBox;
return renderObject.globalToLocal(position).scale(scale, scale);
},
feedback: Material(color: Colors.transparent, child: 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
@@ -312,80 +353,119 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
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: InteractiveViewer(
transformationController: _transformationController,
child: 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
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(
// draw image rect for debugging
if (_imageRect != null && _backgroundImage != null)
Positioned.fromRect(
rect: _imageRect!,
child: Container(decoration: BoxDecoration(border: Border.all(color: Colors.green, width: 2))),
),
if (actionHandler is! RemoteActions || _imageRect != null)
...?actionHandler.supportedApp?.keymap.keyPairs.map((keyPair) {
final Offset offset;
if (_imageRect != null) {
// 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');
offset = Offset(
_imageRect!.left + relativeX * _imageRect!.width,
_imageRect!.top + relativeY * _imageRect!.height,
);
} else {
offset = Offset(
keyPair.touchPosition.dx / devicePixelRatio,
keyPair.touchPosition.dy / devicePixelRatio,
),
);
}
//print('Drawing at offset $offset for keypair with position ${keyPair.touchPosition}');
return _buildDraggableArea(
enableTouch: true,
position: offset,
keyPair: keyPair,
onPositionChanged: (newPos) {
final converted = newPos * devicePixelRatio;
keyPair.touchPosition = converted;
if (_imageRect != null) {
// 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);
} else {
final converted = newPos * devicePixelRatio;
keyPair.touchPosition = converted;
}
setState(() {});
},
color: Colors.red,
),
)
.flatten(),
);
}),
Positioned.fill(child: Testbed()),
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: () {
actionHandler.supportedApp?.keymap.reset();
setState(() {});
},
),
],
icon: Icon(Icons.more_vert),
),
if (kDebugMode) MenuButton(),
],
),
),
),
],
],
),
),
);
}
@@ -403,34 +483,27 @@ class KeypairExplanation extends StatelessWidget {
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (withKey) KeyWidget(label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n')),
if (withKey)
KeyWidget(label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n'))
else
Icon(keyPair.icon),
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),
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'),
] 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'),
],
],
);

View File

@@ -0,0 +1,80 @@
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 {
@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) {
return ('Physical key actions are not supported on iOS');
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
/*if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateMouseClickDown(point);
await keyPressSimulator.simulateMouseClickUp(point);
return 'Mouse clicked at: ${point.dx} ${point.dy}';
} else if (isKeyDown) {
await keyPressSimulator.simulateMouseClickDown(point);
return 'Mouse down at: ${point.dx} ${point.dy}';
} else {
await keyPressSimulator.simulateMouseClickUp(point);
return 'Mouse up at: ${point.dx} ${point.dy}';
}*/
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}';
}
}
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

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:ui';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
@@ -61,6 +62,16 @@ class KeyPair {
physicalKey == PhysicalKeyboardKey.audioVolumeUp ||
physicalKey == PhysicalKeyboardKey.audioVolumeDown;
IconData get icon => switch (physicalKey) {
PhysicalKeyboardKey.mediaPlayPause ||
PhysicalKeyboardKey.mediaStop ||
PhysicalKeyboardKey.mediaTrackPrevious ||
PhysicalKeyboardKey.mediaTrackNext ||
PhysicalKeyboardKey.audioVolumeUp ||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
_ => physicalKey != null ? Icons.keyboard : Icons.touch_app,
};
@override
String toString() {
return logicalKey?.keyLabel ??

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,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,29 +18,12 @@ 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 +32,10 @@ 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, 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,6 +1,7 @@
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/keymap.dart';
import '../pages/touch_area.dart';
@@ -41,7 +42,7 @@ class KeymapExplanation extends StatelessWidget {
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}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
),
),

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

@@ -3,48 +3,51 @@ 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)
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),
];
@@ -69,7 +72,9 @@ class MenuButton extends StatelessWidget {
child: Text(e.name),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.performAction(e);
connection.devices.firstOrNull?.actionStreamInternal.add(
RideNotification(Uint8List(0), analogPaddleThreshold: 25)..buttonsClicked = [e],
);
});
},
),
@@ -95,7 +100,16 @@ class MenuButton extends StatelessWidget {
PopupMenuItem(
child: Text('Changelog'),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (c) => ChangelogPage()));
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(

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

@@ -9,6 +9,7 @@ import 'package:in_app_update/in_app_update.dart';
import 'package:package_info_plus/package_info_plus.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;
@@ -28,11 +29,11 @@ class _AppTitleState extends State<AppTitle> {
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'];
@@ -90,7 +91,7 @@ class _AppTitleState extends State<AppTitle> {
print('Failed to check for update: $e');
}
}
if (_latestVersionUrlValue == null && !kIsWeb) {
if (_latestVersionUrlValue == null && !kIsWeb && !Platform.isIOS) {
final url = await _getLatestVersionUrlIfNewer();
if (url != null && mounted && !kDebugMode) {
ScaffoldMessenger.of(context).showSnackBar(

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

29
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,19 +712,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_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
@@ -734,20 +740,23 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
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 = "Apple Distribution";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Distribution: JONAS TASSILO BARK (UZRHKPVWN9)";
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>

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

@@ -2,9 +2,11 @@
<!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.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

109
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:
@@ -711,10 +775,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:
@@ -843,10 +907,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 +925,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 +985,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 +1001,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 +1029,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:

12
pubspec.yaml Normal file → Executable file
View File

@@ -1,7 +1,7 @@
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.1+22
environment:
sdk: ^3.7.0
@@ -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
@@ -31,6 +35,11 @@ dependencies:
path: accessibility
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 +51,4 @@ flutter:
uses-material-design: true
assets:
- CHANGELOG.md
- TROUBLESHOOTING.md

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