diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ad2836f..f9623f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a8e1f0..fc07437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 272b862..15ccaa7 100644 --- a/README.md +++ b/README.md @@ -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 GetItOnGooglePlay_Badge_Web_color_English +App Store +Mac App Store -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 :) diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..9b19d43 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -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. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5c37ce9..9e0e47e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ - + + + @@ -16,7 +18,7 @@ - + diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml index 7ce9f6e..68099e1 100644 --- a/flutter_launcher_icons.yaml +++ b/flutter_launcher_icons.yaml @@ -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 diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 0000000..bce190a --- /dev/null +++ b/ios/ExportOptions.plist @@ -0,0 +1,29 @@ + + + + + destination + export + generateAppStoreInformation + + manageAppVersionAndBuildNumber + + method + app-store-connect + signingStyle + manual + provisioningProfiles + + de.jonasbark.swiftcontrol.darwin + ios app store + + stripSwiftSymbols + + teamID + UZRHKPVWN9 + testFlightInternalTestingOnly + + uploadSymbols + + + diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f05dfc0..9f76713 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e0c039a..8911d04 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -97,7 +97,6 @@ 8AA6D129479129F106E2298A /* Pods-RunnerTests.release.xcconfig */, EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -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"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..d0d98aa 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..ec46833 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..41b5c40 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..7c2737c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..4f175d5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..b199eff 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..80b1248 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..1c821bb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..7c2737c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..3458d4a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..f91c161 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..007bdfa Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..7e137be Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..c2ce88f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..d19b84b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..f91c161 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..29b8969 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..085692c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..2ee7d38 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..ed168c0 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..42b8029 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..068fe84 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 69ec01c..59e246c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,17 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSBluetoothAlwaysUsageDescription + SwiftControl uses Bluetooth to connect to accessories. + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + bluetooth-peripheral + bluetooth-central + + ITSAppUsesNonExemptEncryption + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,11 +54,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - NSBluetoothAlwaysUsageDescription - SwiftControl diff --git a/lib/bluetooth/devices/base_device.dart b/lib/bluetooth/devices/base_device.dart index 51da9d0..0c9448f 100644 --- a/lib/bluetooth/devices/base_device.dart +++ b/lib/bluetooth/devices/base_device.dart @@ -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 disconnect() async { - _isInited = false; _longPressTimer?.cancel(); // Release any held keys in long press mode if (actionHandler is DesktopActions) { diff --git a/lib/bluetooth/devices/zwift_ride.dart b/lib/bluetooth/devices/zwift_ride.dart index b31ad62..b0f037f 100644 --- a/lib/bluetooth/devices/zwift_ride.dart +++ b/lib/bluetooth/devices/zwift_ride.dart @@ -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?> 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, ); } + } diff --git a/lib/bluetooth/messages/ride_notification.dart b/lib/bluetooth/messages/ride_notification.dart index 2b07250..8b64fb0 100644 --- a/lib/bluetooth/messages/ride_notification.dart +++ b/lib/bluetooth/messages/ride_notification.dart @@ -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 buttonsClicked; + late List 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 diff --git a/lib/bluetooth/protocol/zwift.pb.dart b/lib/bluetooth/protocol/zwift.pb.dart index 4198cce..cb04bea 100644 --- a/lib/bluetooth/protocol/zwift.pb.dart +++ b/lib/bluetooth/protocol/zwift.pb.dart @@ -480,62 +480,19 @@ class RideAnalogKeyPress extends $pb.GeneratedMessage { void clearAnalogValue() => clearField(2); } -class RideAnalogKeyGroup extends $pb.GeneratedMessage { - factory RideAnalogKeyGroup({ - $core.Iterable? 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(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 createRepeated() => $pb.PbList(); - @$core.pragma('dart2js:noInline') - static RideAnalogKeyGroup getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); - static RideAnalogKeyGroup? _defaultInstance; - - @$pb.TagNumber(1) - $core.List 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? 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(2, _omitFieldNames ? '' : 'AnalogButtons', protoName: 'AnalogButtons', subBuilder: RideAnalogKeyGroup.create) + ..pc(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 get analogPaddles => $_getList(1); } /// ------------------ Zwift Click messages diff --git a/lib/bluetooth/protocol/zwift.pbjson.dart b/lib/bluetooth/protocol/zwift.pbjson.dart index a151db6..9951bb1 100644 --- a/lib/bluetooth/protocol/zwift.pbjson.dart +++ b/lib/bluetooth/protocol/zwift.pbjson.dart @@ -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 = { diff --git a/lib/bluetooth/protocol/zwift.proto b/lib/bluetooth/protocol/zwift.proto index e4c3e4e..c14a3ad 100644 --- a/lib/bluetooth/protocol/zwift.proto +++ b/lib/bluetooth/protocol/zwift.proto @@ -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 diff --git a/lib/main.dart b/lib/main.dart index 1303688..ed97790 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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}); diff --git a/lib/pages/changelog.dart b/lib/pages/changelog.dart deleted file mode 100644 index c296d06..0000000 --- a/lib/pages/changelog.dart +++ /dev/null @@ -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 createState() => _ChangelogPageState(); -} - -class _ChangelogPageState extends State { - List? _entries; - String? _error; - - @override - void initState() { - super.initState(); - _loadChangelog(); - } - - Future _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, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/pages/device.dart b/lib/pages/device.dart index b004b2a..080dd53 100644 --- a/lib/pages/device.dart +++ b/lib/pages/device.dart @@ -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 createState() => _DevicePageState(); } -class _DevicePageState extends State { +class _DevicePageState extends State with WidgetsBindingObserver { late StreamSubscription _connectionStateSubscription; final controller = TextEditingController(text: actionHandler.supportedApp?.name); @@ -54,6 +59,21 @@ class _DevicePageState extends State { 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 { @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(); @override @@ -112,12 +148,31 @@ class _DevicePageState extends State { 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; +} diff --git a/lib/pages/markdown.dart b/lib/pages/markdown.dart new file mode 100644 index 0000000..ca5f930 --- /dev/null +++ b/lib/pages/markdown.dart @@ -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 createState() => _ChangelogPageState(); +} + +class _ChangelogPageState extends State { + Markdown? _markdown; + String? _error; + + @override + void initState() { + super.initState(); + _loadChangelog(); + } + + Future _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); + }, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/requirements.dart b/lib/pages/requirements.dart index 6aba629..0d583a3 100644 --- a/lib/pages/requirements.dart +++ b/lib/pages/requirements.dart @@ -21,6 +21,7 @@ class RequirementsPage extends StatefulWidget { class _RequirementsPageState extends State with WidgetsBindingObserver { int _currentStep = 0; + var _local = true; List _requirements = []; @@ -29,6 +30,8 @@ class _RequirementsPageState extends State 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 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 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 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 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 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 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 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) { diff --git a/lib/pages/touch_area.dart b/lib/pages/touch_area.dart index 4ffbb2b..d16be10 100644 --- a/lib/pages/touch_area.dart +++ b/lib/pages/touch_area.dart @@ -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 { File? _backgroundImage; late StreamSubscription _actionSubscription; ZwiftButton? _pressedButton; + final TransformationController _transformationController = TransformationController(); + + Rect? _imageRect; Future _pickScreenshot() async { final picker = ImagePicker(); @@ -41,6 +47,30 @@ class _TouchAreaSetupPageState extends State { 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 { 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 { }); } - List _buildDraggableArea({ + Widget _buildDraggableArea({ required Offset position, required bool enableTouch, required void Function(Offset newPosition) onPositionChanged, @@ -133,7 +166,7 @@ class _TouchAreaSetupPageState extends State { // 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 { 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( - enabled: enableTouch, - tooltip: 'Drag to reposition. Tap to edit.', - itemBuilder: - (context) => [ - PopupMenuItem( - value: null, - child: ListTile( - leading: Icon(Icons.keyboard_alt_outlined), - title: const Text('Simulate Keyboard shortcut'), - ), - onTap: () async { - await showDialog( - context: context, - barrierDismissible: false, // enable Escape key - builder: - (c) => HotKeyListenerDialog( - customApp: actionHandler.supportedApp! as CustomApp, - keyPair: keyPair, - ), - ); - setState(() {}); - }, - ), - PopupMenuItem( - value: null, - child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)), - onTap: () { - keyPair.physicalKey = null; - keyPair.logicalKey = null; - setState(() {}); - }, - ), - PopupMenuItem( - 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( - padding: EdgeInsets.zero, - itemBuilder: - (context) => [ - PopupMenuItem( - value: PhysicalKeyboardKey.mediaPlayPause, - child: const Text('Media: Play/Pause'), - ), - PopupMenuItem( - value: PhysicalKeyboardKey.mediaStop, - child: const Text('Media: Stop'), - ), - PopupMenuItem( - value: PhysicalKeyboardKey.mediaTrackPrevious, - child: const Text('Media: Previous'), - ), - PopupMenuItem( - value: PhysicalKeyboardKey.mediaTrackNext, - child: const Text('Media: Next'), - ), - PopupMenuItem( - value: PhysicalKeyboardKey.audioVolumeUp, - child: const Text('Media: Volume Up'), - ), - PopupMenuItem( - 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( - 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( + enabled: enableTouch, + itemBuilder: + (context) => [ + PopupMenuItem( + value: null, + child: ListTile( + leading: Icon(Icons.keyboard_alt_outlined), + title: const Text('Simulate Keyboard shortcut'), + ), + onTap: () async { + await showDialog( + context: context, + barrierDismissible: false, // enable Escape key + builder: + (c) => + HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair), + ); + setState(() {}); + }, + ), + PopupMenuItem( + value: null, + child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)), + onTap: () { + keyPair.physicalKey = null; + keyPair.logicalKey = null; + setState(() {}); + }, + ), + PopupMenuItem( + 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( + padding: EdgeInsets.zero, + itemBuilder: + (context) => [ + PopupMenuItem( + value: PhysicalKeyboardKey.mediaPlayPause, + child: const Text('Media: Play/Pause'), + ), + PopupMenuItem( + value: PhysicalKeyboardKey.mediaStop, + child: const Text('Media: Stop'), + ), + PopupMenuItem( + value: PhysicalKeyboardKey.mediaTrackPrevious, + child: const Text('Media: Previous'), + ), + PopupMenuItem( + value: PhysicalKeyboardKey.mediaTrackNext, + child: const Text('Media: Next'), + ), + PopupMenuItem( + value: PhysicalKeyboardKey.audioVolumeUp, + child: const Text('Media: Volume Up'), + ), + PopupMenuItem( + 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( + 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 { 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'), ], ], ); diff --git a/lib/utils/actions/remote.dart b/lib/utils/actions/remote.dart new file mode 100644 index 0000000..ec76c2f --- /dev/null +++ b/lib/utils/actions/remote.dart @@ -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 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 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; +} diff --git a/lib/utils/changelog.dart b/lib/utils/changelog.dart deleted file mode 100644 index 4dfd2d8..0000000 --- a/lib/utils/changelog.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/services.dart'; - -class ChangelogEntry { - final String version; - final String date; - final List 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> parse() async { - final content = await rootBundle.loadString('CHANGELOG.md'); - return parseContent(content); - } - - static List parseContent(String content) { - final entries = []; - 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 getLatestEntry() async { - final entries = await parse(); - return entries.isNotEmpty ? entries.first : null; - } - - static Future 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'); - } -} diff --git a/lib/utils/keymap/keymap.dart b/lib/utils/keymap/keymap.dart index ed475bc..b5797b9 100644 --- a/lib/utils/keymap/keymap.dart +++ b/lib/utils/keymap/keymap.dart @@ -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 ?? diff --git a/lib/utils/requirements/android.dart b/lib/utils/requirements/android.dart index fa4491c..d11b02d 100644 --- a/lib/utils/requirements/android.dart +++ b/lib/utils/requirements/android.dart @@ -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 call() async { - return accessibilityHandler.openPermissions(); + Future 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 _showDisclosureDialog(BuildContext context, VoidCallback onUpdate) async { return showDialog( context: context, @@ -72,7 +51,7 @@ class BluetoothScanRequirement extends PlatformRequirement { BluetoothScanRequirement() : super('Allow Bluetooth Scan'); @override - Future call() async { + Future 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 call() async { + Future 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 call() async { + Future 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 call() async { + Future call(BuildContext context, VoidCallback onUpdate) async { await flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation() ?.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, diff --git a/lib/utils/requirements/multi.dart b/lib/utils/requirements/multi.dart index 99af4da..f2136db 100644 --- a/lib/utils/requirements/multi.dart +++ b/lib/utils/requirements/multi.dart @@ -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 call() async { - await keyPressSimulator.requestAccess(); + Future 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 call() async { - await UniversalBle.enableBluetooth(); + Future 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 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 call() async {} + Future call(BuildContext context, VoidCallback onUpdate) async {} @override Future getStatus() async {} } class BluetoothScanning extends PlatformRequirement { - BluetoothScanning() : super('Finding your Zwift® controller...') { + BluetoothScanning() : super('Finding your Controller...') { status = false; } @override - Future call() async {} + Future call(BuildContext context, VoidCallback onUpdate) async {} @override Future getStatus() async {} diff --git a/lib/utils/requirements/platform.dart b/lib/utils/requirements/platform.dart index bd3d784..3b32d4d 100644 --- a/lib/utils/requirements/platform.dart +++ b/lib/utils/requirements/platform.dart @@ -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 getStatus(); - Future call(); + Future call(BuildContext context, VoidCallback onUpdate); Widget? build(BuildContext context, VoidCallback onUpdate) { return null; } } -Future> getRequirements() async { +Future> getRequirements(bool local) async { List 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> getRequirements() async { final deviceInfo = await deviceInfoPlugin.androidInfo; list = [ BluetoothTurnedOn(), - AccessibilityRequirement(), + if (local) AccessibilityRequirement() else RemoteRequirement(), NotificationRequirement(), if (deviceInfo.version.sdkInt <= 30) LocationRequirement() diff --git a/lib/utils/requirements/remote.dart b/lib/utils/requirements/remote.dart new file mode 100644 index 0000000..8c71a22 --- /dev/null +++ b/lib/utils/requirements/remote.dart @@ -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 call(BuildContext context, VoidCallback onUpdate) async {} + + Future reconnect() async { + await peripheralManager.stopAdvertising(); + await peripheralManager.removeAllServices(); + _isServiceAdded = false; + _isAdvertising = false; + (actionHandler as RemoteActions).setConnectedCentral(null, null); + startAdvertising(() {}); + } + + Future 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 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 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(() {}); + } + } +} diff --git a/lib/widgets/changelog_dialog.dart b/lib/widgets/changelog_dialog.dart index ad62ff8..9bf317e 100644 --- a/lib/widgets/changelog_dialog.dart +++ b/lib/widgets/changelog_dialog.dart @@ -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'); diff --git a/lib/widgets/custom_keymap_selector.dart b/lib/widgets/custom_keymap_selector.dart index 500df19..4d3d1bb 100644 --- a/lib/widgets/custom_keymap_selector.dart +++ b/lib/widgets/custom_keymap_selector.dart @@ -86,7 +86,7 @@ class _HotKeyListenerState extends State { 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, diff --git a/lib/widgets/keymap_explanation.dart b/lib/widgets/keymap_explanation.dart index cbc5219..1ad6dd0 100644 --- a/lib/widgets/keymap_explanation.dart +++ b/lib/widgets/keymap_explanation.dart @@ -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), ), ), diff --git a/lib/widgets/loading_widget.dart b/lib/widgets/loading_widget.dart new file mode 100644 index 0000000..176c925 --- /dev/null +++ b/lib/widgets/loading_widget.dart @@ -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 createState() => LoadingWidgetState(); +} + +class LoadingWidgetState extends State { + var _loadingState = LoadingState.Success; + dynamic _error; + + Future reloadState() { + return _initState(); + } + + Future _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()); + } +} diff --git a/lib/widgets/menu.dart b/lib/widgets/menu.dart index b24a59e..73aabad 100644 --- a/lib/widgets/menu.dart +++ b/lib/widgets/menu.dart @@ -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 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( diff --git a/lib/widgets/title.dart b/lib/widgets/title.dart old mode 100644 new mode 100755 index b1330bb..7acc0de --- a/lib/widgets/title.dart +++ b/lib/widgets/title.dart @@ -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 { 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 { 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( diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 6cf6d50..2cb6afc 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,12 +6,16 @@ #include "generated_plugin_registrant.h" +#include #include #include #include #include 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); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 74bbc59..0072807 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + bluetooth_low_energy_linux file_selector_linux screen_retriever_linux url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2fafb04..57430bf 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 74fa3bd..ad60c50 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -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 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj old mode 100644 new mode 100755 index a67224b..7895e37 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -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; diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist old mode 100644 new mode 100755 index 10b1f4b..9c0cf9b --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -2,12 +2,21 @@ + UIBackgroundModes + + bluetooth-peripheral + bluetooth-central + + LSApplicationCategoryType + public.app-category.healthcare-fitness CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIconFile + ITSAppUsesNonExemptEncryption + CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion @@ -23,12 +32,14 @@ LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSBluetoothAlwaysUsageDescription - We need BT access because it's a BT App. + SwiftControl requires Bluetooth to connect to your devices. NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile MainMenu NSPrincipalClass NSApplication + NSAccessibilityUsageDescription + SwiftControl needs to send keys to your trainer app. diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements old mode 100644 new mode 100755 index 33db5ad..a476370 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,9 +2,11 @@ + com.apple.security.app-sandbox + com.apple.security.device.bluetooth com.apple.security.network.client - + diff --git a/playstoreassets/mac_screenshot_1.jpg b/playstoreassets/mac_screenshot_1.jpg new file mode 100644 index 0000000..1604ed4 Binary files /dev/null and b/playstoreassets/mac_screenshot_1.jpg differ diff --git a/playstoreassets/mac_screenshot_2.jpg b/playstoreassets/mac_screenshot_2.jpg new file mode 100644 index 0000000..da254df Binary files /dev/null and b/playstoreassets/mac_screenshot_2.jpg differ diff --git a/pubspec.lock b/pubspec.lock old mode 100644 new mode 100755 index e6e016b..eac694e --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml old mode 100644 new mode 100755 index 0f8d4d3..0d017e3 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/changelog_test.dart b/test/changelog_test.dart deleted file mode 100644 index 7f00915..0000000 --- a/test/changelog_test.dart +++ /dev/null @@ -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')); - }); - }); -} diff --git a/test/zwift_ride_analog_test.dart b/test/zwift_ride_analog_test.dart new file mode 100644 index 0000000..113279c --- /dev/null +++ b/test/zwift_ride_analog_test.dart @@ -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); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4b74dcd..7ff59c2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -15,6 +16,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + BluetoothLowEnergyWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BluetoothLowEnergyWindowsPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index c1bc4ca..e7605d1 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + bluetooth_low_energy_windows file_selector_windows keypress_simulator_windows permission_handler_windows