diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..901011e08 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [jonasbark] +custom: ["https://paypal.me/boni", "https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200"] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..145c59f8c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,348 @@ +name: "Build" + +on: + workflow_dispatch: + inputs: + build_mac: + description: 'Build for macOS' + required: false + default: true + type: boolean + build_github: + description: 'Build for GitHub' + required: false + default: true + type: boolean + build_windows: + description: 'Build for Windows' + required: false + default: true + type: boolean + build_android: + description: 'Build for Android' + required: false + default: true + type: boolean + build_ios: + description: 'Build for iOS' + required: false + default: true + type: boolean + +env: + SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }} + FLUTTER_VERSION: 3.41.4 + +jobs: + build: + name: Build & Release + runs-on: macos-latest + + permissions: + id-token: write + pages: write + contents: write + + steps: + #1 Checkout Repository + - name: Checkout Repository + uses: actions/checkout@v3 + with: + submodules: recursive + token: ${{ secrets.PAT_TOKEN }} + + - name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml + run: | + if [ -f pubspec_overrides_ci.yaml ]; then + mv pubspec_overrides_ci.yaml pubspec_overrides.yaml + else + echo "No pubspec_overrides_ci.yaml found, skipping rename." + fi + + - name: Install certificates + if: inputs.build_mac || inputs.build_ios + 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 + # security default-keychain -s $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # import certificate to keychain + security import $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security import $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH_IOS ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH_IOS_DEV ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles + + - name: 🐦 Setup Shorebird + #if: inputs.build_mac || inputs.build_android || inputs.build_ios + if: inputs.build_android || inputs.build_ios + uses: shorebirdtech/setup-shorebird@v1 + with: + cache: true + + - name: Set Up Flutter maCOS + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Generate translation files + run: | + flutter pub global activate intl_utils; + flutter pub global run intl_utils:generate; + + - name: 🚀 Shorebird Release macOS + if: inputs.build_mac && false + uses: shorebirdtech/shorebird-release@v1 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + platform: macos + args: "--obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" + + - name: Flutter Release macOS + if: inputs.build_mac + run: + flutter build macos --release --obfuscate --split-debug-info=symbols --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }} + + - name: Decode Keystore + if: inputs.build_android + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore; + echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties; + + - name: 🚀 Shorebird Release Android + if: inputs.build_android + uses: shorebirdtech/shorebird-release@v1 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + platform: android + args: "--obfuscate --split-debug-info=symbols -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" + + - name: Extract latest changelog + id: changelog + run: | + chmod +x scripts/get_latest_changelog.sh + mkdir -p whatsnew + ./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US + + - name: Generate release body + if: inputs.build_github + run: | + chmod +x scripts/generate_release_body.sh + ./scripts/generate_release_body.sh > /tmp/release_body.md + + - name: 🚀 Shorebird Release iOS + if: inputs.build_ios + uses: shorebirdtech/shorebird-release@v1 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + platform: ios + args: "--export-options-plist ios/ExportOptions.plist --obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" + + - name: Prepare App Store authentication key + if: inputs.build_ios || inputs.build_mac + env: + API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }} + APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} + run: | + mkdir -p ./private_keys; + printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8"; + + - name: Upload to Play Store + if: inputs.build_android + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: de.jonasbark.swiftcontrol + releaseFiles: build/app/outputs/bundle/release/app-release.aab + track: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }} + whatsNewDirectory: whatsnew + + - name: Upload to macOS App Store + if: inputs.build_mac + env: + APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + run: | + productbuild --component "build/macos/Build/Products/Release/BikeControl.app" /Applications "BikeControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)"; + xcrun altool --upload-app -f BikeControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID"; + + - name: Upload to iOS App Store + if: inputs.build_ios + env: + APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + run: | + xcrun altool --upload-app -f build/ios/ipa/swift_play.ipa -t ios --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID"; + + + #10 Extract Version + - name: Extract version from pubspec.yaml + if: inputs.build_github + id: extract_version + run: | + version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r') + echo "VERSION=$version" >> $GITHUB_ENV + + - name: Upload Symbols + uses: actions/upload-artifact@v4 + with: + overwrite: true + name: Symbols + path: symbols/ + + #13 Create Release + - name: Create Release + if: inputs.build_github + uses: ncipollo/release-action@v1 + with: + artifacts: "build/app/outputs/flutter-apk/BikeControl.android.apk,build/macos/Build/Products/Release/BikeControl.macos.zip" + allowUpdates: true + prerelease: true + bodyFile: /tmp/release_body.md + tag: v${{ env.VERSION }} + token: ${{ secrets.TOKEN }} + + windows: + if: inputs.build_windows + name: Build & Release on Windows + runs-on: windows-2025 + + steps: + #1 Checkout Repository + - name: Checkout Repository + uses: actions/checkout@v3 + with: + submodules: recursive + token: ${{ secrets.PAT_TOKEN }} + + - name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml + shell: pwsh + run: | + if (Test-Path pubspec_overrides_ci.yaml) { + Rename-Item -Path pubspec_overrides_ci.yaml -NewName pubspec_overrides.yaml + } else { + Write-Output "No pubspec_overrides_ci.yaml found, skipping rename." + } + + - name: Extract version from pubspec.yaml (Windows) + shell: pwsh + run: | + $version = Select-String '^version: ' pubspec.yaml | ForEach-Object { + ($_ -split ' ')[1].Trim() + } + echo "VERSION=$version" >> $env:GITHUB_ENV + + - name: 🐦 Setup Shorebird + uses: shorebirdtech/setup-shorebird@v1 + with: + cache: true + + - name: Set Up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Generate translation files + run: | + flutter pub global activate intl_utils; + flutter pub global run intl_utils:generate; + + - name: 🚀 Shorebird Release Windows + uses: shorebirdtech/shorebird-release@v1 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + platform: windows + args: "--obfuscate --split-debug-info=symbols-win -- --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" + + - name: Zip Windows release + shell: pwsh + run: | + $source = "C:\Windows\System32" + $destination = "build\windows\x64\runner\Release" + + # List of required DLLs + $dlls = @("msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll") + + # Copy each file + foreach ($dll in $dlls) { + $srcPath = Join-Path $source $dll + $destPath = Join-Path $destination $dll + + if (Test-Path $srcPath) { + Copy-Item -Path $srcPath -Destination $destPath -Force + Write-Output "Copied $dll to $destination" + } else { + Write-Warning "$dll not found in $source" + } + } + $zipPath = "build/windows/bike_control.windows.zip" + if (Test-Path $zipPath) { + Remove-Item $zipPath -Force + } + Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath $zipPath + + - name: Create Store MSIX package + run: dart run msix:create --store --output-name bike_control.store + + - name: Upload Store Artifact + uses: actions/upload-artifact@v4 + with: + overwrite: true + name: BikeControl Store + path: build/windows/x64/runner/Release/bike_control.store.msix + + - name: Upload Windows artifact + uses: actions/upload-artifact@v4 + with: + overwrite: true + name: Windows Release + path: build/windows/bike_control.windows.zip + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + overwrite: true + name: Windows Symbols + path: symbols-win/ + + - name: Create Release (Windows zip) + if: inputs.build_github + uses: ncipollo/release-action@v1 + with: + artifacts: "build/windows/bike_control.windows.zip" + allowUpdates: true + prerelease: true + replacesArtifacts: false + tag: v${{ env.VERSION }} + token: ${{ secrets.TOKEN }} diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml new file mode 100644 index 000000000..f3911754a --- /dev/null +++ b/.github/workflows/patch.yml @@ -0,0 +1,163 @@ +name: "Patch" + +on: + workflow_dispatch: + +env: + SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }} + FLUTTER_VERSION: 3.38.5 + +jobs: + build: + name: Patch iOS, Android & macOS + runs-on: macos-latest + + permissions: + id-token: write + pages: write + contents: write + + steps: + #1 Checkout Repository + - name: Checkout Repository + uses: actions/checkout@v3 + with: + submodules: recursive + token: ${{ secrets.PAT_TOKEN }} + + - name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml + run: | + if [ -f pubspec_overrides_ci.yaml ]; then + mv pubspec_overrides_ci.yaml pubspec_overrides.yaml + else + echo "No pubspec_overrides_ci.yaml found, skipping rename." + fi + + - name: 🐦 Setup Shorebird + uses: shorebirdtech/setup-shorebird@v1 + with: + cache: true + + - name: Set Up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Generate translation files + run: | + flutter pub global activate intl_utils; + flutter pub global run intl_utils:generate; + + - name: 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 + # security default-keychain -s $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # import certificate to keychain + security import $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security import $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH_IOS ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH_IOS_DEV ~/Library/MobileDevice/Provisioning\ Profiles + cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles + + - name: Decode Keystore + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore; + echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties; + + - name: 🚀 Shorebird Patch macOS + if: false # patch doesn't work: https://github.com/OpenBikeControl/bikecontrol/issues/143 + uses: shorebirdtech/shorebird-patch@v1 + with: + platform: macos + release-version: latest + args: '--allow-asset-diffs --allow-native-diffs --obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}' + + - name: 🚀 Shorebird Patch Android + uses: shorebirdtech/shorebird-patch@v1 + with: + platform: android + release-version: latest + args: '--allow-asset-diffs --allow-native-diffs --obfuscate --split-debug-info=symbols -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}' + + - name: 🚀 Shorebird Patch iOS + uses: shorebirdtech/shorebird-patch@v1 + with: + platform: ios + release-version: latest + args: '--allow-asset-diffs --allow-native-diffs --obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}' + + windows: + name: Patch Windows + runs-on: windows-latest + + steps: + #1 Checkout Repository + - name: Checkout Repository + uses: actions/checkout@v3 + with: + submodules: recursive + token: ${{ secrets.PAT_TOKEN }} + + - name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml + shell: pwsh + run: | + if (Test-Path pubspec_overrides_ci.yaml) { + Rename-Item -Path pubspec_overrides_ci.yaml -NewName pubspec_overrides.yaml + } else { + Write-Output "No pubspec_overrides_ci.yaml found, skipping rename." + } + + - name: 🐦 Setup Shorebird + uses: shorebirdtech/setup-shorebird@v1 + with: + cache: true + + - name: Set Up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Generate translation files + run: | + flutter pub global activate intl_utils; + flutter pub global run intl_utils:generate; + + - name: 🚀 Shorebird Patch Windows + uses: shorebirdtech/shorebird-patch@v1 + with: + platform: windows + release-version: latest + args: '--allow-asset-diffs --allow-native-diffs --obfuscate --split-debug-info=symbols -- --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}' diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 000000000..8c24f4b9e --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,56 @@ +name: "Build Web" + +on: + push: + branches: + - web + - main + paths: + - '.github/workflows/web.yml' + - 'lib/**' + - 'accessibility/**' + - 'keypress_simulator/**' + - 'pubspec.yaml' + +jobs: + build: + name: Build Web + runs-on: macos-latest + + permissions: + id-token: write + pages: write + contents: write + + steps: + #1 Checkout Repository + - name: Checkout Repository + uses: actions/checkout@v3 + + #3 Setup Flutter + - name: Set Up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - name: Generate translation files + + run: | + flutter pub global activate intl_utils; + flutter pub global run intl_utils:generate; + + #4 Install Dependencies + - name: Install Dependencies + run: flutter pub get + + - name: Build Web + run: flutter build web --release --base-href "/bikecontrol/" + + - name: Upload static files as artifact + id: deployment + uses: actions/upload-pages-artifact@v3 + with: + path: build/web + + - name: Web Deploy + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 79c113f9b..bd4c8ae90 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,11 @@ .history .svn/ .swiftpm/ +debug/ migrate_working_dir/ +android/keystore.properties + # IntelliJ related *.iml *.ipr @@ -38,8 +41,19 @@ app.*.symbols # Obfuscation related app.*.map.json +localazy.json # Android Studio will place build artifacts here /android/app/debug /android/app/profile /android/app/release + +lib/gen/ + +service-account.json +.env +lib/generated +pubspec_overrides.yaml + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..028d8e273 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "prop"] + path = prop + url = git@github.com:OpenBikeControl/prop.git diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..29dafdfc7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.2.0", + "configurations": [ + + { + "name": "swiftcontrol", + "request": "launch", + "type": "dart", + "program": "lib/main.dart" + }, + { + "name": "swiftcontrol (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "swiftcontrol (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..e69ed3592 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,305 @@ +### 5.0.0 (10-03-2026) + +BikeControl Pro is now available - subscribe to the Pro version to support the project and get access to additional features such as: +- Cross Platform license: use the same subscription on all your devices (Windows, macOS, Android and iOS) without additional charge +- Synchronize and backup your keymap settings across devices +- Individual actions for single, long and double clicks, e.g. hold a button to steer, double click to perform a specific action. This means your two button devices can now have up to 6 different actions! +- Windows, iOS, macOS: start any command/Shortcut, e.g. capture a screenshot, screen video, open a specific app, ... the possibilities are endless! +- Windows, macOS: new screenshot action to capture a screenshot of the current screen and save it to your specified folder +- Android: Open Assistant +- and much more to come in the future! + +**Features**: +- Bluetooth media buttons are now supported on iOS +- Shimano Di2: long press and double clicks are now supported: + - perform steering using long presses + - gear changes are now reflected properly without losing any button presses +- App is now available in Spanish + +**Fixes**: +- macOS: Send keyboard key even if the trainer app isn't in foreground +- You can now download BikeControl on Windows without the Microsoft Store + +### 4.7.0 (04-02-2026) + +**Features**: +- new connection method: act as Bluetooth Keyboard: +Your device can now act as Bluetooth keyboard, allowing you to send keyboard shortcuts (e.g. for virtual shifting) directly to your connected device. Especially useful for tablets / iPads. +- added new keyboard shortcuts for Rouvy (Kudos, Pause workout) + +**Fixes**: +- you can now finally buy the full version on Android :) +- save "Enable Media Key detection" setting across app restarts +- UI adjustments and fixes in the controller configuration screen +- iOS: Remote pairing now works again + +### 4.6.0 (28-01-2026) + +**Features**: +- Improve Zwift Click V2 connection and handling +- Buttons in Configuration are now grouped by device + +### 4.5.0 (22-01-2026) + +**Features**: +- Android: simulate additional actions for local connection method (Left, Down, Right, Up, Select, Back, Home, Recent Apps) + - control your phone with your controller + - control UI within the trainer app (if supported) +- BikeControl now supports individual mapping when you use more than one Cycplus BC2 and ThinkRider VS200 controller +- Windows & macOS: allow configuration of volume keys on Bluetooth HID devices + +### 4.4.0 (16-01-2026) + +**Features**: +- Support for Thinkrider VS200 + +**Fixes**: +- Android: Local connection method allows passing keyboard events to the trainer app +- macOS: Compatibility with macOS Tahoe +- Windows: send keyboard events to the correct window when using multiple monitors or when another app is focused +- Windows: fix media key detection + +### 4.3.0 (07-01-2026) + +**Features**: +- Onboarding for new users +- support controlling music & volume for Windows, macOS and Android +- App is now available in Italian (thanks to Connect_Thanks2613) + +**Fixes**: +- Vibration setting now available for Zwift Ride devices + +### 4.2.0 (20-12-2025) + +BikeControl now offers a free trial period of 5 days for all features, so you can test everything before deciding to purchase a license. Please contact the support if you experience any issues! + +**Features**: +- support for SRAM AXS/eTap + - only single or double click is supported (no individual button mapping possible, yet) +- use your phone/tablet for steering by attaching your device on your handlebar! +- App is now available in Polish (thanks to Wandrocek) + +**Fixes**: +- You will now be notified when a connection to your controller is lost +- improved UI of the Keymap customization screen + +### 4.1.0 (16-12-2025) + +**Features**: +- control your trainer manually without requiring a controller - just like a Companion app +- support for Wahoo KICKR HEADWIND: control the fan via your controller + +**Fixes**: +- Gamepads: handle analog values correctly on Windows +- MyWhoosh: updated default keymap to use the new A+D keys for steering + +### 4.0.0 (07-12-2025) + +- a brand-new design + - Accessibility Permission is now optional on Android +- Zwift is now fully supported on all operating systems + - you can choose between network based control or bluetooth based control +- MyWhoosh can now also be controlled with BikeControl running on the same iPad / iPhone +- Translations available in German and French +- support for Wahoo KICKR BIKE PRO +- support for the OpenBikeControl protocol for supported Trainer apps + - this enables seamless and official integration, independent of the operating system + - learn more at https://openbikecontrol.org + +### 3.6.0 (23-11-2025) + +SwiftControl is now called BikeControl! + +**Features:** +- show a list of predefined keymaps for the selected trainer app when using a custom keymap +- status icons so it's clear what's missing + +**Fixes:** +- Update Rouvy keymap to support virtual shifting in their latest version + +### 3.5.0 (16-11-2025) +**New Features:** +- Dark mode support +- Cycplus BC2 support (thanks @schneewoehner) +- Ignored devices now persist across app restarts - remove them from ignored devices via the menu + +**Fixes:** +- resolve issues during app start + +### 3.4.0 (08-11-2025) +**New Features:** +- Support for Shimano Di2 +- Support Keyboard shortcuts with modifier keys (Ctrl, Alt, Shift, ...) +- Support cheap BLE HID remotes +- add Keymap for Rouvy, supporting the new keyboard shortcuts for virtual shifting + +**Fixes:** +- fix detection of Elite Square Sterzo devices +- recognize cheap Bluetooth device clicks also when BikeControl is in the background + +### 3.3.0 (31-10-2025) + +**New Features:** +- Support for Elite Sterzo (thanks @michidk) +- Support for Gamepads +- Support for cheap bluetooth remotes (such as [these](https://www.amazon.com/s?k=bluetooth+remote)) +- you can now customize the Keymap right from the Customize section +- show signal strength of connected devices (thanks @michidk) +- Android and Windows only: simulate bluetooth controllers + - enables gamepad and bluetooth remotes support for Zwift, Rouvy and Biketerra + +**Fixes:** +- fix firmware version display for Zwift Click V2 devices +- fix touch position on some Android devices +- Wahoo Kickr Bike Shift can now be connected +- update default keymap for TrainingPeaks + +### 3.2.0 (2025-10-22) +- a brand-new way of controlling MyWhoosh: + - device pairing no longer required as mouse emulation is no longer needed + - BikeControl can now stay in the background + - more devices can be controlled + - do more, such as define Emotes, Camera angles and steering + +### 3.1.0 (2025-10-17) +- new app icon +- adjusted MyWhoosh keyboard navigation mapping (thanks @bin101) +- support for Wahook Kickr Bike Shift (thanks @MattW2) +- initial support for Elite Square Smart Frame +- reconnects to your device automatically when connection is lost +- BikeControl now warns you if your device firmware is outdated +- BikeControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26 + +### 3.0.3 (2025-10-12) +- BikeControl now supports iOS! + - Note that you can't run BikeControl and your trainer app on the same iPhone due to iOS limitations but...: +- You can now use BikeControl as "remote control" for other devices, such as an iPad. Example scenario: + - your phone (Android/iOS) runs BikeControl and connects to your Click devices + - your iPad or other tablet runs e.g. MyWhoosh (does not need to have BikeControl installed) + - after pairing BikeControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet +- Ride: analog paddles are now supported thanks to contributor @jmoro +- you can now zoom in and out in the Keymap customization screen + +### 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 +- fix issue on Android and Desktop where only a "touch down" was sent, but no "touch up" +- improve UI when handling custom keymaps around the edges of the screen + +### 2.6.0 (2025-09-30) +- refactor touch placements: show touches on screen, fix misplaced coordinates - should fix #64 +- show firmware version of connected device +- Fix crashes on some Android devices +- warn the user how to make Zwift Click V2 work properly +- many UI improvements +- add setting to enable or disable vibration on button press for Zwift Ride and Zwift Play controllers + +### 2.5.0 (2025-09-25) +- Improve usability +- BikeControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol + - BikeControl will continue to be available to download for free on GitHub + - contact me if you already donated and I'll get a voucher for you :) + +### 2.4.0+1 (2025-09-17) +- Windows: fix mouse clicks at wrong location due to display scaling (fixes #64) + +### 2.4.0 (2025-09-16) +- Show an overview of the keymap bindings +- Allow customizing an existing keymap +- Add more donation options + +### 2.3.0 (2025-09-11) +- Add support for latest Zwift Click v2 + +### 2.2.0 (2025-09-08) +- Add Long Press Mode option for custom keymaps - buttons can now send sustained key presses instead of repeated taps, perfect for movement controls in games (fixes #61) +- Windows: adjust key sending method to improve compatibility with more apps (fixes #62) + +### 2.1.0 (2025-07-03) +- Windows: automatically focus compatible training apps (MyWhoosh, IndieVelo, Biketerra) when sending keystrokes, enabling seamless multi-window usage + +### 2.0.9 (2025-05-04) +- you can now assign Escape and arrow down key to your custom keymap (#18) + +### 2.0.8 (2025-05-02) +- only use the light theme for the app +- more troubleshooting information + +### 2.0.7 (2025-04-18) +- add Biketerra.com keymap +- some UX improvements + +### 2.0.6 (2025-04-15) +- fix MyWhoosh up / downshift button assignment (I key vs K key) + +### 2.0.5 (2025-04-13) +- fix Zwift Click button assignment (#12) + +### 2.0.4 (2025-04-10) +- vibrate Zwift Play / Zwift Ride controller on gear shift (thanks @cagnulein, closes #16) + +### 2.0.3 (2025-04-08) +- adjust TrainingPeaks Virtual key mapping (#12) +- attempt to reconnect device if connection is lost +- Android: detect freeform windows for MyWhoosh + TrainingPeaks Virtual keymaps + +### 2.0.2 (2025-04-07) +- fix bluetooth scan issues on older Android devices by asking for location permission + +### 2.0.1 (2025-04-06) +- long pressing a button will trigger the action again every 250ms + +### 2.0.0 (2025-04-06) +- You can now customize the actions (touches, mouse clicks or keyboard keys) for all buttons on all supported Zwift devices +- now shows the battery level of the connected devices +- add more troubleshooting information + +### 1.1.10 (2025-04-03) +- Add more troubleshooting during connection + +### 1.1.8 (2025-04-02) +- Android: make sure the touch reassignment page is fullscreen + +### 1.1.7 (2025-04-01) +- Zwift Ride: fix connection issues by connecting only to the left controller +- Windows: connect sequentially to fix (finally?) fix connection issues +- Windows: change the way keyboard is simulated, should fix glitches + +### 1.1.6 (2025-03-31) +- Zwift Ride: add buttonPowerDown to shift gears +- Zwift Play: Fix buttonShift assignment +- Android: fix action to go to next song +- App now checks if you run the latest available version + +### 1.1.5 (2025-03-30) +- fix bluetooth connection #6, also add missing entitlement on macOS + +### 1.1.3 (2025-03-30) +- Windows: fix custom keyboard profile recreation after restart, also warn when choosing MyWhoosh profile (may fix #7) +- Zwift Ride: button map adjustments to prevent double shifting +- potential fix for #6 + +### 1.1.1 (2025-03-30) +- potential fix for Bluetooth device detection + +### 1.1.0 (2025-03-30) +- Windows & macOS: allow setting custom keymap and store the setting +- Android: allow customizing the touch area, so it can work with any device without guesswork where the buttons are (#4) +- Zwift Ride: update Zwift Ride decoding based on Feedback from @JayyajGH (#3) + +### 1.0.6 (2025-03-29) +- Another potential keyboard fix for Windows +- Zwift Play: actually also use the dedicated shift buttons + +### 1.0.5 (2025-03-29) +- Zwift Ride: remap the shifter buttons to the correct values + +### 1.0.0+4 (2025-03-29) +- Zwift Ride: attempt to fix button parsing +- Android: fix missing permissions +- Windows: potential fix for key press issues + +### 1.0.0+3 (2025-03-29) + +- Windows: fix connection by using a different Bluetooth stack (issue #1) +- Android: fix non-working touch propagation (issue #2) diff --git a/INSTRUCTIONS_IOS.md b/INSTRUCTIONS_IOS.md new file mode 100644 index 000000000..0d2c00cee --- /dev/null +++ b/INSTRUCTIONS_IOS.md @@ -0,0 +1 @@ +Moved to [INSTRUCTIONS_MYWHOOSH_LINK.md](INSTRUCTIONS_MYWHOOSH_LINK.md) diff --git a/INSTRUCTIONS_LOCAL.md b/INSTRUCTIONS_LOCAL.md new file mode 100644 index 000000000..5ac2dd588 --- /dev/null +++ b/INSTRUCTIONS_LOCAL.md @@ -0,0 +1,17 @@ +## What is the Local connection method? +* +The Local connection method works by directly controlling the target trainer app on the same device by simulating user input (taps, keyboard inputs). This method does not require any network connection or additional hardware, making it the simplest and most straightforward way to connect to the trainer app. + +There are predefined keymaps (touch positions or keyboard shortcuts) for popular trainer apps, allowing users to easily set up and start using the Local connection method without needing to configure anything manually. You can configure these keymaps in the Configuration tab. Note though that supported keyboard keys depend on the trainer app. + +## When to use the Local connection method? +* +The Local connection method is ideal for users who: + - Are running the trainer app on the same device as the controller app (e.g., both apps on a smartphone or tablet). + - Do not want to deal with network configurations or potential connectivity issues. + +## Limitations of the Local connection method +* +While the Local connection method is easy to set up and use, it has some limitations: + - It may not work well with trainer apps that have complex user interfaces or require precise timing. + - It is limited to the device on which both the controller and trainer apps are running, meaning it cannot be used for remote control scenarios. diff --git a/INSTRUCTIONS_MYWHOOSH_LINK.md b/INSTRUCTIONS_MYWHOOSH_LINK.md new file mode 100644 index 000000000..9b50d5f90 --- /dev/null +++ b/INSTRUCTIONS_MYWHOOSH_LINK.md @@ -0,0 +1,39 @@ +## Instructions for using the MyWhoosh "Link" connection method +* +1) Launch MyWhoosh on the device of your choice +2) Only needed once: open the "MyWhoosh Link" app on the same device where you want to use BikeControl. Make sure the Link app is able to connect to MyWhoosh. If it does, close MyWhoosh Link. +3) Make sure the "MyWhoosh Link" app is not active at the same time as BikeControl +4) Open BikeControl, enable the Link connection method, and follow the on-screen instructions + + +Here's a video with a few explanations. Note that it uses an older version, but the idea is the same. + +[![BikeControl Instruction for iOS](https://img.youtube.com/vi/p8sgQhuufeI/0.jpg)](https://www.youtube.com/watch?v=p8sgQhuufeI) +[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI) + +## MyWhoosh "Link" method never connects +* +This is a network/local-discovery problem. BikeControl needs the same kind of local network access as MyWhoosh Link. + +Checklist: +- Use the MyWhoosh Link app to confirm if "Link" works in general +- Use MyWhoosh Link app and connect, then close it, then open up BikeControl - this is key for some users +- Both devices (if you use BikeControl on another device than MyWhoosh) are on the **same Wi‑Fi SSID** + - Avoid “Guest” networks + - Avoid “extenders/mesh guest mode” and networks with device isolation +- If your router has it, disable: + - “AP isolation / client isolation” +- Try moving both devices to the same band: + - Prefer **2.4 GHz** (often more reliable for local discovery than mixed/steering) +- Temporarily disable: + - VPNs + - iCloud Private Relay (if enabled) + - “Limit IP Address Tracking” (iOS Wi‑Fi option) +- iOS Wi‑Fi settings for that network: + - Turn off **Private Wi‑Fi Address** + - Turn off **Limit IP Address Tracking** +- Mesh networks: may work, but if it doesn’t, test with a simple router or phone hotspot. + +Official MyWhoosh troubleshooting links: +- https://mywhoosh.com/troubleshoot/ +- https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/ diff --git a/INSTRUCTIONS_REMOTE_CONTROL.md b/INSTRUCTIONS_REMOTE_CONTROL.md new file mode 100644 index 000000000..2e1db963c --- /dev/null +++ b/INSTRUCTIONS_REMOTE_CONTROL.md @@ -0,0 +1,13 @@ +## Remote control is not working - nothing happens +* +- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it. +- Try restarting the pairing process in BikeControl +- try restarting Bluetooth on your phone and on the device you want to control +- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled. +- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in BikeControl / restart BikeControl. + +## Remote control only clicks on a single coordinate on my iPad +* +iOS seems to be buggy here - try this in the iOS settings: +AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or BikeControl iOS) > Button 1 +switch the setting to None, then back to Single-Tap and it should work again diff --git a/INSTRUCTIONS_ROUVY.md b/INSTRUCTIONS_ROUVY.md new file mode 100644 index 000000000..47d20c81c --- /dev/null +++ b/INSTRUCTIONS_ROUVY.md @@ -0,0 +1,4 @@ +## Local Connection method +* +The local connection method (avalable on Android, Windows and macOS) allows BikeControl to directly control Rouvy either using touch or keyboard keys. This way you don't need to select any "Controllers" at all in Rouvy. +Make sure the "Virtual Shifting Controls" are enabled: https://support.rouvy.com/hc/en-us/articles/32452137189393-Virtual-Shifting#h_01K9SWGWYMAVQV108SQ9KWQAKC diff --git a/INSTRUCTIONS_ZWIFT.md b/INSTRUCTIONS_ZWIFT.md new file mode 100644 index 000000000..e69de29bb diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..181e74d8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,97 @@ +License notice: +Versions of this project released prior to repository tag gpl3 were licensed +under GPL-3.0. Those versions remain available under their original +license. Versions released after that point are licensed under the +Non-Commercial License. + +NON-COMMERCIAL SOFTWARE LICENSE AGREEMENT + +Version 1.0 + +Copyright (c) 2026 OpenBikeControl UG (haftungsbeschränkt). +All rights reserved. + +⸻ + +1. Definitions + +“Software” means the source code, object code, binaries, and associated documentation made available by the Licensor under this License. + +“Commercial Use” means any use of the Software, directly or indirectly, that is intended for or results in: + • monetary compensation, + • sale, licensing, or subscription fees, + • advertising or sponsorship revenue, + • inclusion in a product or service that is sold or monetized, + • distribution through paid applications or application marketplaces. + +“Licensor” means the copyright holder. + +⸻ + +2. Grant of License + +Subject to the terms of this License, the Licensor grants you a non-exclusive, non-transferable, revocable license to: + • use the Software for personal, educational, or internal evaluation purposes only; + • modify the Software for non-commercial purposes; + • redistribute the Software only in source form, free of charge, and only under this same License. + +⸻ + +3. Restrictions + +You may not, without prior written permission from the Licensor: + • use the Software for any Commercial Use; + • distribute the Software as part of a paid or monetized product or service; + • distribute the Software via application marketplaces (including but not limited to Apple App Store or Google Play) where the application itself or related services are monetized; + • sublicense, sell, rent, or lease the Software. + +⸻ + +4. Attribution + +All copies and derivative works must retain: + • this License text; + • all existing copyright notices. + +⸻ + +5. No Patent License + +This License does not grant any patent rights. + +⸻ + +6. Termination + +Any violation of this License automatically terminates your rights under this License. + +Upon termination, you must cease all use and distribution of the Software. + +⸻ + +7. Disclaimer of Warranty + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND. + +⸻ + +8. Limitation of Liability + +IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY DAMAGES ARISING FROM THE USE OF THE SOFTWARE. + +⸻ + +9. Governing Law + +This License shall be governed by the laws of [YOUR COUNTRY], excluding conflict-of-law rules. + +⸻ + +10. Commercial Licensing + +Commercial use is available under separate commercial license terms. +Contact: jonas@openbikecontrol.org + +⸻ + +End of License diff --git a/README.md b/README.md index ad14eda0d..8a23901d3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,106 @@ -# SwiftControl +# BikeControl (formerly SwiftControl) -SwiftControl - Control your virtual riding +BikeControl Logo -## TODO -- github pipelines -- test Zwift Ride -- implement more actions for Play + Ride -- write readme with logo -- shorebird? +## Description + +With BikeControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, Shimano Di2, or other similar devices. Here's what you can do with it, depending on your configuration: +- Virtual Gear shifting +- Steering / navigation +- adjust workout intensity +- control music on your device +- create screenshots or launch any command / shortcut +- gestures: single click, double click, long press, etc. can be configured to do different things +- more? If you can do it via keyboard, mouse, or touch, you can do it with BikeControl + +[![Youtube Video](https://github.com/user-attachments/assets/14a45ca1-e31b-4fbd-8d03-95aa60470405)](https://youtu.be/0r3LO5lFlyc) + + +## Download +Best follow our landing page and the "Get Started" button: [bikecontrol.app](https://bikecontrol.app/) to understand on which platform you want to run BikeControl. A testing period is available, allowing you to try out the full functionality of BikeControl: + +GetItOnGooglePlay_Badge_Web_color_English + +App Store + +Mac App Store + +Microsoft Store + +(or direct download for Windows [here](https://bikecontrol.app/download/bikecontrol.windows.zip)) + +## Supported Apps +- MyWhoosh +- Zwift +- TrainingPeaks Virtual +- Biketerra.com +- Rouvy +- [OpenBikeControl](https://openbikecontrol.org) compatible apps +- any other! + - You can add custom mapping and adjust touch points or keyboard shortcuts to your liking + +## Supported Devices +- Zwift Click +- Zwift Click v2 (mostly, see issue #68) +- Zwift Ride +- Zwift Play +- Shimano Di2 + - Configure your levers to use D-Fly channels with Shimano E-Tube app +- SRAM AXS/eTap + - Configure your levers not to do any action in the "SRAM AXS" app + - only single or double click is supported (no individual button mapping possible, yet) +- Wahoo Kickr Bike Shift +- Wahoo Kickr Bike Pro +- CYCPLUS BC2 Virtual Shifter +- Thinkrider VS200 Virtual Shifter (beta) +- Elite Sterzo Smart (for steering support) +- Elite Square Smart Frame (beta) +- Your Phone! + - Mount your phone on the handlebar to detect e.g. steering + - Available on Android and iOS +- Gamepads +- Keyboard input + - like a Companion App + - some trainers do not support keyboard input for all functions - now they do! + - useful when remapping keys from other devices using e.g. AutoHotkey +- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta) + - BLE HID devices and classic Bluetooth HID devices are supported + - works out of the box on Android + - on Windows, iOS and macOS requires BikeControl to act as media player +- We're working on creating an affordable alternative based on an open standard, supported by all major trainer apps + - register your interest [here](https://openbikecontrol.org/#HARDWARE) + +Support for other devices can be added; check the issues tab here on GitHub. + +## Supported Accessories +- Wahoo KICKR HEADWIND (beta) + - control fan speed using your controller + +## Supported Platforms + +Follow the "Get Started" button over at [bikecontrol.app](https://bikecontrol.app) to understand on which platform you want to run BikeControl. +You can even try it out in your [Browser](https://openbikecontrol.github.io/bikecontrol/), if it supports Bluetooth connections. No controlling possible, though. + +## Help +Check the troubleshooting guide [here](TROUBLESHOOTING.md). + +## How does it work? +The app connects to your Controller devices (such as Zwift ones) automatically. BikeControl uses different methods of connecting to the trainer app, depending on the trainer app and operating system: +- Connect to the trainer app on the same device or on another device using Network + - available on Android, iOS, iPadOS, macOS, Windows + - supported by e.g. MyWhoosh, Rouvy and Zwift +- Connect to the trainer app on another device by simulating a Bluetooth device + - available on Android, iOS, iPadOS, macOS, Windows + - supported by e.g. Rouvy and Zwift +- Directly control the trainer app via Accessibility features (simulating touch and keyboard input) + - available on Android, macOS, Windows + - supported by all trainer apps +- Connect to the supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol + - available on Android, iOS, iPadOS, macOS, Windows + +## Donate +Please consider donating to support the development of this app :) + +- [via PayPal](https://paypal.me/boni) +- [via Credit Card, Google Pay, Apple Pay, etc. (USD)](https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201) +- [via Credit Card, Google Pay, Apple Pay, etc. (EUR)](https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200) diff --git a/SCRIPTING.md b/SCRIPTING.md new file mode 100644 index 000000000..150698186 --- /dev/null +++ b/SCRIPTING.md @@ -0,0 +1,52 @@ +# Scripting Guide (Experimental) + +## What It Does +You can attach a custom Dart script to each controller device type (for example `ZwiftRide` or `ZwiftClickV2`). +When the device receives a BLE value update, BikeControl can run your script and write a new BLE payload based on the script output. + +## Where To Find It +1. Look for the device in the devices list. +2. Click the menu (same menu that contains `Disconnect and Forget`). +3. Click `Run Script`. + +## Script Storage +- Scripts are saved to a file per **device class/type**. +- Example: one script file for `ZwiftRide`, another for `ZwiftClickV2`. +- If no script file exists yet, the editor is prefilled with the default script from `lib/utils/interpreter.dart`. + +## Required Signature +Your script must expose a top-level `main` function with: + +```dart +Future> main(String characteristicUuid, List data) async { + return [characteristicUuid, data]; +} +``` + +Validation checks on Save: +- A top-level `main` function exists. +- `main` has exactly 2 positional parameters. +- `main` returns `List<...>` or `Future>`. + +## Output Contract +`main(...)` must return a list with 0 items (nothing should happen), or 2 items (send a write command): +1. `String` characteristic UUID to write to. +2. `List` byte payload (values must be in `0..255`). + +Example: + +```dart +Future> main(String characteristicUuid, List data) async { + final mirrored = data.reversed.toList(); + return [characteristicUuid, mirrored]; +} +``` + +## Runtime Behavior +- Your script runs when a value from any characteristic is received for a device of the corresponding type. +- If the script exists for that device type, BikeControl uses the output and writes the value to the characteristic. +- If the returned characteristic does not exist on the connected device, the write is skipped and a log entry is added. + +## Notes +- This is experimental and runs on every value update; keep scripts lightweight. +- Invalid scripts are rejected on Save and are not written to disk. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 000000000..a265c4129 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,44 @@ +## Click / Ride device cannot be found +* +This means BikeControl does NOT see the device via Bluetooth. +- Put the controller into pairing mode (LED should blink) +- Ensure the controller is NOT connected to another app/device (e.g. Zwift) +- Update controller firmware in Zwift Companion, if available +- Reboot Bluetooth / reboot phone/PC + +## Click / Ride device does not send any data +* +You may need to update the firmware in Zwift Companion app. + +## My Click v2 disconnects after a minute or buttons do not work +* + +To make your Click V2 work best you should connect it in the Zwift app once before a workout session. +If you don't do that BikeControl will need to reconnect every minute. + +You have two possibilities: +# BikeControl method +Click on the "Unlock" button in BikeControl in the Devices tab, then follow on-screen instructions: +[https://youtube.com/shorts/bSQKnpHnWCo?feature=share](https://youtube.com/shorts/bSQKnpHnWCo?feature=share) + +# Manual method +1. Open Zwift app (not the Companion) +2. Log in (subscription not required) → device connection screen +3. Connect trainer, then connect Click v2 +4. Keep it connected for ~10–30 seconds +5. Close Zwift completely, then connect in BikeControl + +Details/updates: [GitHub issue](https://github.com/OpenBikeControl/bikecontrol/issues/68) + +## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar +* +- especially for Redmi and other chinese Android devices please follow the instructions on [https://dontkillmyapp.com/](https://dontkillmyapp.com/): + - disable battery optimization for BikeControl + - enable auto start of BikeControl + - grant accessibility permission for BikeControl +- see [https://github.com/OpenBikeControl/bikecontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details + + +## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control +* +Make sure you've enabled Virtual Shifting in MyWhoosh's settings diff --git a/WINDOWS_STORE_VERSION.txt b/WINDOWS_STORE_VERSION.txt new file mode 100644 index 000000000..af9764a59 --- /dev/null +++ b/WINDOWS_STORE_VERSION.txt @@ -0,0 +1 @@ +4.7.2 diff --git a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityApi.kt b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityApi.kt index 0e4ece181..0bc563a1f 100644 --- a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityApi.kt +++ b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityApi.kt @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.2.0), do not edit directly. +// Autogenerated from Pigeon (v25.5.0), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -12,25 +12,57 @@ import io.flutter.plugin.common.StandardMethodCodec import io.flutter.plugin.common.StandardMessageCodec import java.io.ByteArrayOutputStream import java.nio.ByteBuffer +private object AccessibilityApiPigeonUtils { -private fun wrapResult(result: Any?): List { - return listOf(result) -} + fun wrapResult(result: Any?): List { + return listOf(result) + } -private fun wrapError(exception: Throwable): List { - return if (exception is FlutterError) { - listOf( - exception.code, - exception.message, - exception.details - ) - } else { - listOf( - exception.javaClass.simpleName, - exception.toString(), - "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) - ) + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).containsKey(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + } /** @@ -45,26 +77,62 @@ class FlutterError ( val details: Any? = null ) : Throwable() +enum class MediaAction(val raw: Int) { + PLAY_PAUSE(0), + NEXT(1), + VOLUME_UP(2), + VOLUME_DOWN(3); + + companion object { + fun ofRaw(raw: Int): MediaAction? { + return values().firstOrNull { it.raw == raw } + } + } +} + +enum class GlobalAction(val raw: Int) { + BACK(0), + DPAD_CENTER(1), + DOWN(2), + RIGHT(3), + UP(4), + LEFT(5), + HOME(6), + RECENTS(7); + + companion object { + fun ofRaw(raw: Int): GlobalAction? { + return values().firstOrNull { it.raw == raw } + } + } +} + /** Generated class from Pigeon that represents data sent in messages. */ data class WindowEvent ( val packageName: String, - val windowHeight: Long, - val windowWidth: Long + val top: Long, + val bottom: Long, + val right: Long, + val left: Long ) { companion object { fun fromList(pigeonVar_list: List): WindowEvent { val packageName = pigeonVar_list[0] as String - val windowHeight = pigeonVar_list[1] as Long - val windowWidth = pigeonVar_list[2] as Long - return WindowEvent(packageName, windowHeight, windowWidth) + val top = pigeonVar_list[1] as Long + val bottom = pigeonVar_list[2] as Long + val right = pigeonVar_list[3] as Long + val left = pigeonVar_list[4] as Long + return WindowEvent(packageName, top, bottom, right, left) } } fun toList(): List { return listOf( packageName, - windowHeight, - windowWidth, + top, + bottom, + right, + left, ) } override fun equals(other: Any?): Boolean { @@ -74,10 +142,44 @@ data class WindowEvent ( if (this === other) { return true } - return packageName == other.packageName - && windowHeight == other.windowHeight - && windowWidth == other.windowWidth + return AccessibilityApiPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class AKeyEvent ( + val source: String, + val hidKey: String, + val keyDown: Boolean, + val keyUp: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): AKeyEvent { + val source = pigeonVar_list[0] as String + val hidKey = pigeonVar_list[1] as String + val keyDown = pigeonVar_list[2] as Boolean + val keyUp = pigeonVar_list[3] as Boolean + return AKeyEvent(source, hidKey, keyDown, keyUp) + } } + fun toList(): List { + return listOf( + source, + hidKey, + keyDown, + keyUp, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is AKeyEvent) { + return false + } + if (this === other) { + return true + } + return AccessibilityApiPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -85,17 +187,44 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + MediaAction.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as Long?)?.let { + GlobalAction.ofRaw(it.toInt()) + } + } + 131.toByte() -> { return (readValue(buffer) as? List)?.let { WindowEvent.fromList(it) } } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { + AKeyEvent.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { when (value) { - is WindowEvent -> { + is MediaAction -> { stream.write(129) + writeValue(stream, value.raw) + } + is GlobalAction -> { + stream.write(130) + writeValue(stream, value.raw) + } + is WindowEvent -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is AKeyEvent -> { + stream.write(132) writeValue(stream, value.toList()) } else -> super.writeValue(stream, value) @@ -109,7 +238,12 @@ val AccessibilityApiPigeonMethodCodec = StandardMethodCodec(AccessibilityApiPige interface Accessibility { fun hasPermission(): Boolean fun openPermissions() - fun performTouch(x: Double, y: Double) + fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) + fun performGlobalAction(action: GlobalAction) + fun controlMedia(action: MediaAction) + fun isRunning(): Boolean + fun ignoreHidDevices() + fun setHandledKeys(keys: List) companion object { /** The codec used by Accessibility. */ @@ -127,7 +261,7 @@ interface Accessibility { val wrapped: List = try { listOf(api.hasPermission()) } catch (exception: Throwable) { - wrapError(exception) + AccessibilityApiPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -143,7 +277,7 @@ interface Accessibility { api.openPermissions() listOf(null) } catch (exception: Throwable) { - wrapError(exception) + AccessibilityApiPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -158,11 +292,98 @@ interface Accessibility { val args = message as List val xArg = args[0] as Double val yArg = args[1] as Double + val isKeyDownArg = args[2] as Boolean + val isKeyUpArg = args[3] as Boolean val wrapped: List = try { - api.performTouch(xArg, yArg) + api.performTouch(xArg, yArg, isKeyDownArg, isKeyUpArg) listOf(null) } catch (exception: Throwable) { - wrapError(exception) + AccessibilityApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.performGlobalAction$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val actionArg = args[0] as GlobalAction + val wrapped: List = try { + api.performGlobalAction(actionArg) + listOf(null) + } catch (exception: Throwable) { + AccessibilityApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.controlMedia$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val actionArg = args[0] as MediaAction + val wrapped: List = try { + api.controlMedia(actionArg) + listOf(null) + } catch (exception: Throwable) { + AccessibilityApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.isRunning$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.isRunning()) + } catch (exception: Throwable) { + AccessibilityApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.ignoreHidDevices() + listOf(null) + } catch (exception: Throwable) { + AccessibilityApiPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val keysArg = args[0] as List + val wrapped: List = try { + api.setHandledKeys(keysArg) + listOf(null) + } catch (exception: Throwable) { + AccessibilityApiPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -223,3 +444,16 @@ abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWra } } +abstract class HidKeyPressedStreamHandler : AccessibilityApiPigeonEventChannelWrapper { + companion object { + fun register(messenger: BinaryMessenger, streamHandler: HidKeyPressedStreamHandler, instanceName: String = "") { + var channelName: String = "dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed" + if (instanceName.isNotEmpty()) { + channelName += ".$instanceName" + } + val internalStreamHandler = AccessibilityApiPigeonStreamHandler(streamHandler) + EventChannel(messenger, channelName, AccessibilityApiPigeonMethodCodec).setStreamHandler(internalStreamHandler) + } + } +} + diff --git a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityPlugin.kt b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityPlugin.kt index de02f0154..0117a227e 100644 --- a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityPlugin.kt +++ b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityPlugin.kt @@ -1,48 +1,47 @@ package de.jonasbark.accessibility +import AKeyEvent import Accessibility +import GlobalAction +import HidKeyPressedStreamHandler +import MediaAction import PigeonEventSink import StreamEventsStreamHandler import WindowEvent import android.content.Context import android.content.Intent +import android.graphics.Rect import android.os.Bundle import android.provider.Settings +import android.view.KeyEvent import androidx.core.content.ContextCompat.startActivity import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result /** AccessibilityPlugin */ -class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility { +class AccessibilityPlugin: FlutterPlugin, Accessibility { /// The MethodChannel that will the communication between Flutter and native Android /// /// This local reference serves to register the plugin with the Flutter Engine and unregister it /// when the Flutter Engine is detached from the Activity private lateinit var channel : MethodChannel private lateinit var context: Context - private lateinit var eventHandler: EventListener + private lateinit var windowEventHandler: WindowEventListener + private lateinit var hidEventHandler: HidEventListener override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "accessibility") - eventHandler = EventListener() + windowEventHandler = WindowEventListener() + hidEventHandler = HidEventListener() context = flutterPluginBinding.applicationContext Accessibility.setUp(flutterPluginBinding.binaryMessenger, this) - StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, eventHandler) - Observable.fromService = eventHandler - } - - override fun onMethodCall(call: MethodCall, result: Result) { - if (call.method == "getPlatformVersion") { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } else { - result.notImplemented() - } + StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, windowEventHandler) + HidKeyPressedStreamHandler.register(flutterPluginBinding.binaryMessenger, hidEventHandler) + Observable.fromServiceWindow = windowEventHandler + Observable.fromServiceKeys = hidEventHandler } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -54,32 +53,99 @@ class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility { return enabledServices != null && enabledServices.contains(context.packageName) } + override fun isRunning(): Boolean { + return Observable.toService != null + } + override fun openPermissions() { startActivity(context, Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK }, Bundle.EMPTY) } - override fun performTouch(x: Double, y: Double) { - Observable.toService?.performTouch(x = x, y = y) ?: error("Service not running") + override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) { + Observable.toService?.performTouch(x = x, y = y, isKeyUp = isKeyUp, isKeyDown = isKeyDown) ?: error("Service not running") + } + + override fun performGlobalAction(action: GlobalAction) { + Observable.toService?.performGlobalAction(action) ?: error("Service not running") + } + + override fun controlMedia(action: MediaAction) { + val audioService = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager + when (action) { + MediaAction.PLAY_PAUSE -> { + audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + } + MediaAction.NEXT -> { + audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT)) + audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_NEXT)) + } + MediaAction.VOLUME_DOWN -> { + audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI) + } + MediaAction.VOLUME_UP -> { + audioService.adjustVolume(android.media.AudioManager.ADJUST_RAISE, android.media.AudioManager.FLAG_SHOW_UI) + } + } + } + + override fun ignoreHidDevices() { + Observable.ignoreHidDevices = true + } + + override fun setHandledKeys(keys: List) { + // Clear and update the concurrent set + Observable.handledKeys = keys.toSet() } } -class EventListener : StreamEventsStreamHandler(), Receiver { +class WindowEventListener : StreamEventsStreamHandler(), Receiver { private var eventSink: PigeonEventSink? = null override fun onListen(p0: Any?, sink: PigeonEventSink) { eventSink = sink } - fun onEventsDone() { + override fun onCancel(p0: Any?) { eventSink?.endOfStream() eventSink = null } - override fun onChange(packageName: String, windowWidth: Int, windowHeight: Int) { - eventSink?.success(WindowEvent(packageName = packageName, windowWidth = windowWidth.toLong(), windowHeight = windowHeight.toLong())) + override fun onChange(packageName: String, window: Rect) { + eventSink?.success(WindowEvent(packageName = packageName, right = window.right.toLong(), left = window.left.toLong(), bottom = window.bottom.toLong(), top = window.top.toLong())) } + override fun onKeyEvent(event: KeyEvent) { + + } + +} + + +class HidEventListener : HidKeyPressedStreamHandler(), Receiver { + + private var keyEventSink: PigeonEventSink? = null + + override fun onListen(p0: Any?, sink: PigeonEventSink) { + keyEventSink = sink + } + + override fun onChange(packageName: String, window: Rect) { + + } + + override fun onKeyEvent(event: KeyEvent) { + val keyString = KeyEvent.keyCodeToString(event.keyCode) + keyEventSink?.success( + AKeyEvent( + hidKey = keyString, + source = event.device.name, + keyUp = event.action == KeyEvent.ACTION_UP, + keyDown = event.action == KeyEvent.ACTION_DOWN + ) + ) + } } diff --git a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityService.kt b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityService.kt index c5b8632fe..35d050d28 100644 --- a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityService.kt +++ b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityService.kt @@ -3,14 +3,19 @@ package de.jonasbark.accessibility import android.accessibilityservice.AccessibilityService import android.accessibilityservice.GestureDescription import android.accessibilityservice.GestureDescription.StrokeDescription +import android.accessibilityservice.AccessibilityServiceInfo +import android.content.Context import android.graphics.Path import android.graphics.Rect +import android.media.AudioManager import android.os.Build import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent import android.view.ViewConfiguration import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED +import GlobalAction class AccessibilityService : AccessibilityService(), Listener { @@ -26,46 +31,101 @@ class AccessibilityService : AccessibilityService(), Listener { Observable.toService = null } + private val ignorePackages = listOf("com.android.systemui", "com.android.launcher", "com.android.settings") + override fun onAccessibilityEvent(event: AccessibilityEvent) { if (event.packageName == null || rootInActiveWindow == null) { return } - if (event.contentChangeTypes == CONTENT_CHANGE_TYPE_PANE_DISAPPEARED) { + if (event.eventType != TYPE_WINDOW_STATE_CHANGED || event.packageName in ignorePackages) { // we're not interested return } val currentPackageName = event.packageName.toString() val windowSize = getWindowSize() - Observable.fromService?.onChange(packageName = currentPackageName, windowHeight = windowSize.bottom, windowWidth = windowSize.right) + Observable.fromServiceWindow?.onChange(packageName = currentPackageName, window = windowSize) } private fun getWindowSize(): Rect { val outBounds = Rect() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - rootInActiveWindow.getBoundsInWindow(outBounds) + rootInActiveWindow?.getBoundsInScreen(outBounds) + return outBounds + } + + + override fun onInterrupt() { + Log.d("AccessibilityService", "Service Interrupted") + } + + override fun onServiceConnected() { + super.onServiceConnected() + // Request key event filtering so we receive onKeyEvent for hardware/HID media keys + try { + val info = serviceInfo ?: AccessibilityServiceInfo() + info.flags = info.flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS + // keep other capabilities as defined in XML + setServiceInfo(info) + } catch (e: Exception) { + Log.w("AccessibilityService", "Failed to set service info for key events: ${e.message}") + } + } + + override fun onKeyEvent(event: KeyEvent): Boolean { + val keyString = KeyEvent.keyCodeToString(event.keyCode) + // if currently active app is BikeControl => handle it, so keymap can be created + if (!Observable.ignoreHidDevices && isBleRemote(event) && (rootInActiveWindow?.packageName == "de.jonasbark.swiftcontrol" || Observable.handledKeys.contains(keyString))) { + // Handle keys that have a keymap defined + Log.d( + "AccessibilityService", + "onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}" + ) + + // Forward key events to the plugin (Flutter) and swallow them so they don't propagate. + Observable.fromServiceKeys?.onKeyEvent(event) + // Return true to indicate we've handled the event and it should be swallowed. + return true } else { - rootInActiveWindow.getBoundsInScreen(outBounds) + return false } - return outBounds } - private fun simulateTap(x: Double, y: Double) { + private fun isBleRemote(event: KeyEvent): Boolean { + val dev = InputDevice.getDevice(event.deviceId) ?: return false + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + dev.isExternal + } else { + true + } + } + + override fun performGlobalAction(action: GlobalAction) { + val mappedAction = when (action) { + GlobalAction.BACK -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_BACK + GlobalAction.DPAD_CENTER -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_CENTER + GlobalAction.DOWN -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_DOWN + GlobalAction.RIGHT -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_RIGHT + GlobalAction.UP -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_UP + GlobalAction.LEFT -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_LEFT + GlobalAction.HOME -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_HOME + GlobalAction.RECENTS -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_RECENTS + } + performGlobalAction(mappedAction) + } + + override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) { val gestureBuilder = GestureDescription.Builder() val path = Path() path.moveTo(x.toFloat(), y.toFloat()) path.lineTo(x.toFloat()+1, y.toFloat()) - val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong()) + val stroke = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown && !isKeyUp) + } else { + // API 24–25: no “willContinue” support + StrokeDescription(path, 0L, ViewConfiguration.getTapTimeout().toLong()) + } gestureBuilder.addStroke(stroke) dispatchGesture(gestureBuilder.build(), null, null) } - - override fun onInterrupt() { - Log.d("AccessibilityService", "Service Interrupted") - } - - override fun performTouch(x: Double, y: Double) { - simulateTap(x, y) - } } diff --git a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/Listener.kt b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/Listener.kt index 95ce4b0f6..a92a8e99f 100644 --- a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/Listener.kt +++ b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/Listener.kt @@ -1,14 +1,25 @@ package de.jonasbark.accessibility +import android.graphics.Rect +import android.view.KeyEvent +import GlobalAction +import java.util.concurrent.ConcurrentHashMap + object Observable { var toService: Listener? = null - var fromService: Receiver? = null + var fromServiceWindow: Receiver? = null + var fromServiceKeys: Receiver? = null + var ignoreHidDevices: Boolean = false + // Use concurrent set for thread-safe access from AccessibilityService and plugin + var handledKeys: Set = ConcurrentHashMap.newKeySet() } interface Listener { - fun performTouch(x: Double, y: Double) + fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) + fun performGlobalAction(action: GlobalAction) } interface Receiver { - fun onChange(packageName: String, windowWidth: Int, windowHeight: Int) -} \ No newline at end of file + fun onChange(packageName: String, window: Rect) + fun onKeyEvent(event: KeyEvent) +} diff --git a/accessibility/api.dart b/accessibility/api.dart index 62e9e5518..b10cf037c 100644 --- a/accessibility/api.dart +++ b/accessibility/api.dart @@ -6,18 +6,59 @@ abstract class Accessibility { void openPermissions(); - void performTouch(double x, double y); + void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false}); + + void performGlobalAction(GlobalAction action); + + void controlMedia(MediaAction action); + + bool isRunning(); + + void ignoreHidDevices(); + + void setHandledKeys(List keys); +} + +enum MediaAction { playPause, next, volumeUp, volumeDown } + +enum GlobalAction { + back, + dpadCenter, + down, + right, + up, + left, + home, + recents, } class WindowEvent { final String packageName; - final int windowHeight; - final int windowWidth; + final int top; + final int bottom; + final int right; + final int left; + + WindowEvent({ + required this.packageName, + required this.left, + required this.right, + required this.top, + required this.bottom, + }); +} + +class AKeyEvent { + final String source; + final String hidKey; + final bool keyDown; + final bool keyUp; - WindowEvent({required this.packageName, required this.windowHeight, required this.windowWidth}); + AKeyEvent({required this.source, required this.hidKey, required this.keyDown, required this.keyUp}); } @EventChannelApi() abstract class EventChannelMethods { WindowEvent streamEvents(); + AKeyEvent hidKeyPressed(); } diff --git a/accessibility/lib/accessibility.dart b/accessibility/lib/accessibility.dart index e0ca46f86..63df71f66 100644 --- a/accessibility/lib/accessibility.dart +++ b/accessibility/lib/accessibility.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.2.0), do not edit directly. +// Autogenerated from Pigeon (v25.5.0), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -14,25 +14,65 @@ PlatformException _createConnectionError(String channelName) { message: 'Unable to establish connection on channel: "$channelName".', ); } +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == b; +} + + +enum MediaAction { + playPause, + next, + volumeUp, + volumeDown, +} + +enum GlobalAction { + back, + dpadCenter, + down, + right, + up, + left, + home, + recents, +} class WindowEvent { WindowEvent({ required this.packageName, - required this.windowHeight, - required this.windowWidth, + required this.top, + required this.bottom, + required this.right, + required this.left, }); String packageName; - int windowHeight; + int top; + + int bottom; + + int right; - int windowWidth; + int left; List _toList() { return [ packageName, - windowHeight, - windowWidth, + top, + bottom, + right, + left, ]; } @@ -43,8 +83,10 @@ class WindowEvent { result as List; return WindowEvent( packageName: result[0]! as String, - windowHeight: result[1]! as int, - windowWidth: result[2]! as int, + top: result[1]! as int, + bottom: result[2]! as int, + right: result[3]! as int, + left: result[4]! as int, ); } @@ -57,10 +99,63 @@ class WindowEvent { if (identical(this, other)) { return true; } - return - packageName == other.packageName - && windowHeight == other.windowHeight - && windowWidth == other.windowWidth; + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class AKeyEvent { + AKeyEvent({ + required this.source, + required this.hidKey, + required this.keyDown, + required this.keyUp, + }); + + String source; + + String hidKey; + + bool keyDown; + + bool keyUp; + + List _toList() { + return [ + source, + hidKey, + keyDown, + keyUp, + ]; + } + + Object encode() { + return _toList(); } + + static AKeyEvent decode(Object result) { + result as List; + return AKeyEvent( + source: result[0]! as String, + hidKey: result[1]! as String, + keyDown: result[2]! as bool, + keyUp: result[3]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AKeyEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); } @override @@ -77,8 +172,17 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is WindowEvent) { + } else if (value is MediaAction) { buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is GlobalAction) { + buffer.putUint8(130); + writeValue(buffer, value.index); + } else if (value is WindowEvent) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is AKeyEvent) { + buffer.putUint8(132); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -89,7 +193,15 @@ class _PigeonCodec extends StandardMessageCodec { Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : MediaAction.values[value]; + case 130: + final int? value = readValue(buffer) as int?; + return value == null ? null : GlobalAction.values[value]; + case 131: return WindowEvent.decode(readValue(buffer)!); + case 132: + return AKeyEvent.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -162,14 +274,134 @@ class Accessibility { } } - Future performTouch(double x, double y) async { + Future performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false, }) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.performTouch$pigeonVar_messageChannelSuffix'; final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send([x, y]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([x, y, isKeyDown, isKeyUp]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future performGlobalAction(GlobalAction action) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.performGlobalAction$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([action]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future controlMedia(MediaAction action) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.controlMedia$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([action]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future isRunning() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.isRunning$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + Future ignoreHidDevices() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future setHandledKeys(List keys) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([keys]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -197,3 +429,14 @@ Stream streamEvents( {String instanceName = ''}) { }); } +Stream hidKeyPressed( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel hidKeyPressedChannel = + EventChannel('dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed$instanceName', pigeonMethodCodec); + return hidKeyPressedChannel.receiveBroadcastStream().map((dynamic event) { + return event as AKeyEvent; + }); +} + diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d2902135..017f44842 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -23,6 +23,10 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + - require_trailing_commas # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options +formatter: + page_width: 120 + trailing_commas: preserve diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ab2534c3f..342b28d81 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.* + plugins { id("com.android.application") id("kotlin-android") @@ -5,10 +8,15 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } + +val keystorePropertiesFile = rootProject.file("keystore.properties") +val keystoreProperties = Properties() +keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + android { - namespace = "de.jonasbark.swift_play" + namespace = "de.jonasbark.swiftcontrol" compileSdk = flutter.compileSdkVersion - ndkVersion = "27.0.12077973" + ndkVersion = "28.2.13676358" compileOptions { // Flag to enable support for the new language APIs @@ -24,7 +32,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "de.jonasbark.swift_play" + applicationId = "de.jonasbark.swiftcontrol" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 24 @@ -33,11 +41,18 @@ android { versionName = flutter.versionName } + signingConfigs { + create("config") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file("../${keystoreProperties["storeFile"] as String}") + storePassword = keystoreProperties["storePassword"] as String + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + signingConfig = signingConfigs.getByName("config") } } } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f6981d..000000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0849e5c09..a12f2354b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,10 @@ - + + + + @@ -14,13 +17,17 @@ + - + + + + + + + + + + + Boolean)? = null + var motionListener: ((MotionEvent) -> Boolean)? = null + + override fun isGamepadsInputDevice(device: InputDevice): Boolean { + return device.sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD + || device.sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + // Some bluetooth keyboards are identified as GamePad. Check if it is ALPHABETIC keyboard. + // && device.keyboardType != InputDevice.KEYBOARD_TYPE_ALPHABETIC + } + + override fun dispatchGenericMotionEvent(motionEvent: MotionEvent): Boolean { + return motionListener?.invoke(motionEvent) ?: false + } + + override fun dispatchKeyEvent(keyEvent: KeyEvent): Boolean { + if (keyListener?.invoke(keyEvent) == true) { + return true + } + return super.dispatchKeyEvent(keyEvent) + } + + override fun registerInputDeviceListener( + listener: InputManager.InputDeviceListener, handler: Handler?) { + val inputManager = getSystemService(INPUT_SERVICE) as InputManager + inputManager.registerInputDeviceListener(listener, null) + } + + override fun registerKeyEventHandler(handler: (KeyEvent) -> Boolean) { + keyListener = handler + } + + override fun registerMotionEventHandler(handler: (MotionEvent) -> Boolean) { + motionListener = handler + } +} diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification.png b/android/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 000000000..d58a0dd0c Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification.png b/android/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 000000000..3983a8f08 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f3f..0944b4daa 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -4,9 +4,9 @@ - + android:src="@mipmap/ic_launcher" /> + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/android/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 000000000..f57ec2f98 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 000000000..b8ef125a1 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 000000000..4a0e035f6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f88..6a36c4f3f 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -4,9 +4,9 @@ - + android:src="@mipmap/ic_launcher" /> + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..345888d26 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 8c8df10b1..f0384c1c4 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 000000000..af72f79c7 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..60ac95757 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..a10c890c9 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 8bc86a0d4..d0221a8bb 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 000000000..9b7e478cf Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..70f013997 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..f5fd932f9 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 2202b760a..2cf94b05f 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 000000000..b8153b6a5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..d15aa8417 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..67f032f38 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 9c27415d0..27a4345a4 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 000000000..19c325bc4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..39f2d71fd Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..1d23cb711 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 1e24bd1a6..de47f51ae 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 000000000..4228e59dd Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..eba5a733f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 000000000..1d029e23b Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml new file mode 100644 index 000000000..c721bb9bb --- /dev/null +++ b/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml index 9afa975b0..3c58ab7f9 100644 --- a/android/app/src/main/res/xml/accessibility_service_config.xml +++ b/android/app/src/main/res/xml/accessibility_service_config.xml @@ -1,8 +1,10 @@ + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index afa1e8eb0..e6045a983 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index a439442c2..da20e6dac 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,8 +18,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false } include(":app") diff --git a/assets/silence.mp3 b/assets/silence.mp3 new file mode 100644 index 000000000..a0f601c79 Binary files /dev/null and b/assets/silence.mp3 differ diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/bug_report.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 50a4c7b8b..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Bug Report -about: Create a report to help us improve -title: "fix: " -labels: bug ---- - -**Description** - -A clear and concise description of what the bug is. - -**Steps To Reproduce** - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected Behavior** - -A clear and concise description of what you expected to happen. - -**Screenshots** - -If applicable, add screenshots to help explain your problem. - -**Additional Context** - -Add any other context about the problem here. diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/build.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/build.md deleted file mode 100644 index 0cf8e62cd..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/build.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Build System -about: Changes that affect the build system or external dependencies -title: "build: " -labels: build ---- - -**Description** - -Describe what changes need to be done to the build system and why. - -**Requirements** - -- [ ] The build system is passing diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/chore.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/chore.md deleted file mode 100644 index 498ebfd82..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/chore.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Chore -about: Other changes that don't modify src or test files -title: "chore: " -labels: chore ---- - -**Description** - -Clearly describe what change is needed and why. If this changes code then please use another issue type. - -**Requirements** - -- [ ] No functional changes to the code diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/ci.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/ci.md deleted file mode 100644 index fa2dd9e2d..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/ci.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Continuous Integration -about: Changes to the CI configuration files and scripts -title: "ci: " -labels: ci ---- - -**Description** - -Describe what changes need to be done to the ci/cd system and why. - -**Requirements** - -- [ ] The ci system is passing diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/config.yml b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index ec4bb386b..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1 +0,0 @@ -blank_issues_enabled: false \ No newline at end of file diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/documentation.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/documentation.md deleted file mode 100644 index f494a4d98..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/documentation.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Documentation -about: Improve the documentation so all collaborators have a common understanding -title: "docs: " -labels: documentation ---- - -**Description** - -Clearly describe what documentation you are looking to add or improve. - -**Requirements** - -- [ ] Requirements go here diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/feature_request.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index ddd2fcca9..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: Feature Request -about: A new feature to be added to the project -title: "feat: " -labels: feature ---- - -**Description** - -Clearly describe what you are looking to add. The more context the better. - -**Requirements** - -- [ ] Checklist of requirements to be fulfilled - -**Additional Context** - -Add any other context or screenshots about the feature request go here. diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/performance.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/performance.md deleted file mode 100644 index 699b8d45f..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/performance.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Performance Update -about: A code change that improves performance -title: "perf: " -labels: performance ---- - -**Description** - -Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience. - -**Requirements** - -- [ ] There is no drop in test coverage. diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/refactor.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/refactor.md deleted file mode 100644 index 1626c5704..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/refactor.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Refactor -about: A code change that neither fixes a bug nor adds a feature -title: "refactor: " -labels: refactor ---- - -**Description** - -Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize. - -**Requirements** - -- [ ] There is no drop in test coverage. diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/revert.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/revert.md deleted file mode 100644 index 9d121dc56..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/revert.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: Revert Commit -about: Reverts a previous commit -title: "revert: " -labels: revert ---- - -**Description** - -Provide a link to a PR/Commit that you are looking to revert and why. - -**Requirements** - -- [ ] Change has been reverted -- [ ] No change in test coverage has happened -- [ ] A new ticket is created for any follow on work that needs to happen diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/style.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/style.md deleted file mode 100644 index 02244a7bd..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/style.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Style Changes -about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc) -title: "style: " -labels: style ---- - -**Description** - -Clearly describe what you are looking to change and why. - -**Requirements** - -- [ ] There is no drop in test coverage. diff --git a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/test.md b/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/test.md deleted file mode 100644 index 431a7ea76..000000000 --- a/flutter_blue_plus_windows/.github/ISSUE_TEMPLATE/test.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: Test -about: Adding missing tests or correcting existing tests -title: "test: " -labels: test ---- - -**Description** - -List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past. - -**Requirements** - -- [ ] There is no drop in test coverage. diff --git a/flutter_blue_plus_windows/.github/PULL_REQUEST_TEMPLATE.md b/flutter_blue_plus_windows/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 116993637..000000000 --- a/flutter_blue_plus_windows/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,27 +0,0 @@ - - -## Status - -**READY/IN DEVELOPMENT/HOLD** - -## Description - - - -## Type of Change - - - -- [ ] ✨ New feature (non-breaking change which adds functionality) -- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue) -- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change) -- [ ] 🧹 Code refactor -- [ ] ✅ Build configuration change -- [ ] 📝 Documentation -- [ ] 🗑️ Chore diff --git a/flutter_blue_plus_windows/.github/cspell.json b/flutter_blue_plus_windows/.github/cspell.json deleted file mode 100644 index b5f42e1ca..000000000 --- a/flutter_blue_plus_windows/.github/cspell.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "0.2", - "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", - "dictionaries": ["vgv_allowed", "vgv_forbidden"], - "dictionaryDefinitions": [ - { - "name": "vgv_allowed", - "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", - "description": "Allowed VGV Spellings" - }, - { - "name": "vgv_forbidden", - "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", - "description": "Forbidden VGV Spellings" - } - ], - "useGitignore": true, - "words": [ - "flutter_blue_plus_windows" - ] -} diff --git a/flutter_blue_plus_windows/.github/dependabot.yaml b/flutter_blue_plus_windows/.github/dependabot.yaml deleted file mode 100644 index 63b035cde..000000000 --- a/flutter_blue_plus_windows/.github/dependabot.yaml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -enable-beta-ecosystems: true -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - - package-ecosystem: "pub" - directory: "/" - schedule: - interval: "daily" diff --git a/flutter_blue_plus_windows/.gitignore b/flutter_blue_plus_windows/.gitignore deleted file mode 100644 index 6936c89c3..000000000 --- a/flutter_blue_plus_windows/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -# See https://www.dartlang.org/guides/libraries/private-files - -# Files and directories created by pub -.dart_tool/ -.packages -build/ -pubspec.lock -/.idea/ -/.flutter-plugins -/.flutter-plugins-dependencies - -interanl_example \ No newline at end of file diff --git a/flutter_blue_plus_windows/CHANGELOG.md b/flutter_blue_plus_windows/CHANGELOG.md deleted file mode 100644 index b8f020afe..000000000 --- a/flutter_blue_plus_windows/CHANGELOG.md +++ /dev/null @@ -1,106 +0,0 @@ -## 1.26.1 -* Add setOptions. - -## 1.26.0 -* Set `flutter_blue_plus` upperbound <1.35.0 (due to api changes) - -## 1.25.0 -* Ensure tracing connection when reconnection occurs after force disconnection. - -## 1.24.22 -* Upgrade FBP version to `>=1.32.4 <=1.40.0` #24. - -## 1.24.21 -* fix: startScan() doesn't return correct ScanResult #25 - -## 1.24.20 -* Downgrade FBP version to `>=1.32.4 <=1.33.6` due to the breaking changes. -* After upgrade process, the dependencies will be returned to `>=1.34.4 <1.40.0` #24. - -## 1.24.19 -* Fix a bug with `onValueReceived` of emitting write packet #22. - -## 1.24.18 -* Add implementation of `BluetoothDeviceWindow.fromId()` #21. - -## 1.24.15 -* Fix a bug w.r.t. company ID in manufacturer data. (@betto-a #18) - -## 1.24.14 -* Implement cancelOnDisconnect (@jefflongo #16) - -## 1.24.12 -* Fix minor bug w.r.t. `characteristic.isNotifying`. - -## 1.24.11 -* Fix breaking changes of FBP w.r.t. `systemDevices(List withServices)`. - -## 1.24.10 -* Add support for `cancelWhenScanComplete` - -## 1.24.9 -* Implement scan filter (including `withServices`, `withRemoteIds`, `withNames`). - -## 1.24.8 -* Keep manufacturer data when scanning. - -## 1.24.7 -* Keep service uuids when scanning. - -## 1.24.0 -* Update `README.md`. - -## 1.23.6 -* Add unimplemented notification for `read` or `write`. - -## 1.14.0 -* Remove dependencies `ffi` and `win32` to avoid compile error for web - -## 1.9.5 -* Apply `flutter blue plus` to `1.28.13`. - -## 1.9.0 -* Apply a breaking changes `Guid` in `Flutter blue plus` packages. -* Use `uuid128` instead of `toString()`. - -## 1.8.10 -* Fix `Guid` bug related with `Flutter blue plus` packages. - -## 1.8.0 -* Fix bug with Guid converted from string due to starting/ending with '{ }' in `WinBLE` - -## 1.7.0 -* Apply `flutter blue plus 1.28.5` (there is several breaking changes.). - -## 1.6.6 -* Add cache for storing characteristics. - -## 1.6.0 -* Apply `Flutter blue plus 1.26.0`, (there is a breaking change with `connect()`). - -## 1.5.7 -* Remove connection by OS when performing `startScan`. - -## 1.5.3 -* Write logs when connection state stream is started/terminated. - -## 1.5.2 -* Fix a bug of features added in `1.5.1` - -## 1.5.1 -* Remove device from connected device list when device is disconnected. - -## 1.5.0 -* Split functionality of `disconnect` / `removeBond`. - -## 1.4.0 -* Implement `Subscribe/Unsubscribe Characteristic`. - -## 1.1.0 -* Implement `Read/Write Characteristic`. - -## 1.0.5 -* Change `rxdart` version to `0.27.7`. - -## 1.0.0 -* Initial release (using Github action). \ No newline at end of file diff --git a/flutter_blue_plus_windows/LICENSE b/flutter_blue_plus_windows/LICENSE deleted file mode 100644 index 64edbab0f..000000000 --- a/flutter_blue_plus_windows/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2023 Himchan Park - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/flutter_blue_plus_windows/README.md b/flutter_blue_plus_windows/README.md deleted file mode 100644 index 3627c78e9..000000000 --- a/flutter_blue_plus_windows/README.md +++ /dev/null @@ -1,54 +0,0 @@ -[![pub package](https://img.shields.io/pub/v/flutter_blue_plus_windows.svg)](https://pub.dartlang.org/packages/flutter_blue_plus_windows) - -## Flutter Blue Plus Windows - -This project is a wrapper library for `Flutter Blue Plus` and `Win_ble`. -It allows `Flutter_blue_plus` to operate on Windows. - -With minimal effort, you can use Flutter Blue Plus on Windows. - -## Usage -Only you need to do is change the import statement. - -```dart -// instead of import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart'; - -// Alternatively, you can hide FlutterBluePlus when importing the FBP statement -import 'package:flutter_blue_plus/flutter_blue_plus.dart' hide FlutterBluePlus; -import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart'; - -``` - -### Scan devices -```dart -final scannedDevices = {}; - -const timeout = Duration(seconds: 3); -FlutterBluePlus.startScan(timeout: timeout); - -final sub = FlutterBluePlus.scanResults.expand((e)=>e).listen(scannedDevices.add); - -await Future.delayed(timeout); -sub.cancel(); -scannedDevices.forEach(print); -``` - -### Connect a device -```dart -final scannedDevice = scannedDevices - .where((scanResult) => scanResult.device.platformName == DEVICE_NAME) - .firstOrNull; -final device = scannedDevice?.device; -device?.connect(); -``` - -### Disconnect the device -```dart -device?.disconnect(); -``` - -Check out the usage of Flutter Blue Plus on [Flutter Blue Plus](https://pub.dev/packages/flutter_blue_plus) - - - diff --git a/flutter_blue_plus_windows/lib/flutter_blue_plus_windows.dart b/flutter_blue_plus_windows/lib/flutter_blue_plus_windows.dart deleted file mode 100644 index 97fd9254d..000000000 --- a/flutter_blue_plus_windows/lib/flutter_blue_plus_windows.dart +++ /dev/null @@ -1,7 +0,0 @@ -library flutter_blue_plus_windows; - -export 'package:flutter_blue_plus/flutter_blue_plus.dart' hide FlutterBluePlus; -export 'package:win_ble/win_ble.dart'; -export 'package:win_ble/win_file.dart'; - -export 'src/flutter_blue_plus_windows.dart'; diff --git a/flutter_blue_plus_windows/lib/src/extension/bluetooth_adapter_state_extension.dart b/flutter_blue_plus_windows/lib/src/extension/bluetooth_adapter_state_extension.dart deleted file mode 100644 index b15adb60a..000000000 --- a/flutter_blue_plus_windows/lib/src/extension/bluetooth_adapter_state_extension.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:win_ble/win_ble.dart'; - -extension BluetoothAdapterStateExtension on BleState { - BluetoothAdapterState toAdapterState() { - switch(this){ - case BleState.On: - return BluetoothAdapterState.on; - case BleState.Off: - return BluetoothAdapterState.off; - case BleState.Unknown: - return BluetoothAdapterState.unknown; - case BleState.Disabled: - return BluetoothAdapterState.unavailable; - case BleState.Unsupported: - return BluetoothAdapterState.unauthorized; - } - } -} \ No newline at end of file diff --git a/flutter_blue_plus_windows/lib/src/extension/bluetooth_characteristic_extension.dart b/flutter_blue_plus_windows/lib/src/extension/bluetooth_characteristic_extension.dart deleted file mode 100644 index 3f111ae4f..000000000 --- a/flutter_blue_plus_windows/lib/src/extension/bluetooth_characteristic_extension.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart'; -import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart'; - -extension BluetoothCharacteristicExtension on BluetoothCharacteristic { - BmBluetoothCharacteristic toProto() { - return BmBluetoothCharacteristic( - remoteId: DeviceIdentifier(remoteId.str), - serviceUuid: serviceUuid, - characteristicUuid: characteristicUuid, - descriptors: [for (final d in descriptors) d.toProto()], - properties: properties.toProto(), - primaryServiceUuid: null, // TODO: API changes - ); - } -} diff --git a/flutter_blue_plus_windows/lib/src/extension/bluetooth_descriptor_extension.dart b/flutter_blue_plus_windows/lib/src/extension/bluetooth_descriptor_extension.dart deleted file mode 100644 index 6bc3e34ed..000000000 --- a/flutter_blue_plus_windows/lib/src/extension/bluetooth_descriptor_extension.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart'; - -extension BluetoothDescriptorExtension on BluetoothDescriptor { - BmBluetoothDescriptor toProto() { - return BmBluetoothDescriptor( - remoteId: DeviceIdentifier(remoteId.str), - serviceUuid: serviceUuid, - characteristicUuid: characteristicUuid, - descriptorUuid: descriptorUuid, - primaryServiceUuid: null, // TODO: API changes - ); - } -} diff --git a/flutter_blue_plus_windows/lib/src/extension/bluetooth_service_extension.dart b/flutter_blue_plus_windows/lib/src/extension/bluetooth_service_extension.dart deleted file mode 100644 index c2b0af5aa..000000000 --- a/flutter_blue_plus_windows/lib/src/extension/bluetooth_service_extension.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart'; -import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart'; - -extension BluetoothServiceExtension on BluetoothService { - BmBluetoothService toProto() { - return BmBluetoothService( - serviceUuid: serviceUuid, - remoteId: DeviceIdentifier(remoteId.str), - characteristics: [for (final c in characteristics) c.toProto()], - primaryServiceUuid: null, // TODO: API changes - ); - } -} diff --git a/flutter_blue_plus_windows/lib/src/extension/characteristic_properties_extension.dart b/flutter_blue_plus_windows/lib/src/extension/characteristic_properties_extension.dart deleted file mode 100644 index b61baaab1..000000000 --- a/flutter_blue_plus_windows/lib/src/extension/characteristic_properties_extension.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart'; - -extension CharacteristicPropertiesExtension on CharacteristicProperties { - BmCharacteristicProperties toProto() { - return BmCharacteristicProperties( - broadcast: broadcast, - read: read, - writeWithoutResponse: writeWithoutResponse, - write: write, - notify: notify, - indicate: indicate, - authenticatedSignedWrites: authenticatedSignedWrites, - extendedProperties: extendedProperties, - notifyEncryptionRequired: notifyEncryptionRequired, - indicateEncryptionRequired: indicateEncryptionRequired, - ); - } -} diff --git a/flutter_blue_plus_windows/lib/src/extension/extension.dart b/flutter_blue_plus_windows/lib/src/extension/extension.dart deleted file mode 100644 index 78c24f314..000000000 --- a/flutter_blue_plus_windows/lib/src/extension/extension.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'bluetooth_adapter_state_extension.dart'; -export 'bluetooth_characteristic_extension.dart'; -export 'bluetooth_descriptor_extension.dart'; -export 'bluetooth_service_extension.dart'; -export 'characteristic_properties_extension.dart'; diff --git a/flutter_blue_plus_windows/lib/src/flutter_blue_plus_windows.dart b/flutter_blue_plus_windows/lib/src/flutter_blue_plus_windows.dart deleted file mode 100644 index cca4fddb3..000000000 --- a/flutter_blue_plus_windows/lib/src/flutter_blue_plus_windows.dart +++ /dev/null @@ -1,3 +0,0 @@ -export 'extension/extension.dart'; -export 'windows/windows.dart'; -export 'wrapper/wrapper.dart'; diff --git a/flutter_blue_plus_windows/lib/src/windows/bluetooth_characteristic_windows.dart b/flutter_blue_plus_windows/lib/src/windows/bluetooth_characteristic_windows.dart deleted file mode 100644 index e5d47efc8..000000000 --- a/flutter_blue_plus_windows/lib/src/windows/bluetooth_characteristic_windows.dart +++ /dev/null @@ -1,160 +0,0 @@ -part of 'windows.dart'; - -class BluetoothCharacteristicWindows extends BluetoothCharacteristic { - final DeviceIdentifier remoteId; - final Guid serviceUuid; - final Guid? secondaryServiceUuid; - final Guid characteristicUuid; - final List descriptors; - - final Properties propertiesWinBle; - - BluetoothCharacteristicWindows({ - required this.remoteId, - required this.serviceUuid, - required this.characteristicUuid, - required this.descriptors, - required this.propertiesWinBle, - this.secondaryServiceUuid, - }) : super.fromProto( - BmBluetoothCharacteristic( - remoteId: DeviceIdentifier(remoteId.str), - serviceUuid: serviceUuid, - characteristicUuid: characteristicUuid, - descriptors: [ - for (final descriptor in descriptors) - BmBluetoothDescriptor( - remoteId: DeviceIdentifier(descriptor.remoteId.str), - serviceUuid: descriptor.serviceUuid, - characteristicUuid: descriptor.characteristicUuid, - descriptorUuid: descriptor.uuid, - primaryServiceUuid: null, // TODO: API changes - ), - ], - properties: BmCharacteristicProperties( - broadcast: propertiesWinBle.broadcast ?? false, - read: propertiesWinBle.read ?? false, - writeWithoutResponse: propertiesWinBle.writeWithoutResponse ?? false, - write: propertiesWinBle.write ?? false, - notify: propertiesWinBle.notify ?? false, - indicate: propertiesWinBle.indicate ?? false, - authenticatedSignedWrites: propertiesWinBle.authenticatedSignedWrites ?? false, - // TODO: implementation missing - extendedProperties: false, - // TODO: implementation missing - notifyEncryptionRequired: false, - // TODO: implementation missing - indicateEncryptionRequired: false, - ), - primaryServiceUuid: null, // TODO: API changes - ), - ); - - String get _address => remoteId.str.toLowerCase(); - - String get _key => "$serviceUuid:$characteristicUuid"; - - FBP.BluetoothDevice get device => - FlutterBluePlusWindows.connectedDevices.firstWhere((device) => device.remoteId == remoteId); - - /// this variable is updated: - /// - anytime `read()` is called - /// - anytime `write()` is called - /// - anytime a notification arrives (if subscribed) - List get lastValue => FlutterBluePlusWindows._lastChrs[remoteId]?[_key] ?? []; - - /// this stream emits values: - /// - anytime `read()` is called - /// - anytime `write()` is called - /// - anytime a notification arrives (if subscribed) - /// - and when first listened to, it re-emits the last value for convenience - Stream> get lastValueStream => _mergeStreams( - [ - WinBle.characteristicValueStreamOf( - address: _address, - serviceId: serviceUuid.str128, - characteristicId: characteristicUuid.str128, - ), - FlutterBluePlusWindows._charReadWriteStream.where((e) => e.$1 == _key).map((e) => e.$2) - ], - ).map((p) => [...p]).newStreamWithInitialValue(lastValue).asBroadcastStream(); - - /// this stream emits values: - /// - anytime `read()` is called - /// - anytime a notification arrives (if subscribed) - Stream> get onValueReceived => _mergeStreams( - [ - WinBle.characteristicValueStreamOf( - address: _address, - serviceId: serviceUuid.str128, - characteristicId: characteristicUuid.str128, - ), - FlutterBluePlusWindows._charReadStream.where((e) => e.$1 == _key).map((e) => e.$2) - ], - ).map((p) => [...p]).asBroadcastStream(); - - // TODO: need to verify - bool get isNotifying => FlutterBluePlusWindows._isNotifying[remoteId]?[_key] ?? false; - - Future> read({int timeout = 15}) async { - final value = await WinBle.read( - address: _address, - serviceId: serviceUuid.str128, - characteristicId: characteristicUuid.str128, - ); - FlutterBluePlusWindows._charReadWriteStreamController.add((_key, value)); - FlutterBluePlusWindows._charReadStreamController.add((_key, value)); - FlutterBluePlusWindows._lastChrs[remoteId] ??= {}; - FlutterBluePlusWindows._lastChrs[remoteId]?[_key] = value; - return value; - } - - Future write(List value, - {bool allowLongWrite = false, bool withoutResponse = false, int timeout = 15}) async { - await WinBle.write( - address: _address, - service: serviceUuid.str128, - characteristic: characteristicUuid.str128, - data: Uint8List.fromList(value), - writeWithResponse: !withoutResponse, // propertiesWinBle.writeWithoutResponse ?? false, - ); - FlutterBluePlusWindows._charReadWriteStreamController.add((_key, value)); - FlutterBluePlusWindows._lastChrs[remoteId] ??= {}; - FlutterBluePlusWindows._lastChrs[remoteId]?[_key] = value; - } - - // TODO: need to verify - Future setNotifyValue( - bool notify, { - int timeout = 15, // TODO: missing implementation - bool forceIndications = false, // TODO: missing implementation - }) async { - /// unSubscribeFromCharacteristic - try { - await WinBle.unSubscribeFromCharacteristic( - address: _address, - serviceId: serviceUuid.str128, - characteristicId: characteristicUuid.str128, - ); - } catch (e) { - log('WinBle.unSubscribeFromCharacteristic was performed ' - 'before setNotifyValue()'); - } - - /// set notify - try { - if (notify) { - await WinBle.subscribeToCharacteristic( - address: _address, - serviceId: serviceUuid.str128, - characteristicId: characteristicUuid.str128, - ); - } - FlutterBluePlusWindows._isNotifying[remoteId] ??= {}; - FlutterBluePlusWindows._isNotifying[remoteId]?[_key] = notify; - } catch (e) { - log(e.toString()); - } - return true; - } -} diff --git a/flutter_blue_plus_windows/lib/src/windows/bluetooth_device_windows.dart b/flutter_blue_plus_windows/lib/src/windows/bluetooth_device_windows.dart deleted file mode 100644 index 16929d00b..000000000 --- a/flutter_blue_plus_windows/lib/src/windows/bluetooth_device_windows.dart +++ /dev/null @@ -1,310 +0,0 @@ -// Bluetooth Device Page: -// https://github.com/boskokg/flutter_blue_plus/blob/master/lib/src/bluetooth_device.dart - -part of 'windows.dart'; - -class BluetoothDeviceWindows extends FBP.BluetoothDevice { - BluetoothDeviceWindows({required super.remoteId}); - - // used for 'servicesStream' public api - final _services = StreamController>.broadcast(); - - // used for 'isDiscoveringServices' public api - final _isDiscoveringServices = _StreamController(initialValue: false); - - String get _address => remoteId.str.toLowerCase(); - - /// Create a device from an id - /// - to connect, this device must have been discovered by your app in a previous scan - /// - iOS uses 128-bit uuids the remoteId, e.g. e006b3a7-ef7b-4980-a668-1f8005f84383 - /// - Android uses 48-bit mac addresses as the remoteId, e.g. 06:E5:28:3B:FD:E0 - static FBP.BluetoothDevice fromId(String remoteId) { - if (Platform.isWindows) { - return BluetoothDeviceWindows(remoteId: DeviceIdentifier(remoteId.toUpperCase())); - } - return FBP.BluetoothDevice.fromId(remoteId); - } - - /// platform name - /// - this name is kept track of by the platform - /// - this name usually persist between app restarts - /// - iOS: after you connect, iOS uses the GAP name characteristic (0x2A00) - /// if it exists. Otherwise iOS use the advertised name. - /// - Android: always uses the advertised name - String get platformName => FlutterBluePlusWindows._platformNames[remoteId] ?? ""; - - /// Advertised Named - /// - this is the name advertised by the device during scanning - /// - it is only available after you scan with FlutterBluePlus - /// - it is cleared when the app restarts. - /// - not all devices advertise a name - String get advName => FlutterBluePlusWindows._advNames[remoteId] ?? ""; - - // stream return whether or not we are currently discovering services - @Deprecated("planed for removal (Jan 2024). It can be easily implemented yourself") // deprecated on Aug 2023 - Stream get isDiscoveringServices => _isDiscoveringServices.stream; - - /// Get services - /// - returns empty if discoverServices() has not been called - /// or if your device does not have any services (rare) - List get servicesList => FlutterBluePlusWindows._knownServices[remoteId] ?? []; - - /// Stream of bluetooth services offered by the remote device - /// - this stream is only updated when you call discoverServices() - @Deprecated("planed for removal (Jan 2024). It can be easily implemented yourself") // deprecated on Aug 2023 - Stream> get servicesStream { - if (FlutterBluePlusWindows._knownServices[remoteId] != null) { - return _services.stream.newStreamWithInitialValue( - FlutterBluePlusWindows._knownServices[remoteId]!, - ); - } else { - return _services.stream; - } - } - - /// Register a subscription to be canceled when the device is disconnected. - /// This function simplifies cleanup, so you can prevent creating duplicate stream subscriptions. - /// - this is an optional convenience function - /// - prevents accidentally creating duplicate subscriptions on each reconnection. - /// - [next] if true, the the stream will be canceled only on the *next* disconnection. - /// This is useful if you setup your subscriptions before you connect. - /// - [delayed] Note: This option is only meant for `connectionState` subscriptions. - /// When `true`, we cancel after a small delay. This ensures the `connectionState` - /// listener receives the `disconnected` event. - void cancelWhenDisconnected(StreamSubscription subscription, {bool next = false, bool delayed = false}) { - if (isConnected == false && next == false) { - subscription.cancel(); // cancel immediately if already disconnected. - } else if (delayed) { - FlutterBluePlusWindows._delayedSubscriptions[remoteId] ??= []; - FlutterBluePlusWindows._delayedSubscriptions[remoteId]!.add(subscription); - } else { - FlutterBluePlusWindows._deviceSubscriptions[remoteId] ??= []; - FlutterBluePlusWindows._deviceSubscriptions[remoteId]!.add(subscription); - } - } - - /// Returns true if this device is currently connected to your app - bool get isConnected { - return FlutterBluePlusWindows.connectedDevices.contains(this); - } - - /// Returns true if this device is currently disconnected from your app - bool get isDisconnected => isConnected == false; - - Future connect({ - Duration? timeout = const Duration(seconds: 35), // TODO: implementation missing - bool autoConnect = false, // TODO: implementation missing - int? mtu = 512, // TODO: implementation missing - }) async { - try { - await WinBle.connect(_address); - FlutterBluePlusWindows._deviceSet.add(this); - } catch (e) { - log(e.toString()); - } - } - - Future disconnect({ - int androidDelay = 2000, // TODO: implementation missing - int timeout = 35, // TODO: implementation missing - bool queue = true, // TODO: implementation missing - }) async { - try { - await WinBle.disconnect(_address); - } catch (e) { - log(e.toString()); - } finally { - FlutterBluePlusWindows._deviceSet.remove(this); - - FlutterBluePlusWindows._deviceSubscriptions[remoteId]?.forEach((s) => s.cancel()); - FlutterBluePlusWindows._deviceSubscriptions.remove(remoteId); - // use delayed to update the stream before we cancel it - Future.delayed(Duration.zero).then((_) { - FlutterBluePlusWindows._delayedSubscriptions[remoteId]?.forEach((s) => s.cancel()); - FlutterBluePlusWindows._delayedSubscriptions.remove(remoteId); - }); - - FlutterBluePlusWindows._lastChrs[remoteId]?.clear(); - FlutterBluePlusWindows._isNotifying[remoteId]?.clear(); - } - } - - Future> discoverServices({ - bool subscribeToServicesChanged = true, // TODO: implementation missing - int timeout = 15, // TODO: implementation missing - }) async { - List result = List.from(FlutterBluePlusWindows._knownServices[remoteId] ?? []); - - try { - _isDiscoveringServices.add(true); - - final response = await WinBle.discoverServices(_address); - FlutterBluePlusWindows._characteristicCache[remoteId] ??= >{}; - - for (final serviceId in response) { - final characteristic = await WinBle.discoverCharacteristics( - address: _address, - serviceId: serviceId, - ); - FlutterBluePlusWindows._characteristicCache[remoteId] ??= {}; - FlutterBluePlusWindows._characteristicCache[remoteId]?[serviceId] ??= [ - ...characteristic.map( - (e) => BluetoothCharacteristicWindows( - remoteId: remoteId, - serviceUuid: Guid(serviceId), - characteristicUuid: Guid(e.uuid), - descriptors: [], - // TODO: implementation missing - propertiesWinBle: e.properties, - ), - ), - ]; - } - - result = [ - ...response.map( - (p) => BluetoothServiceWindows( - remoteId: remoteId, - serviceUuid: Guid(p), - // TODO: implementation missing - isPrimary: true, - // TODO: implementation missing - characteristics: FlutterBluePlusWindows._characteristicCache[remoteId]![p]!, - // TODO: implementation missing - includedServices: [], - ), - ) - ]; - - FlutterBluePlusWindows._knownServices[remoteId] = result; - - _services.add(result); - } finally { - _isDiscoveringServices.add(false); - } - return result; - } - - DisconnectReason? get disconnectReason { - // TODO: nothing to do - return null; - } - - Stream get connectionState async* { - await FlutterBluePlusWindows._initialize(); - - final map = FlutterBluePlusWindows._connectionStream.latestValue; - - if (map[_address] != null) { - yield map[_address]!.isConnected; - } - - yield* WinBle.connectionStreamOf(_address).map((e) => e.isConnected); - } - - Stream get mtu async* { - bool isEmitted = false; - int retryCount = 0; - while (!isEmitted) { - if (retryCount > 3) throw "Device not found!"; - retryCount++; - try { - yield await WinBle.getMaxMtuSize(_address); - isEmitted = true; - } catch (e) { - await Future.delayed(const Duration(milliseconds: 500)); - log(e.toString()); - } - } - } - - Future readRssi({int timeout = 15}) async { - return FlutterBluePlusWindows._rssiMap[remoteId] ?? -100; - } - - Future requestMtu( - int desiredMtu, { - double predelay = 0.35, - int timeout = 15, - }) async { - // https://github.com/rohitsangwan01/win_ble/issues/8 - return await WinBle.getMaxMtuSize(_address); - } - - Future requestConnectionPriority({ - required ConnectionPriority connectionPriorityRequest, - }) async { - // TODO: nothing to do - return; - } - - /// Set the preferred connection (Android Only) - /// - [txPhy] bitwise OR of all allowed phys for Tx, e.g. (Phy.le2m.mask | Phy.leCoded.mask) - /// - [txPhy] bitwise OR of all allowed phys for Rx, e.g. (Phy.le2m.mask | Phy.leCoded.mask) - /// - [option] preferred coding to use when transmitting on Phy.leCoded - /// Please note that this is just a recommendation given to the system. - Future setPreferredPhy({ - required int txPhy, - required int rxPhy, - required PhyCoding option, - }) async { - // TODO: implementation missing - } - - Future createBond({ - Uint8List? pin, - int timeout = 90, // TODO: implementation missing - }) async { - try { - await WinBle.pair(_address); - } catch (e) { - log(e.toString()); - } - } - - Future removeBond({ - int timeout = 30, // TODO: implementation missing - }) async { - try { - await WinBle.unPair(_address); - } catch (e) { - log(e.toString()); - } - } - - Future clearGattCache() async { - // TODO: implementation missing - } - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is BluetoothDeviceWindows && runtimeType == other.runtimeType && remoteId == other.remoteId); - - @override - int get hashCode => remoteId.hashCode; - - @override - String toString() { - return 'BluetoothDevice{' - 'remoteId: $remoteId, ' - 'platformName: $platformName, ' - 'services: ${FlutterBluePlusWindows._knownServices[remoteId]}' - '}'; - } - - @Deprecated('Use createBond() instead') - Future pair() async => await createBond(); - - @Deprecated('Use remoteId instead') - DeviceIdentifier get id => remoteId; - - @Deprecated('Use localName instead') - String get name => localName; - - @Deprecated('Use connectionState instead') - Stream get state => connectionState; - - @Deprecated('Use servicesStream instead') - Stream> get services => servicesStream; -} diff --git a/flutter_blue_plus_windows/lib/src/windows/bluetooth_service_windows.dart b/flutter_blue_plus_windows/lib/src/windows/bluetooth_service_windows.dart deleted file mode 100644 index 6a8257488..000000000 --- a/flutter_blue_plus_windows/lib/src/windows/bluetooth_service_windows.dart +++ /dev/null @@ -1,24 +0,0 @@ -part of 'windows.dart'; - -class BluetoothServiceWindows extends BluetoothService { - final DeviceIdentifier remoteId; - final Guid serviceUuid; - final bool isPrimary; - final List characteristics; - final List includedServices; - - BluetoothServiceWindows({ - required this.remoteId, - required this.serviceUuid, - required this.isPrimary, - required this.characteristics, - required this.includedServices, - }) : super.fromProto( - BmBluetoothService( - remoteId: DeviceIdentifier(remoteId.str), - serviceUuid: serviceUuid, - characteristics: [for (final c in characteristics) c.toProto()], - primaryServiceUuid: null, - ), - ); -} diff --git a/flutter_blue_plus_windows/lib/src/windows/flutter_blue_plus_windows.dart b/flutter_blue_plus_windows/lib/src/windows/flutter_blue_plus_windows.dart deleted file mode 100644 index 901f96559..000000000 --- a/flutter_blue_plus_windows/lib/src/windows/flutter_blue_plus_windows.dart +++ /dev/null @@ -1,353 +0,0 @@ -part of 'windows.dart'; - -class FlutterBluePlusWindows { - static bool _initialized = false; - - static BluetoothAdapterState _state = BluetoothAdapterState.unknown; - - // stream used for the isScanning public api - static final _isScanning = _StreamController(initialValue: false); - - // we always keep track of these device variables - static final _platformNames = {}; - static final _advNames = {}; - static final _rssiMap = {}; - static final _knownServices = >{}; - static final Map>> _lastChrs = {}; - static final Map> _isNotifying = {}; - static final Map>> _characteristicCache = {}; - static final Map> _deviceSubscriptions = {}; - static final Map> _delayedSubscriptions = {}; - static final List _scanSubscriptions = []; - - // stream used for the scanResults public api - static final _scanResultsList = _StreamController(initialValue: []); - - // the subscription to the scan results stream - static StreamSubscription? _scanSubscription; - - // timeout for scanning that can be cancelled by stopScan - static Timer? _scanTimeout; - - static List get _devices => [..._deviceSet]; - - static final _deviceSet = {}; - static final _removedDeviceTracer = {}; - - // static final _unhandledDeviceSet = {}; - - /// Flutter blue plus windows - static final _charReadWriteStreamController = StreamController<(String, List)>(); - static final _charReadStreamController = StreamController<(String, List)>(); - - static final _charReadWriteStream = _charReadWriteStreamController.stream.asBroadcastStream(); - static final _charReadStream = _charReadStreamController.stream.asBroadcastStream(); - - /// Flutter blue plus windows - static final _connectionStream = _StreamController(initialValue: {}); - - static Future _initialize() async { - if (_initialized) return; - await WinBle.initialize( - serverPath: await WinServer.path(), - enableLog: false, - ); - - WinBle.connectionStream.listen( - (event) { - log('$event - event'); - if (event['device'] == null) return; - if (event['connected'] == null) return; - - final map = _connectionStream.latestValue; - map[event['device']] = event['connected']; - - log('$map - map'); - _connectionStream.add(map); - - if (!event['connected']) { - final removingDevices = [ - ..._deviceSet.where( - (device) => device._address == event['device'], - ), - ]; - for (final device in removingDevices) { - _deviceSet.remove(device); - if (!_removedDeviceTracer.keys.contains(device)) { - _removedDeviceTracer[device] = Stream.periodic(const Duration(seconds: 10), (_) => device).listen( - (event) { - if(event.isConnected) { - _removedDeviceTracer[device]?.cancel(); - _removedDeviceTracer.remove(device); - return; - } - event.connect(); - }, - ); - } - - _deviceSubscriptions[device.remoteId]?.forEach((s) => s.cancel()); - _deviceSubscriptions.remove(device.remoteId); - // use delayed to update the stream before we cancel it - Future.delayed(Duration.zero).then((_) { - _delayedSubscriptions[device.remoteId]?.forEach((s) => s.cancel()); - _delayedSubscriptions.remove(device.remoteId); - }); - - _lastChrs[device.remoteId]?.clear(); - _isNotifying[device.remoteId]?.clear(); - } - } - }, - ); - _initialized = true; - } - - static Future get isSupported async { - return true; - } - - static Future get adapterName async { - return 'Windows'; - } - - static Stream get isScanning => _isScanning.stream; - - static bool get isScanningNow => _isScanning.latestValue; - - static Future turnOn({int timeout = 10}) async { - await _initialize(); - await WinBle.updateBluetoothState(true); - } - - // TODO: compare with original lib - static Stream> get scanResults => _scanResultsList.stream; - - static Stream get adapterState async* { - await _initialize(); - yield _state; - yield* WinBle.bleState.asBroadcastStream().map( - (s) { - _state = s.toAdapterState(); - return _state; - }, - ); - } - - /// Start a scan, and return a stream of results - /// Note: scan filters use an "or" behavior. i.e. if you set `withServices` & `withNames` we - /// return all the advertisments that match any of the specified services *or* any of the specified names. - /// - [withServices] filter by advertised services - /// - [withRemoteIds] filter for known remoteIds (iOS: 128-bit guid, android: 48-bit mac address) - /// - [withNames] filter by advertised names (exact match) - /// - [withKeywords] filter by advertised names (matches any substring) - /// - [withMsd] filter by manfacture specific data - /// - [withServiceData] filter by service data - /// - [timeout] calls stopScan after a specified duration - /// - [removeIfGone] if true, remove devices after they've stopped advertising for X duration - /// - [continuousUpdates] If `true`, we continually update 'lastSeen' & 'rssi' by processing - /// duplicate advertisements. This takes more power. You typically should not use this option. - /// - [continuousDivisor] Useful to help performance. If divisor is 3, then two-thirds of advertisements are - /// ignored, and one-third are processed. This reduces main-thread usage caused by the platform channel. - /// The scan counting is per-device so you always get the 1st advertisement from each device. - /// If divisor is 1, all advertisements are returned. This argument only matters for `continuousUpdates` mode. - /// - [oneByOne] if `true`, we will stream every advertistment one by one, possibly including duplicates. - /// If `false`, we deduplicate the advertisements, and return a list of devices. - /// - [androidLegacy] Android only. If `true`, scan on 1M phy only. - /// If `false`, scan on all supported phys. How the radio cycles through all the supported phys is purely - /// dependent on the your Bluetooth stack implementation. - /// - [androidScanMode] choose the android scan mode to use when scanning - /// - [androidUsesFineLocation] request `ACCESS_FINE_LOCATION` permission at runtime - static Future startScan({ - List withServices = const [], - List withRemoteIds = const [], - List withNames = const [], - //TODO: implementation missing - List withKeywords = const [], - //TODO: implementation missing - List withMsd = const [], - List withServiceData = const [], - Duration? timeout, - Duration? removeIfGone, - bool continuousUpdates = false, - int continuousDivisor = 1, - bool oneByOne = false, - bool androidLegacy = false, - AndroidScanMode androidScanMode = AndroidScanMode.lowLatency, - bool androidUsesFineLocation = false, - }) async { - await _initialize(); - - // stop existing scan - if (_isScanning.latestValue == true) { - await stopScan(); - } - - // push to stream - _isScanning.add(true); - - // Start timer *after* stream is being listened to, to make sure the - // timeout does not fire before _buffer is set - if (timeout != null) { - _scanTimeout = Timer(timeout, stopScan); - } - - /// remove connection by OS. - /// The reason why we add this logic is - /// to avoid uncontrollable devices and to make consistency. - - /// add WinBle scanning - WinBle.startScanning(); - - // check every 250ms for gone devices? - late Stream outputStream; - if (removeIfGone != null) { - outputStream = _mergeStreams([WinBle.scanStream, Stream.periodic(Duration(milliseconds: 250))]); - } else { - outputStream = WinBle.scanStream; - } - - final output = []; - - // listen & push to `scanResults` stream - _scanSubscription = outputStream.listen( - (BleDevice? winBleDevice) { - // print(winBleDevice?.serviceUuids); - if (winBleDevice == null) { - // if null, this is just a periodic update for removing old results - output.removeWhere((elm) => DateTime.now().difference(elm.timeStamp) > removeIfGone!); - - // push to stream - _scanResultsList.add(List.from(output)); - } else { - final remoteId = DeviceIdentifier(winBleDevice.address.toUpperCase()); - final scanResult = output.where((sr) => sr.device.remoteId == remoteId).firstOrNull; - final deviceName = winBleDevice.name.isNotEmpty ? winBleDevice.name : scanResult?.device.platformName ?? ''; - final serviceUuids = winBleDevice.serviceUuids.isNotEmpty - ? [...winBleDevice.serviceUuids.map((e) => Guid((e as String).replaceAll(RegExp(r'[{}]'), '')))] - : scanResult?.advertisementData.serviceUuids ?? []; - - final manufacturerData = winBleDevice.manufacturerData.isNotEmpty - ? { - if (winBleDevice.manufacturerData.length >= 2) - winBleDevice.manufacturerData[0] + (winBleDevice.manufacturerData[1] << 8): - winBleDevice.manufacturerData.sublist(2), - } - : scanResult?.advertisementData.manufacturerData ?? {}; - - final rssi = int.tryParse(winBleDevice.rssi) ?? -100; - - FlutterBluePlusWindows._platformNames[remoteId] = deviceName; - FlutterBluePlusWindows._advNames[remoteId] = deviceName; - FlutterBluePlusWindows._rssiMap[remoteId] = rssi; - - final device = BluetoothDeviceWindows(remoteId: remoteId); - - String hex(int value) => value.toRadixString(16).padLeft(2, '0'); - String hexToId(Iterable values) => values.map((e) => hex(e)).join(); - - final sr = ScanResult( - device: device, - advertisementData: AdvertisementData( - advName: deviceName, - txPowerLevel: winBleDevice.adStructures?.where((e) => e.type == 10).singleOrNull?.data.firstOrNull, - //TODO: Should verify - connectable: !winBleDevice.advType.contains('Non'), - manufacturerData: manufacturerData, - serviceData: { - for (final advStructures in winBleDevice.adStructures ?? []) - if (advStructures.type == 0x16 && advStructures.data.length >= 2) - Guid(hexToId(advStructures.data.sublist(0, 2).reversed)): advStructures.data.sublist(2), - for (final advStructures in winBleDevice.adStructures ?? []) - if (advStructures.type == 0x20 && advStructures.data.length >= 4) - Guid(hexToId(advStructures.data.sublist(0, 4).reversed)): advStructures.data.sublist(4), - for (final advStructures in winBleDevice.adStructures ?? []) - if (advStructures.type == 0x21 && advStructures.data.length >= 16) - Guid(hexToId(advStructures.data.sublist(0, 16).reversed)): advStructures.data.sublist(16), - }, - serviceUuids: serviceUuids, - appearance: null, - ), - rssi: rssi, - timeStamp: DateTime.now(), - ); - - // filter with services - final isFilteredWithServices = - withServices.isNotEmpty && serviceUuids.where((service) => withServices.contains(service)).isEmpty; - - // filter with remote ids - final isFilteredWithRemoteIds = withRemoteIds.isNotEmpty && !withRemoteIds.contains(remoteId); - - // filter with names - final isFilteredWithNames = withNames.isNotEmpty && !withNames.contains(deviceName); - - if (isFilteredWithServices || isFilteredWithRemoteIds || isFilteredWithNames) { - _scanResultsList.add(List.from(output)); - return; - } - - // add result to output - if (oneByOne) { - output - ..clear() - ..add(sr); - } else { - output.addOrUpdate(sr); - } - - // push to stream - _scanResultsList.add(List.from(output)); - } - }, - ); - } - - static List get connectedDevices { - return _devices; - } - - static Future> get bondedDevices async { - return _devices; - } - - /// Stops a scan for Bluetooth Low Energy devices - static Future stopScan() async { - await _initialize(); - WinBle.stopScanning(); - _scanSubscription?.cancel(); - _scanTimeout?.cancel(); - _isScanning.add(false); - - for (var subscription in _scanSubscriptions) { - subscription.cancel(); - } - - _scanResultsList.latestValue = []; - } - - /// Register a subscription to be canceled when scanning is complete. - /// This function simplifies cleanup, so you can prevent creating duplicate stream subscriptions. - /// - this is an optional convenience function - /// - prevents accidentally creating duplicate subscriptions before each scan - static void cancelWhenScanComplete(StreamSubscription subscription) { - _scanSubscriptions.add(subscription); - } - - /// Sets the internal FlutterBlue log level - static Future setLogLevel(LogLevel level, {color = true}) async { - // Nothing to implement - return; - } - - static Future turnOff({int timeout = 10}) async { - await _initialize(); - await WinBle.updateBluetoothState(false); - } - - // TODO: need to test - static Future get isOn async { - await _initialize(); - return await WinBle.bleState.asBroadcastStream().first == BleState.On; - } -} diff --git a/flutter_blue_plus_windows/lib/src/windows/util.dart b/flutter_blue_plus_windows/lib/src/windows/util.dart deleted file mode 100644 index b6114171a..000000000 --- a/flutter_blue_plus_windows/lib/src/windows/util.dart +++ /dev/null @@ -1,433 +0,0 @@ -part of 'windows.dart'; - -String _hexEncode(List numbers) { - return numbers - .map((n) => (n & 0xFF).toRadixString(16).padLeft(2, '0')) - .join(); -} - -List _hexDecode(String hex) { - List numbers = []; - for (int i = 0; i < hex.length; i += 2) { - String hexPart = hex.substring(i, i + 2); - int num = int.parse(hexPart, radix: 16); - numbers.add(num); - } - return numbers; -} - -int _compareAsciiLowerCase(String a, String b) { - const int upperCaseA = 0x41; - const int upperCaseZ = 0x5a; - const int asciiCaseBit = 0x20; - var defaultResult = 0; - for (var i = 0; i < a.length; i++) { - if (i >= b.length) return 1; - var aChar = a.codeUnitAt(i); - var bChar = b.codeUnitAt(i); - if (aChar == bChar) continue; - var aLowerCase = aChar; - var bLowerCase = bChar; - // Upper case if ASCII letters. - if (upperCaseA <= bChar && bChar <= upperCaseZ) { - bLowerCase += asciiCaseBit; - } - if (upperCaseA <= aChar && aChar <= upperCaseZ) { - aLowerCase += asciiCaseBit; - } - if (aLowerCase != bLowerCase) return (aLowerCase - bLowerCase).sign; - if (defaultResult == 0) defaultResult = aChar - bChar; - } - if (b.length > a.length) return -1; - return defaultResult.sign; -} - -// This is a reimplementation of BehaviorSubject from RxDart library. -// It is essentially a stream but: -// 1. we cache the latestValue of the stream -// 2. the "latestValue" is re-emitted whenever the stream is listened to -class _StreamController { - T latestValue; - - final StreamController _controller = StreamController.broadcast(); - - _StreamController({required T initialValue}) - : this.latestValue = initialValue; - - Stream get stream => _controller.stream; - - T get value => latestValue; - - void add(T newValue) { - latestValue = newValue; - _controller.add(newValue); - } - - void listen(Function(T) onData, - {Function? onError, void Function()? onDone, bool? cancelOnError}) { - onData(latestValue); - _controller.stream.listen(onData, - onError: onError, onDone: onDone, cancelOnError: cancelOnError); - } - - Future close() { - return _controller.close(); - } -} - -// imediately starts listening to a broadcast stream and -// buffering it in a new single-subscription stream -class _BufferStream { - final Stream _inputStream; - late final StreamSubscription? _subscription; - late final StreamController _controller; - late bool hasReceivedValue = false; - - _BufferStream.listen(this._inputStream) { - _controller = StreamController( - onCancel: () { - _subscription?.cancel(); - }, - onPause: () { - _subscription?.pause(); - }, - onResume: () { - _subscription?.resume(); - }, - onListen: () {}, // inputStream is already listened to - ); - - // immediately start listening to the inputStream - _subscription = _inputStream.listen( - (data) { - hasReceivedValue = true; - _controller.add(data); - }, - onError: (e) { - _controller.addError(e); - }, - onDone: () { - _controller.close(); - }, - cancelOnError: false, - ); - } - - void close() { - _subscription?.cancel(); - _controller.close(); - } - - Stream get stream async* { - yield* _controller.stream; - } -} - -// helper for 'doOnDone' method for streams. -class _OnDoneTransformer extends StreamTransformerBase { - final Function onDone; - - _OnDoneTransformer({required this.onDone}); - - @override - Stream bind(Stream stream) { - if (stream.isBroadcast) { - return _bindBroadcast(stream); - } - return _bindSingleSubscription(stream); - } - - Stream _bindSingleSubscription(Stream stream) { - StreamController? controller; - StreamSubscription? subscription; - - controller = StreamController( - onListen: () { - subscription = stream.listen( - controller?.add, - onError: controller?.addError, - onDone: () { - onDone(); - controller?.close(); - }, - ); - }, - onPause: ([Future? resumeSignal]) { - subscription?.pause(resumeSignal); - }, - onResume: () { - subscription?.resume(); - }, - onCancel: () { - return subscription?.cancel(); - }, - sync: true, - ); - - return controller.stream; - } - - Stream _bindBroadcast(Stream stream) { - StreamController? controller; - StreamSubscription? subscription; - - controller = StreamController.broadcast( - onListen: () { - subscription = stream - .listen(controller?.add, onError: controller?.addError, onDone: () { - onDone(); - controller?.close(); - }); - }, - onCancel: () { - subscription?.cancel(); - }, - sync: true, - ); - - return controller.stream; - } -} - -// helper for 'doOnCancel' method for streams. -class _OnCancelTransformer extends StreamTransformerBase { - final Function onCancel; - - _OnCancelTransformer({required this.onCancel}); - - @override - Stream bind(Stream stream) { - if (stream.isBroadcast) { - return _bindBroadcast(stream); - } - - return _bindSingleSubscription(stream); - } - - Stream _bindSingleSubscription(Stream stream) { - StreamController? controller; - StreamSubscription? subscription; - - controller = StreamController( - onListen: () { - subscription = stream.listen( - controller?.add, - onError: (Object error) { - controller?.addError(error); - controller?.close(); - }, - onDone: controller?.close, - ); - }, - onPause: ([Future? resumeSignal]) { - subscription?.pause(resumeSignal); - }, - onResume: () { - subscription?.resume(); - }, - onCancel: () { - onCancel(); - return subscription?.cancel(); - }, - sync: true, - ); - - return controller.stream; - } - - Stream _bindBroadcast(Stream stream) { - StreamController? controller; - StreamSubscription? subscription; - - controller = StreamController.broadcast( - onListen: () { - subscription = stream.listen( - controller?.add, - onError: (Object error) { - controller?.addError(error); - controller?.close(); - }, - onDone: controller?.close, - ); - }, - onCancel: () { - onCancel(); - subscription?.cancel(); - }, - sync: true, - ); - - return controller.stream; - } -} - -// Helper for 'newStreamWithInitialValue' method for streams. -class _NewStreamWithInitialValueTransformer - extends StreamTransformerBase { - final T initialValue; - - _NewStreamWithInitialValueTransformer(this.initialValue); - - @override - Stream bind(Stream stream) { - return _bindSingleSubscription(stream); - } - - Stream _bindSingleSubscription(Stream stream) { - StreamController? controller; - StreamSubscription? subscription; - - controller = StreamController( - onListen: () { - // Emit the initial value - controller?.add(initialValue); - - subscription = stream.listen( - controller?.add, - onError: (Object error) { - controller?.addError(error); - controller?.close(); - }, - onDone: controller?.close, - ); - }, - onPause: ([Future? resumeSignal]) { - subscription?.pause(resumeSignal); - }, - onResume: () { - subscription?.resume(); - }, - onCancel: () { - return subscription?.cancel(); - }, - sync: true, - ); - - return controller.stream; - } -} - -extension _StreamDoOnDone on Stream { - // ignore: unused_element - Stream doOnDone(void Function() onDone) { - return transform(_OnDoneTransformer(onDone: onDone)); - } -} - -extension _StreamDoOnCancel on Stream { - // ignore: unused_element - Stream doOnCancel(void Function() onCancel) { - return transform(_OnCancelTransformer(onCancel: onCancel)); - } -} - -extension _StreamNewStreamWithInitialValue on Stream { - Stream newStreamWithInitialValue(T initialValue) { - return transform(_NewStreamWithInitialValueTransformer(initialValue)); - } -} - -// ignore: unused_element -Stream _mergeStreams(List> streams) { - StreamController controller = StreamController(); - List> subscriptions = []; - - void handleData(T data) { - if (!controller.isClosed) { - controller.add(data); - } - } - - void handleError(Object error, StackTrace stackTrace) { - if (!controller.isClosed) { - controller.addError(error, stackTrace); - } - } - - void handleDone() { - if (subscriptions.every((s) => s.isPaused)) { - controller.close(); - } - } - - void subscribeToStream(Stream stream) { - final s = - stream.listen(handleData, onError: handleError, onDone: handleDone); - subscriptions.add(s); - } - - streams.forEach(subscribeToStream); - - controller.onCancel = () async { - await Future.wait(subscriptions.map((s) => s.cancel())); - }; - - return controller.stream; -} - -// dart is single threaded, but still has task switching. -// this mutex lets a single task through at a time. -class _Mutex { - final StreamController _controller = StreamController.broadcast(); - int current = 0; - int issued = 0; - - Future take() async { - int mine = issued; - issued++; - // tasks are executed in the same order they call take() - while (mine != current) { - await _controller.stream.first; // wait - } - } - - void give() { - current++; - _controller.add(null); // release waiting tasks - } -} - -// Create mutexes in a parrallel-safe way, -class _MutexFactory { - static final _Mutex _global = _Mutex(); - static final Map _all = {}; - - static Future<_Mutex> getMutexForKey(String key) async { - _Mutex? value; - await _global.take(); - { - _all[key] ??= _Mutex(); - value = _all[key]; - } - _global.give(); - return value!; - } -} - -String _black(String s) { - // Use ANSI escape codes - return '\x1B[1;30m$s\x1B[0m'; -} - -// ignore: unused_element -String _green(String s) { - // Use ANSI escape codes - return '\x1B[1;32m$s\x1B[0m'; -} - -String _magenta(String s) { - // Use ANSI escape codes - return '\x1B[1;35m$s\x1B[0m'; -} - -String _brown(String s) { - // Use ANSI escape codes - return '\x1B[1;33m$s\x1B[0m'; -} - -extension Boolean2ConnectionState on bool { - BluetoothConnectionState get isConnected { - if (this) return BluetoothConnectionState.connected; - return BluetoothConnectionState.disconnected; - } -} diff --git a/flutter_blue_plus_windows/lib/src/windows/windows.dart b/flutter_blue_plus_windows/lib/src/windows/windows.dart deleted file mode 100644 index abeaea2bc..000000000 --- a/flutter_blue_plus_windows/lib/src/windows/windows.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter_blue_plus/flutter_blue_plus.dart' as FBP; -import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart'; -import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart'; - -part 'bluetooth_characteristic_windows.dart'; -part 'bluetooth_device_windows.dart'; -part 'bluetooth_service_windows.dart'; -part 'flutter_blue_plus_windows.dart'; -part 'util.dart'; diff --git a/flutter_blue_plus_windows/lib/src/wrapper/flutter_blue_plus_wrapper.dart b/flutter_blue_plus_windows/lib/src/wrapper/flutter_blue_plus_wrapper.dart deleted file mode 100644 index d5685d594..000000000 --- a/flutter_blue_plus_windows/lib/src/wrapper/flutter_blue_plus_wrapper.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter_blue_plus/flutter_blue_plus.dart' as FBP; -import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart'; - -class FlutterBluePlus { - static Future startScan({ - List withServices = const [], - List withRemoteIds = const [], - List withNames = const [], - List withKeywords = const [], - List withMsd = const [], - List withServiceData = const [], - Duration? timeout, - Duration? removeIfGone, - bool continuousUpdates = false, - int continuousDivisor = 1, - bool oneByOne = false, - bool androidLegacy = false, - AndroidScanMode androidScanMode = AndroidScanMode.lowLatency, - bool androidUsesFineLocation = false, - List webOptionalServices = const [], - }) async { - if (Platform.isWindows) { - return await FlutterBluePlusWindows.startScan( - withServices: withServices, - withRemoteIds: withRemoteIds, - withNames: withNames, - withKeywords: withKeywords, - withMsd: withMsd, - withServiceData: withServiceData, - timeout: timeout, - removeIfGone: removeIfGone, - continuousUpdates: continuousUpdates, - continuousDivisor: continuousDivisor, - oneByOne: oneByOne, - androidLegacy: androidLegacy, - androidScanMode: androidScanMode, - androidUsesFineLocation: androidUsesFineLocation, - ); - } - - return await FBP.FlutterBluePlus.startScan( - withServices: withServices, - withRemoteIds: withRemoteIds, - withNames: withNames, - withKeywords: withKeywords, - withMsd: withMsd, - withServiceData: withServiceData, - timeout: timeout, - removeIfGone: removeIfGone, - continuousUpdates: continuousUpdates, - continuousDivisor: continuousDivisor, - oneByOne: oneByOne, - androidLegacy: androidLegacy, - androidScanMode: androidScanMode, - androidUsesFineLocation: androidUsesFineLocation, - webOptionalServices: webOptionalServices, - ); - } - - static Stream get adapterState { - if (Platform.isWindows) return FlutterBluePlusWindows.adapterState; - return FBP.FlutterBluePlus.adapterState; - } - - static Stream> get scanResults { - if (Platform.isWindows) return FlutterBluePlusWindows.scanResults; - return FBP.FlutterBluePlus.scanResults; - } - - static bool get isScanningNow { - if (Platform.isWindows) return FlutterBluePlusWindows.isScanningNow; - return FBP.FlutterBluePlus.isScanningNow; - } - - static Stream get isScanning { - if (Platform.isWindows) return FlutterBluePlusWindows.isScanning; - return FBP.FlutterBluePlus.isScanning; - } - - static Future stopScan() async { - if (Platform.isWindows) return await FlutterBluePlusWindows.stopScan(); - return await FBP.FlutterBluePlus.stopScan(); - } - - static Future setLogLevel(LogLevel level, {color = true}) async { - if (Platform.isWindows) return FlutterBluePlusWindows.setLogLevel(level, color: color); - return FBP.FlutterBluePlus.setLogLevel(level, color: color); - } - - /// TODO: need to verify - static LogLevel get logLevel => FBP.FlutterBluePlus.logLevel; - - static Future setOptions({bool restoreState = false, bool showPowerAlert = true}) async { - if (Platform.isWindows) return; - FBP.FlutterBluePlus.setOptions(restoreState: restoreState, showPowerAlert: showPowerAlert); - } - - static Future get isSupported async { - if (Platform.isWindows) return await FlutterBluePlusWindows.isSupported; - return await FBP.FlutterBluePlus.isSupported; - } - - static Future get adapterName async { - if (Platform.isWindows) return await FlutterBluePlusWindows.adapterName; - return await FBP.FlutterBluePlus.adapterName; - } - - static Future turnOn({int timeout = 60}) async { - if (Platform.isWindows) return await FlutterBluePlusWindows.turnOn(timeout: timeout); - return await FBP.FlutterBluePlus.turnOn(timeout: timeout); - } - - static List get connectedDevices { - if (Platform.isWindows) return FlutterBluePlusWindows.connectedDevices; - return FBP.FlutterBluePlus.connectedDevices; - } - - static Future> systemDevices(List withServices) async { - //TODO: connected devices => system devices - if (Platform.isWindows) return FlutterBluePlusWindows.connectedDevices; - return await FBP.FlutterBluePlus.systemDevices(withServices); - } - - static Future getPhySupport() { - return FBP.FlutterBluePlus.getPhySupport(); - } - - static Future> get bondedDevices async { - if (Platform.isWindows) return FlutterBluePlusWindows.connectedDevices; - return await FBP.FlutterBluePlus.bondedDevices; - } - - static void cancelWhenScanComplete(StreamSubscription subscription) { - if (Platform.isWindows) return FlutterBluePlusWindows.cancelWhenScanComplete(subscription); - return FBP.FlutterBluePlus.cancelWhenScanComplete(subscription); - } -} diff --git a/flutter_blue_plus_windows/lib/src/wrapper/wrapper.dart b/flutter_blue_plus_windows/lib/src/wrapper/wrapper.dart deleted file mode 100644 index 5336df61d..000000000 --- a/flutter_blue_plus_windows/lib/src/wrapper/wrapper.dart +++ /dev/null @@ -1 +0,0 @@ -export 'flutter_blue_plus_wrapper.dart'; diff --git a/flutter_blue_plus_windows/pubspec.yaml b/flutter_blue_plus_windows/pubspec.yaml deleted file mode 100644 index 25fbd037c..000000000 --- a/flutter_blue_plus_windows/pubspec.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: flutter_blue_plus_windows -description: Flutter blue plus for Windows -version: 1.26.1 -repository: https://github.com/chan150/flutter_blue_plus_windows -#publish_to: none - -environment: - sdk: ">=3.0.0 <4.0.0" - -dependencies: - flutter_blue_plus: ">=1.32.4" - win_ble: ">=1.1.1" - stream_with_value: ">=0.5.0" diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml deleted file mode 100644 index 2c1088d8f..000000000 --- a/flutter_launcher_icons.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# flutter pub run flutter_launcher_icons -flutter_launcher_icons: - image_path: "icon.png" - - android: "ic_launcher" - # image_path_android: "assets/icon/icon.png" - min_sdk_android: 24 # android min sdk min:16, default 21 - # adaptive_icon_background: "assets/icon/background.png" - # adaptive_icon_foreground: "assets/icon/foreground.png" - # adaptive_icon_monochrome: "assets/icon/monochrome.png" - - ios: false - # image_path_ios: "assets/icon/icon.png" - remove_alpha_channel_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 - - web: - generate: true - image_path: "icon.png" - background_color: "#hexcode" - theme_color: "#hexcode" - - windows: - generate: true - image_path: "icon.png" - icon_size: 48 # min:48, max:256, default: 48 - - macos: - generate: true - image_path: "icon.png" diff --git a/icon.png b/icon.png index 5736b5869..27a4345a4 100644 Binary files a/icon.png and b/icon.png differ diff --git a/ios/ExportOptions.plist b/ios/ExportOptions.plist new file mode 100644 index 000000000..bce190a58 --- /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/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 7c5696400..391a902b2 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/ios/Podfile b/ios/Podfile index e549ee22f..40359aef1 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,6 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' + +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 000000000..cf230c19e --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,311 @@ +PODS: + - app_links (6.4.1): + - Flutter + - AppAuth (2.0.0): + - AppAuth/Core (= 2.0.0) + - AppAuth/ExternalUserAgent (= 2.0.0) + - AppAuth/Core (2.0.0) + - AppAuth/ExternalUserAgent (2.0.0): + - AppAuth/Core + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) + - audio_session (0.0.1): + - Flutter + - bluetooth_low_energy_darwin (0.0.1): + - Flutter + - FlutterMacOS + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - flutter_volume_controller (0.0.1): + - Flutter + - gamepads_ios (0.1.1): + - Flutter + - google_sign_in_ios (0.0.1): + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 9.0) + - GTMSessionFetcher (>= 3.4.0) + - GoogleSignIn (9.1.0): + - AppAuth (~> 2.0) + - AppCheckCore (~> 11.0) + - GTMAppAuth (~> 5.0) + - GTMSessionFetcher/Core (~> 3.3) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMAppAuth (5.0.0): + - AppAuth/Core (~> 2.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher (3.5.0): + - GTMSessionFetcher/Full (= 3.5.0) + - GTMSessionFetcher/Core (3.5.0) + - GTMSessionFetcher/Full (3.5.0): + - GTMSessionFetcher/Core + - image_picker_ios (0.0.1): + - Flutter + - in_app_purchase_storekit (0.0.1): + - Flutter + - FlutterMacOS + - in_app_review (2.0.0): + - Flutter + - integration_test (0.0.1): + - Flutter + - ios_receipt (0.0.1): + - Flutter + - just_audio (0.0.1): + - Flutter + - FlutterMacOS + - media_key_detector_ios (0.0.1): + - Flutter + - nsd_ios (0.0.1): + - Flutter + - package_info_plus (0.4.5): + - Flutter + - permission_handler_apple (9.3.0): + - Flutter + - PromisesObjC (2.4.0) + - purchases_flutter (9.10.6): + - Flutter + - PurchasesHybridCommon (= 17.27.1) + - purchases_ui_flutter (9.10.6): + - Flutter + - PurchasesHybridCommonUI (= 17.27.1) + - PurchasesHybridCommon (17.27.1): + - RevenueCat (= 5.54.1) + - PurchasesHybridCommonUI (17.27.1): + - PurchasesHybridCommon (= 17.27.1) + - RevenueCatUI (= 5.54.1) + - restart_app (0.0.1): + - Flutter + - RevenueCat (5.54.1) + - RevenueCatUI (5.54.1): + - RevenueCat (= 5.54.1) + - SDWebImage (5.21.6): + - SDWebImage/Core (= 5.21.6) + - SDWebImage/Core (5.21.6) + - sensors_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sign_in_with_apple (0.0.1): + - Flutter + - SwiftyGif (5.4.5) + - universal_ble (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_ios (0.0.1): + - Flutter + - wakelock_plus (0.0.1): + - Flutter + +DEPENDENCIES: + - app_links (from `.symlinks/plugins/app_links/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) + - bluetooth_low_energy_darwin (from `.symlinks/plugins/bluetooth_low_energy_darwin/darwin`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - flutter_volume_controller (from `.symlinks/plugins/flutter_volume_controller/ios`) + - gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`) + - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) + - in_app_review (from `.symlinks/plugins/in_app_review/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - ios_receipt (from `.symlinks/plugins/ios_receipt/ios`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - media_key_detector_ios (from `.symlinks/plugins/media_key_detector_ios/ios`) + - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`) + - purchases_ui_flutter (from `.symlinks/plugins/purchases_ui_flutter/ios`) + - restart_app (from `.symlinks/plugins/restart_app/ios`) + - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) + - universal_ble (from `.symlinks/plugins/universal_ble/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + +SPEC REPOS: + trunk: + - AppAuth + - AppCheckCore + - DKImagePickerController + - DKPhotoGallery + - GoogleSignIn + - GoogleUtilities + - GTMAppAuth + - GTMSessionFetcher + - PromisesObjC + - PurchasesHybridCommon + - PurchasesHybridCommonUI + - RevenueCat + - RevenueCatUI + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" + bluetooth_low_energy_darwin: + :path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + flutter_volume_controller: + :path: ".symlinks/plugins/flutter_volume_controller/ios" + gamepads_ios: + :path: ".symlinks/plugins/gamepads_ios/ios" + google_sign_in_ios: + :path: ".symlinks/plugins/google_sign_in_ios/darwin" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + in_app_purchase_storekit: + :path: ".symlinks/plugins/in_app_purchase_storekit/darwin" + in_app_review: + :path: ".symlinks/plugins/in_app_review/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + ios_receipt: + :path: ".symlinks/plugins/ios_receipt/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" + media_key_detector_ios: + :path: ".symlinks/plugins/media_key_detector_ios/ios" + nsd_ios: + :path: ".symlinks/plugins/nsd_ios/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + purchases_flutter: + :path: ".symlinks/plugins/purchases_flutter/ios" + purchases_ui_flutter: + :path: ".symlinks/plugins/purchases_ui_flutter/ios" + restart_app: + :path: ".symlinks/plugins/restart_app/ios" + sensors_plus: + :path: ".symlinks/plugins/sensors_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sign_in_with_apple: + :path: ".symlinks/plugins/sign_in_with_apple/ios" + universal_ble: + :path: ".symlinks/plugins/universal_ble/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + +SPEC CHECKSUMS: + app_links: 585674be3c6661708e6cd794ab4f39fb9d8356f9 + AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f + audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe + bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + flutter_volume_controller: e4d5832f08008180f76e30faf671ffd5a425e529 + gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b + google_sign_in_ios: 7336a3372ea93ea56a21e126a0055ffca3723601 + GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b + in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a + in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + ios_receipt: c2d5b4c36953c377a024992393976214ce6951e6 + just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79 + media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269 + nsd_ios: 8c37babdc6538e3350dbed3a52674d2edde98173 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + purchases_flutter: b3c0792197f69cd7af4c2449b71df6ac6378aace + purchases_ui_flutter: caae6d62ea23c6fe964992a28353211cc74b244a + PurchasesHybridCommon: 027f03312519c51056457eb2e4f7ee1c91b61b8f + PurchasesHybridCommonUI: 48afb5e29204958bff1276b0f7acb8e4b59fe99a + restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a + RevenueCat: ecbba580fa453b0d4a0475449b904196d74ef678 + RevenueCatUI: ac7492873928e9e7f297e5e27a7c4f23f9008326 + SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 + sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440 + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa + wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e + +PODFILE CHECKSUM: 7ebd5c9b932b3af79d5c67e3af873118b74e970f + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c53ad03c7..092390524 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,10 +10,12 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3E50CA021EFA25CF89FE46AB /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C0E42A04700D6B661C7EE82 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 9DEFD285994D09CFCE400F36 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE7ADD07A99710C0FB974A8 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -40,14 +42,20 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0CF32F9ECDBEA4B014717FF8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2C0E42A04700D6B661C7EE82 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5CE7ADD07A99710C0FB974A8 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7D133E5D5548E2EF2879734F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 86D436F6DAF367742EF27F51 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 8AA6D129479129F106E2298A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,19 +63,44 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DFFDC4B9C4D6EF6A3BDE2E73 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + F0D040E82EEF2560009B19C0 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 5046C8DCA17DB268ED17F005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3E50CA021EFA25CF89FE46AB /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9DEFD285994D09CFCE400F36 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 31E2F9ED567016937E8AEA3B /* Pods */ = { + isa = PBXGroup; + children = ( + 86D436F6DAF367742EF27F51 /* Pods-Runner.debug.xcconfig */, + 0CF32F9ECDBEA4B014717FF8 /* Pods-Runner.release.xcconfig */, + 7D133E5D5548E2EF2879734F /* Pods-Runner.profile.xcconfig */, + DFFDC4B9C4D6EF6A3BDE2E73 /* Pods-RunnerTests.debug.xcconfig */, + 8AA6D129479129F106E2298A /* Pods-RunnerTests.release.xcconfig */, + EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -76,6 +109,15 @@ path = RunnerTests; sourceTree = ""; }; + 6A38311855DC1CB8C0E2FD04 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5CE7ADD07A99710C0FB974A8 /* Pods_Runner.framework */, + 2C0E42A04700D6B661C7EE82 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +136,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 31E2F9ED567016937E8AEA3B /* Pods */, + 6A38311855DC1CB8C0E2FD04 /* Frameworks */, ); sourceTree = ""; }; @@ -109,6 +153,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + F0D040E82EEF2560009B19C0 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -128,8 +173,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 5E1D2B1ED00966C758CA2289 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 5046C8DCA17DB268ED17F005 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +192,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + AF2FDC69578083D4D16AB4D6 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + EEF1FBDEE98BA93C4FBDB3AE /* [CP] Embed Pods Frameworks */, + 1F0C44A79AE73641A1C3FF47 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -222,6 +272,27 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1F0C44A79AE73641A1C3FF47 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -238,6 +309,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 5E1D2B1ED00966C758CA2289 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +346,49 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + AF2FDC69578083D4D16AB4D6 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EEF1FBDEE98BA93C4FBDB3AE /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -346,7 +482,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -361,16 +497,24 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 7BL8RUV2K6; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = UZRHKPVWN9; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; 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 = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "ios app store"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -379,6 +523,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DFFDC4B9C4D6EF6A3BDE2E73 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -396,6 +541,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8AA6D129479129F106E2298A /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -411,6 +557,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -428,7 +575,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++"; @@ -473,7 +620,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -485,7 +632,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++"; @@ -524,7 +671,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -541,16 +688,24 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 7BL8RUV2K6; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = UZRHKPVWN9; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; 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 = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "BikeControl Dev"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -564,16 +719,24 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 7BL8RUV2K6; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = UZRHKPVWN9; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; 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 = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "ios app store"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15cada483..e3773d42e 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 626664468..e99c7e244 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,6 +8,8 @@ import UIKit didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png new file mode 100644 index 000000000..4737aae71 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png new file mode 100644 index 000000000..4737aae71 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png new file mode 100644 index 000000000..89ed8aade Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png new file mode 100644 index 000000000..25d2c553b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-20~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png new file mode 100644 index 000000000..a87c340d0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png new file mode 100644 index 000000000..6d1a7f487 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png new file mode 100644 index 000000000..6d1a7f487 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png new file mode 100644 index 000000000..33e596628 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png new file mode 100644 index 000000000..a87c340d0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-29~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png new file mode 100644 index 000000000..315020da5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png new file mode 100644 index 000000000..315020da5 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png new file mode 100644 index 000000000..c9af31bbf Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png new file mode 100644 index 000000000..4737aae71 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-40~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png new file mode 100644 index 000000000..c9af31bbf Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@2x~car.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png new file mode 100644 index 000000000..d062cdb5d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-60@3x~car.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png new file mode 100644 index 000000000..b4ba144d3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png new file mode 100644 index 000000000..c9af31bbf Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png new file mode 100644 index 000000000..75e42ea51 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@2x~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png new file mode 100644 index 000000000..d062cdb5d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png new file mode 100644 index 000000000..450a704b4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png new file mode 100644 index 000000000..91eb91663 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/AppIcon~ipad.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fab2..bd04914ae 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1,134 @@ { - "images" : [ + "images": [ { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" + "filename": "AppIcon@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "60x60" }, { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" + "filename": "AppIcon@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "60x60" }, { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" + "filename": "AppIcon~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "76x76" }, { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" + "filename": "AppIcon@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "76x76" }, { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" + "filename": "AppIcon-83.5@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "83.5x83.5" }, { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" + "filename": "AppIcon-40@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "40x40" }, { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" + "filename": "AppIcon-40@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "40x40" }, { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" + "filename": "AppIcon-40~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "40x40" }, { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" + "filename": "AppIcon-40@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "40x40" }, { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" + "filename": "AppIcon-20@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "20x20" }, { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" + "filename": "AppIcon-20@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "20x20" }, { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" + "filename": "AppIcon-20~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "20x20" }, { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" + "filename": "AppIcon-20@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "20x20" }, { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" + "filename": "AppIcon-29.png", + "idiom": "iphone", + "scale": "1x", + "size": "29x29" }, { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" + "filename": "AppIcon-29@2x.png", + "idiom": "iphone", + "scale": "2x", + "size": "29x29" }, { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" + "filename": "AppIcon-29@3x.png", + "idiom": "iphone", + "scale": "3x", + "size": "29x29" }, { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" + "filename": "AppIcon-29~ipad.png", + "idiom": "ipad", + "scale": "1x", + "size": "29x29" }, { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" + "filename": "AppIcon-29@2x~ipad.png", + "idiom": "ipad", + "scale": "2x", + "size": "29x29" }, { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" + "filename": "AppIcon-60@2x~car.png", + "idiom": "car", + "scale": "2x", + "size": "60x60" + }, + { + "filename": "AppIcon-60@3x~car.png", + "idiom": "car", + "scale": "3x", + "size": "60x60" + }, + { + "filename": "AppIcon~ios-marketing.png", + "idiom": "ios-marketing", + "scale": "1x", + "size": "1024x1024" } ], - "info" : { - "version" : 1, - "author" : "xcode" + "info": { + "author": "iconkitchen", + "version": 1 } -} +} \ 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 deleted file mode 100644 index dc9ada472..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null 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 deleted file mode 100644 index 7353c41ec..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null 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 deleted file mode 100644 index 797d452e4..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null 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 deleted file mode 100644 index 6ed2d933e..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null 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 deleted file mode 100644 index 4cd7b0099..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null 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 deleted file mode 100644 index fe730945a..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null 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 deleted file mode 100644 index 321773cd8..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null 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 deleted file mode 100644 index 797d452e4..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null 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 deleted file mode 100644 index 502f463a9..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null 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 deleted file mode 100644 index 0ec303439..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null 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 deleted file mode 100644 index 0ec303439..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null 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 deleted file mode 100644 index e9f5fea27..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null 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 deleted file mode 100644 index 84ac32ae7..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null 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 deleted file mode 100644 index 8953cba09..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null 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 deleted file mode 100644 index 0467bf12a..000000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min 1.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min 1.png new file mode 100644 index 000000000..997ed2276 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min 1.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min 2.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min 2.png new file mode 100644 index 000000000..997ed2276 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min 2.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min.png new file mode 100644 index 000000000..997ed2276 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/AppIcon-min.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json index 0bedcf2fd..bdf6e1752 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -1,23 +1,23 @@ { "images" : [ { + "filename" : "AppIcon-min 2.png", "idiom" : "universal", - "filename" : "LaunchImage.png", "scale" : "1x" }, { + "filename" : "AppIcon-min 1.png", "idiom" : "universal", - "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { + "filename" : "AppIcon-min.png", "idiom" : "universal", - "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } } diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eaca..000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eaca..000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eaca..000000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b7..000000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index f2e259c7c..9f6e7d694 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,9 +16,11 @@ + + @@ -28,10 +32,10 @@ - + - + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c28516f..ff3ee2178 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 83e3c0f1a..d7e1de218 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,10 +2,12 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - SwiftControl + BikeControl CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -20,10 +22,46 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2 + + + + GIDClientID + 709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2.apps.googleusercontent.com CFBundleVersion $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS + NSBluetoothAlwaysUsageDescription + BikeControl uses Bluetooth to connect to accessories. + NSBonjourServices + + _wahoo-fitness-tnp._tcp + _openbikecontrol._tcp + + NSLocalNetworkUsageDescription + This app connects to your trainer app on your local network. + NSMotionUsageDescription + Access your accelerometer and gyroscope for steering support via your phone. + NSPhotoLibraryUsageDescription + Access your photo library to store screenshots. + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + bluetooth-peripheral + bluetooth-central + audio + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +79,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 000000000..d70538647 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.applesignin + + Default + + keychain-access-groups + + + diff --git a/ios_receipt/.gitattributes b/ios_receipt/.gitattributes new file mode 100644 index 000000000..dfe077042 --- /dev/null +++ b/ios_receipt/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/ios_receipt/.gitignore b/ios_receipt/.gitignore new file mode 100644 index 000000000..96486fd93 --- /dev/null +++ b/ios_receipt/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/ios_receipt/.metadata b/ios_receipt/.metadata new file mode 100644 index 000000000..26d5ffe5a --- /dev/null +++ b/ios_receipt/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "367f9ea16bfae1ca451b9cc27c1366870b187ae2" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + - platform: ios + create_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + base_revision: 367f9ea16bfae1ca451b9cc27c1366870b187ae2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/ios_receipt/CHANGELOG.md b/ios_receipt/CHANGELOG.md new file mode 100644 index 000000000..6270a32fc --- /dev/null +++ b/ios_receipt/CHANGELOG.md @@ -0,0 +1,7 @@ +## 1.1.0 + +* Migrate to StoreKit2 + +## 1.0.0 + +* Implement main method diff --git a/ios_receipt/LICENSE b/ios_receipt/LICENSE new file mode 100644 index 000000000..058481b17 --- /dev/null +++ b/ios_receipt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Dmytro O. Kut'ko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/ios_receipt/README.md b/ios_receipt/README.md new file mode 100644 index 000000000..1bee4e039 --- /dev/null +++ b/ios_receipt/README.md @@ -0,0 +1,32 @@ +# IosReceipt + +The IosReceipt package allows you to easily fetch the App Store receipt in your Flutter application on the iOS platform. + +## Installation + +Add the following dependency to your `pubspec.yaml` file: + +```yaml +dependencies: + ios_receipt: ^0.0.1 # Use the latest version of the package +``` +Then, run flutter pub get to install the package. + +## Usage +Method for get transactions with StoreKit2 +```dart +final transactions = await IosReceipt.getAllTransactions(); +``` + +## Based on +This package is based on the [appStoreReceiptURL](https://developer.apple.com/documentation/foundation/nsbundle/1407276-appstorereceipturl) from the official Apple documentation. + +## Note +- The receipt isn't necessary if you use AppTransaction to validate the app download, or Transaction to validate in-app purchases. +- If the receipt is invalid or missing in your app, use SKReceiptRefreshRequest to request a new receipt. + +## Testing Environments +Keep in mind that receipts aren't initially present in iOS and iPadOS apps in the sandbox environment and in Xcode. Apps receive a receipt after the tester completes the first in-app purchase. + +## License +This project is licensed under the MIT License. \ No newline at end of file diff --git a/ios_receipt/analysis_options.yaml b/ios_receipt/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/ios_receipt/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/ios_receipt/ios/.gitignore b/ios_receipt/ios/.gitignore new file mode 100644 index 000000000..0c885071e --- /dev/null +++ b/ios_receipt/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/ios_receipt/ios/Assets/.gitkeep b/ios_receipt/ios/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ios_receipt/ios/Classes/IosReceiptPlugin.swift b/ios_receipt/ios/Classes/IosReceiptPlugin.swift new file mode 100644 index 000000000..2e0373e17 --- /dev/null +++ b/ios_receipt/ios/Classes/IosReceiptPlugin.swift @@ -0,0 +1,73 @@ +import Flutter +import UIKit +import StoreKit + +public class IosReceiptPlugin: NSObject, FlutterPlugin { + + private func getAppleReceipt() -> String? { + guard let url = Bundle.main.appStoreReceiptURL, + FileManager.default.fileExists(atPath: url.path) else { + return nil + } + let data = try? Data(contentsOf: url, options: .alwaysMapped) + return data?.base64EncodedString() + } + + private func isSandbox() -> Bool { + guard let path = Bundle.main.appStoreReceiptURL?.path else { + return false + } + return path.contains("CoreSimulator") || path.contains("sandboxReceipt") + } + + private func getAllTransactions() async -> [[String: Any]] { + var list: [[String: Any]] = [] + if #available(iOS 15.0, *) { + for await result in Transaction.all { + switch result { + case .verified(let tx): + var item: [String: Any] = [ + "productId": tx.productID, + "transactionId": String(tx.id), + "originalTransactionId": String(tx.originalID), + "purchaseDate": ISO8601DateFormatter().string(from: tx.purchaseDate) + ] + if let revocationDate = tx.revocationDate { + item["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate) + } + if let reason = tx.revocationReason { + item["revocationReason"] = reason.rawValue + } + if #available(iOS 16.0, *) { + item["jws"] = result.jwsRepresentation + } + list.append(item) + + case .unverified(_, _): + continue + } + } + } + return list + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "ios_receipt", binaryMessenger: registrar.messenger()) + let instance = IosReceiptPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + + case "getAppleReceipt": + result(getAppleReceipt()) + case "isSandbox": + result(isSandbox()) + case "getAllTransactions": + Task { result(await self.getAllTransactions()) } + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios_receipt/ios/ios_receipt.podspec b/ios_receipt/ios/ios_receipt.podspec new file mode 100644 index 000000000..4b0dedfd8 --- /dev/null +++ b/ios_receipt/ios/ios_receipt.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint ios_receipt.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'ios_receipt' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '15.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/ios_receipt/lib/ios_receipt.dart b/ios_receipt/lib/ios_receipt.dart new file mode 100644 index 000000000..7efac6d10 --- /dev/null +++ b/ios_receipt/lib/ios_receipt.dart @@ -0,0 +1,22 @@ +import 'package:ios_receipt/models/transaction.dart'; + +import 'ios_receipt_platform_interface.dart'; + +class IosReceipt { + static Future getAppleReceipt() { + return IosReceiptPlatform.instance.getAppleReceipt(); + } + + static Future isSandbox() { + return IosReceiptPlatform.instance.isSandbox(); + } + + static Future> getAllTransactions() async { + final list = await IosReceiptPlatform.instance.getAllTransactions(); + final result = []; + for (var data in list) { + result.add(Transaction.fromMap(data)); + } + return result..sort((a, b) => a.purchaseDate.compareTo(b.purchaseDate)); + } +} diff --git a/ios_receipt/lib/ios_receipt_method_channel.dart b/ios_receipt/lib/ios_receipt_method_channel.dart new file mode 100644 index 000000000..3db984dad --- /dev/null +++ b/ios_receipt/lib/ios_receipt_method_channel.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'ios_receipt_platform_interface.dart'; + +/// An implementation of [IosReceiptPlatform] that uses method channels. +class MethodChannelIosReceipt extends IosReceiptPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('ios_receipt'); + + @override + Future getAppleReceipt() async { + final version = await methodChannel.invokeMethod('getAppleReceipt'); + return version; + } + + @override + Future>> getAllTransactions() async { + final list = await methodChannel.invokeMethod('getAllTransactions'); + return (list as List).map((e) => Map.from(e)).toList(); + } + + @override + Future isSandbox() async { + final isSandbox = await methodChannel.invokeMethod('isSandbox'); + return isSandbox ?? false; + } +} diff --git a/ios_receipt/lib/ios_receipt_platform_interface.dart b/ios_receipt/lib/ios_receipt_platform_interface.dart new file mode 100644 index 000000000..72da10794 --- /dev/null +++ b/ios_receipt/lib/ios_receipt_platform_interface.dart @@ -0,0 +1,37 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'ios_receipt_method_channel.dart'; + +abstract class IosReceiptPlatform extends PlatformInterface { + /// Constructs a IosReceiptPlatform. + IosReceiptPlatform() : super(token: _token); + + static final Object _token = Object(); + + static IosReceiptPlatform _instance = MethodChannelIosReceipt(); + + /// The default instance of [IosReceiptPlatform] to use. + /// + /// Defaults to [MethodChannelIosReceipt]. + static IosReceiptPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [IosReceiptPlatform] when + /// they register themselves. + static set instance(IosReceiptPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getAppleReceipt() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } + + Future>> getAllTransactions() { + throw UnimplementedError('getAllTransactions() has not been implemented.'); + } + + Future isSandbox() async { + throw UnimplementedError('isSandbox() has not been implemented.'); + } +} diff --git a/ios_receipt/lib/models/transaction.dart b/ios_receipt/lib/models/transaction.dart new file mode 100644 index 000000000..f5c9ea3c0 --- /dev/null +++ b/ios_receipt/lib/models/transaction.dart @@ -0,0 +1,41 @@ +class Transaction { + const Transaction({ + required this.jws, + required this.productId, + required this.transactionId, + required this.purchaseDate, + required this.originalTransactionId, + }); + + final String? jws; + final String productId; + final String transactionId; + final DateTime purchaseDate; + final String originalTransactionId; + + @override + bool operator ==(covariant Transaction other) { + if (identical(this, other)) return true; + return other.jws == jws && + other.productId == productId && + other.transactionId == transactionId && + other.purchaseDate == purchaseDate && + other.originalTransactionId == originalTransactionId; + } + + @override + int get hashCode => + jws.hashCode ^ + productId.hashCode ^ + transactionId.hashCode ^ + purchaseDate.hashCode ^ + originalTransactionId.hashCode; + + factory Transaction.fromMap(Map map) => Transaction( + jws: map['jws'] as String?, + productId: map['productId'] as String, + transactionId: map['transactionId'] as String, + purchaseDate: DateTime.parse(map['purchaseDate'] as String), + originalTransactionId: map['originalTransactionId'] as String, + ); +} diff --git a/ios_receipt/macos/.gitignore b/ios_receipt/macos/.gitignore new file mode 100644 index 000000000..0c885071e --- /dev/null +++ b/ios_receipt/macos/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/ios_receipt/macos/Assets/.gitkeep b/ios_receipt/macos/Assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ios_receipt/macos/Classes/IosReceiptPlugin.swift b/ios_receipt/macos/Classes/IosReceiptPlugin.swift new file mode 100644 index 000000000..361f56ef3 --- /dev/null +++ b/ios_receipt/macos/Classes/IosReceiptPlugin.swift @@ -0,0 +1,73 @@ + +import FlutterMacOS +import StoreKit + +public class IosReceiptPlugin: NSObject, FlutterPlugin { + + private func isSandbox() -> Bool { + guard let path = Bundle.main.appStoreReceiptURL?.path else { + return false + } + return path.contains("CoreSimulator") || path.contains("sandboxReceipt") + } + + private func getAppleReceipt() -> String? { + guard let url = Bundle.main.appStoreReceiptURL, + FileManager.default.fileExists(atPath: url.path) else { + return nil + } + let data = try? Data(contentsOf: url, options: .alwaysMapped) + return data?.base64EncodedString() + } + + private func getAllTransactions() async -> [[String: Any]] { + var list: [[String: Any]] = [] + if #available(iOS 15.0, *) { + for await result in Transaction.all { + switch result { + case .verified(let tx): + var item: [String: Any] = [ + "productId": tx.productID, + "transactionId": String(tx.id), + "originalTransactionId": String(tx.originalID), + "purchaseDate": ISO8601DateFormatter().string(from: tx.purchaseDate) + ] + if let revocationDate = tx.revocationDate { + item["revocationDate"] = ISO8601DateFormatter().string(from: revocationDate) + } + if let reason = tx.revocationReason { + item["revocationReason"] = reason.rawValue + } + if #available(iOS 16.0, *) { + item["jws"] = result.jwsRepresentation + } + list.append(item) + + case .unverified(_, _): + continue + } + } + } + return list + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "ios_receipt", binaryMessenger: registrar.messenger) + let instance = IosReceiptPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + + case "getAppleReceipt": + result(getAppleReceipt()) + case "isSandbox": + result(isSandbox()) + case "getAllTransactions": + Task { result(await self.getAllTransactions()) } + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios_receipt/macos/ios_receipt.podspec b/ios_receipt/macos/ios_receipt.podspec new file mode 100644 index 000000000..e80430b01 --- /dev/null +++ b/ios_receipt/macos/ios_receipt.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint ios_receipt.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'ios_receipt' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + s.platform = :osx, '12.00' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/ios_receipt/pubspec.yaml b/ios_receipt/pubspec.yaml new file mode 100644 index 000000000..4bca3183c --- /dev/null +++ b/ios_receipt/pubspec.yaml @@ -0,0 +1,26 @@ +name: ios_receipt +description: The IosReceipt package allows you to easily fetch the App Store receipt in your Flutter application on the iOS platform. +version: 1.1.0 +homepage: https://github.com/DimaKutko/ios_receipt + +environment: + sdk: ">=3.8.0 <4.0.0" + flutter: ">=3.32.0" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.1.8 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + plugin: + platforms: + ios: + pluginClass: IosReceiptPlugin + macos: + pluginClass: IosReceiptPlugin diff --git a/keypress_simulator/.clang-format b/keypress_simulator/.clang-format new file mode 100644 index 000000000..3c73f32a3 --- /dev/null +++ b/keypress_simulator/.clang-format @@ -0,0 +1,12 @@ +# Defines the Chromium style for automatic reformatting. +# http://clang.llvm.org/docs/ClangFormatStyleOptions.html +BasedOnStyle: Chromium +# This defaults to 'Auto'. Explicitly set it for a while, so that +# 'vector >' in existing files gets formatted to +# 'vector>'. ('Auto' means that clang-format will only use +# 'int>>' if the file already contains at least one such instance.) +Standard: Cpp11 +SortIncludes: true +--- +Language: ObjC +ColumnLimit: 100 diff --git a/keypress_simulator/.github/FUNDING.yml b/keypress_simulator/.github/FUNDING.yml new file mode 100644 index 000000000..cb2bfb178 --- /dev/null +++ b/keypress_simulator/.github/FUNDING.yml @@ -0,0 +1 @@ +liberapay: lijy91 diff --git a/keypress_simulator/.github/workflows/build.yml b/keypress_simulator/.github/workflows/build.yml new file mode 100644 index 000000000..1ee5a4aa5 --- /dev/null +++ b/keypress_simulator/.github/workflows/build.yml @@ -0,0 +1,50 @@ +name: build + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.16.8" + channel: "stable" + - uses: bluefireteam/melos-action@v2 + - working-directory: ./packages/keypress_simulator/example + run: | + melos bs + flutter build macos --release + + build-web: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.16.8" + channel: "stable" + - uses: bluefireteam/melos-action@v2 + - working-directory: ./packages/keypress_simulator/example + run: | + melos bs + flutter build web --release + + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.16.8" + channel: "stable" + - uses: bluefireteam/melos-action@v2 + - working-directory: ./packages/keypress_simulator/example + run: | + melos bs + flutter build windows --release diff --git a/keypress_simulator/.github/workflows/lint.yml b/keypress_simulator/.github/workflows/lint.yml new file mode 100644 index 000000000..8d68ba7c9 --- /dev/null +++ b/keypress_simulator/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: lint + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.16.8" + channel: "stable" + - uses: bluefireteam/melos-action@v2 + - run: melos run analyze + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.16.8" + channel: "stable" + cache: true + - uses: bluefireteam/melos-action@v2 + - run: melos run format-check diff --git a/keypress_simulator/.github/workflows/test.yml b/keypress_simulator/.github/workflows/test.yml new file mode 100644 index 000000000..617f89a88 --- /dev/null +++ b/keypress_simulator/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: test + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.16.8" + channel: "stable" + cache: true + - uses: bluefireteam/melos-action@v2 + - run: melos run test --no-select diff --git a/keypress_simulator/.gitignore b/keypress_simulator/.gitignore new file mode 100644 index 000000000..fa08f0546 --- /dev/null +++ b/keypress_simulator/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.idea/ + +*.iml +pubspec_overrides.yaml +pubspec.lock diff --git a/keypress_simulator/LICENSE b/keypress_simulator/LICENSE new file mode 100644 index 000000000..eea05ab78 --- /dev/null +++ b/keypress_simulator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 LiJianying + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/keypress_simulator/README-ZH.md b/keypress_simulator/README-ZH.md new file mode 100644 index 000000000..91223a21a --- /dev/null +++ b/keypress_simulator/README-ZH.md @@ -0,0 +1,93 @@ +> **🚀 快速发布您的应用**: 试试 [Fastforge](https://fastforge.dev) - 构建、打包和分发您的 Flutter 应用最简单的方式。 + +# keypress_simulator + +[![pub version][pub-image]][pub-url] [![][discord-image]][discord-url] + +[pub-image]: https://img.shields.io/pub/v/keypress_simulator.svg +[pub-url]: https://pub.dev/packages/keypress_simulator +[discord-image]: https://img.shields.io/discord/884679008049037342.svg +[discord-url]: https://discord.gg/zPa6EZ2jqb + +这个插件允许 Flutter 桌面应用模拟按键操作。 + +--- + +[English](./README.md) | 简体中文 + +--- + + + + +- [平台支持](#%E5%B9%B3%E5%8F%B0%E6%94%AF%E6%8C%81) +- [快速开始](#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B) + - [安装](#%E5%AE%89%E8%A3%85) + - [用法](#%E7%94%A8%E6%B3%95) +- [谁在用使用它?](#%E8%B0%81%E5%9C%A8%E7%94%A8%E4%BD%BF%E7%94%A8%E5%AE%83) +- [许可证](#%E8%AE%B8%E5%8F%AF%E8%AF%81) + + + +## 平台支持 + +| Linux | macOS | Windows | +| :---: | :---: | :-----: | +| ➖ | ✔️ | ✔️ | + +## 快速开始 + +### 安装 + +将此添加到你的软件包的 pubspec.yaml 文件: + +```yaml +dependencies: + keypress_simulator: ^0.2.0 +``` + +### 用法 + +```dart +import 'package:keypress_simulator/keypress_simulator.dart'; + +// 1. Simulate pressing ⌘ + C + +// 1.1 Simulate key down +await keyPressSimulator.simulateKeyDown( + PhysicalKeyboardKey.keyC, + [ModifierKey.metaModifier], +); + +// 1.2 Simulate key up +await keyPressSimulator.simulateKeyUp( + PhysicalKeyboardKey.keyC, + [ModifierKey.metaModifier], +); + +// 2. Simulate long pressing ⌘ + space + +// 2.1. Simulate key down +await keyPressSimulator.simulateKeyDown( + PhysicalKeyboardKey.space, + [ModifierKey.metaModifier], +); + +await Future.delayed(const Duration(seconds: 5)); + +// 2.2. Simulate key up +await keyPressSimulator.simulateKeyUp( + PhysicalKeyboardKey.space, + [ModifierKey.metaModifier], +); +``` + +> 请看这个插件的示例应用,以了解完整的例子。 + +## 谁在用使用它? + +- [Biyi (比译)](https://biyidev.com/) - 一个便捷的翻译和词典应用程序。 + +## 许可证 + +[MIT](./LICENSE) diff --git a/keypress_simulator/README.md b/keypress_simulator/README.md new file mode 100644 index 000000000..95ee8f4e1 --- /dev/null +++ b/keypress_simulator/README.md @@ -0,0 +1,93 @@ +> **🚀 Ship Your App Faster**: Try [Fastforge](https://fastforge.dev) - The simplest way to build, package and distribute your Flutter apps. + +# keypress_simulator + +[![pub version][pub-image]][pub-url] [![][discord-image]][discord-url] + +[pub-image]: https://img.shields.io/pub/v/keypress_simulator.svg +[pub-url]: https://pub.dev/packages/keypress_simulator +[discord-image]: https://img.shields.io/discord/884679008049037342.svg +[discord-url]: https://discord.gg/zPa6EZ2jqb + +This plugin allows Flutter desktop apps to simulate key presses. + +--- + +English | [简体中文](./README-ZH.md) + +--- + + + + +- [Platform Support](#platform-support) +- [Quick Start](#quick-start) + - [Installation](#installation) + - [Usage](#usage) +- [Who's using it?](#whos-using-it) +- [License](#license) + + + +## Platform Support + +| Linux | macOS | Windows | +| :---: | :---: | :-----: | +| ➖ | ✔️ | ✔️ | + +## Quick Start + +### Installation + +Add this to your package's pubspec.yaml file: + +```yaml +dependencies: + keypress_simulator: ^0.2.0 +``` + +### Usage + +```dart +import 'package:keypress_simulator/keypress_simulator.dart'; + +// 1. Simulate pressing ⌘ + C + +// 1.1 Simulate key down +await keyPressSimulator.simulateKeyDown( + PhysicalKeyboardKey.keyC, + [ModifierKey.metaModifier], +); + +// 1.2 Simulate key up +await keyPressSimulator.simulateKeyUp( + PhysicalKeyboardKey.keyC, + [ModifierKey.metaModifier], +); + +// 2. Simulate long pressing ⌘ + space + +// 2.1. Simulate key down +await keyPressSimulator.simulateKeyDown( + PhysicalKeyboardKey.space, + [ModifierKey.metaModifier], +); + +await Future.delayed(const Duration(seconds: 5)); + +// 2.2. Simulate key up +await keyPressSimulator.simulateKeyUp( + PhysicalKeyboardKey.space, + [ModifierKey.metaModifier], +); +``` + +> Please see the example app of this plugin for a full example. + +## Who's using it? + +- [Biyi (比译)](https://biyidev.com/) - A convenient translation and dictionary app. + +## License + +[MIT](./LICENSE) diff --git a/keypress_simulator/melos.yaml b/keypress_simulator/melos.yaml new file mode 100644 index 000000000..fc9445c17 --- /dev/null +++ b/keypress_simulator/melos.yaml @@ -0,0 +1,35 @@ +name: keypress_simulator_workspace +repository: https://github.com/leanflutter/keypress_simulator + +packages: + - examples/** + - packages/** + +command: + bootstrap: + # Uses the pubspec_overrides.yaml instead of having Melos modifying the lock file. + usePubspecOverrides: true + +scripts: + analyze: + exec: flutter analyze --fatal-infos + description: Run `flutter analyze` for all packages. + + test: + exec: flutter test + description: Run `flutter test` for a specific package. + packageFilters: + dirExists: + - test + + format: + exec: dart format . --fix + description: Run `dart format` for all packages. + + format-check: + exec: dart format . --fix --set-exit-if-changed + description: Run `dart format` checks for all packages. + + fix: + exec: dart fix . --apply + description: Run `dart fix` for all packages. diff --git a/keypress_simulator/packages/keypress_simulator/.gitignore b/keypress_simulator/packages/keypress_simulator/.gitignore new file mode 100644 index 000000000..01c49d55f --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/keypress_simulator/packages/keypress_simulator/.metadata b/keypress_simulator/packages/keypress_simulator/.metadata new file mode 100644 index 000000000..0b5ddf05b --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: db747aa1331bd95bc9b3874c842261ca2d302cd5 + channel: stable + +project_type: plugin diff --git a/keypress_simulator/packages/keypress_simulator/CHANGELOG.md b/keypress_simulator/packages/keypress_simulator/CHANGELOG.md new file mode 100644 index 000000000..a25790099 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/CHANGELOG.md @@ -0,0 +1,7 @@ +## 0.2.0 + +* feat: Convert to federated plugin + +## 0.1.0 + +* First release. diff --git a/keypress_simulator/packages/keypress_simulator/LICENSE b/keypress_simulator/packages/keypress_simulator/LICENSE new file mode 100644 index 000000000..eea05ab78 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 LiJianying + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/keypress_simulator/packages/keypress_simulator/README-ZH.md b/keypress_simulator/packages/keypress_simulator/README-ZH.md new file mode 120000 index 000000000..7da944f1c --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/README-ZH.md @@ -0,0 +1 @@ +../../README-ZH.md \ No newline at end of file diff --git a/keypress_simulator/packages/keypress_simulator/README.md b/keypress_simulator/packages/keypress_simulator/README.md new file mode 120000 index 000000000..fe8400541 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/keypress_simulator/packages/keypress_simulator/analysis_options.yaml b/keypress_simulator/packages/keypress_simulator/analysis_options.yaml new file mode 100644 index 000000000..095b1d67f --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/analysis_options.yaml @@ -0,0 +1 @@ +include: package:mostly_reasonable_lints/flutter.yaml diff --git a/keypress_simulator/packages/keypress_simulator/lib/keypress_simulator.dart b/keypress_simulator/packages/keypress_simulator/lib/keypress_simulator.dart new file mode 100644 index 000000000..4c8a7e899 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/lib/keypress_simulator.dart @@ -0,0 +1 @@ +export 'src/keypress_simulator.dart'; diff --git a/keypress_simulator/packages/keypress_simulator/lib/src/keypress_simulator.dart b/keypress_simulator/packages/keypress_simulator/lib/src/keypress_simulator.dart new file mode 100644 index 000000000..2061f5e9f --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/lib/src/keypress_simulator.dart @@ -0,0 +1,63 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:keypress_simulator_platform_interface/keypress_simulator_platform_interface.dart'; + +class KeyPressSimulator { + KeyPressSimulator._(); + + /// The shared instance of [KeyPressSimulator]. + static final KeyPressSimulator instance = KeyPressSimulator._(); + + KeyPressSimulatorPlatform get _platform => KeyPressSimulatorPlatform.instance; + + Future isAccessAllowed() { + return _platform.isAccessAllowed(); + } + + Future requestAccess({bool onlyOpenPrefPane = false}) { + return _platform.requestAccess(onlyOpenPrefPane: onlyOpenPrefPane); + } + + Future simulateMouseClickDown(Offset position) { + return _platform.simulateMouseClick(position, keyDown: true); + } + + Future simulateMouseClickUp(Offset position) { + return _platform.simulateMouseClick(position, keyDown: false); + } + + /// Simulate key down. + Future simulateKeyDown(PhysicalKeyboardKey? key, [List modifiers = const [], String? targetApp]) { + return _platform.simulateKeyPress(key: key, modifiers: modifiers, keyDown: true, targetApp: targetApp); + } + + /// Simulate key up. + Future simulateKeyUp(PhysicalKeyboardKey? key, [List modifiers = const [], String? targetApp]) { + return _platform.simulateKeyPress(key: key, modifiers: modifiers, keyDown: false, targetApp: targetApp); + } + + /// Simulate media key press. + Future simulateMediaKey(PhysicalKeyboardKey mediaKey) { + return _platform.simulateMediaKey(mediaKey); + } + + @Deprecated('Please use simulateKeyDown & simulateKeyUp methods.') + Future simulateCtrlCKeyPress() async { + const key = PhysicalKeyboardKey.keyC; + final modifiers = Platform.isMacOS ? [ModifierKey.metaModifier] : [ModifierKey.controlModifier]; + await simulateKeyDown(key, modifiers); + await simulateKeyUp(key, modifiers); + } + + @Deprecated('Please use simulateKeyDown & simulateKeyUp methods.') + Future simulateCtrlVKeyPress() async { + const key = PhysicalKeyboardKey.keyV; + final modifiers = Platform.isMacOS ? [ModifierKey.metaModifier] : [ModifierKey.controlModifier]; + await simulateKeyDown(key, modifiers); + await simulateKeyUp(key, modifiers); + } +} + +final keyPressSimulator = KeyPressSimulator.instance; diff --git a/keypress_simulator/packages/keypress_simulator/pubspec.yaml b/keypress_simulator/packages/keypress_simulator/pubspec.yaml new file mode 100644 index 000000000..8cd7cccdf --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator/pubspec.yaml @@ -0,0 +1,36 @@ +name: keypress_simulator +description: This plugin allows Flutter desktop apps to simulate key presses. +version: 0.2.0 +homepage: https://github.com/leanflutter/keypress_simulator + +platforms: + macos: + windows: + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.3.0" + +dependencies: + flutter: + sdk: flutter + keypress_simulator_macos: + path: ../keypress_simulator_macos + keypress_simulator_platform_interface: + path: ../keypress_simulator_platform_interface + keypress_simulator_windows: + path: ../keypress_simulator_windows + +dev_dependencies: + flutter_test: + sdk: flutter + mostly_reasonable_lints: ^0.1.1 + +flutter: + plugin: + platforms: + macos: + default_package: keypress_simulator_macos + windows: + default_package: keypress_simulator_windows + diff --git a/keypress_simulator/packages/keypress_simulator_macos/.gitignore b/keypress_simulator/packages/keypress_simulator_macos/.gitignore new file mode 100644 index 000000000..ac5aa9893 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/keypress_simulator/packages/keypress_simulator_macos/.metadata b/keypress_simulator/packages/keypress_simulator_macos/.metadata new file mode 100644 index 000000000..6422a9994 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "67457e669f79e9f8d13d7a68fe09775fefbb79f4" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + - platform: macos + create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/keypress_simulator/packages/keypress_simulator_macos/CHANGELOG.md b/keypress_simulator/packages/keypress_simulator_macos/CHANGELOG.md new file mode 100644 index 000000000..2f145cea2 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.2.0 + +* First release. diff --git a/keypress_simulator/packages/keypress_simulator_macos/LICENSE b/keypress_simulator/packages/keypress_simulator_macos/LICENSE new file mode 100644 index 000000000..eea05ab78 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 LiJianying + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/keypress_simulator/packages/keypress_simulator_macos/README.md b/keypress_simulator/packages/keypress_simulator_macos/README.md new file mode 100644 index 000000000..87a076f02 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/README.md @@ -0,0 +1,12 @@ +# keypress_simulator_macos + +[![pub version][pub-image]][pub-url] + +[pub-image]: https://img.shields.io/pub/v/keypress_simulator_macos.svg +[pub-url]: https://pub.dev/packages/keypress_simulator_macos + +The macOS implementation of [keypress_simulator](https://pub.dev/packages/keypress_simulator). + +## License + +[MIT](./LICENSE) diff --git a/keypress_simulator/packages/keypress_simulator_macos/analysis_options.yaml b/keypress_simulator/packages/keypress_simulator_macos/analysis_options.yaml new file mode 100644 index 000000000..095b1d67f --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/analysis_options.yaml @@ -0,0 +1 @@ +include: package:mostly_reasonable_lints/flutter.yaml diff --git a/keypress_simulator/packages/keypress_simulator_macos/macos/Classes/KeypressSimulatorMacosPlugin.swift b/keypress_simulator/packages/keypress_simulator_macos/macos/Classes/KeypressSimulatorMacosPlugin.swift new file mode 100644 index 000000000..fba03804c --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/macos/Classes/KeypressSimulatorMacosPlugin.swift @@ -0,0 +1,195 @@ +import Cocoa +import FlutterMacOS + +public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "dev.leanflutter.plugins/keypress_simulator", binaryMessenger: registrar.messenger) + let instance = KeypressSimulatorMacosPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "isAccessAllowed": + isAccessAllowed(call, result: result) + break + case "requestAccess": + requestAccess(call, result: result) + break + case "simulateKeyPress": + simulateKeyPress(call, result: result) + break + case "simulateMouseClick": + simulateMouseClick(call, result: result) + break + case "simulateMediaKey": + simulateMediaKey(call, result: result) + break + default: + result(FlutterMethodNotImplemented) + } + } + public func isAccessAllowed(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result(AXIsProcessTrusted()) + } + + public func requestAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args:[String: Any] = call.arguments as! [String: Any] + let onlyOpenPrefPane: Bool = args["onlyOpenPrefPane"] as! Bool + + if (!onlyOpenPrefPane) { + let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] as CFDictionary + AXIsProcessTrustedWithOptions(options) + } else { + let prefpaneUrl = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! + NSWorkspace.shared.open(prefpaneUrl) + } + result(true) + } + + public func simulateKeyPress(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args:[String: Any] = call.arguments as! [String: Any] + + let keyCode: Int? = args["keyCode"] as? Int + let modifiers: Array = args["modifiers"] as! Array + let keyDown: Bool = args["keyDown"] as! Bool + let targetAppName: String? = args["targetAppName"] as? String + + let event: CGEvent = _createKeyPressEvent(keyCode, modifiers, keyDown); + + if let appName = targetAppName, !appName.isEmpty { + let runningApps = NSWorkspace.shared.runningApplications + if let targetApp = runningApps.first(where: { + $0.localizedName?.lowercased().contains(appName.lowercased()) == true || + $0.executableURL?.deletingPathExtension().lastPathComponent.lowercased().contains(appName.lowercased()) == true + }) { + + event.postToPid(targetApp.processIdentifier) + result(true) + return + } + } + + event.post(tap: .cghidEventTap); + result(true) + } + + + public func simulateMouseClick(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args:[String: Any] = call.arguments as! [String: Any] + + let x: Double = args["x"] as! Double + let y: Double = args["y"] as! Double + let keyDown: Bool = args["keyDown"] as! Bool + + let point = CGPoint(x: x, y: y) + + // Move mouse to the point + let move = CGEvent(mouseEventSource: nil, + mouseType: .mouseMoved, + mouseCursorPosition: point, + mouseButton: .left) + move?.post(tap: .cghidEventTap) + + if (keyDown) { + // Mouse down + let mouseDown = CGEvent(mouseEventSource: nil, + mouseType: .leftMouseDown, + mouseCursorPosition: point, + mouseButton: .left) + mouseDown?.post(tap: .cghidEventTap) + } else { + // Mouse up + let mouseUp = CGEvent(mouseEventSource: nil, + mouseType: .leftMouseUp, + mouseCursorPosition: point, + mouseButton: .left) + mouseUp?.post(tap: .cghidEventTap) + } + result(true) + } + + public func _createKeyPressEvent(_ keyCode: Int?, _ modifiers: Array, _ keyDown: Bool) -> CGEvent { + let virtualKey: CGKeyCode = CGKeyCode(UInt32(keyCode ?? 0)) + var flags: CGEventFlags = CGEventFlags() + + if (modifiers.contains("shiftModifier")) { + flags.insert(CGEventFlags.maskShift) + } + if (modifiers.contains("controlModifier")) { + flags.insert(CGEventFlags.maskControl) + } + if (modifiers.contains("altModifier")) { + flags.insert(CGEventFlags.maskAlternate) + } + if (modifiers.contains("metaModifier")) { + flags.insert(CGEventFlags.maskCommand) + } + if (modifiers.contains("functionModifier")) { + flags.insert(CGEventFlags.maskSecondaryFn) + } + let src = CGEventSource(stateID: .hidSystemState) + + let eventKeyPress = CGEvent(keyboardEventSource: src, virtualKey: virtualKey, keyDown: keyDown); + eventKeyPress!.flags = flags + return eventKeyPress! + } + + public func simulateMediaKey(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args:[String: Any] = call.arguments as! [String: Any] + let keyIdentifier: String = args["key"] as! String + + // Map string identifier to macOS NX key codes + var mediaKeyCode: Int32 = 0 + switch keyIdentifier { + case "playPause": + mediaKeyCode = NX_KEYTYPE_PLAY + case "stop": + // macOS doesn't have a dedicated stop key in its media control API. + // Following macOS conventions, we map stop to play/pause which toggles playback. + // This matches the behavior of the physical media keys on Mac keyboards. + mediaKeyCode = NX_KEYTYPE_PLAY + case "next": + mediaKeyCode = NX_KEYTYPE_FAST + case "previous": + mediaKeyCode = NX_KEYTYPE_REWIND + case "volumeUp": + mediaKeyCode = NX_KEYTYPE_SOUND_UP + case "volumeDown": + mediaKeyCode = NX_KEYTYPE_SOUND_DOWN + default: + result(FlutterError(code: "UNSUPPORTED_KEY", message: "Unsupported media key identifier", details: nil)) + return + } + + // Create and post the media key event (key down) + let eventDown = NSEvent.otherEvent( + with: .systemDefined, + location: NSPoint.zero, + modifierFlags: NSEvent.ModifierFlags(rawValue: 0xa00), + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: 8, + data1: Int((mediaKeyCode << 16) | (0xa << 8)), + data2: -1 + ) + eventDown?.cgEvent?.post(tap: .cghidEventTap) + + // Create and post the media key event (key up) + let eventUp = NSEvent.otherEvent( + with: .systemDefined, + location: NSPoint.zero, + modifierFlags: NSEvent.ModifierFlags(rawValue: 0xb00), + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: 8, + data1: Int((mediaKeyCode << 16) | (0xb << 8)), + data2: -1 + ) + eventUp?.cgEvent?.post(tap: .cghidEventTap) + + result(true) + } +} diff --git a/keypress_simulator/packages/keypress_simulator_macos/macos/keypress_simulator_macos.podspec b/keypress_simulator/packages/keypress_simulator_macos/macos/keypress_simulator_macos.podspec new file mode 100644 index 000000000..81ed85326 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/macos/keypress_simulator_macos.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint keypress_simulator_macos.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'keypress_simulator_macos' + s.version = '0.0.1' + s.summary = 'A new Flutter plugin project.' + s.description = <<-DESC +A new Flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/keypress_simulator/packages/keypress_simulator_macos/pubspec.yaml b/keypress_simulator/packages/keypress_simulator_macos/pubspec.yaml new file mode 100644 index 000000000..c414c0b11 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_macos/pubspec.yaml @@ -0,0 +1,27 @@ +name: keypress_simulator_macos +description: macOS implementation of the keypress_simulator plugin. +version: 0.2.0 +repository: https://github.com/leanflutter/keypress_simulator/tree/main/packages/keypress_simulator_macos + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + keypress_simulator_platform_interface: + path: ../keypress_simulator_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + mostly_reasonable_lints: ^0.1.1 + +flutter: + plugin: + implements: keypress_simulator + platforms: + macos: + pluginClass: KeypressSimulatorMacosPlugin + diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/.gitignore b/keypress_simulator/packages/keypress_simulator_platform_interface/.gitignore new file mode 100644 index 000000000..ac5aa9893 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/.metadata b/keypress_simulator/packages/keypress_simulator_platform_interface/.metadata new file mode 100644 index 000000000..9e91d2fd4 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/.metadata @@ -0,0 +1,27 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "67457e669f79e9f8d13d7a68fe09775fefbb79f4" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/CHANGELOG.md b/keypress_simulator/packages/keypress_simulator_platform_interface/CHANGELOG.md new file mode 100644 index 000000000..2f145cea2 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.2.0 + +* First release. diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/LICENSE b/keypress_simulator/packages/keypress_simulator_platform_interface/LICENSE new file mode 100644 index 000000000..eea05ab78 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 LiJianying + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/README.md b/keypress_simulator/packages/keypress_simulator_platform_interface/README.md new file mode 100644 index 000000000..907200d10 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/README.md @@ -0,0 +1,16 @@ +# keypress_simulator_platform_interface + +[![pub version][pub-image]][pub-url] + +[pub-image]: https://img.shields.io/pub/v/keypress_simulator_platform_interface.svg +[pub-url]: https://pub.dev/packages/keypress_simulator_platform_interface + +A common platform interface for the [keypress_simulator](https://pub.dev/packages/keypress_simulator) plugin. + +## Usage + +To implement a new platform-specific implementation of keypress_simulator, extend `KeyPressSimulatorPlatform` with an implementation that performs the platform-specific behavior, and when you register your plugin, set the default `KeyPressSimulatorPlatform` by calling `KeyPressSimulatorPlatform.instance = MyPlatformKeyPressSimulator()`. + +## License + +[MIT](./LICENSE) diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/analysis_options.yaml b/keypress_simulator/packages/keypress_simulator_platform_interface/analysis_options.yaml new file mode 100644 index 000000000..095b1d67f --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/analysis_options.yaml @@ -0,0 +1 @@ +include: package:mostly_reasonable_lints/flutter.yaml diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/lib/keypress_simulator_platform_interface.dart b/keypress_simulator/packages/keypress_simulator_platform_interface/lib/keypress_simulator_platform_interface.dart new file mode 100644 index 000000000..fdd2ecad6 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/lib/keypress_simulator_platform_interface.dart @@ -0,0 +1,4 @@ +library keypress_simulator_platform_interface; + +export 'src/keypress_simulator_method_channel.dart'; +export 'src/keypress_simulator_platform_interface.dart'; diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/lib/src/keypress_simulator_method_channel.dart b/keypress_simulator/packages/keypress_simulator_platform_interface/lib/src/keypress_simulator_method_channel.dart new file mode 100644 index 000000000..fa4f23f03 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/lib/src/keypress_simulator_method_channel.dart @@ -0,0 +1,92 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:keypress_simulator_platform_interface/src/keypress_simulator_platform_interface.dart'; +import 'package:uni_platform/uni_platform.dart'; + +/// An implementation of [KeyPressSimulatorPlatform] that uses method channels. +class MethodChannelKeyPressSimulator extends KeyPressSimulatorPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel( + 'dev.leanflutter.plugins/keypress_simulator', + ); + + @override + Future isAccessAllowed() async { + if (UniPlatform.isMacOS) { + return await methodChannel.invokeMethod('isAccessAllowed'); + } + return true; + } + + @override + Future requestAccess({ + bool onlyOpenPrefPane = false, + }) async { + if (UniPlatform.isMacOS) { + final Map arguments = { + 'onlyOpenPrefPane': onlyOpenPrefPane, + }; + await methodChannel.invokeMethod('requestAccess', arguments); + } + } + + @override + Future simulateKeyPress({ + KeyboardKey? key, + List modifiers = const [], + bool keyDown = true, + String? targetApp, + }) async { + PhysicalKeyboardKey? physicalKey = key is PhysicalKeyboardKey ? key : null; + if (key is LogicalKeyboardKey) { + physicalKey = PhysicalKeyboardKey(key.keyId); + } + if (key != null && physicalKey == null) { + throw UnsupportedError('Unsupported key: $key.'); + } + final Map arguments = { + 'keyCode': physicalKey?.usbHidUsage, + 'modifiers': modifiers.map((e) => e.name).toList(), + 'keyDown': keyDown, + 'targetAppName': targetApp, + }..removeWhere((key, value) => value == null); + await methodChannel.invokeMethod('simulateKeyPress', arguments); + } + + @override + Future simulateMouseClick(Offset position, {required bool keyDown}) async { + final Map arguments = { + 'x': position.dx, + 'y': position.dy, + 'keyDown': keyDown, + }; + await methodChannel.invokeMethod('simulateMouseClick', arguments); + } + + @override + Future simulateMediaKey(PhysicalKeyboardKey mediaKey) async { + // Map PhysicalKeyboardKey to string identifier since keyCode is null for media keys + final keyMap = { + PhysicalKeyboardKey.mediaPlayPause: 'playPause', + PhysicalKeyboardKey.mediaStop: 'stop', + PhysicalKeyboardKey.mediaTrackNext: 'next', + PhysicalKeyboardKey.mediaTrackPrevious: 'previous', + PhysicalKeyboardKey.audioVolumeUp: 'volumeUp', + PhysicalKeyboardKey.audioVolumeDown: 'volumeDown', + }; + + final keyIdentifier = keyMap[mediaKey]; + if (keyIdentifier == null) { + throw UnsupportedError('Unsupported media key: $mediaKey'); + } + + final Map arguments = { + 'key': keyIdentifier, + }; + await methodChannel.invokeMethod('simulateMediaKey', arguments); + } +} + + + diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/lib/src/keypress_simulator_platform_interface.dart b/keypress_simulator/packages/keypress_simulator_platform_interface/lib/src/keypress_simulator_platform_interface.dart new file mode 100644 index 000000000..fd3bd55c5 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/lib/src/keypress_simulator_platform_interface.dart @@ -0,0 +1,52 @@ +import 'package:flutter/services.dart'; +import 'package:keypress_simulator_platform_interface/src/keypress_simulator_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +abstract class KeyPressSimulatorPlatform extends PlatformInterface { + /// Constructs a KeyPressSimulatorPlatform. + KeyPressSimulatorPlatform() : super(token: _token); + + static final Object _token = Object(); + + static KeyPressSimulatorPlatform _instance = MethodChannelKeyPressSimulator(); + + /// The default instance of [KeyPressSimulatorPlatform] to use. + /// + /// Defaults to [MethodChannelKeyPressSimulator]. + static KeyPressSimulatorPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [KeyPressSimulatorPlatform] when + /// they register themselves. + static set instance(KeyPressSimulatorPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future isAccessAllowed() { + throw UnimplementedError('isAccessAllowed() has not been implemented.'); + } + + Future requestAccess({ + bool onlyOpenPrefPane = false, + }) { + throw UnimplementedError('requestAccess() has not been implemented.'); + } + + Future simulateKeyPress({ + KeyboardKey? key, + List modifiers = const [], + bool keyDown = true, + String? targetApp, + }) { + throw UnimplementedError('simulateKeyPress() has not been implemented.'); + } + + Future simulateMouseClick(Offset position, {required bool keyDown}) { + throw UnimplementedError('simulateMouseClick() has not been implemented.'); + } + + Future simulateMediaKey(PhysicalKeyboardKey mediaKey) { + throw UnimplementedError('simulateMediaKey() has not been implemented.'); + } +} diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/pubspec.yaml b/keypress_simulator/packages/keypress_simulator_platform_interface/pubspec.yaml new file mode 100644 index 000000000..4b85fcf11 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/pubspec.yaml @@ -0,0 +1,20 @@ +name: keypress_simulator_platform_interface +description: A common platform interface for the keypress_simulator plugin. +version: 0.2.0 +homepage: https://github.com/leanflutter/keypress_simulator/blob/main/packages/keypress_simulator_platform_interface + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + collection: ^1.18.0 + flutter: + sdk: flutter + plugin_platform_interface: ^2.1.8 + uni_platform: ^0.1.2 + +dev_dependencies: + flutter_test: + sdk: flutter + mostly_reasonable_lints: ^0.1.1 diff --git a/keypress_simulator/packages/keypress_simulator_platform_interface/test/src/keypress_simulator_method_channel_test.dart b/keypress_simulator/packages/keypress_simulator_platform_interface/test/src/keypress_simulator_method_channel_test.dart new file mode 100644 index 000000000..af2c3b90e --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_platform_interface/test/src/keypress_simulator_method_channel_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:keypress_simulator_platform_interface/src/keypress_simulator_method_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + MethodChannelKeyPressSimulator platform = MethodChannelKeyPressSimulator(); + const MethodChannel channel = MethodChannel( + 'dev.leanflutter.plugins/keypress_simulator', + ); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (MethodCall methodCall) async { + if (methodCall.method == 'isAccessAllowed') return true; + return '42'; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('isAccessAllowed', () async { + expect(await platform.isAccessAllowed(), true); + }); +} diff --git a/keypress_simulator/packages/keypress_simulator_windows/.gitignore b/keypress_simulator/packages/keypress_simulator_windows/.gitignore new file mode 100644 index 000000000..ac5aa9893 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +build/ diff --git a/keypress_simulator/packages/keypress_simulator_windows/.metadata b/keypress_simulator/packages/keypress_simulator_windows/.metadata new file mode 100644 index 000000000..bcaf04fcc --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "67457e669f79e9f8d13d7a68fe09775fefbb79f4" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + - platform: windows + create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/keypress_simulator/packages/keypress_simulator_windows/CHANGELOG.md b/keypress_simulator/packages/keypress_simulator_windows/CHANGELOG.md new file mode 100644 index 000000000..2f145cea2 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.2.0 + +* First release. diff --git a/keypress_simulator/packages/keypress_simulator_windows/LICENSE b/keypress_simulator/packages/keypress_simulator_windows/LICENSE new file mode 100644 index 000000000..eea05ab78 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-2024 LiJianying + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/keypress_simulator/packages/keypress_simulator_windows/README.md b/keypress_simulator/packages/keypress_simulator_windows/README.md new file mode 100644 index 000000000..a7ee3cad9 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/README.md @@ -0,0 +1,12 @@ +# keypress_simulator_windows + +[![pub version][pub-image]][pub-url] + +[pub-image]: https://img.shields.io/pub/v/keypress_simulator_windows.svg +[pub-url]: https://pub.dev/packages/keypress_simulator_windows + +The Windows implementation of [keypress_simulator](https://pub.dev/packages/keypress_simulator). + +## License + +[MIT](./LICENSE) diff --git a/keypress_simulator/packages/keypress_simulator_windows/analysis_options.yaml b/keypress_simulator/packages/keypress_simulator_windows/analysis_options.yaml new file mode 100644 index 000000000..095b1d67f --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/analysis_options.yaml @@ -0,0 +1 @@ +include: package:mostly_reasonable_lints/flutter.yaml diff --git a/keypress_simulator/packages/keypress_simulator_windows/pubspec.yaml b/keypress_simulator/packages/keypress_simulator_windows/pubspec.yaml new file mode 100644 index 000000000..1f94f85aa --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/pubspec.yaml @@ -0,0 +1,26 @@ +name: keypress_simulator_windows +description: Windows implementation of the keypress_simulator plugin. +version: 0.2.0 +repository: https://github.com/leanflutter/keypress_simulator/tree/main/packages/keypress_simulator_windows + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + keypress_simulator_platform_interface: + path: ../keypress_simulator_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + mostly_reasonable_lints: ^0.1.1 + +flutter: + plugin: + implements: keypress_simulator + platforms: + windows: + pluginClass: KeypressSimulatorWindowsPluginCApi diff --git a/keypress_simulator/packages/keypress_simulator_windows/windows/.gitignore b/keypress_simulator/packages/keypress_simulator_windows/windows/.gitignore new file mode 100644 index 000000000..b3eb2be16 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/keypress_simulator/packages/keypress_simulator_windows/windows/CMakeLists.txt b/keypress_simulator/packages/keypress_simulator_windows/windows/CMakeLists.txt new file mode 100644 index 000000000..a516e24b8 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/windows/CMakeLists.txt @@ -0,0 +1,100 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "keypress_simulator_windows") +project(${PROJECT_NAME} LANGUAGES CXX) + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "keypress_simulator_windows_plugin") + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "keypress_simulator_windows_plugin.cpp" + "keypress_simulator_windows_plugin.h" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + "include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h" + "keypress_simulator_windows_plugin_c_api.cpp" + ${PLUGIN_SOURCES} +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(keypress_simulator_windows_bundled_libraries + "" + PARENT_SCOPE +) + +# === Tests === +# These unit tests can be run from a terminal after building the example, or +# from Visual Studio after opening the generated solution file. + +# Only enable test builds when building the example (which sets this variable) +# so that plugin clients aren't building the tests. +if (${include_${PROJECT_NAME}_tests}) +set(TEST_RUNNER "${PROJECT_NAME}_test") +enable_testing() + +# Add the Google Test dependency. +include(FetchContent) +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/release-1.11.0.zip +) +# Prevent overriding the parent project's compiler/linker settings +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +# Disable install commands for gtest so it doesn't end up in the bundle. +set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE) +FetchContent_MakeAvailable(googletest) + +# The plugin's C API is not very useful for unit testing, so build the sources +# directly into the test binary rather than using the DLL. +add_executable(${TEST_RUNNER} + test/keypress_simulator_windows_plugin_test.cpp + ${PLUGIN_SOURCES} +) +apply_standard_settings(${TEST_RUNNER}) +target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin) +target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) +# flutter_wrapper_plugin has link dependencies on the Flutter DLL. +add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${FLUTTER_LIBRARY}" $ +) + +# Enable automatic test discovery. +include(GoogleTest) +gtest_discover_tests(${TEST_RUNNER}) +endif() diff --git a/keypress_simulator/packages/keypress_simulator_windows/windows/include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h b/keypress_simulator/packages/keypress_simulator_windows/windows/include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h new file mode 100644 index 000000000..f69a1c287 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/windows/include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_ diff --git a/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.cpp b/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.cpp new file mode 100644 index 000000000..4576d7543 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.cpp @@ -0,0 +1,382 @@ +#include "keypress_simulator_windows_plugin.h" + +// This must be included before many other Windows headers. +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +using flutter::EncodableList; +using flutter::EncodableMap; +using flutter::EncodableValue; + +namespace keypress_simulator_windows { + +// Forward declarations +struct FindWindowData { + std::string targetProcessName; + std::string targetWindowTitle; + HWND foundWindow; +}; + +BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam); +HWND FindTargetWindow(const std::string& processName, + const std::string& windowTitle); + +// static +void KeypressSimulatorWindowsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "dev.leanflutter.plugins/keypress_simulator", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); +} + +KeypressSimulatorWindowsPlugin::KeypressSimulatorWindowsPlugin() {} + +KeypressSimulatorWindowsPlugin::~KeypressSimulatorWindowsPlugin() {} + +void KeypressSimulatorWindowsPlugin::SimulateKeyPress( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const EncodableMap& args = std::get(*method_call.arguments()); + + UINT keyCode = std::get(args.at(EncodableValue("keyCode"))); + std::vector modifiers; + bool keyDown = std::get(args.at(EncodableValue("keyDown"))); + + EncodableList key_modifier_list = + std::get(args.at(EncodableValue("modifiers"))); + for (flutter::EncodableValue key_modifier_value : key_modifier_list) { + std::string key_modifier = std::get(key_modifier_value); + modifiers.push_back(key_modifier); + } + + // List of compatible training apps to look for + std::vector compatibleApps = {"MyWhooshHD.exe", "MyWhoosh.exe", + "indieVelo.exe", "biketerra.exe", + "Rouvy.exe"}; + + // Try to find and focus (or directly target) a compatible app + std::string foundProcessName; + bool supportsBackgroundInput = true; + HWND targetWindow = NULL; + for (const std::string& processName : compatibleApps) { + targetWindow = FindTargetWindow(processName, ""); + if (targetWindow != NULL) { + foundProcessName = processName; + if (!supportsBackgroundInput && GetForegroundWindow() != targetWindow) { + SetForegroundWindow(targetWindow); + Sleep(50); // Brief delay to ensure window is focused + } + break; + } + } + + // If we found a target window that supports background input and it's not + // focused, send messages directly + auto postKeyMessage = [](HWND hwnd, UINT vkCode, bool down) { + const WORD scanCode = + static_cast(MapVirtualKey(vkCode, MAPVK_VK_TO_VSC)); + // Build lParam with repeat count 1 and scan code; set transition states for + // key up + LPARAM lParam = 1 | (static_cast(scanCode) << 16); + if (vkCode == VK_LEFT || vkCode == VK_RIGHT || vkCode == VK_UP || + vkCode == VK_DOWN || vkCode == VK_INSERT || vkCode == VK_DELETE || + vkCode == VK_HOME || vkCode == VK_END || vkCode == VK_PRIOR || + vkCode == VK_NEXT) { + lParam |= (1 << 24); // extended key + } + if (!down) { + lParam |= (1 << 30); // previous key state + lParam |= (1 << 31); // transition state + } + PostMessage(hwnd, down ? WM_KEYDOWN : WM_KEYUP, vkCode, lParam); + }; + + auto sendKeyToWindow = [&postKeyMessage](HWND hwnd, + const std::vector& mods, + UINT keyCode, bool down) { + auto handleModifier = [&postKeyMessage, hwnd](UINT vk, bool press) { + postKeyMessage(hwnd, vk, press); + }; + + if (down) { + for (const std::string& modifier : mods) { + if (modifier == "shiftModifier") { + handleModifier(VK_SHIFT, true); + } else if (modifier == "controlModifier") { + handleModifier(VK_CONTROL, true); + } else if (modifier == "altModifier") { + handleModifier(VK_MENU, true); + } else if (modifier == "metaModifier") { + handleModifier(VK_LWIN, true); + } + } + postKeyMessage(hwnd, keyCode, true); + } else { + postKeyMessage(hwnd, keyCode, false); + // release modifiers + for (const std::string& modifier : mods) { + if (modifier == "shiftModifier") { + handleModifier(VK_SHIFT, false); + } else if (modifier == "controlModifier") { + handleModifier(VK_CONTROL, false); + } else if (modifier == "altModifier") { + handleModifier(VK_MENU, false); + } else if (modifier == "metaModifier") { + handleModifier(VK_LWIN, false); + } + } + } + }; + + if (targetWindow != NULL && !foundProcessName.empty() && + supportsBackgroundInput && GetForegroundWindow() != targetWindow) { + sendKeyToWindow(targetWindow, modifiers, keyCode, keyDown); + result->Success(flutter::EncodableValue(true)); + return; + } + + // Helper function to send modifier key events + auto sendModifierKey = [](UINT vkCode, bool down) { + WORD sc = (WORD)MapVirtualKey(vkCode, MAPVK_VK_TO_VSC); + INPUT in = {0}; + in.type = INPUT_KEYBOARD; + in.ki.wVk = 0; + in.ki.wScan = sc; + in.ki.dwFlags = KEYEVENTF_SCANCODE | (down ? 0 : KEYEVENTF_KEYUP); + SendInput(1, &in, sizeof(INPUT)); + }; + + // Helper function to process modifiers + auto processModifiers = [&sendModifierKey]( + const std::vector& mods, bool down) { + for (const std::string& modifier : mods) { + if (modifier == "shiftModifier") { + sendModifierKey(VK_SHIFT, down); + } else if (modifier == "controlModifier") { + sendModifierKey(VK_CONTROL, down); + } else if (modifier == "altModifier") { + sendModifierKey(VK_MENU, down); + } else if (modifier == "metaModifier") { + sendModifierKey(VK_LWIN, down); + } + } + }; + + // Press modifier keys first (if keyDown) + if (keyDown) { + processModifiers(modifiers, true); + } + + // Send the main key + WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC); + + INPUT in = {0}; + in.type = INPUT_KEYBOARD; + in.ki.wVk = 0; // when using SCANCODE, set VK=0 + in.ki.wScan = sc; + in.ki.dwFlags = KEYEVENTF_SCANCODE | (keyDown ? 0 : KEYEVENTF_KEYUP); + if (keyCode == VK_LEFT || keyCode == VK_RIGHT || keyCode == VK_UP || + keyCode == VK_DOWN || keyCode == VK_INSERT || keyCode == VK_DELETE || + keyCode == VK_HOME || keyCode == VK_END || keyCode == VK_PRIOR || + keyCode == VK_NEXT) { + in.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + } + SendInput(1, &in, sizeof(INPUT)); + + // Release modifier keys (if keyUp) + if (!keyDown) { + processModifiers(modifiers, false); + } + + /*BYTE byteValue = static_cast(keyCode); + keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);*/ + + result->Success(flutter::EncodableValue(true)); +} + +void KeypressSimulatorWindowsPlugin::SimulateMouseClick( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const EncodableMap& args = std::get(*method_call.arguments()); + double x = 0; + double y = 0; + + bool keyDown = std::get(args.at(EncodableValue("keyDown"))); + auto it_x = args.find(EncodableValue("x")); + if (it_x != args.end() && std::holds_alternative(it_x->second)) { + x = std::get(it_x->second); + } + + auto it_y = args.find(EncodableValue("y")); + if (it_y != args.end() && std::holds_alternative(it_y->second)) { + y = std::get(it_y->second); + } + + // Get the monitor containing the target point and its DPI + const POINT target_point = {static_cast(x), static_cast(y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + // Scale the coordinates according to the DPI scaling + int scaled_x = static_cast(x * scale_factor); + int scaled_y = static_cast(y * scale_factor); + + // Move the mouse to the specified coordinates + SetCursorPos(scaled_x, scaled_y); + + // Prepare input for mouse down and up + INPUT input = {0}; + input.type = INPUT_MOUSE; + + if (keyDown) { + // Mouse left button down + input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN; + SendInput(1, &input, sizeof(INPUT)); + + } else { + // Mouse left button up + input.mi.dwFlags = MOUSEEVENTF_LEFTUP; + SendInput(1, &input, sizeof(INPUT)); + } + + result->Success(flutter::EncodableValue(true)); +} + +BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) { + FindWindowData* data = reinterpret_cast(lParam); + + // Check if window is visible and not minimized + if (!IsWindowVisible(hwnd) || IsIconic(hwnd)) { + return TRUE; // Continue enumeration + } + + // Get window title + char windowTitle[256]; + GetWindowTextA(hwnd, windowTitle, sizeof(windowTitle)); + + // Get process name + DWORD processId; + GetWindowThreadProcessId(hwnd, &processId); + HANDLE hProcess = + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); + char processName[MAX_PATH]; + if (hProcess) { + DWORD size = sizeof(processName); + if (QueryFullProcessImageNameA(hProcess, 0, processName, &size)) { + // Extract just the filename from the full path + char* filename = strrchr(processName, '\\'); + if (filename) { + filename++; // Skip the backslash + } else { + filename = processName; + } + + // Check if this matches our target + if (!data->targetProcessName.empty() && + _stricmp(filename, data->targetProcessName.c_str()) == 0) { + data->foundWindow = hwnd; + return FALSE; // Stop enumeration + } + } + CloseHandle(hProcess); + } + + // Check window title if process name didn't match + if (!data->targetWindowTitle.empty() && + _stricmp(windowTitle, data->targetWindowTitle.c_str()) == 0) { + data->foundWindow = hwnd; + return FALSE; // Stop enumeration + } + + return TRUE; // Continue enumeration +} + +HWND FindTargetWindow(const std::string& processName, + const std::string& windowTitle) { + FindWindowData data; + data.targetProcessName = processName; + data.targetWindowTitle = windowTitle; + data.foundWindow = NULL; + + EnumWindows(EnumWindowsCallback, reinterpret_cast(&data)); + return data.foundWindow; +} + +void KeypressSimulatorWindowsPlugin::SimulateMediaKey( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + const EncodableMap& args = std::get(*method_call.arguments()); + std::string keyIdentifier = + std::get(args.at(EncodableValue("key"))); + + // Map string identifier to Windows virtual key codes + static const std::unordered_map keyMap = { + {"playPause", VK_MEDIA_PLAY_PAUSE}, {"stop", VK_MEDIA_STOP}, + {"next", VK_MEDIA_NEXT_TRACK}, {"previous", VK_MEDIA_PREV_TRACK}, + {"volumeUp", VK_VOLUME_UP}, {"volumeDown", VK_VOLUME_DOWN}}; + + auto it = keyMap.find(keyIdentifier); + if (it == keyMap.end()) { + result->Error("UNSUPPORTED_KEY", "Unsupported media key identifier"); + return; + } + UINT vkCode = it->second; + + // Send key down event + INPUT inputs[2] = {}; + inputs[0].type = INPUT_KEYBOARD; + inputs[0].ki.wVk = static_cast(vkCode); + inputs[0].ki.dwFlags = 0; // Key down + + // Send key up event + inputs[1].type = INPUT_KEYBOARD; + inputs[1].ki.wVk = static_cast(vkCode); + inputs[1].ki.dwFlags = KEYEVENTF_KEYUP; + + UINT eventsSent = SendInput(2, inputs, sizeof(INPUT)); + if (eventsSent != 2) { + result->Error("SEND_INPUT_FAILED", "Failed to send media key input events"); + return; + } + + result->Success(flutter::EncodableValue(true)); +} + +void KeypressSimulatorWindowsPlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + if (method_call.method_name().compare("simulateKeyPress") == 0) { + SimulateKeyPress(method_call, std::move(result)); + } else if (method_call.method_name().compare("simulateMouseClick") == 0) { + SimulateMouseClick(method_call, std::move(result)); + } else if (method_call.method_name().compare("simulateMediaKey") == 0) { + SimulateMediaKey(method_call, std::move(result)); + } else { + result->NotImplemented(); + } +} + +} // namespace keypress_simulator_windows diff --git a/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.h b/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.h new file mode 100644 index 000000000..3d9800e19 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin.h @@ -0,0 +1,45 @@ +#ifndef FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_ +#define FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_ + +#include +#include + +#include + +namespace keypress_simulator_windows { + +class KeypressSimulatorWindowsPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); + + KeypressSimulatorWindowsPlugin(); + + virtual ~KeypressSimulatorWindowsPlugin(); + + // Disallow copy and assign. + KeypressSimulatorWindowsPlugin(const KeypressSimulatorWindowsPlugin&) = + delete; + KeypressSimulatorWindowsPlugin& operator=( + const KeypressSimulatorWindowsPlugin&) = delete; + + void KeypressSimulatorWindowsPlugin::SimulateKeyPress( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + void KeypressSimulatorWindowsPlugin::SimulateMouseClick( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + void KeypressSimulatorWindowsPlugin::SimulateMediaKey( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); +}; + +} // namespace keypress_simulator_windows + +#endif // FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_ diff --git a/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin_c_api.cpp b/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin_c_api.cpp new file mode 100644 index 000000000..5c6fb9bad --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/windows/keypress_simulator_windows_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h" + +#include + +#include "keypress_simulator_windows_plugin.h" + +void KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + keypress_simulator_windows::KeypressSimulatorWindowsPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/keypress_simulator/packages/keypress_simulator_windows/windows/test/keypress_simulator_windows_plugin_test.cpp b/keypress_simulator/packages/keypress_simulator_windows/windows/test/keypress_simulator_windows_plugin_test.cpp new file mode 100644 index 000000000..8e0518994 --- /dev/null +++ b/keypress_simulator/packages/keypress_simulator_windows/windows/test/keypress_simulator_windows_plugin_test.cpp @@ -0,0 +1,43 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "keypress_simulator_windows_plugin.h" + +namespace keypress_simulator_windows { +namespace test { + +namespace { + +using flutter::EncodableMap; +using flutter::EncodableValue; +using flutter::MethodCall; +using flutter::MethodResultFunctions; + +} // namespace + +TEST(KeypressSimulatorWindowsPlugin, GetPlatformVersion) { + KeypressSimulatorWindowsPlugin plugin; + // Save the reply value from the success callback. + std::string result_string; + plugin.HandleMethodCall( + MethodCall("getPlatformVersion", std::make_unique()), + std::make_unique>( + [&result_string](const EncodableValue* result) { + result_string = std::get(*result); + }, + nullptr, nullptr)); + + // Since the exact string varies by host, just ensure that it's a string + // with the expected format. + EXPECT_TRUE(result_string.rfind("Windows ", 0) == 0); +} + +} // namespace test +} // namespace keypress_simulator_windows diff --git a/keypress_simulator/pubspec.yaml b/keypress_simulator/pubspec.yaml new file mode 100644 index 000000000..9d261665d --- /dev/null +++ b/keypress_simulator/pubspec.yaml @@ -0,0 +1,9 @@ +name: keypress_simulator_workspace +homepage: https://github.com/leanflutter/keypress_simulator +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + +dev_dependencies: + melos: ^3.1.0 diff --git a/lib/bluetooth/ble.dart b/lib/bluetooth/ble.dart new file mode 100644 index 000000000..6e5e8808e --- /dev/null +++ b/lib/bluetooth/ble.dart @@ -0,0 +1,7 @@ +class BleUuid { + static const DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb"; + static const DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb"; + + static const DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb"; + static const DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002A19-0000-1000-8000-00805F9B34FB"; +} diff --git a/lib/bluetooth/connection.dart b/lib/bluetooth/connection.dart new file mode 100644 index 000000000..acc85a8f9 --- /dev/null +++ b/lib/bluetooth/connection.dart @@ -0,0 +1,523 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/bluetooth_device.dart'; +import 'package:bike_control/bluetooth/devices/gamepad/gamepad_device.dart'; +import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart'; +import 'package:bike_control/bluetooth/devices/hid/hid_device.dart'; +import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/interpreter.dart'; +import 'package:bike_control/utils/requirements/android.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:gamepads/gamepads.dart'; +import 'package:prop/prop.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import 'devices/base_device.dart'; +import 'devices/zwift/constants.dart'; +import 'messages/notification.dart'; + +class Connection { + final devices = []; + + List get bluetoothDevices => devices.whereType().toList(); + List get gamepadDevices => devices.whereType().toList(); + List get gyroscopeDevices => devices.whereType().toList(); + List get accessories => devices.whereType().toList(); + List get controllerDevices => [ + ...bluetoothDevices.where((d) => d is! WahooKickrHeadwind), + ...gamepadDevices, + ...gyroscopeDevices, + ...devices.whereType(), + ]; + + var _androidNotificationsSetup = false; + + final _connectionQueue = []; + var _handlingConnectionQueue = false; + + final Map> _streamSubscriptions = {}; + final StreamController _actionStreams = StreamController.broadcast(); + Stream get actionStream => _actionStreams.stream; + List<({DateTime date, String entry})> lastLogEntries = []; + + final Map> _connectionSubscriptions = {}; + final StreamController _connectionStreams = StreamController.broadcast(); + Stream get connectionStream => _connectionStreams.stream; + final StreamController _rssiConnectionStreams = StreamController.broadcast(); + Stream get rssiConnectionStream => _rssiConnectionStreams.stream; + + final _lastScanResult = []; + final ValueNotifier hasDevices = ValueNotifier(false); + final ValueNotifier isScanning = ValueNotifier(false); + + Timer? _gamePadSearchTimer; + + void initialize() { + actionStream.listen((log) { + lastLogEntries.add((date: DateTime.now(), entry: log.toString())); + lastLogEntries = lastLogEntries.takeLast(kIsWeb ? 1000 : 60).toList(); + }); + + if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isIOS)) { + core.mediaKeyHandler.initialize(); + // Load saved media key detection state + core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = core.settings.getMediaKeyDetectionEnabled(); + } + + UniversalBle.onAvailabilityChange = (available) { + _actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn)); + if (available == AvailabilityState.poweredOn && !kIsWeb) { + core.permissions.getScanRequirements().then((perms) { + if (perms.isEmpty) { + performScanning(); + } + }); + } else if (available == AvailabilityState.poweredOff) { + disconnectAll(); + stop(); + } + }; + UniversalBle.onScanResult = (result) { + // Update RSSI for already connected devices + final existingDevice = bluetoothDevices.firstOrNullWhere( + (e) => e.device.deviceId == result.deviceId, + ); + if (existingDevice != null && existingDevice.rssi != result.rssi) { + existingDevice.rssi = result.rssi; + _rssiConnectionStreams.add(existingDevice); // Notify UI of update + } + + if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) { + _lastScanResult.add(result); + + if (kDebugMode) { + debugPrint('Scan result: ${result.name} - ${result.deviceId} - Services: ${result.services}'); + } + + try { + final scanResult = BluetoothDevice.fromScanResult(result); + + if (scanResult != null) { + _actionStreams.add( + LogNotification('Found new device: ${kIsWeb ? scanResult.toString() : scanResult.runtimeType}'), + ); + addDevices([scanResult]); + } else { + final manufacturerData = result.manufacturerDataList; + final data = manufacturerData + .firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID) + ?.payload; + if (data != null && kDebugMode) { + _actionStreams.add( + LogNotification('Found unknown device ${result.name} with identifier: ${data.firstOrNull}'), + ); + } + } + } catch (e, backtrace) { + _actionStreams.add( + LogNotification("Error processing scan result for device ${result.deviceId}: $e\n$backtrace"), + ); + if (kDebugMode) { + print(e); + print("backtrace: $backtrace"); + } + } + } + }; + + UniversalBle.onValueChange = (deviceId, characteristicUuid, value) async { + final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId); + if (device == null) { + _actionStreams.add(LogNotification('Device not found: $deviceId')); + UniversalBle.disconnect(deviceId); + return; + } else { + if (kIsWeb) { + // on web, log all characteristic changes for debugging + _actionStreams.add( + LogNotification( + 'Characteristic update for device ${device.toString()}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}', + ), + ); + } + try { + await device.processCharacteristic(characteristicUuid, value); + } catch (e, backtrace) { + _actionStreams.add( + LogNotification( + "Error processing characteristic for device ${device.toString()} and char: $characteristicUuid: $e\n$backtrace", + ), + ); + if (kDebugMode) { + print(e); + print("backtrace: $backtrace"); + } + } + + try { + await _runCustomDeviceScript( + device: device, + characteristicUuid: characteristicUuid, + value: value, + ); + } catch (e, backtrace) { + _actionStreams.add( + LogNotification( + "Error executing script for ${device.runtimeType} and char: $characteristicUuid: $e\n$backtrace", + ), + ); + if (kDebugMode) { + print(e); + print("backtrace: $backtrace"); + } + } + } + }; + + UniversalBle.onConnectionChange = (String deviceId, bool isConnected, String? error) { + final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId); + if (device != null && !isConnected) { + // allow reconnection + _lastScanResult.removeWhere((d) => d.deviceId == deviceId); + } + }; + + if (!kIsWeb && !screenshotMode) { + core.permissions.getScanRequirements().then((perms) { + if (perms.isEmpty) { + performScanning(); + } + }); + if (core.settings.getPhoneSteeringEnabled()) { + toggleGyroscopeSteering(true); + } + } + } + + Future performScanning() async { + if (isScanning.value) { + return; + } + isScanning.value = true; + _actionStreams.add(LogNotification('Scanning for devices...')); + + if (screenshotMode) { + return; + } + + // does not work on web, may not work on Windows + if (!kIsWeb && !Platform.isWindows) { + UniversalBle.getSystemDevices( + withServices: BluetoothDevice.servicesToScan, + ).then((devices) async { + final baseDevices = devices.mapNotNull(BluetoothDevice.fromScanResult).toList(); + if (baseDevices.isNotEmpty) { + addDevices(baseDevices); + } + }); + } + + await UniversalBle.startScan( + // allow all to enable Wahoo Kickr Bike Shift detection + //scanFilter: kIsWeb ? ScanFilter(withServices: BluetoothDevice.servicesToScan) : null, + platformConfig: PlatformConfig(web: WebOptions(optionalServices: BluetoothDevice.servicesToScan)), + ); + + if (!kIsWeb) { + _gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) { + Gamepads.list().then((list) { + final pads = list.map((pad) => GamepadDevice(pad.name.isEmpty ? 'Gamepad' : pad.name, id: pad.id)).toList(); + addDevices(pads); + + final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList(); + for (var device in removedDevices) { + devices.remove(device); + _streamSubscriptions[device]?.cancel(); + _streamSubscriptions.remove(device); + _connectionSubscriptions[device]?.cancel(); + _connectionSubscriptions.remove(device); + signalChange(device); + } + }); + }); + } else { + isScanning.value = false; + } + } + + Future _runCustomDeviceScript({ + required BluetoothDevice device, + required String characteristicUuid, + required Uint8List value, + }) async { + if (!IAPManager.instance.isPurchased.value && !IAPManager.instance.hasActiveSubscription) { + return; + } + + final scriptOutput = await DeviceScriptService.instance.runCustomScript( + deviceType: device.runtimeType.toString(), + characteristicUuid: characteristicUuid, + data: value, + ); + + if (scriptOutput == null) { + return; + } + + final serviceUuid = device.serviceUuidForCharacteristic(scriptOutput.characteristicUuid); + if (serviceUuid == null) { + _actionStreams.add( + LogNotification( + 'Script output characteristic ${scriptOutput.characteristicUuid} was not found on ${device.runtimeType}.', + ), + ); + return; + } + + final characteristic = device.services + ?.firstOrNullWhere((s) => s.uuid == serviceUuid) + ?.characteristics + .firstOrNullWhere((c) => c.uuid == scriptOutput.characteristicUuid); + + if (characteristic == null) { + _actionStreams.add( + LogNotification( + 'Script output characteristic ${scriptOutput.characteristicUuid} was not found on ${device.runtimeType}.', + ), + ); + return; + } else if (!characteristic.properties.containsAny([ + CharacteristicProperty.write, + CharacteristicProperty.writeWithoutResponse, + ])) { + _actionStreams.add( + LogNotification( + 'Script output characteristic ${scriptOutput.characteristicUuid} on ${device.runtimeType} does not support writing.', + ), + ); + return; + } + + await UniversalBle.write( + device.device.deviceId, + serviceUuid, + scriptOutput.characteristicUuid, + scriptOutput.data, + withoutResponse: characteristic.properties.contains(CharacteristicProperty.writeWithoutResponse) == true, + ); + } + + Future startMyWhooshServer() { + return core.whooshLink.startServer().catchError((e) { + core.settings.setMyWhooshLinkEnabled(false); + _actionStreams.add(LogNotification('Error starting MyWhoosh "Link" server: $e')); + _actionStreams.add( + AlertNotification( + LogLevel.LOGLEVEL_ERROR, + 'Error starting MyWhoosh "Link" server. Please make sure the "MyWhoosh Link" app is not already running on this device.', + ), + ); + }); + } + + void addDevices(List dev) { + final ignoredDevices = core.settings.getIgnoredDevices(); + final ignoredDeviceIds = ignoredDevices.map((d) => d.id).toSet(); + final newDevices = dev.where((device) { + if (devices.contains(device)) return false; + + // Check if device is in the ignored list + if (device is BluetoothDevice) { + if (ignoredDeviceIds.contains(device.device.deviceId)) { + return false; + } + } + + return true; + }).toList(); + devices.addAll(newDevices); + _connectionQueue.addAll(newDevices); + + _handleConnectionQueue(); + + hasDevices.value = devices.isNotEmpty; + } + + void toggleGyroscopeSteering(bool enable) { + final existing = gyroscopeDevices.firstOrNull; + if (existing != null && !enable) { + // Remove gyroscope steering + disconnect(existing, forget: true, persistForget: false); + } else if (enable) { + // Add gyroscope steering + final gyroDevice = GyroscopeSteering(); + addDevices([gyroDevice]); + } + } + + void _handleConnectionQueue() { + // windows apparently has issues when connecting to multiple devices at once, so don't + if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue && !screenshotMode) { + _handlingConnectionQueue = true; + final device = _connectionQueue.removeAt(0); + _actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.toString()}')); + _connect(device) + .then((_) { + _handlingConnectionQueue = false; + _actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.toString()}')); + if (_connectionQueue.isNotEmpty) { + _handleConnectionQueue(); + } + }) + .catchError((e) { + device.isConnected = false; + _handlingConnectionQueue = false; + if (e is TimeoutException) { + _actionStreams.add( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.toString()}: Timeout'), + ); + } else { + _actionStreams.add( + AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.toString()} - $e'), + ); + } + if (_connectionQueue.isNotEmpty) { + _handleConnectionQueue(); + } + }); + } + } + + Future _connect(BaseDevice device) async { + try { + final actionSubscription = device.actionStream.listen((data) { + _actionStreams.add(data); + }); + if (device is BluetoothDevice) { + final connectionStateSubscription = device.device.connectionStream.listen((state) { + device.isConnected = state; + _connectionStreams.add(device); + core.flutterLocalNotificationsPlugin.show( + 1338, + '${device.toString()} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}', + !state ? AppLocalizations.current.tryingToConnectAgain : null, + NotificationDetails( + android: AndroidNotificationDetails('Connection', 'Connection Status'), + iOS: DarwinNotificationDetails(presentAlert: true, presentSound: false), + ), + ); + if (!device.isConnected) { + disconnect(device, forget: false, persistForget: false); + // try reconnect + performScanning(); + } + }); + _connectionSubscriptions[device] = connectionStateSubscription; + } + + await device.connect(); + signalChange(device); + + IAPManager.instance.setAttributes(); + + core.actionHandler.supportedApp?.keymap.addNewButtons(device.availableButtons); + + _streamSubscriptions[device] = actionSubscription; + + if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) { + _androidNotificationsSetup = true; + // start foreground service only when app is in foreground + NotificationRequirement.addPersistentNotification().catchError((e) { + _actionStreams.add(LogNotification(e.toString())); + }); + } + } catch (e, backtrace) { + _actionStreams.add(LogNotification("$e\n$backtrace")); + if (kDebugMode) { + print(e); + print("backtrace: $backtrace"); + } + rethrow; + } + } + + void signalNotification(BaseNotification notification) { + _actionStreams.add(notification); + } + + void signalChange(BaseDevice baseDevice) { + _connectionStreams.add(baseDevice); + } + + Future disconnect(BaseDevice device, {required bool persistForget, required bool forget}) async { + if (device.isConnected) { + await device.disconnect(); + } + + if (device is BluetoothDevice) { + if (persistForget) { + // Add device to ignored list when forgetting + await core.settings.addIgnoredDevice(device.device.deviceId, device.toString()); + _actionStreams.add(LogNotification('Device ignored: ${device.toString()}')); + } + if (!forget) { + // allow reconnection + _lastScanResult.removeWhere((d) => d.deviceId == device.device.deviceId); + } + + // Clean up subscriptions and scan results for reconnection + _streamSubscriptions[device]?.cancel(); + _streamSubscriptions.remove(device); + _connectionSubscriptions[device]?.cancel(); + _connectionSubscriptions.remove(device); + + // Remove device from the list + devices.remove(device); + hasDevices.value = devices.isNotEmpty; + } else if (device is GyroscopeSteering) { + // Clean up subscriptions + _streamSubscriptions[device]?.cancel(); + _streamSubscriptions.remove(device); + _connectionSubscriptions[device]?.cancel(); + _connectionSubscriptions.remove(device); + + // Remove device from the list + devices.remove(device); + hasDevices.value = devices.isNotEmpty; + } + + signalChange(device); + } + + Future disconnectAll() async { + _actionStreams.add(LogNotification('Disconnecting all devices')); + for (var device in bluetoothDevices) { + _streamSubscriptions[device]?.cancel(); + _streamSubscriptions.remove(device); + _connectionSubscriptions[device]?.cancel(); + _connectionSubscriptions.remove(device); + device.disconnect(); + signalChange(device); + devices.remove(device); + } + _gamePadSearchTimer?.cancel(); + _lastScanResult.clear(); + hasDevices.value = false; + } + + Future stop() async { + final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn; + if (isBtEnabled) { + UniversalBle.stopScan(); + } + isScanning.value = false; + _androidNotificationsSetup = false; + } +} diff --git a/lib/bluetooth/devices/base_device.dart b/lib/bluetooth/devices/base_device.dart new file mode 100644 index 000000000..cb13e1aba --- /dev/null +++ b/lib/bluetooth/devices/base_device.dart @@ -0,0 +1,414 @@ +import 'dart:async'; + +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/actions/desktop.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/apps/custom_app.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/utils/keymap/manager.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:prop/prop.dart' show LogLevel; + +import '../../utils/keymap/buttons.dart'; +import '../messages/notification.dart'; + +abstract class BaseDevice { + final String? _name; + final bool isBeta; + bool supportsLongPress; + final String uniqueId; + final List availableButtons; + + BaseDevice( + this._name, { + required this.uniqueId, + required this.availableButtons, + this.isBeta = false, + this.supportsLongPress = true, + String? buttonPrefix, + }) { + if (availableButtons.isEmpty && core.actionHandler.supportedApp is CustomApp) { + final allButtons = core.actionHandler.supportedApp!.keymap.keyPairs + .flatMap((e) => e.buttons) + .filter( + (e) => + e.sourceDeviceId == uniqueId || + (e.sourceDeviceId == null && buttonPrefix != null && e.name.startsWith(buttonPrefix)), + ) + .toSet(); + availableButtons.addAll(allButtons); + } + } + + bool isConnected = false; + + static const Duration _longPressTriggerDelay = Duration(milliseconds: 550); + static const Duration _doubleClickDelay = Duration(milliseconds: 320); + + Timer? _longPressTimer; + Timer? _singleClickTimer; + Set _previouslyPressedButtons = {}; + Set _activeLongPressButtons = {}; + ControllerButton? _pendingSingleClickButton; + + String get name => _name ?? runtimeType.toString(); + + String get buttonExplanation => isConnected ? 'Connecting...' : 'Click a button on this device to configure them.'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BaseDevice && runtimeType == other.runtimeType && toString() == other.toString(); + + @override + int get hashCode => name.hashCode; + + @override + String toString() => name; + + final StreamController actionStreamInternal = StreamController.broadcast(); + + Stream get actionStream => actionStreamInternal.stream; + + Future connect(); + + Future handleButtonsClicked(List? buttonsClicked, {bool longPress = false}) async { + try { + if (buttonsClicked == null) { + return; + } + + if (longPress) { + await _handleExplicitLongPress(buttonsClicked); + return; + } + + if (buttonsClicked.isEmpty) { + await _handleButtonsReleased(); + return; + } + + actionStreamInternal.add(ButtonNotification(buttonsClicked: buttonsClicked, device: this)); + + if (buttonsClicked.length != 1) { + _cancelPendingClickTimers(); + if (_activeLongPressButtons.isNotEmpty) { + await performRelease(_activeLongPressButtons.toList(), trigger: ButtonTrigger.longPress); + _activeLongPressButtons.clear(); + } + _previouslyPressedButtons = buttonsClicked.toSet(); + await performClick(buttonsClicked, trigger: ButtonTrigger.singleClick); + return; + } + + final button = buttonsClicked.single; + final wasAlreadyPressed = + _previouslyPressedButtons.length == 1 && _previouslyPressedButtons.singleOrNull == button; + _previouslyPressedButtons = {button}; + if (wasAlreadyPressed) { + return; + } + + final hasSingleAction = _hasTriggerAction(button, ButtonTrigger.singleClick); + final hasDoubleAction = _hasTriggerAction(button, ButtonTrigger.doubleClick); + final hasLongPressAction = _hasTriggerAction(button, ButtonTrigger.longPress); + final isLongPressOnly = hasLongPressAction && !hasSingleAction && !hasDoubleAction; + if (supportsLongPress && isLongPressOnly && !_isLongPressSuppressed(button)) { + _cancelPendingClickTimers(); + _longPressTimer?.cancel(); + _activeLongPressButtons = {button}; + await performDown([button], trigger: ButtonTrigger.longPress); + return; + } + + _scheduleLongPress(button); + } catch (e, st) { + actionStreamInternal.add( + LogNotification('Error handling button clicks: $e\n$st'), + ); + } + } + + Future _handleButtonsReleased() async { + actionStreamInternal.add(LogNotification('Buttons released')); + + _longPressTimer?.cancel(); + final releasedButtons = _previouslyPressedButtons.toList(); + _previouslyPressedButtons.clear(); + + if (releasedButtons.isEmpty) { + return; + } + + if (_activeLongPressButtons.isNotEmpty && supportsLongPress) { + await performRelease(_activeLongPressButtons.toList(), trigger: ButtonTrigger.longPress); + _activeLongPressButtons.clear(); + return; + } + + if (releasedButtons.length != 1) { + return; + } + + await _handleSingleButtonTap(releasedButtons.single); + } + + Future _handleExplicitLongPress(List buttonsClicked) async { + if (buttonsClicked.isEmpty) { + if (_activeLongPressButtons.isNotEmpty) { + await performRelease(_activeLongPressButtons.toList(), trigger: ButtonTrigger.longPress); + _activeLongPressButtons.clear(); + } + _previouslyPressedButtons.clear(); + return; + } + + actionStreamInternal.add(ButtonNotification(buttonsClicked: buttonsClicked, device: this)); + _cancelPendingClickTimers(); + _activeLongPressButtons = buttonsClicked.toSet(); + _previouslyPressedButtons = buttonsClicked.toSet(); + await performDown(buttonsClicked, trigger: ButtonTrigger.longPress); + } + + void _scheduleLongPress(ControllerButton button) { + _longPressTimer?.cancel(); + if (!supportsLongPress || !_hasTriggerAction(button, ButtonTrigger.longPress)) { + return; + } + if (_isLongPressSuppressed(button)) { + return; + } + + _longPressTimer = Timer(_longPressTriggerDelay, () { + final stillPressed = _previouslyPressedButtons.length == 1 && _previouslyPressedButtons.singleOrNull == button; + if (!stillPressed) { + return; + } + _activeLongPressButtons = {button}; + unawaited(performDown([button], trigger: ButtonTrigger.longPress)); + }); + } + + bool _isLongPressSuppressed(ControllerButton button) { + return button == ZwiftButtons.onOffLeft || button == ZwiftButtons.onOffRight; + } + + Future _handleSingleButtonTap(ControllerButton button) async { + final hasSingleAction = _hasTriggerAction(button, ButtonTrigger.singleClick); + final hasDoubleAction = _hasTriggerAction(button, ButtonTrigger.doubleClick); + final hasLongPressAction = _hasTriggerAction(button, ButtonTrigger.longPress); + + if (!supportsLongPress && hasLongPressAction) { + _cancelPendingClickTimers(); + final isLongPressAlreadyHeld = _activeLongPressButtons.contains(button); + if (isLongPressAlreadyHeld) { + await performRelease([button], trigger: ButtonTrigger.longPress); + _activeLongPressButtons.remove(button); + } else { + await performDown([button], trigger: ButtonTrigger.longPress); + _activeLongPressButtons.add(button); + } + return; + } + + if (hasDoubleAction) { + final isSecondTap = + _pendingSingleClickButton == button && + (_singleClickTimer?.isActive ?? false) && + _activeLongPressButtons.isEmpty; + + if (isSecondTap) { + _singleClickTimer?.cancel(); + _singleClickTimer = null; + _pendingSingleClickButton = null; + await performClick([button], trigger: ButtonTrigger.doubleClick); + return; + } + + _singleClickTimer?.cancel(); + _singleClickTimer = Timer(_doubleClickDelay, () { + final pendingButton = _pendingSingleClickButton; + _pendingSingleClickButton = null; + _singleClickTimer = null; + if (pendingButton != null && hasSingleAction) { + unawaited(performClick([pendingButton], trigger: ButtonTrigger.singleClick)); + } + }); + _pendingSingleClickButton = button; + return; + } + + if (hasSingleAction) { + await performClick([button], trigger: ButtonTrigger.singleClick); + } + } + + bool _hasTriggerAction(ControllerButton button, ButtonTrigger trigger) { + final keyPair = core.actionHandler.supportedApp?.keymap.getKeyPair(button, trigger: trigger); + if (keyPair == null && core.actionHandler.supportedApp == null) { + return trigger == ButtonTrigger.singleClick; + } + return keyPair != null && !keyPair.hasNoAction; + } + + void _cancelPendingClickTimers() { + _singleClickTimer?.cancel(); + _singleClickTimer = null; + _pendingSingleClickButton = null; + } + + String _getCommandLimitMessage() { + return AppLocalizations.current.dailyCommandLimitReachedNotification; + } + + String _getCommandLimitTitle() { + return AppLocalizations.current + .dailyLimitReached(IAPManager.dailyCommandLimit, IAPManager.dailyCommandLimit) + .replaceAll( + '${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}', + IAPManager.dailyCommandLimit.toString(), + ) + .replaceAll( + '${IAPManager.dailyCommandLimit} / ${IAPManager.dailyCommandLimit}', + IAPManager.dailyCommandLimit.toString(), + ); + } + + bool _canExecuteCommand() { + try { + return IAPManager.instance.canExecuteCommand; + } catch (_) { + return true; + } + } + + Future performDown( + List buttonsClicked, { + ButtonTrigger trigger = ButtonTrigger.longPress, + }) async { + for (final action in buttonsClicked) { + // Check IAP status before executing command + if (!_canExecuteCommand()) { + //actionStreamInternal.add(AlertNotification(LogLevel.LOGLEVEL_ERROR, _getCommandLimitMessage())); + continue; + } + + // For repeated actions, don't trigger key down/up events (useful for long press) + final result = await core.actionHandler.performAction( + action, + isKeyDown: true, + isKeyUp: false, + trigger: trigger, + ); + + actionStreamInternal.add(ActionNotification(result)); + } + } + + Future performClick( + List buttonsClicked, { + ButtonTrigger trigger = ButtonTrigger.singleClick, + }) async { + for (final action in buttonsClicked) { + // Check IAP status before executing command + if (!_canExecuteCommand()) { + _showCommandLimitAlert(); + continue; + } + + final result = await core.actionHandler.performAction( + action, + isKeyDown: true, + isKeyUp: true, + trigger: trigger, + ); + actionStreamInternal.add(ActionNotification(result)); + } + } + + Future performRelease( + List buttonsReleased, { + ButtonTrigger trigger = ButtonTrigger.longPress, + }) async { + for (final action in buttonsReleased) { + // Check IAP status before executing command + if (!_canExecuteCommand()) { + _showCommandLimitAlert(); + continue; + } + + final result = await core.actionHandler.performAction( + action, + isKeyDown: false, + isKeyUp: true, + trigger: trigger, + ); + actionStreamInternal.add(LogNotification(result.message)); + } + } + + Future disconnect() async { + _longPressTimer?.cancel(); + _singleClickTimer?.cancel(); + _singleClickTimer = null; + _pendingSingleClickButton = null; + // Release any held keys in long press mode + if (core.actionHandler is DesktopActions) { + await (core.actionHandler as DesktopActions).releaseAllHeldKeys(_activeLongPressButtons.toList()); + } + _activeLongPressButtons.clear(); + _previouslyPressedButtons.clear(); + isConnected = false; + } + + Widget showInformation(BuildContext context); + + ControllerButton getOrAddButton(String key, ControllerButton Function() creator) { + if (core.actionHandler.supportedApp == null) { + return creator(); + } + if (core.actionHandler.supportedApp is! CustomApp) { + final currentProfile = core.actionHandler.supportedApp!.name; + // should we display this to the user? + KeymapManager().duplicateSync(currentProfile, '$currentProfile (Copy)'); + } + var createdButton = creator(); + if (createdButton.sourceDeviceId == null) { + createdButton = createdButton.copyWith(sourceDeviceId: uniqueId); + } + final button = core.actionHandler.supportedApp!.keymap.getOrAddButton(key, createdButton); + + if (availableButtons.none((e) => e.name == button.name)) { + availableButtons.add(button); + core.settings.setKeyMap(core.actionHandler.supportedApp!); + } + return button; + } + + void _showCommandLimitAlert() { + actionStreamInternal.add( + AlertNotification( + LogLevel.LOGLEVEL_ERROR, + _getCommandLimitMessage(), + buttonTitle: AppLocalizations.current.purchase, + onTap: () { + IAPManager.instance.purchaseFullVersion(navigatorKey.currentContext!); + }, + ), + ); + core.flutterLocalNotificationsPlugin.show( + 1337, + _getCommandLimitTitle(), + _getCommandLimitMessage(), + NotificationDetails( + android: AndroidNotificationDetails('Limit', 'Limit reached'), + iOS: DarwinNotificationDetails(presentAlert: true), + ), + ); + } +} diff --git a/lib/bluetooth/devices/bluetooth_device.dart b/lib/bluetooth/devices/bluetooth_device.dart new file mode 100644 index 000000000..643f4e1d5 --- /dev/null +++ b/lib/bluetooth/devices/bluetooth_device.dart @@ -0,0 +1,425 @@ +import 'dart:async'; + +import 'package:bike_control/bluetooth/ble.dart'; +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart'; +import 'package:bike_control/bluetooth/devices/proxy/proxy_device.dart'; +import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart'; +import 'package:bike_control/bluetooth/devices/sram/sram_axs.dart'; +import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart'; +import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart'; +import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart'; +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_click.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/pages/device.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/widgets/device_script_drawer.dart'; +import 'package:bike_control/widgets/ui/beta_pill.dart'; +import 'package:bike_control/widgets/ui/device_info.dart'; +import 'package:bike_control/widgets/ui/loading_widget.dart'; +import 'package:bike_control/widgets/ui/pro_badge.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import 'cycplus/cycplus_bc2.dart'; +import 'elite/elite_square.dart'; +import 'elite/elite_sterzo.dart'; +import 'thinkrider/thinkrider_vs200.dart'; + +abstract class BluetoothDevice extends BaseDevice { + final BleDevice scanResult; + + BluetoothDevice( + this.scanResult, { + required List availableButtons, + bool allowMultiple = false, + bool isBeta = false, + bool supportsLongPress = true, + String? buttonPrefix, + }) : super( + scanResult.name, + uniqueId: scanResult.deviceId, + availableButtons: allowMultiple + ? availableButtons.toList().map((b) => b.copyWith(sourceDeviceId: scanResult.deviceId)).toList() + : availableButtons.toList(), + isBeta: isBeta, + supportsLongPress: supportsLongPress, + buttonPrefix: buttonPrefix, + ) { + rssi = scanResult.rssi; + } + + int? batteryLevel; + String? firmwareVersion; + int? rssi; + + static List servicesToScan = [ + ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID, + ZwiftConstants.ZWIFT_CUSTOM_SERVICE_SHORT_UUID, + ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID, + SquareConstants.SERVICE_UUID, + WahooKickrBikeShiftConstants.SERVICE_UUID, + WahooKickrHeadwindConstants.SERVICE_UUID, + SterzoConstants.SERVICE_UUID, + CycplusBc2Constants.SERVICE_UUID, + ShimanoDi2Constants.SERVICE_UUID, + ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE, + OpenBikeControlConstants.SERVICE_UUID, + ThinkRiderVs200Constants.SERVICE_UUID, + ]; + + static final List _ignoredNames = ['ASSIOMA', 'QUARQ', 'POWERCRANK']; + + List? services; + + static BluetoothDevice? fromScanResult(BleDevice scanResult) { + // skip devices with ignored names + if (scanResult.name != null && + _ignoredNames.any((ignoredName) => scanResult.name!.toUpperCase().startsWith(ignoredName))) { + return null; + } + + // Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data + BluetoothDevice? device; + if (kIsWeb) { + device = switch (scanResult.name) { + 'Zwift Ride' => ZwiftRide(scanResult), + 'Zwift Play' => ZwiftPlay(scanResult, deviceType: ZwiftDeviceType.playLeft), + 'Zwift Click' => ZwiftClickV2(scanResult), + 'SQUARE' => EliteSquare(scanResult), + 'OpenBike' => OpenBikeControlDevice(scanResult), + null => null, + _ when scanResult.name!.toUpperCase().startsWith('HEADWIND') => WahooKickrHeadwind(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') => + CycplusBc2(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('THINK VS') => ThinkRiderVs200(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('SRAM') => SramAxs(scanResult), + _ => null, + }; + } else { + device = switch (scanResult.name) { + null => null, + //'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller + // https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/ + //'Zwift Play' => ZwiftPlay(scanResult), + //'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller + _ when scanResult.name!.toUpperCase().startsWith('HEADWIND') => WahooKickrHeadwind(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult), + _ when scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') => + CycplusBc2(scanResult), + _ when scanResult.name!.toUpperCase().startsWith('THINK VS') => ThinkRiderVs200(scanResult), + //_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult), + _ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult), + _ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE.toLowerCase()) => ShimanoDi2( + scanResult, + ), + _ when scanResult.services.containsAny(ProxyDevice.proxyServiceUUIDs) && kDebugMode => ProxyDevice(scanResult), + _ when scanResult.services.contains(SramAxsConstants.SERVICE_UUID.toLowerCase()) => SramAxs( + scanResult, + ), + _ when scanResult.services.contains(OpenBikeControlConstants.SERVICE_UUID.toLowerCase()) => + OpenBikeControlDevice(scanResult), + _ when scanResult.services.contains(WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase()) => + WahooKickrHeadwind(scanResult), + // otherwise the service UUIDs will be used + _ => null, + }; + } + + if (device != null) { + return device; + } else if (scanResult.services.containsAny([ + ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(), + ZwiftConstants.ZWIFT_CUSTOM_SERVICE_SHORT_UUID.toLowerCase(), + ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(), + ])) { + // otherwise use the manufacturer data to identify the device + final manufacturerData = scanResult.manufacturerDataList; + final data = manufacturerData + .firstOrNullWhere((e) => e.companyId == ZwiftConstants.ZWIFT_MANUFACTURER_ID) + ?.payload; + + if (data == null || data.isEmpty) { + } else { + final type = ZwiftDeviceType.fromManufacturerData(data.first); + device = switch (type) { + ZwiftDeviceType.click => ZwiftClick(scanResult), + ZwiftDeviceType.playRight => ZwiftPlay(scanResult, deviceType: type!), + ZwiftDeviceType.playLeft => ZwiftPlay(scanResult, deviceType: type!), + ZwiftDeviceType.rideLeft => ZwiftRide(scanResult), + //DeviceType.rideRight => ZwiftRide(scanResult), // see comment above + ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult), + //DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above + _ => null, + }; + } + } + + if (scanResult.name == 'Zwift Ride' && + device == null && + core.connection.controllerDevices.none((d) => d is ZwiftRide)) { + // Fallback for Zwift Ride if nothing else matched => old firmware + buildToast( + title: 'You may need to update your Zwift Ride firmware.', + duration: Duration(seconds: 6), + ); + } + return device; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BluetoothDevice && runtimeType == other.runtimeType && scanResult.deviceId == other.scanResult.deviceId; + + @override + int get hashCode => scanResult.deviceId.hashCode; + + BleDevice get device => scanResult; + + @override + Future connect() async { + try { + await UniversalBle.connect(device.deviceId); + } catch (e) { + isConnected = false; + rethrow; + } + + if (!kIsWeb) { + await UniversalBle.requestMtu(device.deviceId, 517); + } + + services = await UniversalBle.discoverServices(device.deviceId); + + final deviceInformationService = services!.firstOrNullWhere( + (service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(), + ); + final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere( + (c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(), + ); + if (firmwareCharacteristic != null) { + final firmwareData = await UniversalBle.read( + device.deviceId, + deviceInformationService!.uuid, + firmwareCharacteristic.uuid, + ); + firmwareVersion = String.fromCharCodes(firmwareData); + + core.connection.signalChange(this); + } + + final batteryService = services!.firstOrNullWhere( + (service) => service.uuid == BleUuid.DEVICE_BATTERY_SERVICE_UUID.toLowerCase(), + ); + + final batteryCharacteristic = batteryService?.characteristics.firstOrNullWhere( + (c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL.toLowerCase(), + ); + if (batteryCharacteristic != null) { + final batteryData = await UniversalBle.read( + device.deviceId, + batteryService!.uuid, + batteryCharacteristic.uuid, + ); + if (batteryData.isNotEmpty) { + batteryLevel = batteryData.first; + core.connection.signalChange(this); + } + } + + await handleServices(services!); + } + + Future handleServices(List services); + Future processCharacteristic(String characteristic, Uint8List bytes); + + @override + Future disconnect() async { + services?.clear(); + await UniversalBle.disconnect(device.deviceId); + super.disconnect(); + } + + String? serviceUuidForCharacteristic(String characteristicUuid) { + return services + ?.firstOrNullWhere((service) => service.characteristics.any((c) => c.uuid == characteristicUuid.toLowerCase())) + ?.uuid; + } + + @override + Widget showInformation(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + Text( + toString().screenshot ?? runtimeType.toString(), + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (isBeta) BetaPill(), + Expanded(child: SizedBox()), + Builder( + builder: (context) { + return LoadingWidget( + futureCallback: () async { + final completer = showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + MenuButton( + child: Text('Disconnect and Forget for this session'), + onPressed: (context) { + closeOverlay(context, false); + }, + ), + MenuButton( + child: Text('Disconnect and Forget'), + onPressed: (context) { + closeOverlay(context, true); + }, + ), + MenuDivider(), + MenuLabel(child: Text('Experimental')), + MenuButton( + trailing: + !IAPManager.instance.isPurchased.value && !IAPManager.instance.hasActiveSubscription + ? ProBadge() + : null, + onPressed: (overlayContext) async { + await closeOverlay(context, null); + if (!IAPManager.instance.isPurchased.value && + !IAPManager.instance.hasActiveSubscription) { + buildToast( + title: 'This feature is Full Version or Pro only.', + duration: Duration(seconds: 4), + ); + return; + } + openDrawer( + context: context, + position: OverlayPosition.end, + builder: (c) => DeviceScriptDrawer(deviceType: runtimeType.toString()), + ); + }, + child: Text('Run Script'), + ), + ], + ), + ); + + final persist = await completer.future; + if (persist != null) { + await core.connection.disconnect(this, forget: true, persistForget: persist); + } + }, + renderChild: (isLoading, tap) => IconButton( + variance: ButtonVariance.muted, + icon: isLoading ? SmallProgressIndicator() : Icon(Icons.clear), + onPressed: tap, + ), + ); + }, + ), + ], + ), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + DeviceInfo( + title: context.i18n.connection, + icon: switch (isConnected) { + true => Icons.bluetooth_connected_outlined, + false => Icons.bluetooth_disabled_outlined, + }, + value: isConnected ? context.i18n.connected : context.i18n.disconnected, + ), + + if (batteryLevel != null) + DeviceInfo( + title: context.i18n.battery, + icon: switch (batteryLevel!) { + >= 80 => Icons.battery_full, + >= 60 => Icons.battery_6_bar, + >= 50 => Icons.battery_5_bar, + >= 25 => Icons.battery_4_bar, + >= 10 => Icons.battery_2_bar, + _ => Icons.battery_alert, + }, + value: '$batteryLevel%', + ), + if (firmwareVersion != null) + DeviceInfo( + title: context.i18n.firmware, + icon: this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion + ? Icons.warning + : Icons.text_fields_sharp, + value: firmwareVersion!, + additionalInfo: (this is ZwiftDevice && firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion) + ? Text( + ' (${context.i18n.latestVersion((this as ZwiftDevice).latestFirmwareVersion)})', + style: TextStyle(color: Theme.of(context).colorScheme.destructive, fontSize: 12), + ) + : null, + ), + + if (rssi != null) + StreamBuilder( + stream: core.connection.rssiConnectionStream + .where((device) => device == this) + .map((event) => event.rssi), + builder: (context, rssiValue) { + return DeviceInfo( + title: context.i18n.signal, + icon: switch (rssiValue.data ?? rssi!) { + >= -50 => Icons.signal_cellular_4_bar, + >= -60 => Icons.signal_cellular_alt_2_bar, + >= -70 => Icons.signal_cellular_alt_1_bar, + _ => Icons.signal_cellular_alt, + }, + value: '$rssi dBm', + ); + }, + ), + ], + ), + ], + ); + } + + void debugSubscribeToAll(List services) { + for (final service in services) { + for (final characteristic in service.characteristics) { + if (characteristic.properties.contains(CharacteristicProperty.indicate)) { + debugPrint('Subscribing to indications for ${service.uuid} / ${characteristic.uuid}'); + UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid); + } + if (characteristic.properties.contains(CharacteristicProperty.notify)) { + debugPrint('Subscribing to notifications for ${service.uuid} / ${characteristic.uuid}'); + UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid); + } + } + } + } +} diff --git a/lib/bluetooth/devices/cycplus/cycplus_bc2.dart b/lib/bluetooth/devices/cycplus/cycplus_bc2.dart new file mode 100644 index 000000000..c22263c47 --- /dev/null +++ b/lib/bluetooth/devices/cycplus/cycplus_bc2.dart @@ -0,0 +1,129 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../bluetooth_device.dart'; + +class CycplusBc2 extends BluetoothDevice { + CycplusBc2(super.scanResult) + : super( + availableButtons: CycplusBc2Buttons.values, + allowMultiple: true, + ); + + @override + Future handleServices(List services) async { + final service = services.firstWhere( + (e) => e.uuid.toLowerCase() == CycplusBc2Constants.SERVICE_UUID.toLowerCase(), + orElse: () => throw Exception('Service not found: ${CycplusBc2Constants.SERVICE_UUID}'), + ); + final characteristic = service.characteristics.firstWhere( + (e) => e.uuid.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase(), + orElse: () => throw Exception('Characteristic not found: ${CycplusBc2Constants.TX_CHARACTERISTIC_UUID}'), + ); + + await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid); + } + + // Track last state for index 6 and 7 + int _lastStateIndex6 = 0x00; + int _lastStateIndex7 = 0x00; + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) { + if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) { + if (bytes.length > 7) { + final buttonsToPress = []; + + // Process index 6 (shift up) + final currentByte6 = bytes[6]; + if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) { + buttonsToPress.add(availableButtons[0]); + _lastStateIndex6 = 0x00; // Reset after successful press + } else { + _updateState(currentByte6, (val) => _lastStateIndex6 = val); + } + + // Process index 7 (shift down) + final currentByte7 = bytes[7]; + if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) { + buttonsToPress.add(availableButtons[1]); + _lastStateIndex7 = 0x00; // Reset after successful press + } else { + _updateState(currentByte7, (val) => _lastStateIndex7 = val); + } + + handleButtonsClicked(buttonsToPress); + } else { + actionStreamInternal.add( + LogNotification( + 'CYCPLUS BC2 received unexpected packet: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join()}', + ), + ); + handleButtonsClicked([]); + } + } + return Future.value(); + } + + // Check if we should trigger a shift based on current and last state + bool _shouldTriggerShift(int currentByte, int lastByte) { + const pressedValues = {0x01, 0x02, 0x03}; + + // State change from one pressed value to another different pressed value + // This is the ONLY time we trigger a shift + if (pressedValues.contains(currentByte) && pressedValues.contains(lastByte) && currentByte != lastByte) { + return true; + } + + return false; + } + + // Update state tracking + void _updateState(int currentByte, void Function(int) setState) { + const pressedValues = {0x01, 0x02, 0x03}; + const releaseValue = 0x00; + + // Button released: current is 0x00 and last was pressed + if (currentByte == releaseValue) { + setState(releaseValue); + } + // Lock the new pressed state + else if (pressedValues.contains(currentByte)) { + setState(currentByte); + } + } +} + +class CycplusBc2Constants { + // Nordic UART Service (NUS) - commonly used by CYCPLUS BC2 + static const String SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"; + + // TX Characteristic - device sends data to app + static const String TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"; + + // RX Characteristic - app sends data to device (not used for button reading) + static const String RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"; +} + +class CycplusBc2Buttons { + static const ControllerButton shiftUp = ControllerButton( + 'shiftUp', + action: InGameAction.shiftUp, + icon: Icons.add, + ); + + static const ControllerButton shiftDown = ControllerButton( + 'shiftDown', + action: InGameAction.shiftDown, + icon: Icons.remove, + ); + + static const List values = [ + shiftUp, + shiftDown, + ]; +} diff --git a/lib/bluetooth/devices/elite/elite_square.dart b/lib/bluetooth/devices/elite/elite_square.dart new file mode 100644 index 000000000..b4f537e51 --- /dev/null +++ b/lib/bluetooth/devices/elite/elite_square.dart @@ -0,0 +1,155 @@ +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../../messages/notification.dart'; +import '../bluetooth_device.dart'; + +class EliteSquare extends BluetoothDevice { + EliteSquare(super.scanResult) + : super( + availableButtons: EliteSquareButtons.values.toList(), + isBeta: true, + ); + + String? _lastValue; + + @override + Future handleServices(List services) async { + final service = services.firstOrNullWhere((e) => e.uuid == SquareConstants.SERVICE_UUID); + if (service == null) { + throw Exception('Service not found: ${SquareConstants.SERVICE_UUID}'); + } + final characteristic = service.characteristics.firstOrNullWhere( + (e) => e.uuid == SquareConstants.CHARACTERISTIC_UUID, + ); + if (characteristic == null) { + throw Exception('Characteristic not found: ${SquareConstants.CHARACTERISTIC_UUID}'); + } + + await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + if (characteristic == SquareConstants.CHARACTERISTIC_UUID) { + final fullValue = _bytesToHex(bytes); + final currentValue = _extractButtonCode(fullValue); + if (kDebugMode) { + actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)')); + } + + if (_lastValue != null) { + final currentRelevantPart = fullValue.length >= 14 ? fullValue.substring(6, 14) : fullValue.substring(6); + final lastRelevantPart = _lastValue!.length >= 14 ? _lastValue!.substring(6, 14) : _lastValue!.substring(6); + + if (currentRelevantPart != lastRelevantPart) { + final buttonClicked = SquareConstants.BUTTON_MAPPING[currentValue]; + if (kDebugMode) { + actionStreamInternal.add(LogNotification('Button pressed: $buttonClicked')); + } + handleButtonsClicked([ + if (buttonClicked != null) buttonClicked, + ]); + } + } + + _lastValue = fullValue; + } + } + + String _extractButtonCode(String hexValue) { + if (hexValue.length >= 14) { + final buttonCode = hexValue.substring(6, 14); + if (SquareConstants.BUTTON_MAPPING.containsKey(buttonCode)) { + return buttonCode; + } + } + return hexValue; + } + + String _bytesToHex(List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + } +} + +class SquareConstants { + static const String DEVICE_NAME = "SQUARE"; + static const String CHARACTERISTIC_UUID = "347b0043-7635-408b-8918-8ff3949ce592"; + static const String SERVICE_UUID = "347b0001-7635-408b-8918-8ff3949ce592"; + static const int RECONNECT_DELAY = 5; // seconds between reconnection attempts + + // Button mapping https://images.bike24.com/i/mb/c7/36/d9/elite-square-smart-frame-indoor-bike-3-1724305.jpg + static const Map BUTTON_MAPPING = { + "00000200": EliteSquareButtons.up, //"Up", + "00000100": EliteSquareButtons.left, //"Left", + "00000800": EliteSquareButtons.down, // "Down", + "00000400": EliteSquareButtons.right, //"Right", + "00002000": EliteSquareButtons.x, //"X", + "00001000": EliteSquareButtons.square, // "Square", + "00008000": EliteSquareButtons.campagnoloLeft, // "Left Campagnolo", + "00004000": EliteSquareButtons.leftBrake, //"Left brake", + "00000002": EliteSquareButtons.leftShift1, //"Left shift 1", + "00000001": EliteSquareButtons.leftShift2, // "Left shift 2", + "02000000": EliteSquareButtons.y, // "Y", + "01000000": EliteSquareButtons.a, //"A", + "08000000": EliteSquareButtons.b, // "B", + "04000000": EliteSquareButtons.z, // "Z", + "20000000": EliteSquareButtons.circle, // "Circle", + "10000000": EliteSquareButtons.triangle, //"Triangle", + "80000000": EliteSquareButtons.campagnoloRight, // "Right Campagnolo", + "40000000": EliteSquareButtons.rightBrake, //"Right brake", + "00020000": EliteSquareButtons.rightShift1, //"Right shift 1", + "00010000": EliteSquareButtons.rightShift2, //"Right shift 2", + }; +} + +class EliteSquareButtons { + static const ControllerButton up = ControllerButton('eliteSquareUp', action: null); + static const ControllerButton left = ControllerButton('eliteSquareLeft', action: InGameAction.navigateLeft); + static const ControllerButton down = ControllerButton('eliteSquareDown', action: null); + static const ControllerButton right = ControllerButton('eliteSquareRight', action: InGameAction.navigateRight); + static const ControllerButton x = ControllerButton('eliteSquareX', action: null); + static const ControllerButton square = ControllerButton('eliteSquareSquare', action: null); + static const ControllerButton campagnoloLeft = ControllerButton('eliteSquareCampagnoloLeft', action: null); + static const ControllerButton leftBrake = ControllerButton('eliteSquareLeftBrake', action: null); + static const ControllerButton leftShift1 = ControllerButton('eliteSquareLeftShift1', action: InGameAction.shiftUp); + static const ControllerButton leftShift2 = ControllerButton('eliteSquareLeftShift2', action: InGameAction.shiftDown); + static const ControllerButton y = ControllerButton('y', action: null); + static const ControllerButton a = ControllerButton('a', action: null); + static const ControllerButton b = ControllerButton('b', action: null); + static const ControllerButton z = ControllerButton('z', action: null); + static const ControllerButton circle = ControllerButton('eliteSquareCircle', action: null); + static const ControllerButton triangle = ControllerButton('eliteSquareTriangle', action: null); + static const ControllerButton campagnoloRight = ControllerButton('eliteSquareCampagnoloRight', action: null); + static const ControllerButton rightBrake = ControllerButton('eliteSquareRightBrake', action: null); + static const ControllerButton rightShift1 = ControllerButton('eliteSquareRightShift1', action: InGameAction.shiftUp); + static const ControllerButton rightShift2 = ControllerButton( + 'eliteSquareRightShift2', + action: InGameAction.shiftDown, + ); + + static const List values = [ + up, + left, + down, + right, + x, + square, + campagnoloLeft, + leftBrake, + leftShift1, + leftShift2, + y, + a, + b, + z, + circle, + triangle, + campagnoloRight, + rightBrake, + rightShift1, + rightShift2, + ]; +} diff --git a/lib/bluetooth/devices/elite/elite_sterzo.dart b/lib/bluetooth/devices/elite/elite_sterzo.dart new file mode 100644 index 000000000..57d19cdd6 --- /dev/null +++ b/lib/bluetooth/devices/elite/elite_sterzo.dart @@ -0,0 +1,390 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:bike_control/bluetooth/devices/bluetooth_device.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../../messages/notification.dart'; + +class EliteSterzo extends BluetoothDevice { + EliteSterzo(super.scanResult) : super(availableButtons: SterzoButtons.values); + + double _lastAngle = 0.0; + int? _latestChallenge; + String? _serviceUuid; + static Uint8List? _challengeCodesData; + static bool _isLoadingChallenges = false; + + // Calibration state + final List _calibrationSamples = []; + double _calibrationOffset = 0.0; + bool _isCalibrated = false; + + // Last rounded angle for logging optimization + int? _lastRoundedAngle; + + // Debounce timer for PWM-like keypress behavior + Timer? _keypressTimer; + bool _isProcessingKeypresses = false; + + @override + Future handleServices(List services) async { + final service = services.firstOrNullWhere( + (e) => e.uuid.toLowerCase().startsWith('347b0'), + ); + if (service == null) { + throw Exception('Elite Sterzo service not found'); + } + + _serviceUuid = service.uuid; + + // Find characteristics + final challengeChar = service.characteristics.firstOrNullWhere( + (e) => e.uuid == SterzoConstants.CHALLENGE_CODE_CHARACTERISTIC_UUID, + ); + final measurementChar = service.characteristics.firstOrNullWhere( + (e) => e.uuid == SterzoConstants.MEASUREMENT_CHARACTERISTIC_UUID, + ); + final controlChar = service.characteristics.firstOrNullWhere( + (e) => e.uuid == SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID, + ); + + if (challengeChar == null || measurementChar == null || controlChar == null) { + throw Exception('Required Sterzo characteristics not found'); + } + + // Subscribe to challenge code indications + await UniversalBle.subscribeIndications( + device.deviceId, + service.uuid, + challengeChar.uuid, + ); + + // Subscribe to measurement notifications + await UniversalBle.subscribeNotifications( + device.deviceId, + service.uuid, + measurementChar.uuid, + ); + + // Request to start challenge + await UniversalBle.write( + device.deviceId, + service.uuid, + controlChar.uuid, + Uint8List.fromList([0x03, 0x10]), + withoutResponse: false, + ); + + actionStreamInternal.add(LogNotification('Elite Sterzo: Initialization started')); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + if (characteristic == SterzoConstants.CHALLENGE_CODE_CHARACTERISTIC_UUID) { + _handleChallengeCode(bytes); + } else if (characteristic == SterzoConstants.MEASUREMENT_CHARACTERISTIC_UUID) { + _handleSteeringMeasurement(bytes); + } + } + + Future _handleChallengeCode(Uint8List bytes) async { + if (bytes.length >= 4) { + // Challenge is in bytes 2-3 (big-endian) + final challenge = (bytes[2] << 8) | bytes[3]; + _latestChallenge = challenge; + + actionStreamInternal.add(LogNotification('Elite Sterzo: Received challenge code: $challenge')); + + // Respond to challenge + await _activateSteeringMeasurements(); + } + } + + Future _activateSteeringMeasurements() async { + if (_latestChallenge == null || _serviceUuid == null) { + return; + } + + // Ensure challenge codes are loaded + await _ensureChallengeCodesLoaded(); + + // Get response codes for the challenge + final challengeCodes = _getChallengeResponse(_latestChallenge!); + + // Send challenge response + await UniversalBle.write( + device.deviceId, + _serviceUuid!, + SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID, + Uint8List.fromList([0x03, 0x11, challengeCodes[0], challengeCodes[1]]), + withoutResponse: false, + ); + + await Future.delayed(const Duration(seconds: 1)); + + // Activate measurements + await UniversalBle.write( + device.deviceId, + _serviceUuid!, + SterzoConstants.CONTROL_POINT_CHARACTERISTIC_UUID, + Uint8List.fromList([0x02, 0x02]), + withoutResponse: false, + ); + + actionStreamInternal.add(LogNotification('Elite Sterzo: Steering measurements activated')); + } + + static Future _ensureChallengeCodesLoaded() async { + if (_challengeCodesData != null) { + return; // Already loaded + } + + // Wait if already loading + while (_isLoadingChallenges) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + // Check again after waiting + if (_challengeCodesData != null) { + return; + } + + _isLoadingChallenges = true; + + try { + if (kIsWeb) { + // On web, always fetch from HTTP + _challengeCodesData = await _fetchChallengeCodes(); + } else { + // On native platforms, try to load from cache first + _challengeCodesData = await _loadCachedChallengeCodes(); + + if (_challengeCodesData == null) { + // Cache miss - fetch from HTTP and cache it + _challengeCodesData = await _fetchChallengeCodes(); + if (_challengeCodesData != null) { + await _cacheChallengeCodes(_challengeCodesData!); + } + } + } + } finally { + _isLoadingChallenges = false; + } + } + + static Future _fetchChallengeCodes() async { + final url = kIsWeb + ? 'https://corsproxy.io/${SterzoConstants.CHALLENGE_CODES_URL}' + : SterzoConstants.CHALLENGE_CODES_URL; + try { + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + return response.bodyBytes; + } + } catch (e) { + if (kDebugMode) { + print('Failed to fetch challenge codes for URL $url: $e'); + } + } + return null; + } + + static Future _loadCachedChallengeCodes() async { + try { + final prefs = await SharedPreferences.getInstance(); + final cached = prefs.getString(SterzoConstants.CACHE_KEY); + + if (cached != null) { + // Decode from base64 + return base64Decode(cached); + } + } catch (e) { + if (kDebugMode) { + print('Failed to load cached challenge codes: $e'); + } + } + return null; + } + + static Future _cacheChallengeCodes(Uint8List data) async { + try { + final prefs = await SharedPreferences.getInstance(); + // Encode to base64 for storage + await prefs.setString(SterzoConstants.CACHE_KEY, base64Encode(data)); + } catch (e) { + if (kDebugMode) { + print('Failed to cache challenge codes: $e'); + } + } + } + + void _handleSteeringMeasurement(Uint8List bytes) { + if (bytes.length >= 4) { + // Steering angle is a 32-bit float (little-endian) + final rawAngle = ByteData.sublistView(bytes).getFloat32(0, Endian.little); + + // Ignore NaN readings during initial connection + if (rawAngle.isNaN) { + return; + } + + // Handle calibration: collect initial samples to compute offset + if (!_isCalibrated) { + _calibrationSamples.add(rawAngle); + if (_calibrationSamples.length >= SterzoConstants.CALIBRATION_SAMPLE_COUNT) { + // Compute average offset from collected samples + _calibrationOffset = _calibrationSamples.reduce((a, b) => a + b) / _calibrationSamples.length; + _isCalibrated = true; + actionStreamInternal.add( + LogNotification('Elite Sterzo: Calibration complete, offset: ${_calibrationOffset.toStringAsFixed(2)}°'), + ); + } + return; // Don't process steering during calibration + } + + // Apply calibration offset + final calibratedAngle = rawAngle - _calibrationOffset; + + // Round to whole degrees to reduce noise + final roundedAngle = calibratedAngle.round(); + + // Only log and process steering when rounded value changes to reduce verbosity + if (_lastRoundedAngle != roundedAngle) { + actionStreamInternal.add(LogNotification('Steering angle: $roundedAngle°')); + _lastRoundedAngle = roundedAngle; + + // Apply PWM-like steering behavior only when angle changes + _applyPWMSteering(roundedAngle); + } + + _lastAngle = calibratedAngle; + } + } + + /// Applies PWM-like steering behavior with repeated keypresses proportional to angle magnitude + void _applyPWMSteering(int roundedAngle) { + // Cancel any pending keypress timer + _keypressTimer?.cancel(); + + // Determine if we're steering + if (roundedAngle.abs() > SterzoConstants.STEERING_THRESHOLD) { + // Determine direction + final button = roundedAngle > 0 ? SterzoButtons.rightSteer : SterzoButtons.leftSteer; + + // Calculate number of keypress levels based on angle magnitude + final levels = _calculateKeypressLevels(roundedAngle.abs()); + + // Only trigger new keypresses when rounded angle changes to avoid overlap + // The check for _lastRoundedAngle change is already done in _handleSteeringMeasurement + // so we know this is a new angle value + _scheduleRepeatedKeypresses(button, levels); + } else { + // Center position - release any held buttons + handleButtonsClicked([]); + } + } + + /// Calculates the number of keypress levels based on angle magnitude + int _calculateKeypressLevels(int absAngle) { + final levels = (absAngle / SterzoConstants.LEVEL_DEGREE_STEP).floor(); + return levels.clamp(1, SterzoConstants.MAX_LEVELS); + } + + /// Schedules repeated keypresses to simulate PWM behavior + Future _scheduleRepeatedKeypresses(ControllerButton button, int levels) async { + // Don't overlap keypress sequences + if (_isProcessingKeypresses) { + return; + } + + _isProcessingKeypresses = true; + + // Send keypresses in sequence with delays between them + for (int i = 0; i < levels; i++) { + await Future.delayed(Duration(milliseconds: SterzoConstants.KEY_REPEAT_INTERVAL_MS)); + handleButtonsClicked([button]); + } + + _isProcessingKeypresses = false; + } + + List _getChallengeResponse(int challenge) { + if (_challengeCodesData == null) { + // Fallback if data not loaded + return [0x96, 0x96]; + } + + final index = challenge * 2; + if (index >= 0 && index < _challengeCodesData!.length - 1) { + return [_challengeCodesData![index], _challengeCodesData![index + 1]]; + } + + // Fallback for out of range challenges + return [0x96, 0x96]; + } + + @override + Future disconnect() async { + _keypressTimer?.cancel(); + await super.disconnect(); + } +} + +class SterzoConstants { + static const String DEVICE_NAME = "STERZO"; + + // Elite Sterzo Smart characteristic UUIDs + static const String MEASUREMENT_CHARACTERISTIC_UUID = "347b0030-7635-408b-8918-8ff3949ce592"; + static const String CONTROL_POINT_CHARACTERISTIC_UUID = "347b0031-7635-408b-8918-8ff3949ce592"; + static const String CHALLENGE_CODE_CHARACTERISTIC_UUID = "347b0032-7635-408b-8918-8ff3949ce592"; + + // Service UUID pattern (matches Elite devices) + static const String SERVICE_UUID = "347b0001-7635-408b-8918-8ff3949ce592"; + + // Steering angle threshold in degrees to trigger steering action + static const double STEERING_THRESHOLD = 10.0; + + static const int RECONNECT_DELAY = 5; // seconds between reconnection attempts + + // URL to fetch challenge codes + static const String CHALLENGE_CODES_URL = + 'https://github.com/zacharyedwardbull/pycycling/raw/refs/heads/master/pycycling/data/sterzo-challenge-codes.dat'; + + // Cache key for SharedPreferences + static const String CACHE_KEY = 'elite_sterzo_challenge_codes'; + + // Calibration settings + // Number of initial valid samples to collect for calibration offset + static const int CALIBRATION_SAMPLE_COUNT = 10; + + // PWM-like steering behavior constants + // Degrees per level for repeated keypress behavior + static const double LEVEL_DEGREE_STEP = 10.0; + // Maximum number of keypress levels + static const int MAX_LEVELS = 5; + // Interval between repeated keypresses in milliseconds + static const int KEY_REPEAT_INTERVAL_MS = 40; +} + +class SterzoButtons { + static final ControllerButton leftSteer = ControllerButton( + 'leftSteer', + action: InGameAction.steerLeft, + ); + static final ControllerButton rightSteer = ControllerButton( + 'rightSteer', + action: InGameAction.steerRight, + ); + + static List get values => [ + leftSteer, + rightSteer, + ]; +} diff --git a/lib/bluetooth/devices/gamepad/gamepad_device.dart b/lib/bluetooth/devices/gamepad/gamepad_device.dart new file mode 100644 index 000000000..b6714ea3d --- /dev/null +++ b/lib/bluetooth/devices/gamepad/gamepad_device.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/pages/device.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/widgets/ui/beta_pill.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:dartx/dartx.dart'; +import 'package:gamepads/gamepads.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class GamepadDevice extends BaseDevice { + final String id; + + GamepadDevice(super.name, {required this.id}) : super(availableButtons: [], uniqueId: id); + + List _lastButtonsClicked = []; + + @override + Future connect() async { + Gamepads.eventsByGamepad(id).listen((event) async { + actionStreamInternal.add(LogNotification('Gamepad event: ${event.key} value ${event.value} type ${event.type}')); + + final int normalizedValue = switch (event.value) { + > 1.0 => 1, + < -1.0 => -1, + _ => event.value.toInt(), + }; + + final buttonKey = event.type == KeyType.analog ? '${event.key}_$normalizedValue' : event.key; + ControllerButton button = getOrAddButton( + buttonKey, + () => ControllerButton(buttonKey, sourceDeviceId: id), + ); + + switch (event.type) { + case KeyType.analog: + final releasedValue = Platform.isWindows ? 1 : 0; + + if (event.value.round().abs() != releasedValue) { + final buttonsClicked = [button]; + if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) { + handleButtonsClicked(buttonsClicked); + } + _lastButtonsClicked = buttonsClicked; + } else { + _lastButtonsClicked = []; + handleButtonsClicked([]); + } + case KeyType.button: + final buttonsClicked = event.value.toInt() != 1 ? [button] : []; + if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) { + handleButtonsClicked(buttonsClicked); + } + _lastButtonsClicked = buttonsClicked; + } + }); + } + + @override + Widget showInformation(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + spacing: 8, + children: [ + Row( + spacing: 8, + children: [ + Text( + toString().screenshot, + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (isBeta) BetaPill(), + ], + ), + if (Platform.isAndroid && !core.settings.getLocalEnabled()) + Warning( + children: [ + Text( + 'For it to work properly, even when BikeControl is in the background, you need to enable the local connection method in the next tab.', + ).small, + ], + ), + ], + ), + ); + } +} diff --git a/lib/bluetooth/devices/gyroscope/gyroscope_steering.dart b/lib/bluetooth/devices/gyroscope/gyroscope_steering.dart new file mode 100644 index 000000000..4b45b0ae0 --- /dev/null +++ b/lib/bluetooth/devices/gyroscope/gyroscope_steering.dart @@ -0,0 +1,485 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/bluetooth/devices/gyroscope/steering_estimator.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/pages/device.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/widgets/ui/beta_pill.dart'; +import 'package:bike_control/widgets/ui/device_info.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:flutter/foundation.dart'; +import 'package:sensors_plus/sensors_plus.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Gyroscope and Accelerometer based steering device +/// Detects handlebar movement when the phone is mounted on the handlebar +class GyroscopeSteering extends BaseDevice { + GyroscopeSteering() + : super( + 'Phone Steering', + availableButtons: GyroscopeSteeringButtons.values, + isBeta: true, + uniqueId: 'gyroscope_steering_device', + buttonPrefix: 'gyro', + ); + + StreamSubscription? _gyroscopeSubscription; + StreamSubscription? _accelerometerSubscription; + StreamSubscription? _magnetometerSubscription; + + // Calibration state + final SteeringEstimator _estimator = SteeringEstimator(); + bool _isCalibrated = false; + ControllerButton? _lastSteeringButton; + + // Accelerometer raw data + bool _hasAccelData = false; + + // Time tracking for integration + DateTime? _lastGyroUpdate; + + // Last rounded angle for change detection + int? _lastRoundedAngle; + + // Debounce timer for PWM-like keypress behavior + Timer? _keypressTimer; + + // Magnetometer mode + bool _useMagnetometer = false; + double? _magnetometerCalibrationHeading; + double _currentMagnetometerAngle = 0.0; + final List _magnetometerCalibrationSamples = []; + + // Magnetometer filtering state + double? _filteredMagX; + double? _filteredMagY; + static const double _magnetometerFilterAlpha = 0.15; // Lower = more smoothing + + // Configuration (can be made customizable later) + static const double STEERING_THRESHOLD = 5.0; // degrees + static const double LEVEL_DEGREE_STEP = 10.0; // degrees per level + static const int MAX_LEVELS = 5; + static const int KEY_REPEAT_INTERVAL_MS = 40; + static const double COMPLEMENTARY_FILTER_ALPHA = 0.98; // Weight for gyroscope + static const double LOW_PASS_FILTER_ALPHA = 0.9; // Smoothing factor + + /// Start listening to the appropriate sensors based on the current mode + Future _startSensorStreams() async { + // Cancel all existing subscriptions first + await _gyroscopeSubscription?.cancel(); + await _accelerometerSubscription?.cancel(); + await _magnetometerSubscription?.cancel(); + _gyroscopeSubscription = null; + _accelerometerSubscription = null; + _magnetometerSubscription = null; + + if (_useMagnetometer) { + // Magnetometer mode: only listen to magnetometer + _magnetometerSubscription = magnetometerEventStream().listen( + _handleMagnetometerEvent, + onError: (error) { + actionStreamInternal.add(LogNotification('Magnetometer error: $error')); + }, + ); + actionStreamInternal.add(LogNotification('Started magnetometer stream')); + } else { + // Gyroscope mode: listen to gyroscope and accelerometer + _gyroscopeSubscription = gyroscopeEventStream().listen( + _handleGyroscopeEvent, + onError: (error) { + actionStreamInternal.add(LogNotification('Gyroscope error: $error')); + }, + ); + + _accelerometerSubscription = accelerometerEventStream().listen( + _handleAccelerometerEvent, + onError: (error) { + actionStreamInternal.add(LogNotification('Accelerometer error: $error')); + }, + ); + actionStreamInternal.add(LogNotification('Started gyroscope and accelerometer streams')); + } + } + + @override + Future connect() async { + if (isConnected) { + return; + } + + try { + // Start listening to sensors based on current mode + await _startSensorStreams(); + + isConnected = true; + actionStreamInternal.add(LogNotification('Gyroscope Steering: Connected - Calibrating...')); + + // Reset calibration/estimator + _isCalibrated = false; + _hasAccelData = false; + _estimator.reset(); + _lastGyroUpdate = null; + _lastRoundedAngle = null; + _lastSteeringButton = null; + } catch (e) { + actionStreamInternal.add(LogNotification('Failed to connect Gyroscope Steering: $e')); + isConnected = false; + rethrow; + } + } + + void _handleGyroscopeEvent(GyroscopeEvent event) { + final now = DateTime.now(); + + if (!_hasAccelData) { + _lastGyroUpdate = now; + return; + } + + final dt = _lastGyroUpdate != null ? (now.difference(_lastGyroUpdate!).inMicroseconds / 1000000.0) : 0.0; + _lastGyroUpdate = now; + + if (dt <= 0 || dt >= 1.0) { + return; + } + + // iOS drift fix: + // - integrate bias-corrected gyro z (yaw) into an estimator + // - learn bias while the device is still + final angleDeg = _estimator.updateGyro(wz: event.z, dt: dt); + + if (!_isCalibrated) { + // Consider calibration complete once we have a bit of stillness and sensor data. + // This gives the bias estimator time to settle. + if (_estimator.stillTimeSec >= 0.6) { + _estimator.calibrate(seedBiasZRadPerSec: _estimator.biasZRadPerSec); + _isCalibrated = true; + /*actionStreamInternal.add( + AlertNotification(LogLevel.LOGLEVEL_INFO, 'Calibration complete.'), + );*/ + } + return; + } + + _processSteeringAngle(angleDeg); + } + + void _handleAccelerometerEvent(AccelerometerEvent event) { + _hasAccelData = true; + _estimator.updateAccel(x: event.x, y: event.y, z: event.z); + } + + void _handleMagnetometerEvent(MagnetometerEvent event) { + // Magnetometer mode: calculate heading from X and Y components + // This is more stable than using a single axis + + // Apply low-pass filter to reduce noise + if (_filteredMagX == null || _filteredMagY == null) { + // Initialize on first reading + _filteredMagX = event.x; + _filteredMagY = event.y; + } else { + // Exponential moving average (low-pass filter) + _filteredMagX = _magnetometerFilterAlpha * event.x + (1 - _magnetometerFilterAlpha) * _filteredMagX!; + _filteredMagY = _magnetometerFilterAlpha * event.y + (1 - _magnetometerFilterAlpha) * _filteredMagY!; + } + + // Calculate heading from filtered X and Y components + // atan2(y, x) gives the angle in radians, convert to degrees + double heading = atan2(_filteredMagY!, _filteredMagX!) * (180 / pi); + + // Normalize heading to 0-360 range + if (heading < 0) heading += 360; + + if (kDebugMode) { + print( + 'Magnetometer - X: ${event.x.toStringAsFixed(2)}, Y: ${event.y.toStringAsFixed(2)}, ' + 'Filtered X: ${_filteredMagX!.toStringAsFixed(2)}, Filtered Y: ${_filteredMagY!.toStringAsFixed(2)}, ' + 'Heading: ${heading.toStringAsFixed(2)}°', + ); + } + + // During calibration, collect heading samples + if (!_isCalibrated) { + _magnetometerCalibrationSamples.add(heading); + + // After 30 samples (~1 second at typical rates), calculate calibration heading + if (_magnetometerCalibrationSamples.length >= 30) { + // For heading, we need to handle the circular nature (0° and 360° are the same) + // Use circular mean calculation + double sumSin = 0, sumCos = 0; + for (var h in _magnetometerCalibrationSamples) { + final radians = h * (pi / 180); + sumSin += sin(radians); + sumCos += cos(radians); + } + final avgSin = sumSin / _magnetometerCalibrationSamples.length; + final avgCos = sumCos / _magnetometerCalibrationSamples.length; + _magnetometerCalibrationHeading = atan2(avgSin, avgCos) * (180 / pi); + if (_magnetometerCalibrationHeading! < 0) + _magnetometerCalibrationHeading = _magnetometerCalibrationHeading! + 360; + + _magnetometerCalibrationSamples.clear(); + _isCalibrated = true; + actionStreamInternal.add( + LogNotification( + 'Magnetometer calibration complete. Reference heading: ${_magnetometerCalibrationHeading!.toStringAsFixed(2)}°', + ), + ); + } + return; + } + + // Calculate steering angle relative to calibrated heading + // This is the angular difference, accounting for wrap-around + double angleDeg = heading - _magnetometerCalibrationHeading!; + + // Normalize to -180 to +180 range + if (angleDeg > 180) { + angleDeg -= 360; + } else if (angleDeg < -180) { + angleDeg += 360; + } + + _currentMagnetometerAngle = angleDeg; + + _processSteeringAngle(angleDeg); + } + + void _processSteeringAngle(double steeringAngleDeg) { + final roundedAngle = steeringAngleDeg.round(); + + if (_lastRoundedAngle != roundedAngle) { + if (kDebugMode) { + actionStreamInternal.add( + LogNotification( + 'Steering angle: $roundedAngle° (biasZ=${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s)', + ), + ); + } + _lastRoundedAngle = roundedAngle; + _applyPWMSteering(roundedAngle); + } + } + + /// Applies PWM-like steering behavior with repeated keypresses proportional to angle magnitude + void _applyPWMSteering(int roundedAngle) { + // Cancel any pending keypress timer + _keypressTimer?.cancel(); + + // Determine if we're steering + if (roundedAngle.abs() > core.settings.getPhoneSteeringThreshold()) { + // Determine direction + final button = roundedAngle < 0 ? GyroscopeSteeringButtons.rightSteer : GyroscopeSteeringButtons.leftSteer; + + if (_lastSteeringButton != button) { + // New steering direction - reset any previous state + _lastSteeringButton = button; + } else { + return; + } + + handleButtonsClicked([button]); + } else { + _lastSteeringButton = null; + // Center position - release any held buttons + handleButtonsClicked([]); + } + } + + @override + Future disconnect() async { + await _gyroscopeSubscription?.cancel(); + await _accelerometerSubscription?.cancel(); + await _magnetometerSubscription?.cancel(); + _gyroscopeSubscription = null; + _accelerometerSubscription = null; + _magnetometerSubscription = null; + _keypressTimer?.cancel(); + isConnected = false; + _isCalibrated = false; + _hasAccelData = false; + _estimator.reset(); + _magnetometerCalibrationHeading = null; + _magnetometerCalibrationSamples.clear(); + _currentMagnetometerAngle = 0.0; + _filteredMagX = null; + _filteredMagY = null; + actionStreamInternal.add(LogNotification('Gyroscope Steering: Disconnected')); + } + + @override + Widget showInformation(BuildContext context) { + return StatefulBuilder( + builder: (c, setState) => Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Row( + spacing: 12, + children: [ + Text( + toString().screenshot, + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (isBeta) BetaPill(), + ], + ), + // Magnetometer mode toggle + Checkbox( + trailing: Expanded(child: Text('Use Magnetometer Mode')), + state: _useMagnetometer ? CheckboxState.checked : CheckboxState.unchecked, + onChanged: (value) async { + setState(() { + _useMagnetometer = value == CheckboxState.checked; + // Reset calibration when switching modes + _isCalibrated = false; + _hasAccelData = false; + _estimator.reset(); + _lastGyroUpdate = null; + _lastRoundedAngle = null; + _lastSteeringButton = null; + _magnetometerCalibrationHeading = null; + _magnetometerCalibrationSamples.clear(); + _currentMagnetometerAngle = 0.0; + _filteredMagX = null; + _filteredMagY = null; + }); + + // Restart sensor streams if device is connected + if (isConnected) { + await _startSensorStreams(); + actionStreamInternal.add( + LogNotification( + 'Switched to ${_useMagnetometer ? "magnetometer" : "gyroscope + accelerometer"} mode', + ), + ); + } + }, + ), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + DeviceInfo( + title: 'Calibration', + icon: BootstrapIcons.wrenchAdjustable, + value: _isCalibrated ? 'Complete' : 'In Progress', + ), + DeviceInfo( + title: 'Steering Angle', + icon: RadixIcons.angle, + value: _isCalibrated + ? '${(_useMagnetometer ? _currentMagnetometerAngle : _estimator.angleDeg).toStringAsFixed(2)}°' + : 'Calibrating...', + ), + if (kDebugMode && !_useMagnetometer) + DeviceInfo( + title: 'Gyro Bias', + icon: BootstrapIcons.speedometer, + value: '${_estimator.biasZRadPerSec.toStringAsFixed(4)} rad/s', + ), + if (kDebugMode && _useMagnetometer && _magnetometerCalibrationHeading != null) + DeviceInfo( + title: 'Mag Heading', + icon: BootstrapIcons.compass, + value: '${_magnetometerCalibrationHeading!.toStringAsFixed(2)}°', + ), + ], + ), + Row( + spacing: 8, + children: [ + PrimaryButton( + size: ButtonSize.small, + leading: !_isCalibrated ? SmallProgressIndicator() : null, + onPressed: !_isCalibrated + ? null + : () { + // Reset calibration + _isCalibrated = false; + if (_useMagnetometer) { + _magnetometerCalibrationHeading = null; + _magnetometerCalibrationSamples.clear(); + _currentMagnetometerAngle = 0.0; + _filteredMagX = null; + _filteredMagY = null; + } else { + _hasAccelData = false; + _estimator.reset(); + _lastGyroUpdate = null; + } + _lastRoundedAngle = null; + _lastSteeringButton = null; + setState(() {}); + }, + child: Text(_isCalibrated ? 'Calibrate' : 'Calibrating...'), + ), + Builder( + builder: (context) { + return PrimaryButton( + size: ButtonSize.small, + trailing: Container( + padding: EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.destructive, + borderRadius: BorderRadius.circular(4), + ), + child: Text('${core.settings.getPhoneSteeringThreshold().toInt()}°'), + ), + onPressed: () { + final values = [for (var i = 3; i <= 12; i += 1) i]; + showDropdown( + context: context, + builder: (b) => DropdownMenu( + children: values + .map( + (v) => MenuButton( + child: Text('$v°'), + onPressed: (c) { + core.settings.setPhoneSteeringThreshold(v); + setState(() {}); + }, + ), + ) + .toList(), + ), + ); + }, + child: Text('Trigger Threshold:'), + ); + }, + ), + ], + ), + if (!_isCalibrated) + Text( + _useMagnetometer + ? 'Calibrating the magnetometer now. Attach your phone/tablet on your handlebar and keep it still for a second.' + : 'Calibrating the sensors now. Attach your phone/tablet on your handlebar and keep it still for a second.', + ).xSmall, + ], + ), + ); + } +} + +class GyroscopeSteeringButtons { + static final ControllerButton leftSteer = ControllerButton( + 'gyroLeftSteer', + action: InGameAction.steerLeft, + ); + static final ControllerButton rightSteer = ControllerButton( + 'gyroRightSteer', + action: InGameAction.steerRight, + ); + + static List get values => [ + leftSteer, + rightSteer, + ]; +} diff --git a/lib/bluetooth/devices/gyroscope/steering_estimator.dart b/lib/bluetooth/devices/gyroscope/steering_estimator.dart new file mode 100644 index 000000000..f71f357e9 --- /dev/null +++ b/lib/bluetooth/devices/gyroscope/steering_estimator.dart @@ -0,0 +1,200 @@ +import 'dart:math'; + +/// Pure-Dart steering estimator for phone-on-handlebar steering. +/// +/// Design goals: +/// - Avoid long-term drift on platforms like iOS by continuously estimating +/// and subtracting gyro bias. +/// - Keep it testable (no Flutter/sensors dependencies). +/// +/// NOTE: This is not a full AHRS. It uses bias-corrected integration and +/// a "stillness" detector to learn gyro bias and optionally auto-recenter. +class SteeringEstimator { + SteeringEstimator({ + this.biasLearningRate = 0.02, + this.gyroStillThresholdRadPerSec = 0.03, + this.accelStillThresholdMS2 = 0.6, + this.minStillTimeForBiasSec = 0.35, + this.biasLearningDeadbandDeg = 3.0, + this.minStillTimeForRecenterSec = double.infinity, + this.recenterHalfLifeSec = 0.7, + this.recenterDeadbandDeg = 2.0, + this.maxAngleAbsDeg = 60, + this.lowPassAlpha = 0.9, + + // Responsiveness / smoothing tuning. + // When steering changes quickly we reduce smoothing, but keep more + // smoothing when stable to avoid jitter. + this.lowPassAlphaStable = 0.9, + this.lowPassAlphaMoving = 0.55, + this.motionAngleRateDegPerSecForMinAlpha = 90.0, + + // Cap dt to avoid "freezing" the estimator on occasional long frames. + this.maxDtSec = 0.05, + }); + + // Tunables + final double biasLearningRate; + final double gyroStillThresholdRadPerSec; + final double accelStillThresholdMS2; + final double minStillTimeForBiasSec; + final double biasLearningDeadbandDeg; + final double minStillTimeForRecenterSec; + final double recenterHalfLifeSec; + final double recenterDeadbandDeg; + final double maxAngleAbsDeg; + + /// Backwards-compatible, kept as-is. + /// + /// If you set `lowPassAlpha = 0.0`, filtering is disabled. + final double lowPassAlpha; + + /// Smoothing used when the angle is stable. + /// + /// Default mirrors the original behavior (`0.9`). + final double lowPassAlphaStable; + + /// Smoothing used when the angle is changing quickly. + /// + /// Lower alpha => faster response. + final double lowPassAlphaMoving; + + /// Angle rate (deg/s) at which we reach `lowPassAlphaMoving`. + final double motionAngleRateDegPerSecForMinAlpha; + + /// Maximum timestep used for integration/bias learning. + final double maxDtSec; + + // State + double _accelX = 0, _accelY = 0, _accelZ = 0; + bool _hasAccel = false; + + double _biasZ = 0.0; // rad/s + double _yawDeg = 0.0; + double _filteredYawDeg = 0.0; + + double _stillTimeSec = 0.0; + + /// Resets the estimator state. + void reset() { + _biasZ = 0.0; + _yawDeg = 0.0; + _filteredYawDeg = 0.0; + _stillTimeSec = 0.0; + _hasAccel = false; + _accelX = _accelY = _accelZ = 0; + } + + /// One-time calibration: assume device is held still and centered. + /// + /// This resets yaw and also seeds the bias to the current z gyro rate. + void calibrate({double? seedBiasZRadPerSec}) { + _yawDeg = 0.0; + _filteredYawDeg = 0.0; + _stillTimeSec = 0.0; + if (seedBiasZRadPerSec != null) { + _biasZ = seedBiasZRadPerSec; + } + } + + void updateAccel({required double x, required double y, required double z}) { + _accelX = x; + _accelY = y; + _accelZ = z; + _hasAccel = true; + } + + /// Update with gyro z-rate (rad/s) and dt (seconds). + /// + /// Returns the current filtered steering angle in degrees. + double updateGyro({required double wz, required double dt}) { + if (dt <= 0) { + return angleDeg; + } + + // If dt spikes (app paused/jank), cap it instead of bailing out. + // This keeps the estimator responsive and avoids "stuck" output. + final usedDt = dt > maxDtSec ? maxDtSec : dt; + + final still = _isStill(wz); + if (still) { + _stillTimeSec += usedDt; + + // Learn gyro bias only when we're still AND near our calibrated center. + // Otherwise, if the user holds a steady steering angle, wz≈0 and we'd + // incorrectly move bias towards 0 and cause the angle to be wrong when + // they return to center. + final nearCenter = _yawDeg.abs() <= biasLearningDeadbandDeg; + if (nearCenter && _stillTimeSec >= minStillTimeForBiasSec) { + // Exponential moving average towards the observed rate. + _biasZ = (1.0 - biasLearningRate) * _biasZ + biasLearningRate * wz; + } + + // IMPORTANT: only auto-recenter when we're already close to center. + // Users may hold a constant steering angle for several seconds. + final canRecenter = _stillTimeSec >= minStillTimeForRecenterSec && _yawDeg.abs() <= recenterDeadbandDeg; + if (canRecenter) { + _applyRecenter(usedDt); + } + } else { + _stillTimeSec = 0.0; + } + + final correctedWz = wz - _biasZ; + _yawDeg += correctedWz * usedDt * (180.0 / pi); + + // Clamp to avoid runaway if something goes wrong. + _yawDeg = _yawDeg.clamp(-maxAngleAbsDeg, maxAngleAbsDeg).toDouble(); + + // Low-pass filter for noise smoothing. + // + // Make it adaptive: when the angle is changing fast, we reduce smoothing + // (more responsive). When stable, we keep stronger smoothing. + // + // If user forces lowPassAlpha=0.0 (existing tests do), we keep behavior + // equivalent (no filtering). + if (lowPassAlpha <= 0.0) { + _filteredYawDeg = _yawDeg; + } else { + final stableAlpha = ((lowPassAlphaStable.isFinite ? lowPassAlphaStable : lowPassAlpha)).clamp(0.0, 0.999); + final movingAlpha = lowPassAlphaMoving.clamp(0.0, stableAlpha); + + // Use a rate estimate derived from the filtered-vs-raw divergence. + final rateDegPerSec = ((_yawDeg - _filteredYawDeg).abs()) / usedDt; + final t = (rateDegPerSec / motionAngleRateDegPerSecForMinAlpha).clamp(0.0, 1.0); + + final alpha = stableAlpha + (movingAlpha - stableAlpha) * t; + _filteredYawDeg = alpha * _filteredYawDeg + (1 - alpha) * _yawDeg; + } + + return angleDeg; + } + + double get angleDeg => _filteredYawDeg; + + double get biasZRadPerSec => _biasZ; + + double get stillTimeSec => _stillTimeSec; + + bool _isStill(double wz) { + // If we don't have accel yet, be conservative: don't learn bias. + if (!_hasAccel) return false; + + final gyroOk = wz.abs() < gyroStillThresholdRadPerSec; + + // Check accel magnitude close to gravity (device not being bumped). + final aMag = sqrt(_accelX * _accelX + _accelY * _accelY + _accelZ * _accelZ); + const g = 9.80665; + final accelOk = (aMag - g).abs() < accelStillThresholdMS2; + + return gyroOk && accelOk; + } + + void _applyRecenter(double dt) { + // Exponential decay towards 0 with given half-life. + // decay = 0.5^(dt/halfLife) + if (recenterHalfLifeSec <= 0) return; + final decay = pow(0.5, dt / recenterHalfLifeSec).toDouble(); + _yawDeg *= decay; + } +} diff --git a/lib/bluetooth/devices/hid/hid_device.dart b/lib/bluetooth/devices/hid/hid_device.dart new file mode 100644 index 000000000..b5665a65d --- /dev/null +++ b/lib/bluetooth/devices/hid/hid_device.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:flutter/material.dart' show PopupMenuButton, PopupMenuItem; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class HidDevice extends BaseDevice { + HidDevice(super.name) : super(availableButtons: [], uniqueId: name!, supportsLongPress: false); + + @override + Future connect() { + return Future.value(null); + } + + @override + Widget showInformation(BuildContext context) { + return Column( + children: [ + Row( + children: [ + Expanded(child: Text(toString()).bold), + PopupMenuButton( + itemBuilder: (c) => [ + PopupMenuItem( + child: Text('Ignore'), + onTap: () { + core.connection.disconnect(this, forget: true, persistForget: true); + if (core.actionHandler is AndroidActions) { + (core.actionHandler as AndroidActions).ignoreHidDevices(); + } else if (core.mediaKeyHandler.isMediaKeyDetectionEnabled.value) { + core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = false; + core.settings.setMediaKeyDetectionEnabled(false); + } + }, + ), + ], + ), + ], + ), + if (Platform.isAndroid && !core.settings.getLocalEnabled()) + Warning( + children: [ + Text( + 'For it to work properly, even when BikeControl is in the background, you need to enable the local connection method in the next tab.', + ).small, + ], + ), + ], + ); + } +} diff --git a/lib/bluetooth/devices/mywhoosh/link.dart b/lib/bluetooth/devices/mywhoosh/link.dart new file mode 100644 index 000000000..686e87c38 --- /dev/null +++ b/lib/bluetooth/devices/mywhoosh/link.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; + +class WhooshLink extends TrainerConnection { + Socket? _socket; + ServerSocket? _server; + + static const String connectionTitle = 'MyWhoosh Link'; + + WhooshLink() + : super( + title: connectionTitle, + supportedActions: [ + InGameAction.shiftUp, + InGameAction.shiftDown, + InGameAction.cameraAngle, + InGameAction.emote, + InGameAction.uturn, + InGameAction.tuck, + InGameAction.steerLeft, + InGameAction.steerRight, + ], + ); + + void stopServer() async { + await _socket?.close(); + await _server?.close(); + isConnected.value = false; + isStarted.value = false; + if (kDebugMode) { + print('Server stopped.'); + } + } + + Future startServer() async { + isStarted.value = true; + try { + // Create and bind server socket + _server = await ServerSocket.bind( + InternetAddress.anyIPv6, + 21587, + shared: true, + v6Only: false, + ); + } catch (e) { + if (kDebugMode) { + print('Failed to start server: $e'); + } + isConnected.value = false; + isStarted.value = false; + rethrow; + } + if (kDebugMode) { + print('Server started on port ${_server!.port}'); + } + + // Accept connection + _server!.listen( + (Socket socket) async { + if (kDebugMode) { + print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}'); + } + + SharedLogic.keepAlive(); + _socket = socket; + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.myWhooshLinkConnected), + ); + isConnected.value = true; + // Listen for data from the client + socket.listen( + (List data) { + try { + if (kDebugMode) { + // TODO we could check if virtual shifting is enabled + final message = utf8.decode(data); + print('Received message: $message'); + } + } catch (_) {} + }, + onDone: () { + print('Client disconnected: $socket'); + + SharedLogic.stopKeepAlive(); + isConnected.value = false; + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'MyWhoosh Link disconnected'), + ); + }, + ); + }, + ); + } + + @override + Future sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async { + final jsonObject = switch (keyPair.inGameAction) { + InGameAction.shiftUp => { + 'MessageType': 'Controls', + 'InGameControls': { + 'GearShifting': '1', + }, + }, + InGameAction.shiftDown => { + 'MessageType': 'Controls', + 'InGameControls': { + 'GearShifting': '-1', + }, + }, + InGameAction.cameraAngle => { + 'MessageType': 'Controls', + 'InGameControls': { + 'CameraAngle': '${keyPair.inGameActionValue}', + }, + }, + InGameAction.emote => { + 'MessageType': 'Controls', + 'InGameControls': { + 'Emote': '${keyPair.inGameActionValue}', + }, + }, + InGameAction.uturn => { + 'MessageType': 'Controls', + 'InGameControls': { + 'UTurn': 'true', + }, + }, + InGameAction.tuck => { + 'MessageType': 'Controls', + 'InGameControls': { + 'Tuck': 'true', + }, + }, + InGameAction.steerLeft => { + 'MessageType': 'Controls', + 'InGameControls': { + 'Steering': isKeyDown ? '-1' : '0', + }, + }, + InGameAction.steerRight => { + 'MessageType': 'Controls', + 'InGameControls': { + 'Steering': isKeyDown ? '1' : '0', + }, + }, + InGameAction.increaseResistance => null, + InGameAction.decreaseResistance => null, + InGameAction.navigateLeft => null, + InGameAction.navigateRight => null, + InGameAction.toggleUi => null, + _ => null, + }; + + final supportsIsKeyUpActions = [ + InGameAction.steerLeft, + InGameAction.steerRight, + ]; + if (jsonObject != null && !isKeyDown && !supportsIsKeyUpActions.contains(keyPair.inGameAction)) { + return Ignored('No Action sent on key down for action: ${keyPair.inGameAction}'); + } else if (jsonObject != null) { + final jsonString = jsonEncode(jsonObject); + _socket?.writeln(jsonString); + return Success('Sent action to MyWhoosh: ${keyPair.inGameAction} ${keyPair.inGameActionValue ?? ''}'); + } else { + return NotHandled('No action available for button: ${keyPair.inGameAction}'); + } + } + + bool isCompatible(Target target) { + return kIsWeb + ? false + : switch (target) { + Target.thisDevice => !Platform.isWindows, + _ => true, + }; + } +} diff --git a/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart b/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart new file mode 100644 index 000000000..5dd166ad9 --- /dev/null +++ b/lib/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart @@ -0,0 +1,287 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/ble.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart' show AlertNotification, LogNotification; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/widgets/title.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; + +class OpenBikeControlBluetoothEmulator extends TrainerConnection { + late final _peripheralManager = PeripheralManager(); + final ValueNotifier connectedApp = ValueNotifier(null); + bool _isServiceAdded = false; + bool _isSubscribedToEvents = false; + Central? _central; + + late GATTCharacteristic _buttonCharacteristic; + + static const String connectionTitle = 'OpenBikeControl BLE Emulator'; + + OpenBikeControlBluetoothEmulator() + : super( + title: connectionTitle, + supportedActions: InGameAction.values, + ); + + Future startServer() async { + isStarted.value = true; + + _peripheralManager.stateChanged.forEach((state) { + print('Peripheral manager state: ${state.state}'); + }); + + if (!kIsWeb && Platform.isAndroid) { + _peripheralManager.connectionStateChanged.forEach((state) { + print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); + if (state.state == ConnectionState.connected) { + } else if (state.state == ConnectionState.disconnected) { + if (connectedApp.value != null) { + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'), + ); + } + isConnected.value = false; + connectedApp.value = null; + _central = null; + } + }); + } + + while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getObpBleEnabled()) { + print('Waiting for peripheral manager to be powered on...'); + await Future.delayed(Duration(seconds: 1)); + } + + _buttonCharacteristic = GATTCharacteristic.mutable( + uuid: UUID.fromString(OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID), + descriptors: [], + properties: [ + GATTCharacteristicProperty.notify, + ], + permissions: [], + ); + + if (!_isServiceAdded) { + await Future.delayed(Duration(seconds: 1)); + + if (!_isSubscribedToEvents) { + _isSubscribedToEvents = true; + _peripheralManager.characteristicReadRequested.forEach((eventArgs) async { + print('Read request for characteristic: ${eventArgs.characteristic.uuid}'); + + switch (eventArgs.characteristic.uuid.toString().toUpperCase()) { + case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL: + await _peripheralManager.respondReadRequestWithValue( + eventArgs.request, + value: Uint8List.fromList([100]), + ); + return; + default: + print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}'); + } + + final request = eventArgs.request; + final trimmedValue = Uint8List.fromList([]); + await _peripheralManager.respondReadRequestWithValue( + request, + value: trimmedValue, + ); + // You can respond to read requests here if needed + }); + + _peripheralManager.characteristicNotifyStateChanged.forEach((char) { + _central = char.central; + print( + 'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}', + ); + }); + + Uint8List? firstAppInfoMessage; + + _peripheralManager.characteristicWriteRequested.forEach((eventArgs) async { + final characteristic = eventArgs.characteristic; + final request = eventArgs.request; + final value = request.value; + if (kDebugMode) { + print('Write request for characteristic: ${characteristic.uuid}: ${bytesToReadableHex(value)}'); + } + + switch (eventArgs.characteristic.uuid.toString().toLowerCase()) { + case OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID: + try { + // use this fallback if first message is incomplete (e.g. TrainingPeaks on macOS) + + AppInfo appInfo = OpenBikeProtocolParser.parseAppInfo( + Uint8List.fromList([...?firstAppInfoMessage, ...value]), + ); + firstAppInfoMessage = null; + isConnected.value = true; + connectedApp.value = appInfo; + supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList(); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'), + ); + core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo')); + } catch (e) { + core.connection.signalNotification(LogNotification('Error parsing App Info ${bytesToHex(value)}: $e')); + if (firstAppInfoMessage == null) { + firstAppInfoMessage = value; + return; + } + } + break; + default: + print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}'); + } + + await _peripheralManager.respondWriteRequest(request); + }); + } + + if (!Platform.isWindows) { + // Device Information + await _peripheralManager.addService( + GATTService( + uuid: UUID.fromString('180A'), + isPrimary: true, + characteristics: [ + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A29'), + value: Uint8List.fromList('BikeControl'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A25'), + value: Uint8List.fromList('1337'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A27'), + value: Uint8List.fromList('1.0'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A26'), + value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits), + descriptors: [], + ), + ], + includedServices: [], + ), + ); + } + // Battery Service + await _peripheralManager.addService( + GATTService( + uuid: UUID.fromString('180F'), + isPrimary: true, + characteristics: [ + GATTCharacteristic.mutable( + uuid: UUID.fromString('2A19'), + descriptors: [], + properties: [ + GATTCharacteristicProperty.read, + GATTCharacteristicProperty.notify, + ], + permissions: [ + GATTCharacteristicPermission.read, + ], + ), + ], + includedServices: [], + ), + ); + + // Unknown Service + await _peripheralManager.addService( + GATTService( + uuid: UUID.fromString(OpenBikeControlConstants.SERVICE_UUID), + isPrimary: true, + characteristics: [ + _buttonCharacteristic, + GATTCharacteristic.mutable( + uuid: UUID.fromString(OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID), + descriptors: [], + properties: [ + GATTCharacteristicProperty.writeWithoutResponse, + GATTCharacteristicProperty.write, + ], + permissions: [ + GATTCharacteristicPermission.read, + GATTCharacteristicPermission.write, + ], + ), + ], + includedServices: [], + ), + ); + _isServiceAdded = true; + } + + final advertisement = Advertisement( + name: 'BikeControl', + serviceUUIDs: [UUID.fromString(OpenBikeControlConstants.SERVICE_UUID)], + ); + print('Starting advertising with OpenBikeControl service...'); + + await _peripheralManager.startAdvertising(advertisement); + } + + Future stopServer() async { + if (kDebugMode) { + print('Stopping OpenBikeControl BLE server...'); + } + await _peripheralManager.removeAllServices(); + _isServiceAdded = false; + await _peripheralManager.stopAdvertising(); + isStarted.value = false; + isConnected.value = false; + connectedApp.value = null; + } + + @override + Future sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async { + final inGameAction = keyPair.inGameAction; + + final mappedButtons = connectedApp.value!.supportedButtons.filter( + (supportedButton) => supportedButton.action == inGameAction, + ); + + if (inGameAction == null) { + return Error('Invalid in-game action for key pair: $keyPair'); + } else if (_central == null) { + return Error('No central connected'); + } else if (connectedApp.value == null) { + return Error('No app info received from central'); + } else if (mappedButtons.isEmpty) { + return NotHandled('App does not support all buttons for action: ${inGameAction.title}'); + } + + if (isKeyDown && isKeyUp) { + final responseDataDown = OpenBikeProtocolParser.encodeButtonState( + mappedButtons.map((b) => ButtonState(b, 1)).toList(), + ); + await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataDown); + final responseDataUp = OpenBikeProtocolParser.encodeButtonState( + mappedButtons.map((b) => ButtonState(b, 0)).toList(), + ); + await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataUp); + } else { + final responseData = OpenBikeProtocolParser.encodeButtonState( + mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(), + ); + await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseData); + } + + return Success('Buttons ${inGameAction.title} sent'); + } +} diff --git a/lib/bluetooth/devices/openbikecontrol/obc_dircon.dart b/lib/bluetooth/devices/openbikecontrol/obc_dircon.dart new file mode 100644 index 000000000..59a8ec6ca --- /dev/null +++ b/lib/bluetooth/devices/openbikecontrol/obc_dircon.dart @@ -0,0 +1,39 @@ +import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart'; +import 'package:prop/emulators/dircon/dircon.dart'; +import 'package:universal_ble/universal_ble.dart'; + +abstract class OnMessage { + void onMessage(List message); +} + +class ObcDircon extends DirCon { + final OnMessage onMessageCallback; + ObcDircon({required super.socket, required this.onMessageCallback}); + + @override + List getCharacteristics(String serviceUUID) { + if (serviceUUID.toLowerCase() == OpenBikeControlConstants.SERVICE_UUID) { + return [ + BleCharacteristic( + OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID, + [CharacteristicProperty.notify], + ), + BleCharacteristic( + OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID, + [CharacteristicProperty.writeWithoutResponse, CharacteristicProperty.write], + ), + ]; + } + return []; + } + + @override + void processWriteCallback(String characteristicUUID, List characteristicData) { + if (characteristicUUID.toLowerCase() == OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID) { + onMessageCallback.onMessage(characteristicData); + } + } + + @override + List get serviceUUIDs => [OpenBikeControlConstants.SERVICE_UUID]; +} diff --git a/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart b/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart new file mode 100644 index 000000000..92adaa129 --- /dev/null +++ b/lib/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart @@ -0,0 +1,248 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_dircon.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:nsd/nsd.dart'; +import 'package:prop/prop.dart'; + +class OpenBikeControlMdnsEmulator extends TrainerConnection implements OnMessage { + ServerSocket? _server; + Registration? _mdnsRegistration; + + static const String connectionTitle = 'OpenBikeControl mDNS Emulator'; + + final ValueNotifier connectedApp = ValueNotifier(null); + + Socket? _socket; + ObcDircon? _dirCon; + + OpenBikeControlMdnsEmulator() + : super( + title: connectionTitle, + supportedActions: InGameAction.values, + ); + + bool get _useDirCon => + core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.dircon) ?? false; + + Future startServer() async { + print('Starting mDNS server...'); + isStarted.value = true; + + // Get local IP + final interfaces = await NetworkInterface.list(); + InternetAddress? localIP; + + for (final interface in interfaces) { + for (final addr in interface.addresses) { + if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) { + localIP = addr; + break; + } + } + if (localIP != null) break; + } + + if (localIP == null) { + throw 'Could not find network interface'; + } + + await _createTcpServer(); + + if (kDebugMode) { + enableLogging(LogTopic.calls); + enableLogging(LogTopic.errors); + } + disableServiceTypeValidation(true); + + try { + // Create service + _mdnsRegistration = await register( + Service( + name: 'BikeControl', + type: _useDirCon ? '_wahoo-fitness-tnp._tcp' : '_openbikecontrol._tcp', + port: 36867, + addresses: [localIP], + txt: _useDirCon + ? { + 'ble-service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits), + 'mac-address': Uint8List.fromList('00:11:22:33:44:55'.codeUnits), + 'serial-number': Uint8List.fromList('1234567890'.codeUnits), + } + : { + 'version': Uint8List.fromList([0x01]), + 'id': Uint8List.fromList('1337'.codeUnits), + 'name': Uint8List.fromList('BikeControl'.codeUnits), + 'service-uuids': Uint8List.fromList(OpenBikeControlConstants.SERVICE_UUID.codeUnits), + 'manufacturer': Uint8List.fromList('OpenBikeControl'.codeUnits), + 'model': Uint8List.fromList('BikeControl app'.codeUnits), + }, + ), + ); + print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration'); + print('Server started - advertising service!'); + } catch (e, s) { + core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start mDNS server: $e')); + rethrow; + } + } + + Future stopServer() async { + if (kDebugMode) { + print('Stopping OpenBikeControl mDNS server...'); + } + if (_mdnsRegistration != null) { + unregister(_mdnsRegistration!); + _mdnsRegistration = null; + } + isStarted.value = false; + isConnected.value = false; + connectedApp.value = null; + _socket?.destroy(); + _socket = null; + } + + Future _createTcpServer() async { + try { + _server = await ServerSocket.bind( + InternetAddress.anyIPv4, + 36867, + shared: true, + v6Only: false, + ); + } catch (e) { + core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start server: $e')); + rethrow; + } + if (kDebugMode) { + print('Server started on port ${_server!.port}'); + } + + // Accept connection + _server!.listen( + (Socket socket) async { + SharedLogic.keepAlive(); + _socket = socket; + + if (kDebugMode) { + print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}'); + } + + if (_useDirCon) { + _dirCon = ObcDircon(socket: socket, onMessageCallback: this); + } + + // Listen for data from the client + socket.listen( + (List data) { + if (kDebugMode) { + print('Received message: ${bytesToHex(data)}'); + } + if (_dirCon != null) { + _dirCon!.handleIncomingData(data); + return; + } + onMessage(data); + }, + onDone: () { + _dirCon = null; + SharedLogic.stopKeepAlive(); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, 'Disconnected from app: ${connectedApp.value?.appId}'), + ); + isConnected.value = false; + connectedApp.value = null; + _socket = null; + }, + ); + }, + ); + } + + @override + Future sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async { + final inGameAction = keyPair.inGameAction; + + final mappedButtons = connectedApp.value!.supportedButtons.filter( + (supportedButton) => supportedButton.action == inGameAction, + ); + + if (inGameAction == null) { + return Error('Invalid in-game action for key pair: $keyPair'); + } else if (_socket == null) { + print('No client connected, cannot send button press'); + return Error('No client connected'); + } else if (connectedApp.value == null) { + return Error('No app info received from central'); + } else if (mappedButtons.isEmpty) { + return NotHandled('App does not support: ${inGameAction.title}'); + } + + if (isKeyDown && isKeyUp) { + final responseDataDown = OpenBikeProtocolParser.encodeButtonState( + mappedButtons.map((b) => ButtonState(b, 1)).toList(), + ); + _write(_socket!, responseDataDown); + final responseDataUp = OpenBikeProtocolParser.encodeButtonState( + mappedButtons.map((b) => ButtonState(b, 0)).toList(), + ); + _write(_socket!, responseDataUp); + } else { + final responseData = OpenBikeProtocolParser.encodeButtonState( + mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(), + ); + _write(_socket!, responseData); + } + + return Success('Sent ${inGameAction.title} button press'); + } + + void _write(Socket socket, List responseData) { + debugPrint('Sending response: ${bytesToHex(responseData)}'); + if (_dirCon != null) { + _dirCon!.sendCharacteristicNotification(OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID, responseData); + return; + } else { + socket.add(responseData); + } + } + + @override + void onMessage(List message) { + if (kDebugMode) { + print('Received message from OBC: ${bytesToHex(message)}'); + } + final messageType = message[0]; + switch (messageType) { + case OpenBikeProtocolParser.MSG_TYPE_APP_INFO: + try { + final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(message)); + isConnected.value = true; + connectedApp.value = appInfo; + + supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList(); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'), + ); + } catch (e) { + core.connection.signalNotification(LogNotification('Failed to parse app info: $e')); + } + break; + case OpenBikeProtocolParser.MSG_TYPE_HAPTIC_FEEDBACK: + // noop + break; + default: + print('Unknown message type: $messageType'); + } + } +} diff --git a/lib/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart b/lib/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart new file mode 100644 index 000000000..97563bc6e --- /dev/null +++ b/lib/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart @@ -0,0 +1,80 @@ +import 'dart:typed_data'; + +import 'package:prop/prop.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/widgets/title.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../bluetooth_device.dart'; +import 'protocol_parser.dart'; + +class OpenBikeControlDevice extends BluetoothDevice { + OpenBikeControlDevice(super.scanResult) + : super( + availableButtons: OpenBikeProtocolParser.BUTTON_NAMES.values.toList(), + ); + + @override + Future handleServices(List services) async { + final service = services.firstWhere( + (e) => e.uuid.toLowerCase() == OpenBikeControlConstants.SERVICE_UUID.toLowerCase(), + orElse: () => throw Exception('Service not found: ${OpenBikeControlConstants.SERVICE_UUID}'), + ); + + final characteristic = service.characteristics.firstWhere( + (e) => e.uuid.toLowerCase() == OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID.toLowerCase(), + orElse: () => + throw Exception('Characteristic not found: ${OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID}'), + ); + + await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid); + + final appInfoCharacteristic = service.characteristics.firstWhere( + (e) => e.uuid.toLowerCase() == OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID.toLowerCase(), + orElse: () => + throw Exception('Characteristic not found: ${OpenBikeControlConstants.APPINFO_CHARACTERISTIC_UUID}'), + ); + + await UniversalBle.write( + device.deviceId, + service.uuid, + appInfoCharacteristic.uuid, + OpenBikeProtocolParser.encodeAppInfo( + appId: 'BikeControl', + appVersion: packageInfoValue!.version, + supportedButtons: OpenBikeProtocolParser.BUTTON_NAMES.values.toList(), + ), + ); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + final charLower = characteristic.toLowerCase(); + + if (charLower == OpenBikeControlConstants.BUTTON_STATE_CHARACTERISTIC_UUID.toLowerCase()) { + try { + final parsed = OpenBikeProtocolParser.parseButtonState(bytes); + + final buttonsToPress = parsed.where((e) => e.state == 1).map((e) => e.button).toList(); + + await handleButtonsClicked(buttonsToPress); + } catch (e) { + actionStreamInternal.add(AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Error parsing OpenBike message: $e')); + } + } + } +} + +class OpenBikeControlConstants { + // OpenBikeControl BLE service and characteristic UUIDs (see BLE.md) + static const String SERVICE_UUID = 'd273f680-d548-419d-b9d1-fa0472345229'; + + // Button State Characteristic (Notify) + static const String BUTTON_STATE_CHARACTERISTIC_UUID = 'd273f681-d548-419d-b9d1-fa0472345229'; + + // Haptic Feedback Characteristic (Write) + static const String HAPTIC_CHARACTERISTIC_UUID = 'd273f682-d548-419d-b9d1-fa0472345229'; + + // App Info Characteristic (Write) + static const String APPINFO_CHARACTERISTIC_UUID = 'd273f683-d548-419d-b9d1-fa0472345229'; +} diff --git a/lib/bluetooth/devices/openbikecontrol/protocol_parser.dart b/lib/bluetooth/devices/openbikecontrol/protocol_parser.dart new file mode 100644 index 000000000..d1cb57684 --- /dev/null +++ b/lib/bluetooth/devices/openbikecontrol/protocol_parser.dart @@ -0,0 +1,308 @@ +// OpenBikeControl Protocol Parser (Dart) + +// This file is a translation of the Python `protocol_parser.py` example into Dart. +// It provides simple encoding/decoding utilities for the OpenBikeControl message +// types used in the Python example. This is intentionally a small, focused +// module that mirrors the original Python API. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:dartx/dartx.dart'; +import 'package:prop/prop.dart'; + +class ProtocolParseException implements Exception { + final String message; + final Uint8List? raw; + ProtocolParseException(this.message, [this.raw]); + + @override + String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${bytesToReadableHex(raw!)}' : ''}'; +} + +class OpenBikeProtocolParser { + // Button ID to name mapping (based on PROTOCOL.md in Python example) + static const Map BUTTON_NAMES = { + // Gear Shifting (0x01-0x0F) + 0x01: ControllerButton('Shift Up', identifier: 0x01, action: InGameAction.shiftUp), + 0x02: ControllerButton('Shift Down', identifier: 0x02, action: InGameAction.shiftDown), + 0x03: ControllerButton('Gear Set', identifier: 0x03), + // Navigation (0x10-0x1F) + 0x10: ControllerButton('Up', identifier: 0x10, action: InGameAction.up), + 0x11: ControllerButton('Down', identifier: 0x11, action: InGameAction.down), + 0x12: ControllerButton('Left/Look Left', identifier: 0x12, action: InGameAction.navigateLeft), + 0x13: ControllerButton('Right/Look Right', identifier: 0x13, action: InGameAction.navigateRight), + 0x14: ControllerButton('Select/Confirm', identifier: 0x14, action: InGameAction.select), + 0x15: ControllerButton('Back/Cancel', identifier: 0x15, action: InGameAction.back), + 0x16: ControllerButton('Menu', identifier: 0x16, action: InGameAction.menu), + 0x17: ControllerButton('Home', identifier: 0x17, action: InGameAction.home), + 0x18: ControllerButton('Steer Left', identifier: 0x18, action: InGameAction.steerLeft), + 0x19: ControllerButton('Steer Right', identifier: 0x19, action: InGameAction.steerRight), + // Social/Emotes (0x20-0x2F) + 0x20: ControllerButton('Emote', identifier: 0x20, action: InGameAction.emote), + 0x21: ControllerButton('Push to Talk', identifier: 0x21), + // Training Controls (0x30-0x3F) + 0x30: ControllerButton('ERG Up', identifier: 0x30, action: InGameAction.increaseResistance), + 0x31: ControllerButton('ERG Down', identifier: 0x31, action: InGameAction.decreaseResistance), + 0x32: ControllerButton('Skip Interval', identifier: 0x32), + 0x33: ControllerButton('Pause', identifier: 0x33), + 0x34: ControllerButton('Resume', identifier: 0x34), + 0x35: ControllerButton('Lap', identifier: 0x35), + // View Controls (0x40-0x4F) + 0x40: ControllerButton('Camera Angle', identifier: 0x40, action: InGameAction.cameraAngle), + 0x41: ControllerButton('Camera 1', identifier: 0x41, action: InGameAction.cameraAngle), + 0x42: ControllerButton('Camera 2', identifier: 0x42, action: InGameAction.cameraAngle), + 0x43: ControllerButton('Camera 3', identifier: 0x43, action: InGameAction.cameraAngle), + 0x44: ControllerButton('HUD Toggle', identifier: 0x44, action: InGameAction.toggleUi), + 0x45: ControllerButton('Map Toggle', identifier: 0x45), + // Power-ups (0x50-0x5F) + 0x50: ControllerButton('Power-up 1', identifier: 0x50, action: InGameAction.usePowerUp), + 0x51: ControllerButton('Power-up 2', identifier: 0x51, action: InGameAction.usePowerUp), + 0x52: ControllerButton('Power-up 3', identifier: 0x52, action: InGameAction.usePowerUp), + }; + + // Haptic feedback patterns + static const Map HAPTIC_PATTERNS = { + 'none': 0x00, + 'short': 0x01, + 'double': 0x02, + 'triple': 0x03, + 'long': 0x04, + 'success': 0x05, + 'warning': 0x06, + 'error': 0x07, + }; + + // Message types (for TCP/mDNS protocol) + static const int MSG_TYPE_BUTTON_STATE = 0x01; + static const int MSG_TYPE_DEVICE_STATUS = 0x02; + static const int MSG_TYPE_HAPTIC_FEEDBACK = 0x03; + static const int MSG_TYPE_APP_INFO = 0x04; + + /// Parse button state data from binary format. + /// Data format: [Message_Type, Button_ID_1, State_1, Button_ID_2, State_2, ...] + static List parseButtonState(Uint8List data) { + final buttons = []; + + if (data.isEmpty) return buttons; + + if (data[0] != MSG_TYPE_BUTTON_STATE) return buttons; + + for (var i = 1; i < data.length; i += 2) { + if (i + 1 < data.length) { + final buttonId = data[i]; + final state = data[i + 1]; + if (BUTTON_NAMES[buttonId] != null) { + buttons.add(ButtonState(BUTTON_NAMES[buttonId]!, state)); + } else { + throw ProtocolParseException('Unknown button ID: 0x${buttonId.toRadixString(16).padLeft(2, '0')}', data); + } + } + } + + return buttons; + } + + static Uint8List encodeButtonState(List buttons) { + final bytes = BytesBuilder(); + bytes.addByte(MSG_TYPE_BUTTON_STATE); + for (final b in buttons) { + bytes.addByte(b.button.identifier!); + bytes.addByte(b.state); + } + return bytes.toBytes(); + } + + static DeviceStatus parseDeviceStatus(Uint8List data) { + if (data.length < 3) { + throw ProtocolParseException('Device status message too short', data); + } + if (data[0] != MSG_TYPE_DEVICE_STATUS) { + throw ProtocolParseException('Invalid message type: ${data[0]}, expected $MSG_TYPE_DEVICE_STATUS', data); + } + final battery = data[1] == 0xFF ? null : data[1]; + final connected = data[2] == 0x01; + return DeviceStatus(battery: battery, connected: connected); + } + + static Uint8List encodeDeviceStatus({int? battery, bool connected = true}) { + final batteryByte = battery == null ? 0xFF : (battery & 0xFF); + final connectedByte = connected ? 0x01 : 0x00; + return Uint8List.fromList([MSG_TYPE_DEVICE_STATUS, batteryByte, connectedByte]); + } + + static Uint8List encodeHapticFeedback({String pattern = 'short', int duration = 0, int intensity = 0}) { + final patternByte = HAPTIC_PATTERNS[pattern] ?? HAPTIC_PATTERNS['short']!; + final bytes = Uint8List(4); + bytes[0] = MSG_TYPE_HAPTIC_FEEDBACK; + bytes[1] = patternByte; + bytes[2] = duration & 0xFF; + bytes[3] = intensity & 0xFF; + return bytes; + } + + static HapticFeedbackMessage parseHapticFeedback(Uint8List data) { + if (data.length < 4) { + throw ProtocolParseException('Haptic feedback message too short', data); + } + if (data[0] != MSG_TYPE_HAPTIC_FEEDBACK) { + throw ProtocolParseException('Invalid message type: ${data[0]}', data); + } + final patternByte = data[1]; + final duration = data[2]; + final intensity = data[3]; + String patternName = 'unknown'; + HAPTIC_PATTERNS.forEach((name, value) { + if (value == patternByte) patternName = name; + }); + + return HapticFeedbackMessage( + pattern: patternName, + patternByte: patternByte, + duration: duration, + intensity: intensity, + ); + } + + static Uint8List encodeAppInfo({ + required String appId, + required String appVersion, + required List supportedButtons, + }) { + final appIdBytes = utf8.encode(appId).take(32).toList(); + final appVersionBytes = utf8.encode(appVersion).take(32).toList(); + + final builder = BytesBuilder(); + builder.addByte(MSG_TYPE_APP_INFO); + builder.addByte(0x01); // Version + builder.addByte(appIdBytes.length); + builder.add(appIdBytes); + builder.addByte(appVersionBytes.length); + builder.add(appVersionBytes); + builder.addByte(supportedButtons.length); + builder.add(supportedButtons.map((e) => e.identifier!).toList()); + + return builder.toBytes(); + } + + static AppInfo parseAppInfo(Uint8List data) { + if (data.isEmpty || data[0] != MSG_TYPE_APP_INFO) { + throw ProtocolParseException('Invalid message type', data); + } + + var idx = 1; + if (data.length < idx + 3) { + throw ProtocolParseException('App info message too short', data); + } + + final version = data[idx]; + idx += 1; + if (version != 0x01) { + throw ProtocolParseException('Unsupported app info version: $version', data); + } + + if (idx >= data.length) throw ProtocolParseException('Missing app ID length', data); + final appIdLen = data[idx]; + idx += 1; + if (idx + appIdLen > data.length) throw ProtocolParseException('App ID length exceeds buffer', data); + final appId = utf8.decode(data.sublist(idx, idx + appIdLen)); + idx += appIdLen; + + if (idx >= data.length) throw ProtocolParseException('Missing app version length', data); + final appVersionLen = data[idx]; + idx += 1; + if (idx + appVersionLen > data.length) throw ProtocolParseException('App version length exceeds buffer', data); + final appVersion = utf8.decode(data.sublist(idx, idx + appVersionLen)); + idx += appVersionLen; + + if (idx >= data.length) throw ProtocolParseException('Missing button count', data); + final buttonCount = data[idx]; + idx += 1; + if (idx + buttonCount > data.length) throw ProtocolParseException('Button count exceeds buffer', data); + final buttonIds = data.sublist(idx, idx + buttonCount).toList(); + + final controllerButtons = buttonIds.mapNotNull((id) => BUTTON_NAMES[id]).toList(); + + return AppInfo( + appId: appId, + appVersion: appVersion, + supportedButtons: controllerButtons, + supportedActions: controllerButtons.mapNotNull((b) => b.action).toList(), + ); + } +} + +class AppInfo { + final String appId; + final String appVersion; + final List supportedButtons; + final List supportedActions; + + AppInfo({ + required this.appId, + required this.appVersion, + required this.supportedButtons, + required this.supportedActions, + }); + + @override + String toString() => + 'AppInfo(appId: $appId, appVersion: $appVersion, supportedButtons: $supportedButtons, supportedActions: $supportedActions)'; +} + +/// DeviceStatus message representation +class DeviceStatus { + final int? battery; // 0-100, null if 0xFF + final bool connected; + + DeviceStatus({required this.battery, required this.connected}); + + @override + String toString() => 'DeviceStatus(battery: ${battery ?? 'unknown'}, connected: $connected)'; +} + +class ButtonState { + /// Represents a single button id/state pair.class ButtonState { + final ControllerButton button; + final int state; // 0=released,1=pressed,2-255=analog + + const ButtonState(this.button, this.state); + + @override + String toString() => formatButtonState(button, state); + + String formatButtonState(ControllerButton button, int state) { + final buttonName = button.name; + + String stateStr; + if (state == 0) { + stateStr = 'RELEASED'; + } else if (state == 1) { + stateStr = 'PRESSED'; + } else { + final percentage = ((state - 2) / (255 - 2) * 100).round(); + stateStr = 'ANALOG $percentage%'; + } + + return '$buttonName: $stateStr'; + } +} + +/// Haptic feedback representation +class HapticFeedbackMessage { + final String pattern; + final int patternByte; + final int duration; // in 10ms units + final int intensity; // 0-255 + + HapticFeedbackMessage({ + required this.pattern, + required this.patternByte, + required this.duration, + required this.intensity, + }); + + @override + String toString() => 'HapticFeedback(pattern: $pattern, duration: $duration, intensity: $intensity)'; +} diff --git a/lib/bluetooth/devices/proxy/proxy_device.dart b/lib/bluetooth/devices/proxy/proxy_device.dart new file mode 100644 index 000000000..9a5b5a757 --- /dev/null +++ b/lib/bluetooth/devices/proxy/proxy_device.dart @@ -0,0 +1,64 @@ +import 'dart:typed_data'; + +import 'package:bike_control/bluetooth/devices/bluetooth_device.dart'; +import 'package:prop/emulators/ftms_emulator.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; + +class ProxyDevice extends BluetoothDevice { + static final List proxyServiceUUIDs = [ + '0000180d-0000-1000-8000-00805f9b34fb', // Heart Rate + '00001818-0000-1000-8000-00805f9b34fb', // Cycling Power + '00001826-0000-1000-8000-00805f9b34fb', // Fitness Machine + ]; + + final FtmsEmulator emulator = FtmsEmulator(); + + ProxyDevice(super.scanResult) + : super( + availableButtons: const [], + isBeta: true, + ); + + @override + Future handleServices(List services) async { + emulator.setScanResult(scanResult); + emulator.handleServices(services); + + emulator.startServer(); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + emulator.processCharacteristic(characteristic, bytes); + } + + @override + Widget showInformation(BuildContext context) { + return Column( + spacing: 16, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + super.showInformation(context), + if (!isConnected) + Button.primary( + style: ButtonStyle.primary(size: ButtonSize.small), + onPressed: () { + super.connect(); + }, + child: Text('Proxy'), + ), + ], + ); + } + + @override + Future connect() async {} + + @override + Future disconnect() { + emulator.stop(); + return super.disconnect(); + } +} diff --git a/lib/bluetooth/devices/shimano/shimano_di2.dart b/lib/bluetooth/devices/shimano/shimano_di2.dart new file mode 100644 index 000000000..52b4d2a1b --- /dev/null +++ b/lib/bluetooth/devices/shimano/shimano_di2.dart @@ -0,0 +1,171 @@ +import 'dart:typed_data'; + +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart'; +import 'package:prop/prop.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../bluetooth_device.dart'; + +class ShimanoDi2 extends BluetoothDevice { + ShimanoDi2(super.scanResult) : super(availableButtons: [], buttonPrefix: 'D-Fly Channel '); + + @override + Future handleServices(List services) async { + final service = services.firstWhere( + (e) => e.uuid.toLowerCase() == ShimanoDi2Constants.SERVICE_UUID.toLowerCase(), + orElse: () => throw Exception('Service not found: ${ShimanoDi2Constants.SERVICE_UUID}'), + ); + final characteristic = service.characteristics.firstWhere( + (e) => e.uuid.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID.toLowerCase(), + orElse: () => throw Exception('Characteristic not found: ${ShimanoDi2Constants.D_FLY_CHANNEL_UUID}'), + ); + + await UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid); + } + + final _lastButtons = {}; + bool _isInitialized = false; + + @override + String get buttonExplanation => 'Click a D-Fly button to configure them.'; + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + Logger.info('Received data from $characteristic: ${bytesToReadableHex(bytes)}'); + if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) { + final channels = bytes.sublist(1); + + // On first data reception, just initialize the state without triggering buttons + if (!_isInitialized) { + channels.forEachIndexed((int value, int index) { + final readableIndex = index + 1; + _lastButtons[index] = (value: value, type: _Di2State.released); + + getOrAddButton( + 'D-Fly Channel $readableIndex', + () => ControllerButton('D-Fly Channel $readableIndex', sourceDeviceId: device.deviceId), + ); + }); + _isInitialized = true; + return Future.value(); + } + + var actualChange = false; + channels.forEachIndexed((int value, int index) { + final didChange = _lastButtons[index]?.value != value; + + final readableIndex = index + 1; + + if (didChange) { + if ((value & 0x10) != 0) { + if (_lastButtons[index]?.type == _Di2State.longPress || _lastButtons[index]?.type == _Di2State.keep) { + // short press is triggered after long press, until it's released later on + _lastButtons[index] = (value: value, type: _Di2State.keep); + Logger.info('Button $readableIndex still long pressed'); + } else { + _lastButtons[index] = (value: value, type: _Di2State.shortPress); + actualChange = true; + Logger.info('Button $readableIndex short pressed'); + } + } else if ((value & 0x20) != 0) { + _lastButtons[index] = (value: value, type: _Di2State.longPress); + actualChange = true; + Logger.info('Button $readableIndex long pressed'); + } else if ((value & 0x40) != 0) { + _lastButtons[index] = (value: value, type: _Di2State.doublePress); + actualChange = true; + Logger.info('Button $readableIndex double pressed'); + } else { + _lastButtons[index] = (value: value, type: _Di2State.released); + actualChange = true; + Logger.info('Button $readableIndex released'); + } + } + }); + + if (actualChange) { + final Map<_Di2State, List> mapped = _lastButtons.entries.groupBy((e) => e.value.type).map(( + key, + value, + ) { + final buttons = value + .map((entry) => availableButtons.firstWhere((button) => button.name == 'D-Fly Channel ${entry.key + 1}')) + .toList(); + return MapEntry(key, buttons); + }); + + final shortPress = [...?mapped[_Di2State.shortPress], ...?mapped[_Di2State.doublePress]]; + if (shortPress.isNotEmpty) { + Logger.debug('Short Press Buttons to trigger: ${shortPress.map((b) => b.name).join(', ')}'); + handleButtonsClicked(shortPress); + handleButtonsClicked([]); + _resetButtonsForState([_Di2State.shortPress]); + } + + final longPress = mapped[_Di2State.longPress] ?? []; + if (longPress.isNotEmpty) { + Logger.debug('Long Press Buttons to trigger: ${longPress.map((b) => b.name).join(', ')}'); + handleButtonsClicked(longPress); + } + + final released = mapped[_Di2State.released] ?? []; + final keepPress = mapped[_Di2State.longPress] ?? []; + + if (released.isNotEmpty && keepPress.isEmpty) { + Logger.debug('Releasing all Buttons'); + handleButtonsClicked([]); + } + + final doublePress = mapped[_Di2State.doublePress] ?? []; + if (doublePress.isNotEmpty) { + Logger.debug('Buttons to still trigger: ${doublePress.map((b) => b.name).join(', ')}'); + handleButtonsClicked(doublePress); + handleButtonsClicked([]); + _resetButtonsForState([_Di2State.doublePress]); + } + } + } + } + + void _resetButtonsForState(List<_Di2State> list) { + _lastButtons.forEach((key, value) { + if (list.contains(value.type)) { + _lastButtons[key] = (value: value.value, type: _Di2State.released); + } + }); + } + + @override + Widget showInformation(BuildContext context) { + return Column( + spacing: 12, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + super.showInformation(context), + if (!core.settings.getShowOnboarding()) + Text( + 'Make sure to set your Di2 buttons to D-Fly channels in the Shimano E-TUBE app.', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ); + } +} + +class ShimanoDi2Constants { + static const String SERVICE_UUID = "000018ef-5348-494d-414e-4f5f424c4500"; + static const String SERVICE_UUID_ALTERNATIVE = "000018ff-5348-494d-414e-4f5f424c4500"; + + static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500"; +} + +enum _Di2State { + shortPress, + longPress, + keep, + doublePress, + released, +} diff --git a/lib/bluetooth/devices/sram/sram_axs.dart b/lib/bluetooth/devices/sram/sram_axs.dart new file mode 100644 index 000000000..da73f74ee --- /dev/null +++ b/lib/bluetooth/devices/sram/sram_axs.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../bluetooth_device.dart'; + +class SramAxs extends BluetoothDevice { + SramAxs(super.scanResult) : super(availableButtons: [], isBeta: true, supportsLongPress: false); + + Timer? _singleClickTimer; + int _tapCount = 0; + + @override + Future disconnect() async { + _singleClickTimer?.cancel(); + _singleClickTimer = null; + _tapCount = 0; + await super.disconnect(); + } + + @override + Future handleServices(List services) async { + final service = services.firstOrNullWhere( + (e) => e.uuid.toLowerCase() == SramAxsConstants.SERVICE_UUID_RELEVANT.toLowerCase(), + ); + + if (service == null) { + actionStreamInternal.add( + LogNotification('SramAxs: Relevant service not found: ${SramAxsConstants.SERVICE_UUID_RELEVANT}'), + ); + return; + } + + final characteristic = service.characteristics.firstWhere( + (e) => e.uuid.toLowerCase() == SramAxsConstants.TRIGGER_UUID.toLowerCase(), + orElse: () => throw Exception('Characteristic not found: ${SramAxsConstants.TRIGGER_UUID}'), + ); + + await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid); + + // add both buttons + _singleClickButton(); + _doubleClickButton(); + } + + ControllerButton _singleClickButton() => getOrAddButton( + 'SRAM Tap', + () => ControllerButton('SRAM Tap', action: InGameAction.shiftUp, sourceDeviceId: device.deviceId), + ); + + ControllerButton _doubleClickButton() => getOrAddButton( + 'SRAM Double Tap', + () => ControllerButton('SRAM Double Tap', action: InGameAction.shiftDown, sourceDeviceId: device.deviceId), + ); + + Future _emitClick(ControllerButton button) async { + // Use the common pipeline so long-press handling and app action execution stays consistent. + await handleButtonsClicked([button]); + await handleButtonsClicked([]); + } + + void _registerTap() { + final windowMs = core.settings.getSramAxsDoubleClickWindowMs(); + + _tapCount++; + + // First tap: start a timer. If no second tap arrives in time => single click. + if (_tapCount == 1) { + _singleClickTimer?.cancel(); + _singleClickTimer = Timer(Duration(milliseconds: windowMs), () { + if (_tapCount == 1) { + unawaited(_emitClick(_singleClickButton())); + } + _tapCount = 0; + }); + return; + } + + // Second tap within window: double click. + if (_tapCount == 2) { + _singleClickTimer?.cancel(); + _singleClickTimer = null; + unawaited(_emitClick(_doubleClickButton())); + _tapCount = 0; + return; + } + + // If we get more than two taps fast, treat as a double click and restart counting. + _singleClickTimer?.cancel(); + _singleClickTimer = null; + unawaited(_emitClick(_doubleClickButton())); + _tapCount = 0; + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + if (kDebugMode) { + debugPrint('SramAxs: Received data on characteristic $characteristic: ${bytesToHex(bytes)}'); + } + + if (characteristic.toLowerCase() == SramAxsConstants.TRIGGER_UUID.toLowerCase()) { + // At the moment we can only detect "some button pressed". We therefore interpret each + // notification as a tap and provide two logical buttons (single & double click). + _registerTap(); + } + + return Future.value(); + } + + @override + Widget showInformation(BuildContext context) { + final windowMs = core.settings.getSramAxsDoubleClickWindowMs(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + super.showInformation(context), + Text( + "Don't forget to turn off the function of the button you want to use in the SRAM AXS app!\n" + "Unfortunately, at the moment it's not possible to determine which physical button was pressed on your SRAM AXS device. Let us know if you have a contact at SRAM who can help :)\n\n" + 'So the app exposes two logical buttons:\n' + '• SRAM Tap, assigned to Shift Up\n' + '• SRAM Double Tap, assigned to Shift Down\n\n' + 'You can assign an action to each in the app settings.', + ).xSmall, + Builder( + builder: (context) { + return PrimaryButton( + size: ButtonSize.small, + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(4), + ), + child: Text('${windowMs}ms'), + ), + onPressed: () { + final values = [ + for (var v = 150; v <= 600; v += 50) v, + ]; + showDropdown( + context: context, + builder: (b) => DropdownMenu( + children: values + .map( + (v) => MenuButton( + child: Text('${v}ms'), + onPressed: (c) async { + await core.settings.setSramAxsDoubleClickWindowMs(v); + // Force rebuild to show new value. + (context as Element).markNeedsBuild(); + }, + ), + ) + .toList(), + ), + ); + }, + child: const Text('Double-click window:'), + ); + }, + ), + ], + ); + } +} + +class SramAxsConstants { + static const String SERVICE_UUID = "0000fe51-0000-1000-8000-00805f9b34fb"; + static const String SERVICE_UUID_RELEVANT = "d9050053-90aa-4c7c-b036-1e01fb8eb7ee"; + + static const String TRIGGER_UUID = "d9050054-90aa-4c7c-b036-1e01fb8eb7ee"; +} diff --git a/lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart b/lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart new file mode 100644 index 000000000..fa14c4a02 --- /dev/null +++ b/lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart @@ -0,0 +1,93 @@ +import 'dart:typed_data'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:flutter/material.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../bluetooth_device.dart'; + +class ThinkRiderVs200 extends BluetoothDevice { + ThinkRiderVs200(super.scanResult) + : super( + availableButtons: ThinkRiderVs200Buttons.values, + isBeta: true, + allowMultiple: true, + supportsLongPress: false, + ); + + @override + Future handleServices(List services) async { + // Only subscribe to service 0xFEA0 + final service = services.firstWhere( + (e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase(), + orElse: () => throw Exception('Service not found: ${ThinkRiderVs200Constants.SERVICE_UUID}'), + ); + final characteristic = service.characteristics.firstWhere( + (e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase(), + orElse: () => throw Exception('Characteristic not found: ${ThinkRiderVs200Constants.CHARACTERISTIC_UUID}'), + ); + + await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + if (characteristic.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase()) { + final hexValue = _bytesToHex(bytes).toLowerCase(); + + // Log all received values while in beta + if (isBeta) { + actionStreamInternal.add(LogNotification('VS200 received: $hexValue')); + } + + // Check for specific byte patterns + if (hexValue == ThinkRiderVs200Constants.SHIFT_UP_PATTERN) { + // Plus button pressed + actionStreamInternal.add(LogNotification('Shift Up detected: $hexValue')); + await handleButtonsClicked([availableButtons[0]]); + await handleButtonsClicked([]); + } else if (hexValue == ThinkRiderVs200Constants.SHIFT_DOWN_PATTERN) { + // Minus button pressed + actionStreamInternal.add(LogNotification('Shift Down detected: $hexValue')); + await handleButtonsClicked([availableButtons[1]]); + await handleButtonsClicked([]); + } + } + + return Future.value(); + } + + String _bytesToHex(List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + } +} + +class ThinkRiderVs200Constants { + // Service and characteristic UUIDs based on the nRF Connect screenshot + static const String SERVICE_UUID = "0000fea0-0000-1000-8000-00805f9b34fb"; + static const String CHARACTERISTIC_UUID = "0000fea1-0000-1000-8000-00805f9b34fb"; + + // Byte patterns for button detection + static const String SHIFT_UP_PATTERN = "f3050301fc"; + static const String SHIFT_DOWN_PATTERN = "f3050300fb"; +} + +class ThinkRiderVs200Buttons { + static const ControllerButton shiftUp = ControllerButton( + 'shiftUp', + action: InGameAction.shiftUp, + icon: Icons.add, + ); + + static const ControllerButton shiftDown = ControllerButton( + 'shiftDown', + action: InGameAction.shiftDown, + icon: Icons.remove, + ); + + static const List values = [ + shiftUp, + shiftDown, + ]; +} diff --git a/lib/bluetooth/devices/trainer_connection.dart b/lib/bluetooth/devices/trainer_connection.dart new file mode 100644 index 000000000..501b34443 --- /dev/null +++ b/lib/bluetooth/devices/trainer_connection.dart @@ -0,0 +1,16 @@ +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:flutter/foundation.dart'; + +abstract class TrainerConnection { + final String title; + List supportedActions; + + final ValueNotifier isStarted = ValueNotifier(false); + final ValueNotifier isConnected = ValueNotifier(false); + + TrainerConnection({required this.title, required this.supportedActions}); + + Future sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}); +} diff --git a/lib/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart b/lib/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart new file mode 100644 index 000000000..7bdab35c2 --- /dev/null +++ b/lib/bluetooth/devices/wahoo/wahoo_kickr_bike_pro.dart @@ -0,0 +1,10 @@ +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; + +import '../zwift/constants.dart'; + +class WahooKickrBikePro extends ZwiftRide { + WahooKickrBikePro(super.scanResult) : super(); + + @override + String get customServiceId => ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID; +} diff --git a/lib/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart b/lib/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart new file mode 100644 index 000000000..cdc515692 --- /dev/null +++ b/lib/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart @@ -0,0 +1,187 @@ +import 'dart:collection'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../bluetooth_device.dart'; + +class WahooKickrBikeShift extends BluetoothDevice { + WahooKickrBikeShift(super.scanResult) + : super( + availableButtons: WahooKickrBikeShiftConstants.prefixToButton.values.toList(), + ); + + @override + Future handleServices(List services) async { + final service = services.firstWhere( + (e) => e.uuid == WahooKickrBikeShiftConstants.SERVICE_UUID, + orElse: () => throw Exception('Service not found: ${WahooKickrBikeShiftConstants.SERVICE_UUID}'), + ); + final characteristic = service.characteristics.firstWhere( + (e) => e.uuid == WahooKickrBikeShiftConstants.CHARACTERISTIC_UUID, + orElse: () => throw Exception('Characteristic not found: ${WahooKickrBikeShiftConstants.CHARACTERISTIC_UUID}'), + ); + + await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) { + if (characteristic == WahooKickrBikeShiftConstants.CHARACTERISTIC_UUID) { + final hex = toHex(bytes); + + // Short-frame detection (hard-coded families) + final s = parseShortFrame(hex); + if (s != null) { + if (s.pressed) { + handleButtonsClicked([s.button]); + } else { + handleButtonsClicked([]); + } + return Future.value(); + } + } + return Future.value(); + } + + // Deduplicate per (prefix, type) using the 7-bit rolling sequence + final Map lastSeqByPrefix = HashMap(); + + String toHex(Uint8List bytes) => bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + + // Parse short frames like "PPQQRR" (e.g., "0001E6", "80005E", "40008F", "010004") + ShortFrame? parseShortFrame(String hex) { + final re = RegExp(r'^[0-9A-F]{6}$', caseSensitive: false); + if (!re.hasMatch(hex)) return null; + + final prefix = hex.substring(0, 4); // PPQQ + final rrHex = hex.substring(4, 6); // RR + if (!WahooKickrBikeShiftConstants.prefixToButton.containsKey(prefix)) return null; + + final idx = int.parse(rrHex, radix: 16); + final type = (idx & 0x80) != 0 ? true : false; // MSB of RR + final seq = idx & 0x7F; // rolling counter for dedupe + + return ShortFrame( + prefix: prefix, + rrHex: rrHex, + idx: idx, + pressed: type, + seq: seq, + button: WahooKickrBikeShiftConstants.prefixToButton[prefix]!, + ); + } + + bool isLongFrame(String hex) { + final re = RegExp(r'^FF0F01', caseSensitive: false); + return re.hasMatch(hex); + } + + // Returns true if this (prefix,type,seq) has not been handled yet + bool shouldHandleOnce(String prefix, String type, int seq) { + final key = '$prefix:$type'; + final last = lastSeqByPrefix[key]; + if (last == seq) return false; + lastSeqByPrefix[key] = seq; + return true; + } +} + +class ShortFrame { + final String prefix; // PPQQ + final String rrHex; // RR + final int idx; + final bool pressed; + final int seq; + final ControllerButton button; + + ShortFrame({ + required this.prefix, + required this.rrHex, + required this.idx, + required this.pressed, + required this.seq, + required this.button, + }); +} + +class WahooKickrBikeShiftConstants { + static const String SERVICE_UUID = "a026ee0d-0a7d-4ab3-97fa-f1500f9feb8b"; + static const String CHARACTERISTIC_UUID = "a026e03c-0a7d-4ab3-97fa-f1500f9feb8b"; + + // https://support.wahoofitness.com/hc/en-us/articles/22259367275410-Shifter-and-button-configuration-for-KICKR-BIKE-1-2 + static const Map prefixToButton = { + '0001': WahooKickrShiftButtons.rightUp, //'Right Up', + '8000': WahooKickrShiftButtons.rightDown, //'Right Down', + '0008': WahooKickrShiftButtons.rightSteer, //'Right Steer', + '0200': WahooKickrShiftButtons.leftUp, // 'Left Up', + '0400': WahooKickrShiftButtons.leftDown, //'Left Down', + '2000': WahooKickrShiftButtons.leftSteer, //'Left Steer', + '0004': WahooKickrShiftButtons.shiftUpRight, // 'Right Shift Up', + '0002': WahooKickrShiftButtons.shiftDownRight, // 'Right Shift Down', + '1000': WahooKickrShiftButtons.shiftUpLeft, //'Left Shift Up', + '0800': WahooKickrShiftButtons.shiftDownLeft, //'Left Shift Down', + '4000': WahooKickrShiftButtons.rightBrake, //'Right Brake', + '0100': WahooKickrShiftButtons.leftBrake, //'Left Brake', + }; +} + +class WahooKickrShiftButtons { + static const ControllerButton leftSteer = ControllerButton( + 'leftSteer', + action: InGameAction.navigateLeft, + icon: Icons.keyboard_arrow_left, + color: Colors.black, + ); + static const ControllerButton rightSteer = ControllerButton( + 'rightSteer', + action: InGameAction.navigateRight, + icon: Icons.keyboard_arrow_right, + color: Colors.black, + ); + static const ControllerButton leftDown = ControllerButton('leftDown', action: InGameAction.shiftDown); + static const ControllerButton leftBrake = ControllerButton('leftBrake', action: InGameAction.shiftDown); + + static const ControllerButton shiftUpLeft = ControllerButton( + 'shiftUpLeft', + action: InGameAction.shiftDown, + icon: Icons.remove, + color: Colors.black, + ); + static const ControllerButton shiftDownLeft = ControllerButton( + 'shiftDownLeft', + action: InGameAction.shiftDown, + icon: Icons.remove, + color: Colors.black, + ); + static const ControllerButton leftUp = ControllerButton('leftUp', action: InGameAction.shiftDown); + + static const ControllerButton rightDown = ControllerButton('rightDown', action: InGameAction.shiftUp); + static const ControllerButton rightBrake = ControllerButton('rightBrake', action: InGameAction.shiftUp); + + static const ControllerButton shiftUpRight = ControllerButton( + 'shiftUpRight', + action: InGameAction.shiftUp, + icon: Icons.add, + color: Colors.black, + ); + static const ControllerButton shiftDownRight = ControllerButton('shiftDownRight', action: InGameAction.shiftUp); + static const ControllerButton rightUp = ControllerButton('rightUp', action: InGameAction.shiftUp); + + static const List values = [ + leftSteer, + rightSteer, + leftDown, + leftBrake, + shiftUpLeft, + shiftDownLeft, + leftUp, + rightDown, + rightBrake, + shiftUpRight, + shiftDownRight, + rightUp, + ]; +} diff --git a/lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart b/lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart new file mode 100644 index 000000000..594f28f22 --- /dev/null +++ b/lib/bluetooth/devices/wahoo/wahoo_kickr_headwind.dart @@ -0,0 +1,154 @@ +import 'dart:typed_data'; + +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../bluetooth_device.dart'; + +class WahooKickrHeadwind extends BluetoothDevice { + // Current mode state + HeadwindMode _currentMode = HeadwindMode.unknown; + int _currentSpeed = 0; + + WahooKickrHeadwind(super.scanResult) + : super( + availableButtons: const [], + isBeta: true, + ); + + @override + Future handleServices(List services) async { + final service = services.firstWhere( + (e) => e.uuid == WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase(), + orElse: () => throw Exception('Service not found: ${WahooKickrHeadwindConstants.SERVICE_UUID}'), + ); + final characteristic = service.characteristics.firstWhere( + (e) => e.uuid == WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase(), + orElse: () => throw Exception('Characteristic not found: ${WahooKickrHeadwindConstants.CHARACTERISTIC_UUID}'), + ); + + // Subscribe to notifications for status updates + await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) { + // Analyze the received bytes to determine current state + if (bytes.length >= 4 && bytes[0] == 0xFD && bytes[1] == 0x01) { + final mode = bytes[3]; + final speed = bytes[2]; + + switch (mode) { + case 0x02: + _currentMode = HeadwindMode.heartRate; + break; + case 0x03: + _currentMode = HeadwindMode.speed; + break; + case 0x01: + _currentMode = HeadwindMode.off; + break; + case 0x04: + _currentMode = HeadwindMode.manual; + _currentSpeed = speed; + break; + } + } + return Future.value(); + } + + Future setSpeed(int speedPercent) async { + // Validate against the allowed speed values + const allowedSpeeds = [0, 25, 50, 75, 100]; + if (!allowedSpeeds.contains(speedPercent)) { + throw ArgumentError('Speed must be one of: ${allowedSpeeds.join(", ")}'); + } + + final service = WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase(); + final characteristic = WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase(); + + // Check if manual mode is enabled, if not enable it first + if (_currentMode != HeadwindMode.manual) { + final manualModeData = Uint8List.fromList([0x04, 0x04]); + await UniversalBle.write( + device.deviceId, + service, + characteristic, + manualModeData, + withoutResponse: true, + ); + _currentMode = HeadwindMode.manual; + + // Small delay to ensure mode change is processed before speed command + await Future.delayed(const Duration(milliseconds: 100)); + } + + // Command format: [0x02, speed_value] + // Speed value: 0x00 to 0x64 (0-100 in hex) + final data = Uint8List.fromList([0x02, speedPercent]); + + await UniversalBle.write( + device.deviceId, + service, + characteristic, + data, + withoutResponse: true, + ); + _currentSpeed = speedPercent; + } + + Future setHeartRateMode() async { + final service = WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase(); + final characteristic = WahooKickrHeadwindConstants.CHARACTERISTIC_UUID.toLowerCase(); + + // Command format: [0x04, 0x02] for HR mode + final data = Uint8List.fromList([0x04, 0x02]); + + await UniversalBle.write( + device.deviceId, + service, + characteristic, + data, + withoutResponse: true, + ); + _currentMode = HeadwindMode.heartRate; + } + + Future handleKeypair(KeyPair keyPair, {required bool isKeyDown}) async { + if (!isKeyDown) { + return NotHandled(''); + } + + try { + if (keyPair.inGameAction == InGameAction.headwindSpeed) { + final speed = keyPair.inGameActionValue ?? 0; + await setSpeed(speed); + return Success('Headwind speed set to $speed%'); + } else if (keyPair.inGameAction == InGameAction.headwindHeartRateMode) { + await setHeartRateMode(); + return Success('Headwind set to Heart Rate mode'); + } + } catch (e) { + return Error('Failed to control Headwind: $e'); + } + + return NotHandled(''); + } +} + +class WahooKickrHeadwindConstants { + // Wahoo KICKR Headwind service and characteristic UUIDs + // These are standard Wahoo fitness equipment UUIDs + static const String SERVICE_UUID = "A026EE0C-0A7D-4AB3-97FA-F1500F9FEB8B"; + static const String CHARACTERISTIC_UUID = "A026E038-0A7D-4AB3-97FA-F1500F9FEB8B"; +} + +enum HeadwindMode { + unknown, + heartRate, // HR mode (0x02) + speed, // Speed mode (0x03) + off, // OFF mode (0x01) + manual, // Manual speed mode (0x04) +} diff --git a/lib/bluetooth/devices/zwift/constants.dart b/lib/bluetooth/devices/zwift/constants.dart new file mode 100644 index 000000000..d6b68f539 --- /dev/null +++ b/lib/bluetooth/devices/zwift/constants.dart @@ -0,0 +1,185 @@ +import 'dart:typed_data'; + +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:flutter/material.dart'; + +class ZwiftConstants { + static const ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1"; + static const ZWIFT_CUSTOM_SERVICE_SHORT_UUID = "0001"; + static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb"; + static const ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT = "fc82"; + static const ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1"; + static const ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1"; + static const ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1"; + + static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A + + // Zwift Play = RC1 + static const RC1_LEFT_SIDE = 0x03; + static const RC1_RIGHT_SIDE = 0x02; + + // Zwift Ride + static const RIDE_RIGHT_SIDE = 0x07; + static const RIDE_LEFT_SIDE = 0x08; + + // Zwift Click = BC1 + static const BC1 = 0x09; + + // Zwift Click v2 Right (unconfirmed) + static const CLICK_V2_RIGHT_SIDE = 0x0A; + // Zwift Click v2 Right (unconfirmed) + static const CLICK_V2_LEFT_SIDE = 0x0B; + + static final RIDE_ON = Uint8List.fromList([0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e]); + static final VIBRATE_PATTERN = Uint8List.fromList([0x12, 0x12, 0x08, 0x0A, 0x06, 0x08, 0x02, 0x10, 0x00, 0x18]); + + // these don't actually seem to matter, its just the header has to be 7 bytes RIDEON + 2 + static final REQUEST_START = Uint8List.fromList([0x00, 0x09]); //byteArrayOf(1, 2) + static final RESPONSE_START_CLICK = Uint8List.fromList([0x01, 0x03]); // from device + static final RESPONSE_START_PLAY = Uint8List.fromList([0x01, 0x04]); // from device + static final RESPONSE_START_CLICK_V2 = Uint8List.fromList([0x02, 0x03]); // from device + static final RESPONSE_STOPPED_CLICK_V2_VARIANT_1 = Uint8List.fromList([0xff, 0x05, 0x00, 0xea, 0x05]); // from device + static final RESPONSE_STOPPED_CLICK_V2_VARIANT_2 = Uint8List.fromList([0xff, 0x05, 0x00, 0xfa, 0x05]); // from device + + // Message types received from device + static const CONTROLLER_NOTIFICATION_MESSAGE_TYPE = 07; + static const EMPTY_MESSAGE_TYPE = 21; // 0x15 + static const BATTERY_LEVEL_TYPE = 25; + static const UNKNOWN_CLICKV2_TYPE = 0x3C; + + // not figured out the protobuf type this really is, the content is just two varints. + static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55; // 0x37 + static const int PLAY_NOTIFICATION_MESSAGE_TYPE = 7; + static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35; // 0x23 + + // see this if connected to Core then Zwift connects to it. just one byte + static const DISCONNECT_MESSAGE_TYPE = 0xFE; +} + +class ZwiftButtons { + // left controller + static const ControllerButton navigationUp = ControllerButton( + 'navigationUp', + action: InGameAction.up, + icon: Icons.keyboard_arrow_up, + color: Colors.black, + ); + static const ControllerButton navigationDown = ControllerButton( + 'navigationDown', + action: InGameAction.down, + icon: Icons.keyboard_arrow_down, + color: Colors.black, + ); + static const ControllerButton navigationLeft = ControllerButton( + 'navigationLeft', + action: InGameAction.steerLeft, + icon: Icons.keyboard_arrow_left, + color: Colors.black, + ); + static const ControllerButton navigationRight = ControllerButton( + 'navigationRight', + action: InGameAction.steerRight, + icon: Icons.keyboard_arrow_right, + color: Colors.black, + ); + static const ControllerButton onOffLeft = ControllerButton('onOffLeft', action: InGameAction.toggleUi); + static const ControllerButton sideButtonLeft = ControllerButton('sideButtonLeft', action: InGameAction.shiftDown); + static const ControllerButton paddleLeft = ControllerButton('paddleLeft', action: InGameAction.shiftDown); + + // zwift ride only + static const ControllerButton shiftUpLeft = ControllerButton( + 'shiftUpLeft', + action: InGameAction.shiftDown, + icon: Icons.remove, + color: Colors.black, + ); + static const ControllerButton shiftDownLeft = ControllerButton( + 'shiftDownLeft', + action: InGameAction.shiftDown, + ); + static const ControllerButton powerUpLeft = ControllerButton('powerUpLeft', action: InGameAction.shiftDown); + + // right controller + static const ControllerButton a = ControllerButton('a', action: InGameAction.select, color: Colors.lightGreen); + static const ControllerButton b = ControllerButton('b', action: InGameAction.back, color: Colors.pinkAccent); + static const ControllerButton z = ControllerButton( + 'z', + action: InGameAction.rideOnBomb, + color: Colors.deepOrangeAccent, + ); + static const ControllerButton y = ControllerButton('y', action: InGameAction.menu, color: Colors.lightBlue); + static const ControllerButton onOffRight = ControllerButton('onOffRight', action: InGameAction.toggleUi); + static const ControllerButton sideButtonRight = ControllerButton('sideButtonRight', action: InGameAction.shiftUp); + static const ControllerButton paddleRight = ControllerButton('paddleRight', action: InGameAction.shiftUp); + + // zwift ride only + static const ControllerButton shiftUpRight = ControllerButton( + 'shiftUpRight', + action: InGameAction.shiftUp, + icon: Icons.add, + color: Colors.black, + ); + static const ControllerButton shiftDownRight = ControllerButton('shiftDownRight', action: InGameAction.shiftUp); + static const ControllerButton powerUpRight = ControllerButton('powerUpRight', action: InGameAction.shiftUp); + + static List get values => [ + // left + navigationUp, + navigationDown, + navigationLeft, + navigationRight, + onOffLeft, + sideButtonLeft, + paddleLeft, + shiftUpLeft, + shiftDownLeft, + powerUpLeft, + // right + a, + b, + z, + y, + onOffRight, + sideButtonRight, + paddleRight, + shiftUpRight, + shiftDownRight, + powerUpRight, + ]; +} + +enum ZwiftDeviceType { + click, + clickV2Right, + clickV2Left, + playLeft, + playRight, + rideRight, + rideLeft; + + @override + String toString() { + return super.toString().split('.').last; + } + + // add constructor + static ZwiftDeviceType? fromManufacturerData(int data) { + switch (data) { + case ZwiftConstants.BC1: + return ZwiftDeviceType.click; + case ZwiftConstants.CLICK_V2_RIGHT_SIDE: + return ZwiftDeviceType.clickV2Right; + case ZwiftConstants.CLICK_V2_LEFT_SIDE: + return ZwiftDeviceType.clickV2Left; + case ZwiftConstants.RC1_LEFT_SIDE: + return ZwiftDeviceType.playLeft; + case ZwiftConstants.RC1_RIGHT_SIDE: + return ZwiftDeviceType.playRight; + case ZwiftConstants.RIDE_RIGHT_SIDE: + return ZwiftDeviceType.rideRight; + case ZwiftConstants.RIDE_LEFT_SIDE: + return ZwiftDeviceType.rideLeft; + } + return null; + } +} diff --git a/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart b/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart new file mode 100644 index 000000000..f76fdd251 --- /dev/null +++ b/lib/bluetooth/devices/zwift/ftms_mdns_emulator.dart @@ -0,0 +1,122 @@ +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/rouvy.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart' hide RideButtonMask; + +class FtmsMdnsEmulator extends TrainerConnection { + static const String connectionTitle = 'Zwift Network Emulator'; + + late final ClickEmulator clickEmulator = ClickEmulator(); + var lastMessageId = 0; + + FtmsMdnsEmulator() + : super( + title: connectionTitle, + supportedActions: [ + InGameAction.shiftUp, + InGameAction.shiftDown, + InGameAction.uturn, + InGameAction.steerLeft, + InGameAction.steerRight, + InGameAction.openActionBar, + InGameAction.usePowerUp, + InGameAction.select, + InGameAction.back, + InGameAction.rideOnBomb, + ], + ) { + clickEmulator.isStarted.addListener(() { + isStarted.value = clickEmulator.isStarted.value; + }); + clickEmulator.isConnected.addListener(() { + isConnected.value = clickEmulator.isConnected.value; + if (isConnected.value) { + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.connected), + ); + } else { + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected), + ); + } + }); + } + + Future startServer() async { + return clickEmulator.startServer(); + } + + void stop() { + clickEmulator.stop(); + } + + @override + Future sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async { + final button = switch (keyPair.inGameAction) { + InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN, + InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN, + InGameAction.uturn => RideButtonMask.DOWN_BTN, + InGameAction.steerLeft => RideButtonMask.LEFT_BTN, + InGameAction.steerRight => RideButtonMask.RIGHT_BTN, + InGameAction.openActionBar => RideButtonMask.UP_BTN, + InGameAction.usePowerUp => RideButtonMask.Y_BTN, + InGameAction.select => RideButtonMask.A_BTN, + InGameAction.back => RideButtonMask.B_BTN, + InGameAction.rideOnBomb => RideButtonMask.Z_BTN, + _ => null, + }; + + if (button == null) { + return NotHandled('Action ${keyPair.inGameAction!.name} not supported by Zwift Emulator'); + } + + if (isKeyDown) { + final status = RideKeyPadStatus() + ..buttonMap = (~button.mask) & 0xFFFFFFFF + ..analogPaddles.clear(); + + final bytes = status.writeToBuffer(); + + clickEmulator.writeNotification(bytes); + } + + if (isKeyUp) { + clickEmulator.writeNotification( + Uint8List.fromList([0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]), + ); + } + if (kDebugMode) { + print('Sent action up $isKeyUp vs down $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator'); + } + return Success('Sent action ${isKeyDown ? 'down' : ''} ${isKeyUp ? 'up' : ''}: ${keyPair.inGameAction!.title}'); + } +} + +class FtmsMdnsConstants { + static const DC_RC_REQUEST_COMPLETED_SUCCESSFULLY = 0; // Request completed successfully + static const DC_RC_UNKNOWN_MESSAGE_TYPE = 1; // Unknown Message Type + static const DC_RC_UNEXPECTED_ERROR = 2; // Unexpected Error + static const DC_RC_SERVICE_NOT_FOUND = 3; // Service Not Found + static const DC_RC_CHARACTERISTIC_NOT_FOUND = 4; // Characteristic Not Found + static const DC_RC_CHARACTERISTIC_OPERATION_NOT_SUPPORTED = + 5; // Characteristic Operation Not Supported (See Characteristic Properties) + static const DC_RC_CHARACTERISTIC_WRITE_FAILED_INVALID_SIZE = + 6; // Characteristic Write Failed – Invalid characteristic data size + static const DC_RC_UNKNOWN_PROTOCOL_VERSION = + 7; // Unknown Protocol Version – the command contains a protocol version that the device does not recognize + + static const DC_MESSAGE_DISCOVER_SERVICES = 0x01; // Discover Services + static const DC_MESSAGE_DISCOVER_CHARACTERISTICS = 0x02; // Discover Characteristics + static const DC_MESSAGE_READ_CHARACTERISTIC = 0x03; // Read Characteristic + static const DC_MESSAGE_WRITE_CHARACTERISTIC = 0x04; // Write Characteristic + static const DC_MESSAGE_ENABLE_CHARACTERISTIC_NOTIFICATIONS = 0x05; // Enable Characteristic Notifications + static const DC_MESSAGE_CHARACTERISTIC_NOTIFICATION = 0x06; // Characteristic Notification +} + diff --git a/lib/bluetooth/devices/zwift/zwift_click.dart b/lib/bluetooth/devices/zwift/zwift_click.dart new file mode 100644 index 000000000..b958521a4 --- /dev/null +++ b/lib/bluetooth/devices/zwift/zwift_click.dart @@ -0,0 +1,23 @@ +import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; + +import 'constants.dart'; + +class ZwiftClick extends ZwiftDevice { + ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftUpLeft]); + + @override + List processClickNotification(Uint8List message) { + final status = ClickKeyPadStatus.fromBuffer(message); + final buttonsClicked = [ + if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButtons.shiftUpRight, + if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftUpLeft, + ]; + return buttonsClicked; + } + + @override + String get latestFirmwareVersion => '1.1.0'; +} diff --git a/lib/bluetooth/devices/zwift/zwift_clickv2.dart b/lib/bluetooth/devices/zwift/zwift_clickv2.dart new file mode 100644 index 000000000..9b1ca90e3 --- /dev/null +++ b/lib/bluetooth/devices/zwift/zwift_clickv2.dart @@ -0,0 +1,267 @@ +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/pages/unlock.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/interpreter.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:prop/emulators/ftms_emulator.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; + +final FtmsEmulator ftmsEmulator = FtmsEmulator(); + +class ZwiftClickV2 extends ZwiftRide { + ZwiftClickV2(super.scanResult) + : super( + isBeta: true, + availableButtons: [ + ZwiftButtons.navigationLeft, + ZwiftButtons.navigationRight, + ZwiftButtons.navigationUp, + ZwiftButtons.navigationDown, + ZwiftButtons.a, + ZwiftButtons.b, + ZwiftButtons.y, + ZwiftButtons.z, + ZwiftButtons.shiftUpLeft, + ZwiftButtons.shiftUpRight, + ], + ) { + ftmsEmulator.setScanResult(scanResult); + } + + @override + List get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK_V2; + + @override + String get latestFirmwareVersion => '1.1.0'; + + @override + bool get canVibrate => false; + + @override + String toString() { + return "$name V2"; + } + + bool get isUnlocked { + final lastUnlock = propPrefs.getZwiftClickV2LastUnlock(scanResult.deviceId); + if (lastUnlock == null) { + return false; + } + return lastUnlock > DateTime.now().subtract(const Duration(days: 1)); + } + + @override + Future setupHandshake() async { + final hasScript = await DeviceScriptService.instance.hasCustomScript(runtimeType.toString()); + if (isUnlocked || hasScript) { + super.setupHandshake(); + await sendCommandBuffer(Uint8List.fromList([0xFF, 0x04, 0x00])); + } + } + + @override + Future handleServices(List services) async { + ftmsEmulator.handleServices(services); + await super.handleServices(services); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + if (!ftmsEmulator.processCharacteristic(characteristic, bytes)) { + await super.processCharacteristic(characteristic, bytes); + } + } + + @override + Widget showInformation(BuildContext context) { + final lastUnlockDate = propPrefs.getZwiftClickV2LastUnlock(scanResult.deviceId); + return StatefulBuilder( + builder: (context, setState) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + super.showInformation(context), + + if (isConnected && !core.settings.getShowOnboarding()) + if (isUnlocked && lastUnlockDate != null) + Warning( + important: false, + children: [ + Row( + spacing: 8, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(24), + ), + padding: const EdgeInsets.all(4), + child: Icon(Icons.lock_open_rounded, color: Colors.white), + ), + Flexible( + child: Text( + AppLocalizations.of(context).unlock_unlockedUntilAroundDate( + DateFormat('EEEE, HH:MM').format(lastUnlockDate.add(const Duration(days: 1))), + ), + ).xSmall, + ), + Tooltip( + tooltip: (c) => Text('Unlock again'), + child: IconButton.ghost( + icon: Icon(Icons.lock_reset_rounded), + + onPressed: () { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (_) => UnlockPage(device: this), + ); + }, + ), + ), + ], + ), + if (kDebugMode) + Button( + onPressed: () { + test(); + }, + leading: const Icon(Icons.translate_sharp), + style: ButtonStyle.primary(size: ButtonSize.small), + child: Text('Reset'), + ), + ], + ) + else + Warning( + important: false, + children: [ + Row( + spacing: 8, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(24), + ), + padding: const EdgeInsets.all(4), + child: Icon(Icons.lock_rounded, color: Colors.white), + ), + Flexible(child: Text(AppLocalizations.of(context).unlock_deviceIsCurrentlyLocked).xSmall), + Button( + onPressed: () { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (_) => UnlockPage(device: this), + ); + }, + leading: const Icon(Icons.lock_open_rounded), + style: ButtonStyle.primary(size: ButtonSize.small), + child: Text(AppLocalizations.of(context).unlock_unlockNow), + ), + ], + ), + if (kDebugMode && !isUnlocked) + Button( + onPressed: () { + super.setupHandshake(); + }, + leading: const Icon(Icons.handshake), + style: ButtonStyle.primary(size: ButtonSize.small), + child: Text('Handshake'), + ), + if (kDebugMode) + Button( + onPressed: () { + test(); + }, + leading: const Icon(Icons.translate_sharp), + style: ButtonStyle.primary(size: ButtonSize.small), + child: Text('Reset'), + ), + ], + ), + /*else + Warning( + important: false, + children: [ + Text( + AppLocalizations.of(context).clickV2EventInfo, + ).xSmall, + LinkButton( + child: Text(context.i18n.troubleshootingGuide), + onPressed: () { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'), + ); + }, + ), + ], + ),*/ + ], + ); + }, + ); + } + + Future test() async { + await sendCommand(Opcode.RESET, null); + //await sendCommand(Opcode.GET, Get(dataObjectId: VendorDO.PAGE_DEVICE_PAIRING.value)); // 0008 82E0 03 + + /*await sendCommand(Opcode.GET, Get(dataObjectId: DO.PAGE_DEV_INFO.value)); // 0008 00 + await sendCommand(Opcode.LOG_LEVEL_SET, LogLevelSet(logLevel: LogLevel.LOGLEVEL_TRACE)); // 4108 05 + + await sendCommand(Opcode.GET, Get(dataObjectId: DO.PAGE_CLIENT_SERVER_CONFIGURATION.value)); // 0008 10 + await sendCommand(Opcode.GET, Get(dataObjectId: DO.PAGE_CLIENT_SERVER_CONFIGURATION.value)); // 0008 10 + await sendCommand(Opcode.GET, Get(dataObjectId: DO.PAGE_CLIENT_SERVER_CONFIGURATION.value)); // 0008 10 + + await sendCommand(Opcode.GET, Get(dataObjectId: DO.PAGE_CONTROLLER_INPUT_CONFIG.value)); // 0008 80 08 + + await sendCommand(Opcode.GET, Get(dataObjectId: DO.BATTERY_STATE.value)); // 0008 83 06 + + // Value: FF04 000A 1540 E9D9 C96B 7463 C27F 1B4E 4D9F 1CB1 205D 882E D7CE + // Value: FF04 000A 15B2 6324 0A31 D6C6 B81F C129 D6A4 E99D FFFC B9FC 418D + await sendCommandBuffer( + Uint8List.fromList([ + 0xFF, + 0x04, + 0x00, + 0x0A, + 0x15, + 0xC2, + 0x63, + 0x24, + 0x0A, + 0x31, + 0xD6, + 0xC6, + 0xB8, + 0x1F, + 0xC1, + 0x29, + 0xD6, + 0xA4, + 0xE9, + 0x9D, + 0xFF, + 0xFC, + 0xB9, + 0xFC, + 0x41, + 0x8D, + ]), + );*/ + } +} diff --git a/lib/bluetooth/devices/zwift/zwift_device.dart b/lib/bluetooth/devices/zwift/zwift_device.dart new file mode 100644 index 000000000..0f12d2beb --- /dev/null +++ b/lib/bluetooth/devices/zwift/zwift_device.dart @@ -0,0 +1,224 @@ +import 'dart:async'; + +import 'package:bike_control/bluetooth/devices/bluetooth_device.dart'; +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/utils/single_line_exception.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; + +abstract class ZwiftDevice extends BluetoothDevice { + ZwiftDevice(super.scanResult, {required super.availableButtons, super.isBeta}); + + BleCharacteristic? syncRxCharacteristic; + + List? _lastButtonsClicked; + + BleService? customService; + + String get latestFirmwareVersion; + List get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_CLICK; + bool get canVibrate => false; + + @override + Future handleServices(List services) async { + customService = + services.firstOrNullWhere( + (service) => service.uuid == ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase(), + ) ?? + services.firstOrNullWhere( + (service) => service.uuid == ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase(), + ); + + if (customService == null) { + actionStreamInternal.add( + AlertNotification( + LogLevel.LOGLEVEL_ERROR, + 'You may need to update the firmware of ${scanResult.name} in Zwift Companion app', + ), + ); + throw Exception( + 'Custom service ${[ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID, ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID]} not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}', + ); + } + + final asyncCharacteristic = customService!.characteristics.firstOrNullWhere( + (characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(), + ); + final syncTxCharacteristic = customService!.characteristics.firstOrNullWhere( + (characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID.toLowerCase(), + ); + syncRxCharacteristic = customService!.characteristics.firstOrNullWhere( + (characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID.toLowerCase(), + ); + + if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) { + throw Exception('Characteristics not found'); + } + + await UniversalBle.subscribeNotifications(device.deviceId, customService!.uuid, asyncCharacteristic.uuid); + await UniversalBle.subscribeIndications(device.deviceId, customService!.uuid, syncTxCharacteristic.uuid); + + await setupHandshake(); + + if (firmwareVersion != latestFirmwareVersion && firmwareVersion != null) { + actionStreamInternal.add( + AlertNotification( + LogLevel.LOGLEVEL_WARNING, + 'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.', + ), + ); + } + } + + Future setupHandshake() async { + await UniversalBle.write( + device.deviceId, + customService!.uuid, + syncRxCharacteristic!.uuid, + ZwiftConstants.RIDE_ON, + withoutResponse: true, + ); + } + + @override + Future processCharacteristic(String characteristic, Uint8List bytes) async { + if (kDebugMode && false) { + actionStreamInternal.add( + LogNotification( + "Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}", + ), + ); + } + if (bytes.isEmpty) { + return; + } + + try { + if (bytes.length >= startCommand.length && bytes.sublist(0, startCommand.length).toString() == startCommand.toString()) { + processDevicePublicKeyResponse(bytes); + } else { + processData(bytes); + } + } catch (e, stackTrace) { + print("Error processing data: $e"); + print("Stack Trace: $stackTrace"); + if (e is SingleLineException) { + actionStreamInternal.add(LogNotification(e.message)); + } else { + actionStreamInternal.add(LogNotification("$e\n$stackTrace")); + } + } + } + + void processDevicePublicKeyResponse(Uint8List bytes) { + final devicePublicKeyBytes = bytes.sublist( + ZwiftConstants.RIDE_ON.length + ZwiftConstants.RESPONSE_START_CLICK.length, + ); + if (kDebugMode) { + print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}"); + } + } + + Future processData(Uint8List bytes) async { + int type = bytes[0]; + Uint8List message = bytes.sublist(1); + + switch (type) { + case ZwiftConstants.EMPTY_MESSAGE_TYPE: + //print("Empty Message"); // expected when nothing happening + break; + case ZwiftConstants.BATTERY_LEVEL_TYPE: + if (batteryLevel != message[1]) { + batteryLevel = message[1]; + core.connection.signalChange(this); + } + break; + case ZwiftConstants.CLICK_NOTIFICATION_MESSAGE_TYPE: + case ZwiftConstants.PLAY_NOTIFICATION_MESSAGE_TYPE: + case ZwiftConstants.RIDE_NOTIFICATION_MESSAGE_TYPE: + try { + final buttonsClicked = processClickNotification(message); + handleButtonsClicked(buttonsClicked); + } catch (e) { + actionStreamInternal.add(LogNotification(e.toString())); + } + break; + } + } + + @override + Future handleButtonsClicked(List? buttonsClicked, {bool longPress = false}) async { + // the same messages are sent multiple times, so ignore + if (_lastButtonsClicked == null || _lastButtonsClicked?.contentEquals(buttonsClicked ?? []) == false) { + super.handleButtonsClicked(buttonsClicked, longPress: longPress); + } + _lastButtonsClicked = buttonsClicked; + } + + List processClickNotification(Uint8List message); + + @override + Future performDown( + List buttonsClicked, { + ButtonTrigger trigger = ButtonTrigger.longPress, + }) async { + if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) && + core.settings.getVibrationEnabled()) { + await _vibrate(); + } + return super.performDown(buttonsClicked, trigger: trigger); + } + + @override + Future performClick( + List buttonsClicked, { + ButtonTrigger trigger = ButtonTrigger.singleClick, + }) async { + if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) && + core.settings.getVibrationEnabled() && + canVibrate) { + await _vibrate(); + } + return super.performClick(buttonsClicked, trigger: trigger); + } + + Future _vibrate() async { + final vibrateCommand = Uint8List.fromList([...ZwiftConstants.VIBRATE_PATTERN, 0x20]); + await UniversalBle.write( + device.deviceId, + customService!.uuid, + syncRxCharacteristic!.uuid, + vibrateCommand, + withoutResponse: true, + ); + } + + @override + Widget showInformation(BuildContext context) { + return Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + super.showInformation(context), + + if (canVibrate) + Checkbox( + trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)), + state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked, + onChanged: (value) async { + await core.settings.setVibrationEnabled(value == CheckboxState.checked); + }, + ), + ], + ); + } +} + diff --git a/lib/bluetooth/devices/zwift/zwift_emulator.dart b/lib/bluetooth/devices/zwift/zwift_emulator.dart new file mode 100644 index 000000000..efe4c0127 --- /dev/null +++ b/lib/bluetooth/devices/zwift/zwift_emulator.dart @@ -0,0 +1,375 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/ble.dart'; +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:bike_control/widgets/title.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:prop/prop.dart' hide RideButtonMask; + +class ZwiftEmulator extends TrainerConnection { + bool get isLoading => _isLoading; + + static const String connectionTitle = 'Zwift BLE Emulator'; + + late final _peripheralManager = PeripheralManager(); + bool _isLoading = false; + bool _isServiceAdded = false; + bool _isSubscribedToEvents = false; + Central? _central; + GATTCharacteristic? _asyncCharacteristic; + GATTCharacteristic? _syncTxCharacteristic; + + ZwiftEmulator() + : super( + title: connectionTitle, + supportedActions: [ + InGameAction.shiftUp, + InGameAction.shiftDown, + InGameAction.uturn, + InGameAction.steerLeft, + InGameAction.steerRight, + InGameAction.openActionBar, + InGameAction.usePowerUp, + InGameAction.select, + InGameAction.back, + InGameAction.rideOnBomb, + ], + ); + + Future reconnect() async { + await _peripheralManager.stopAdvertising(); + await _peripheralManager.removeAllServices(); + _isServiceAdded = false; + startAdvertising(() {}); + } + + Future startAdvertising(VoidCallback onUpdate) async { + _isLoading = true; + isStarted.value = true; + onUpdate(); + + _peripheralManager.stateChanged.forEach((state) { + print('Peripheral manager state: ${state.state}'); + }); + + if (!kIsWeb && Platform.isAndroid) { + _peripheralManager.connectionStateChanged.forEach((state) { + print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); + if (state.state == ConnectionState.connected) { + } else if (state.state == ConnectionState.disconnected) { + _central = null; + isConnected.value = false; + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected), + ); + onUpdate(); + } + }); + + final status = await Permission.bluetoothAdvertise.request(); + if (!status.isGranted) { + print('Bluetooth advertise permission not granted'); + isStarted.value = false; + onUpdate(); + return; + } + } + + while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && + core.settings.getZwiftBleEmulatorEnabled()) { + print('Waiting for peripheral manager to be powered on...'); + if (core.settings.getLastTarget() == Target.thisDevice) { + return; + } + await Future.delayed(Duration(seconds: 1)); + } + + _syncTxCharacteristic = GATTCharacteristic.mutable( + uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID), + descriptors: [], + properties: [ + GATTCharacteristicProperty.read, + GATTCharacteristicProperty.indicate, + ], + permissions: [ + GATTCharacteristicPermission.read, + ], + ); + + _asyncCharacteristic = GATTCharacteristic.mutable( + uuid: UUID.fromString(ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID), + descriptors: [], + properties: [ + GATTCharacteristicProperty.notify, + ], + permissions: [], + ); + + if (!_isServiceAdded) { + await Future.delayed(Duration(seconds: 1)); + + if (!_isSubscribedToEvents) { + _isSubscribedToEvents = true; + _peripheralManager.characteristicReadRequested.forEach((eventArgs) async { + print('Read request for characteristic: ${eventArgs.characteristic.uuid}'); + + switch (eventArgs.characteristic.uuid.toString().toUpperCase()) { + case ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID: + print('Handling read request for SYNC TX characteristic'); + break; + case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL: + await _peripheralManager.respondReadRequestWithValue( + eventArgs.request, + value: Uint8List.fromList([100]), + ); + break; + default: + print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}'); + } + + final request = eventArgs.request; + final trimmedValue = Uint8List.fromList([]); + await _peripheralManager.respondReadRequestWithValue( + request, + value: trimmedValue, + ); + // You can respond to read requests here if needed + }); + + _peripheralManager.characteristicNotifyStateChanged.forEach((char) { + print( + 'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}', + ); + }); + _peripheralManager.characteristicWriteRequested.forEach((eventArgs) async { + _central = eventArgs.central; + isConnected.value = true; + + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.connected), + ); + + final request = eventArgs.request; + final response = SharedLogic.handleWriteRequest(eventArgs.characteristic.uuid.toString(), request.value); + if (response != null) { + await _peripheralManager.notifyCharacteristic( + _central!, + _syncTxCharacteristic!, + value: response, + ); + onUpdate(); + if (response == ZwiftConstants.RIDE_ON) { + _sendKeepAlive(); + } + } + + await _peripheralManager.respondWriteRequest(request); + }); + } + + if (!Platform.isWindows) { + // Device Information + await _peripheralManager.addService( + GATTService( + uuid: UUID.fromString('180A'), + isPrimary: true, + characteristics: [ + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A29'), + value: Uint8List.fromList('BikeControl'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A25'), + value: Uint8List.fromList('09-B48123283828F1337'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A27'), + value: Uint8List.fromList('A.0'.codeUnits), + descriptors: [], + ), + GATTCharacteristic.immutable( + uuid: UUID.fromString('2A26'), + value: Uint8List.fromList((packageInfoValue?.version ?? '1.0.0').codeUnits), + descriptors: [], + ), + ], + includedServices: [], + ), + ); + } + // Battery Service + await _peripheralManager.addService( + GATTService( + uuid: UUID.fromString('180F'), + isPrimary: true, + characteristics: [ + GATTCharacteristic.mutable( + uuid: UUID.fromString('2A19'), + descriptors: [], + properties: [ + GATTCharacteristicProperty.read, + GATTCharacteristicProperty.notify, + ], + permissions: [ + GATTCharacteristicPermission.read, + ], + ), + ], + includedServices: [], + ), + ); + + // Unknown Service + await _peripheralManager.addService( + GATTService( + uuid: UUID.fromString(ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID), + isPrimary: true, + characteristics: [ + _asyncCharacteristic!, + GATTCharacteristic.mutable( + uuid: UUID.fromString(ZwiftConstants.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID), + descriptors: [], + properties: [ + GATTCharacteristicProperty.writeWithoutResponse, + ], + permissions: [], + ), + _syncTxCharacteristic!, + GATTCharacteristic.mutable( + uuid: UUID.fromString('00000005-19CA-4651-86E5-FA29DCDD09D1'), + descriptors: [], + properties: [ + GATTCharacteristicProperty.notify, + ], + permissions: [], + ), + GATTCharacteristic.mutable( + uuid: UUID.fromString('00000006-19CA-4651-86E5-FA29DCDD09D1'), + descriptors: [], + properties: [ + GATTCharacteristicProperty.indicate, + GATTCharacteristicProperty.read, + GATTCharacteristicProperty.writeWithoutResponse, + GATTCharacteristicProperty.write, + ], + permissions: [ + GATTCharacteristicPermission.read, + GATTCharacteristicPermission.write, + ], + ), + ], + includedServices: [], + ), + ); + _isServiceAdded = true; + } + + final advertisement = Advertisement( + name: 'KICKR BIKE PRO 1337', + serviceUUIDs: [UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT)], + /*serviceData: { + UUID.fromString(ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID_SHORT): Uint8List.fromList([0x02]), + }, + manufacturerSpecificData: [ + ManufacturerSpecificData( + id: 0x094A, + data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x13, 0x37]), + ), + ],*/ + ); + print('Starting advertising with Zwift service...'); + + await _peripheralManager.startAdvertising(advertisement); + _isLoading = false; + onUpdate(); + } + + Future stopAdvertising() async { + await _peripheralManager.removeAllServices(); + _isServiceAdded = false; + await _peripheralManager.stopAdvertising(); + isStarted.value = false; + isConnected.value = false; + _isLoading = false; + } + + Future _sendKeepAlive() async { + await Future.delayed(const Duration(seconds: 5)); + if (isConnected.value && _central != null) { + final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]); + _peripheralManager.notifyCharacteristic(_central!, _syncTxCharacteristic!, value: zero); + _sendKeepAlive(); + } + } + + @override + Future sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async { + final button = switch (keyPair.inGameAction) { + InGameAction.shiftUp => RideButtonMask.SHFT_UP_R_BTN, + InGameAction.shiftDown => RideButtonMask.SHFT_UP_L_BTN, + InGameAction.uturn => RideButtonMask.DOWN_BTN, + InGameAction.steerLeft => RideButtonMask.LEFT_BTN, + InGameAction.steerRight => RideButtonMask.RIGHT_BTN, + InGameAction.openActionBar => RideButtonMask.UP_BTN, + InGameAction.usePowerUp => RideButtonMask.Y_BTN, + InGameAction.select => RideButtonMask.A_BTN, + InGameAction.back => RideButtonMask.B_BTN, + InGameAction.rideOnBomb => RideButtonMask.Z_BTN, + _ => null, + }; + + if (button == null) { + return NotHandled('Action ${keyPair.inGameAction!.name} not supported by Zwift Emulator'); + } + + final status = RideKeyPadStatus() + ..buttonMap = (~button.mask) & 0xFFFFFFFF + ..analogPaddles.clear(); + + final bytes = status.writeToBuffer(); + + if (isKeyDown) { + final commandProto = Uint8List.fromList([ + Opcode.CONTROLLER_NOTIFICATION.value, + ...bytes, + ]); + + _peripheralManager.notifyCharacteristic( + _central!, + _asyncCharacteristic!, + value: commandProto, + ); + } + + if (isKeyUp) { + final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]); + _peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero); + } + + return Success('Sent action: ${keyPair.inGameAction!.name}'); + } + + void cleanup() { + _peripheralManager.stopAdvertising(); + _peripheralManager.removeAllServices(); + _isServiceAdded = false; + _isSubscribedToEvents = false; + _central = null; + isConnected.value = false; + isStarted.value = false; + _isLoading = false; + } +} diff --git a/lib/bluetooth/devices/zwift/zwift_play.dart b/lib/bluetooth/devices/zwift/zwift_play.dart new file mode 100644 index 000000000..55feeea69 --- /dev/null +++ b/lib/bluetooth/devices/zwift/zwift_play.dart @@ -0,0 +1,72 @@ +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/widgets/keymap_explanation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; + +class ZwiftPlay extends ZwiftDevice { + final ZwiftDeviceType deviceType; + + ZwiftPlay(super.scanResult, {required this.deviceType}) + : super( + availableButtons: [ + if (deviceType == ZwiftDeviceType.playLeft) ...[ + ZwiftButtons.navigationUp, + ZwiftButtons.navigationLeft, + ZwiftButtons.navigationRight, + ZwiftButtons.navigationDown, + ZwiftButtons.onOffLeft, + ZwiftButtons.sideButtonLeft, + ZwiftButtons.paddleLeft, + ], + if (deviceType == ZwiftDeviceType.playRight) ...[ + ZwiftButtons.y, + ZwiftButtons.z, + ZwiftButtons.a, + ZwiftButtons.b, + ZwiftButtons.onOffRight, + ZwiftButtons.sideButtonRight, + ZwiftButtons.paddleRight, + ], + ], + ); + + @override + List get startCommand => ZwiftConstants.RIDE_ON + ZwiftConstants.RESPONSE_START_PLAY; + + @override + bool get canVibrate => true; + + @override + String get name => '${super.name} (${deviceType.name.splitByUpperCase().split(' ').last})'; + + @override + List processClickNotification(Uint8List message) { + final status = PlayKeyPadStatus.fromBuffer(message); + + return [ + if (status.rightPad == PlayButtonStatus.ON) ...[ + if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButtons.y, + if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButtons.z, + if (status.buttonARight == PlayButtonStatus.ON) ZwiftButtons.a, + if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButtons.b, + if (status.buttonOn == PlayButtonStatus.ON) ZwiftButtons.onOffRight, + if (status.buttonShift == PlayButtonStatus.ON) ZwiftButtons.sideButtonRight, + if (status.analogLR.abs() == 100) ZwiftButtons.paddleRight, + ], + if (status.rightPad == PlayButtonStatus.OFF) ...[ + if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButtons.navigationUp, + if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButtons.navigationLeft, + if (status.buttonARight == PlayButtonStatus.ON) ZwiftButtons.navigationRight, + if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButtons.navigationDown, + if (status.buttonOn == PlayButtonStatus.ON) ZwiftButtons.onOffLeft, + if (status.buttonShift == PlayButtonStatus.ON) ZwiftButtons.sideButtonLeft, + if (status.analogLR.abs() == 100) ZwiftButtons.paddleLeft, + ], + ]; + } + + @override + String get latestFirmwareVersion => '1.3.1'; +} diff --git a/lib/bluetooth/devices/zwift/zwift_ride.dart b/lib/bluetooth/devices/zwift/zwift_ride.dart new file mode 100644 index 000000000..8172e5761 --- /dev/null +++ b/lib/bluetooth/devices/zwift/zwift_ride.dart @@ -0,0 +1,247 @@ +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; +import 'package:protobuf/protobuf.dart' as $pb; +import 'package:universal_ble/universal_ble.dart'; + +class ZwiftRide extends ZwiftDevice { + /// 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.isBeta, List? availableButtons}) + : super( + availableButtons: + availableButtons ?? + [ + ZwiftButtons.navigationLeft, + ZwiftButtons.navigationRight, + ZwiftButtons.navigationUp, + ZwiftButtons.navigationDown, + ZwiftButtons.a, + ZwiftButtons.b, + ZwiftButtons.y, + ZwiftButtons.z, + ZwiftButtons.shiftUpLeft, + ZwiftButtons.shiftDownLeft, + ZwiftButtons.shiftUpRight, + ZwiftButtons.shiftDownRight, + ZwiftButtons.powerUpLeft, + ZwiftButtons.powerUpRight, + ZwiftButtons.onOffLeft, + ZwiftButtons.onOffRight, + ZwiftButtons.paddleLeft, + ZwiftButtons.paddleRight, + ], + ); + + @override + String get latestFirmwareVersion => '1.2.0'; + + @override + bool get canVibrate => true; + + @override + Future processData(Uint8List bytes) async { + Opcode? opcode = Opcode.valueOf(bytes[0]); + Uint8List message = bytes.sublist(1); + + if (kDebugMode) { + print( + '${DateTime.now().toString().split(" ").last} Received $opcode: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}', + ); + } + + switch (opcode) { + case Opcode.RIDE_ON: + //print("Empty RideOn response - unencrypted mode"); + + break; + case Opcode.STATUS_RESPONSE: + final status = StatusResponse.fromBuffer(message); + if (kDebugMode) { + print('StatusResponse: ${status.command} status: ${Status.valueOf(status.status)}'); + } + break; + case Opcode.GET_RESPONSE: + final response = GetResponse.fromBuffer(message); + final dataObjectType = DO.valueOf(response.dataObjectId); + if (kDebugMode) { + print( + 'GetResponse: ${dataObjectType?.value.toRadixString(16).padLeft(4, '0') ?? response.dataObjectId} $dataObjectType', + ); + } + + switch (dataObjectType) { + case DO.PAGE_DEV_INFO: + final pageDevInfo = DevInfoPage.fromBuffer(response.dataObjectData); + if (kDebugMode) { + print('PageDevInfo: $pageDevInfo'); + } + break; + case DO.PAGE_DATE_TIME: + final pageDateTime = DateTimePage.fromBuffer(response.dataObjectData); + if (kDebugMode) { + print('PageDateTime: $pageDateTime'); + } + break; + case DO.PAGE_CONTROLLER_INPUT_CONFIG: + final pageDateTime = ControllerInputConfigPage.fromBuffer(response.dataObjectData); + if (kDebugMode) { + print('PageDateTime: $pageDateTime'); + } + break; + case null: + break; + default: + break; + } + break; + case Opcode.VENDOR_MESSAGE: + break; + case Opcode.LOG_DATA: + final logMessage = LogDataNotification.fromBuffer(message); + if (kDebugMode) { + actionStreamInternal.add(LogNotification(logMessage.toString())); + } + break; + case Opcode.BATTERY_NOTIF: + final notification = BatteryNotification.fromBuffer(message); + if (batteryLevel != notification.newPercLevel) { + batteryLevel = notification.newPercLevel; + core.connection.signalChange(this); + } + break; + case Opcode.CONTROLLER_NOTIFICATION: + try { + final buttonsClicked = processClickNotification(message); + handleButtonsClicked(buttonsClicked); + } catch (e) { + actionStreamInternal.add(LogNotification(e.toString())); + } + break; + case null: + if (bytes[0] == 0x1A) { + final batteryStatus = BatteryStatus.fromBuffer(message); + if (kDebugMode) { + print('BatteryStatus: $batteryStatus'); + } + } + break; + } + } + + @override + List processClickNotification(Uint8List message) { + final status = RideKeyPadStatus.fromBuffer(message); + + // Debug: Log all button mask detections (moved to ZwiftRide.processClickNotification) + + // Process DIGITAL buttons separately + final buttonsClicked = [ + if (status.buttonMap & RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationLeft, + if (status.buttonMap & RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationRight, + if (status.buttonMap & RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationUp, + if (status.buttonMap & RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.navigationDown, + if (status.buttonMap & RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.a, + if (status.buttonMap & RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.b, + if (status.buttonMap & RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.y, + if (status.buttonMap & RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.z, + if (status.buttonMap & RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpLeft, + if (status.buttonMap & RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftDownLeft, + if (status.buttonMap & RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.shiftUpRight, + if (status.buttonMap & RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value) + ZwiftButtons.shiftDownRight, + if (status.buttonMap & RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpLeft, + if (status.buttonMap & RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.powerUpRight, + if (status.buttonMap & RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffLeft, + if (status.buttonMap & RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButtons.onOffRight, + ]; + + // Process ANALOG inputs separately - now properly separated from digital + // All analog paddles (L0-L3) appear in field 3 as repeated RideAnalogKeyPress + final List 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 => ZwiftButtons.paddleLeft, // L0 = left paddle + 1 => ZwiftButtons.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'); + } + } + return buttonsClicked; + } + + Future sendCommand(Opcode opCode, $pb.GeneratedMessage? message) async { + final buffer = Uint8List.fromList([opCode.value, ...message?.writeToBuffer() ?? []]); + if (kDebugMode) { + print("Sending $opCode: ${buffer.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}"); + } + await UniversalBle.write( + device.deviceId, + customService!.uuid, + syncRxCharacteristic!.uuid, + buffer, + withoutResponse: true, + ); + await Future.delayed(Duration(milliseconds: 500)); + } + + Future sendCommandBuffer(Uint8List buffer) async { + if (kDebugMode) { + print("Sending ${buffer.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}"); + } + await UniversalBle.write( + device.deviceId, + customService!.uuid, + syncRxCharacteristic!.uuid, + buffer, + withoutResponse: true, + ); + } +} + +enum RideButtonMask { + LEFT_BTN(0x00001), + UP_BTN(0x00002), + RIGHT_BTN(0x00004), + DOWN_BTN(0x00008), + + A_BTN(0x00010), + B_BTN(0x00020), + Y_BTN(0x00040), + Z_BTN(0x00080), + + SHFT_UP_L_BTN(0x00100), + SHFT_DN_L_BTN(0x00200), + SHFT_UP_R_BTN(0x01000), + SHFT_DN_R_BTN(0x02000), + + POWERUP_L_BTN(0x00400), + POWERUP_R_BTN(0x04000), + ONOFF_L_BTN(0x00800), + ONOFF_R_BTN(0x08000); + + final int mask; + + const RideButtonMask(this.mask); +} diff --git a/lib/bluetooth/messages/notification.dart b/lib/bluetooth/messages/notification.dart new file mode 100644 index 000000000..6bf861b14 --- /dev/null +++ b/lib/bluetooth/messages/notification.dart @@ -0,0 +1,80 @@ +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/widgets/keymap_explanation.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; + +class BaseNotification {} + +class LogNotification extends BaseNotification { + final String message; + + LogNotification(this.message) { + Logger.debug('LogNotification: $message'); + } + + @override + String toString() { + return message; + } +} + +class BluetoothAvailabilityNotification extends BaseNotification { + final bool isAvailable; + + BluetoothAvailabilityNotification(this.isAvailable); + + @override + String toString() { + return 'Bluetooth is ${isAvailable ? "available" : "unavailable"}'; + } +} + +class ButtonNotification extends BaseNotification { + final BaseDevice device; + final List buttonsClicked; + + ButtonNotification({this.buttonsClicked = const [], required this.device}); + + @override + String toString() { + return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name.splitByUpperCase())}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ButtonNotification && + runtimeType == other.runtimeType && + buttonsClicked.contentEquals(other.buttonsClicked); + + @override + int get hashCode => buttonsClicked.hashCode; +} + +class ActionNotification extends BaseNotification { + final ActionResult result; + + ActionNotification(this.result); + + @override + String toString() { + return result.message; + } +} + +class AlertNotification extends LogNotification { + final LogLevel level; + final String alertMessage; + final VoidCallback? onTap; + final String? buttonTitle; + + AlertNotification(this.level, this.alertMessage, {this.onTap, this.buttonTitle}) : super(alertMessage); + + @override + String toString() { + return 'Warning: $alertMessage'; + } +} diff --git a/lib/bluetooth/remote_keyboard_pairing.dart b/lib/bluetooth/remote_keyboard_pairing.dart new file mode 100644 index 000000000..b59383b31 --- /dev/null +++ b/lib/bluetooth/remote_keyboard_pairing.dart @@ -0,0 +1,418 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:prop/prop.dart'; + +import '../utils/keymap/keymap.dart'; + +class RemoteKeyboardPairing extends TrainerConnection { + bool get isLoading => _isLoading; + + late final _peripheralManager = PeripheralManager(); + bool _isLoading = false; + bool _isServiceAdded = false; + bool _isSubscribedToEvents = false; + + Central? _central; + GATTCharacteristic? _inputReport; + + static const String connectionTitle = 'Keyboard Remote Control'; + + RemoteKeyboardPairing() + : super( + title: connectionTitle, + supportedActions: InGameAction.values, + ); + + Future reconnect() async { + await _peripheralManager.stopAdvertising(); + await _peripheralManager.removeAllServices(); + _isServiceAdded = false; + startAdvertising().catchError((e) { + core.settings.setRemoteControlEnabled(false); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Remote Control pairing: $e'), + ); + }); + } + + Future startAdvertising() async { + _isLoading = true; + isStarted.value = true; + + _peripheralManager.stateChanged.forEach((state) { + print('Peripheral manager state: ${state.state}'); + }); + + if (!kIsWeb && Platform.isAndroid) { + _peripheralManager.connectionStateChanged.forEach((state) { + print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); + if (state.state == ConnectionState.connected) { + } else if (state.state == ConnectionState.disconnected) { + _central = null; + isConnected.value = false; + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected), + ); + } + }); + + final status = await Permission.bluetoothAdvertise.request(); + if (!status.isGranted) { + print('Bluetooth advertise permission not granted'); + isStarted.value = false; + return; + } + } + + while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getRemoteControlEnabled()) { + print('Waiting for peripheral manager to be powered on...'); + if (core.settings.getLastTarget() == Target.thisDevice) { + return; + } + await Future.delayed(Duration(seconds: 1)); + } + final inputReport = GATTCharacteristic.mutable( + uuid: UUID.fromString('2A4D'), + permissions: [GATTCharacteristicPermission.read], + properties: [GATTCharacteristicProperty.notify, GATTCharacteristicProperty.read], + descriptors: [ + GATTDescriptor.immutable( + // Report Reference: ID=1, Type=Input(1) + uuid: UUID.fromString('2908'), + value: Uint8List.fromList([0x01, 0x01]), + ), + ], + ); + + if (!_isServiceAdded) { + await Future.delayed(Duration(seconds: 1)); + + final reportMapDataAbsolute = Uint8List.fromList([ + // Keyboard Report (Report ID 1) + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x06, // Usage (Keyboard) + 0xA1, 0x01, // Collection (Application) + 0x85, 0x01, // Report ID (1) + 0x05, 0x07, // Usage Page (Keyboard/Keypad) + 0x19, 0xE0, // Usage Minimum (Left Control) + 0x29, 0xE7, // Usage Maximum (Right GUI) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x08, // Report Count (8) + 0x81, 0x02, // Input (Data,Var,Abs) - Modifier byte + 0x95, 0x01, // Report Count (1) + 0x75, 0x08, // Report Size (8) + 0x81, 0x01, // Input (Const) - Reserved byte + 0x95, 0x06, // Report Count (6) + 0x75, 0x08, // Report Size (8) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x65, // Logical Maximum (101) + 0x05, 0x07, // Usage Page (Keyboard/Keypad) + 0x19, 0x00, // Usage Minimum (0) + 0x29, 0x65, // Usage Maximum (101) + 0x81, 0x00, // Input (Data,Array) - Key array (6 keys) + 0xC0, // End Collection + ]); + + // 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: [], + ); + + // 2) HID service + final hidService = GATTService( + uuid: UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB'), + isPrimary: true, + characteristics: [ + hidInfo, + reportMap, + protocolMode, + hidControlPoint, + 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) { + // Check if this is the input report characteristic (2A4D) + if (char.characteristic.uuid == inputReport.uuid) { + if (char.state) { + _central = char.central; + _inputReport = char.characteristic; + isConnected.value = true; + print('Input report subscribed'); + } else { + _inputReport = null; + _central = null; + isConnected.value = false; + print('Input report unsubscribed'); + } + } + print('Notify state changed for characteristic: ${char.characteristic.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: + 'BikeControl ${Platform.isIOS + ? 'iOS' + : Platform.isAndroid + ? 'Android' + : ''}', + serviceUUIDs: [UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB')], + ); + print('Starting advertising with Remote service...'); + + try { + await _peripheralManager.startAdvertising(advertisement); + } catch (e) { + if (e.toString().contains("Advertising has already started")) { + print('Advertising already started, ignoring error'); + return; + } else { + rethrow; + } + } + _isLoading = false; + } + + Future stopAdvertising() async { + await _peripheralManager.removeAllServices(); + _isServiceAdded = false; + await _peripheralManager.stopAdvertising(); + isStarted.value = false; + isConnected.value = false; + _isLoading = false; + } + + Future notifyCharacteristic(Uint8List value) async { + if (_inputReport != null && _central != null) { + await _peripheralManager.notifyCharacteristic(_central!, _inputReport!, value: value); + } + } + + @override + Future sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async { + if (isKeyDown && isKeyUp) { + await sendKeyPress(keyPair); + return Success('Key ${keyPair.toString()} press sent'); + } else if (isKeyDown) { + await sendKeyDown(keyPair); + return Success('Key ${keyPair.toString()} down sent'); + } else if (isKeyUp) { + await sendKeyUp(); + return Success('Key ${keyPair.toString()} up sent'); + } + return NotHandled('Illegal combination'); + } + + /// USB HID Keyboard scan codes for common keys + static const Map hidKeyCodes = { + 'a': 0x04, + 'b': 0x05, + 'c': 0x06, + 'd': 0x07, + 'e': 0x08, + 'f': 0x09, + 'g': 0x0A, + 'h': 0x0B, + 'i': 0x0C, + 'j': 0x0D, + 'k': 0x0E, + 'l': 0x0F, + 'm': 0x10, + 'n': 0x11, + 'o': 0x12, + 'p': 0x13, + 'q': 0x14, + 'r': 0x15, + 's': 0x16, + 't': 0x17, + 'u': 0x18, + 'v': 0x19, + 'w': 0x1A, + 'x': 0x1B, + 'y': 0x1C, + 'z': 0x1D, + '1': 0x1E, + '2': 0x1F, + '3': 0x20, + '4': 0x21, + '5': 0x22, + '6': 0x23, + '7': 0x24, + '8': 0x25, + '9': 0x26, + '0': 0x27, + 'enter': 0x28, + 'escape': 0x29, + 'backspace': 0x2A, + 'tab': 0x2B, + 'space': 0x2C, + 'minus': 0x2D, + 'equals': 0x2E, + 'leftbracket': 0x2F, + 'rightbracket': 0x30, + 'backslash': 0x31, + 'semicolon': 0x33, + 'quote': 0x34, + 'grave': 0x35, + 'comma': 0x36, + 'period': 0x37, + 'slash': 0x38, + 'capslock': 0x39, + 'f1': 0x3A, + 'f2': 0x3B, + 'f3': 0x3C, + 'f4': 0x3D, + 'f5': 0x3E, + 'f6': 0x3F, + 'f7': 0x40, + 'f8': 0x41, + 'f9': 0x42, + 'f10': 0x43, + 'f11': 0x44, + 'f12': 0x45, + 'printscreen': 0x46, + 'scrolllock': 0x47, + 'pause': 0x48, + 'insert': 0x49, + 'home': 0x4A, + 'pageup': 0x4B, + 'delete': 0x4C, + 'end': 0x4D, + 'pagedown': 0x4E, + 'right': 0x4F, + 'left': 0x50, + 'down': 0x51, + 'up': 0x52, + }; + + /// Modifier key bit masks + static const int modLeftCtrl = 0x01; + static const int modLeftShift = 0x02; + static const int modLeftAlt = 0x04; + static const int modLeftGui = 0x08; + static const int modRightCtrl = 0x10; + static const int modRightShift = 0x20; + static const int modRightAlt = 0x40; + static const int modRightGui = 0x80; + + /// Create a keyboard HID report + /// [modifiers] - bit mask for modifier keys (Ctrl, Shift, Alt, GUI) + /// [keyCodes] - list of up to 6 key codes to send + Uint8List keyboardReport(int modifiers, List keyCodes) { + final keys = List.filled(6, 0); + for (var i = 0; i < keyCodes.length && i < 6; i++) { + keys[i] = keyCodes[i]; + } + // Report format: [modifiers, reserved, key1, key2, key3, key4, key5, key6] + return Uint8List.fromList([modifiers, 0x00, ...keys]); + } + + /// Send a keyboard key press and release + /// [key] - the key name (e.g., 'a', 'enter', 'space', 'f1', 'up', 'down') + /// [modifiers] - optional modifier keys (use modLeftCtrl, modLeftShift, etc.) + Future sendKeyPress(KeyPair keyPair, {int modifiers = 0}) async { + final usbHidUsage = keyPair.physicalKey!.usbHidUsage; + final keyCode = usbHidUsage & 0xFF; + + // Send key down + final downReport = keyboardReport(modifiers, [keyCode]); + if (kDebugMode) { + print( + 'Sending keyboard key down: $keyPair (0x${keyCode.toRadixString(16)}) with modifiers: 0x${modifiers.toRadixString(16)}', + ); + } + await notifyCharacteristic(downReport); + + await Future.delayed(Duration(milliseconds: 20)); + + // Send key up (empty report) + final upReport = keyboardReport(0, []); + if (kDebugMode) { + print('Sending keyboard key up'); + } + await notifyCharacteristic(upReport); + + await Future.delayed(Duration(milliseconds: 10)); + } + + /// Send a key down event only (for holding keys) + Future sendKeyDown(KeyPair keyPair, {int modifiers = 0}) async { + final usbHidUsage = keyPair.physicalKey!.usbHidUsage; + final keyCode = usbHidUsage & 0xFF; + + final report = keyboardReport(modifiers, [keyCode]); + await notifyCharacteristic(report); + } + + /// Send a key up event (release all keys) + Future sendKeyUp() async { + final report = keyboardReport(0, []); + await notifyCharacteristic(report); + } +} diff --git a/lib/bluetooth/remote_pairing.dart b/lib/bluetooth/remote_pairing.dart new file mode 100644 index 000000000..7770152f9 --- /dev/null +++ b/lib/bluetooth/remote_pairing.dart @@ -0,0 +1,291 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/actions/remote.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; +import 'package:flutter/foundation.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:prop/prop.dart'; + +import '../utils/keymap/keymap.dart'; + +class RemotePairing extends TrainerConnection { + bool get isLoading => _isLoading; + + late final _peripheralManager = PeripheralManager(); + bool _isLoading = false; + bool _isServiceAdded = false; + bool _isSubscribedToEvents = false; + + Central? _central; + GATTCharacteristic? _inputReport; + + static const String connectionTitle = 'Remote Control'; + + RemotePairing() + : super( + title: connectionTitle, + supportedActions: InGameAction.values, + ); + + Future reconnect() async { + await _peripheralManager.stopAdvertising(); + await _peripheralManager.removeAllServices(); + _isServiceAdded = false; + startAdvertising().catchError((e) { + core.settings.setRemoteControlEnabled(false); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Remote Control pairing: $e'), + ); + }); + } + + Future startAdvertising() async { + _isLoading = true; + isStarted.value = true; + + _peripheralManager.stateChanged.forEach((state) { + print('Peripheral manager state: ${state.state}'); + }); + + if (!kIsWeb && Platform.isAndroid) { + _peripheralManager.connectionStateChanged.forEach((state) { + print('Peripheral connection state: ${state.state} of ${state.central.uuid}'); + if (state.state == ConnectionState.connected) { + } else if (state.state == ConnectionState.disconnected) { + _central = null; + isConnected.value = false; + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected), + ); + } + }); + + final status = await Permission.bluetoothAdvertise.request(); + if (!status.isGranted) { + print('Bluetooth advertise permission not granted'); + isStarted.value = false; + return; + } + } + + while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getRemoteControlEnabled()) { + print('Waiting for peripheral manager to be powered on...'); + if (core.settings.getLastTarget() == Target.thisDevice) { + return; + } + await Future.delayed(Duration(seconds: 1)); + } + final inputReport = GATTCharacteristic.mutable( + uuid: UUID.fromString('2A4D'), + permissions: [GATTCharacteristicPermission.read], + properties: [GATTCharacteristicProperty.notify, GATTCharacteristicProperty.read], + descriptors: [ + GATTDescriptor.immutable( + // Report Reference: ID=1, Type=Input(1) + uuid: UUID.fromString('2908'), + value: Uint8List.fromList([0x01, 0x01]), + ), + ], + ); + + 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: [], + ); + + // 2) HID service + final hidService = GATTService( + uuid: UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB'), + isPrimary: true, + characteristics: [ + hidInfo, + reportMap, + protocolMode, + hidControlPoint, + 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) { + // Check if this is the input report characteristic (2A4D) + if (char.characteristic.uuid == inputReport.uuid) { + if (char.state) { + _central = char.central; + _inputReport = char.characteristic; + isConnected.value = true; + print('Input report subscribed'); + } else { + _inputReport = null; + _central = null; + isConnected.value = false; + print('Input report unsubscribed'); + } + } + print('Notify state changed for characteristic: ${char.characteristic.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: + 'BikeControl ${Platform.isIOS + ? 'iOS' + : Platform.isAndroid + ? 'Android' + : ''}', + serviceUUIDs: [UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB')], + ); + print('Starting advertising with Remote service...'); + + try { + await _peripheralManager.startAdvertising(advertisement); + } catch (e) { + if (e.toString().contains("Advertising has already started")) { + print('Advertising already started, ignoring error'); + return; + } else { + rethrow; + } + } + _isLoading = false; + } + + Future stopAdvertising() async { + await _peripheralManager.removeAllServices(); + _isServiceAdded = false; + await _peripheralManager.stopAdvertising(); + isStarted.value = false; + isConnected.value = false; + _isLoading = false; + } + + Future notifyCharacteristic(Uint8List value) async { + if (_inputReport != null && _central != null) { + await _peripheralManager.notifyCharacteristic(_central!, _inputReport!, value: value); + } + } + + @override + Future sendAction(KeyPair keyPair, {required bool isKeyDown, required bool isKeyUp}) async { + final point = await (core.actionHandler as RemoteActions).resolveTouchPosition(keyPair: keyPair, windowInfo: null); + final point2 = point; //Offset(100, 99.0); + await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt()); + await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt()); + await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt()); + + return Success('Mouse clicked at: ${point2.dx.toInt()} ${point2.dy.toInt()}'); + } + + Uint8List absMouseReport(int buttons3bit, int x, int y) { + final b = buttons3bit & 0x07; + final xi = x.clamp(0, 100); + final yi = y.clamp(0, 100); + return Uint8List.fromList([b, xi, yi]); + } + + // Send a relative mouse move + button state as 3-byte report: [buttons, dx, dy] + Future sendAbsMouseReport(int buttons, int dx, int dy) async { + final bytes = absMouseReport(buttons, dx, dy); + if (kDebugMode) { + print('Preparing to send abs mouse report: buttons=$buttons, dx=$dx, dy=$dy'); + print('Sending abs mouse report: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0'))}'); + } + + await notifyCharacteristic(bytes); + + // we don't want to overwhelm the target device + await Future.delayed(Duration(milliseconds: 10)); + } +} diff --git a/lib/i10n/intl_de.arb b/lib/i10n/intl_de.arb new file mode 100644 index 000000000..2520033fe --- /dev/null +++ b/lib/i10n/intl_de.arb @@ -0,0 +1,532 @@ +{ + "accessibilityDescription": "BikeControl benötigt Zugriffsberechtigungen, um Ihre Trainings-Apps zu steuern.", + "accessibilityDisclaimer": "BikeControl greift nur auf Ihren Bildschirm zu, um die von Ihnen konfigurierten Gesten auszuführen. Es werden keine weiteren Bedienungshilfen oder persönlichen Daten abgerufen.", + "accessibilityReasonControl": "• Um Ihnen die Steuerung von Apps wie MyWhoosh, TrainingPeaks und anderen über Ihre Zwift-Geräte zu ermöglichen", + "accessibilityReasonTouch": "• Um Berührungsgesten auf Ihrem Bildschirm zur Steuerung von Trainer-Apps zu simulieren.", + "accessibilityReasonWindow": "• Um zu erkennen, welches Trainings-App-Fenster aktuell aktiv ist", + "accessibilityServiceExplanation": "BikeControl benötigt die AccessibilityService API von Android, um ordnungsgemäß zu funktionieren.", + "accessibilityServiceNotRunning": "Der Bedienungshilfe-Dienst ist nicht verfügbar. \nFolge den Anweisungen unter", + "accessibilityServicePermissionRequired": "Berechtigung für barrierefreie Dienste erforderlich", + "accessibilityUsageGestures": "• Wenn Du die Tasten auf Deinem Zwift Click-, Zwift Ride- oder Zwift Play-Geräten drückst, simuliert BikeControl Berührungsgesten an bestimmten Bildschirmpositionen.", + "accessibilityUsageMonitor": "• Die App überwacht, welches Trainings-App-Fenster aktiv ist, um sicherzustellen, dass Gesten an die richtige App gesendet werden.", + "accessibilityUsageNoData": "• Über diesen Dienst werden keine personenbezogenen Daten abgerufen oder erfasst.", + "accessories": "Zubehör", + "account": "Konto", + "actAsBluetoothKeyboard": "Als Bluetooth-Tastatur fungieren", + "action": "Aktion", + "additionalTriggerAssignment": "Zusätzliche Zuweisung", + "adjustControllerButtons": "Controller-Tasten anpassen", + "afterDate": "Nach dem {date}", + "allow": "Erlauben", + "allowAccessibilityService": "Barrierefreiheitsdienst zulassen", + "allowBluetoothConnections": "Bluetooth-Verbindungen zulassen", + "allowBluetoothScan": "Bluetooth-Scan zulassen", + "allowLocationForBluetooth": "Standortzugriff erlauben, damit Bluetooth-Scan funktioniert", + "allowPersistentNotification": "Benachrichtigungen zulassen", + "allowsRunningInBackground": "Ermöglicht es BikeControl, im Hintergrund weiterzulaufen.", + "alreadyBoughtTheApp": "App bereits gekauft? Dann musst Du BikeControl nicht erneut kaufen. Aus technischen Gründen lässt sich leider nicht feststellen, ob die App früher bereits erworben wurde. \n\nGebe Deine Play Store-Kauf-ID (z.B. GPA.3356-1337-1338-1339) ein, um die Vollversion freizuschalten. Falls Du diese nicht finden kannst, kontaktiere mich bitte direkt.", + "alreadyBoughtTheAppPreviously": "App zuvor bereits gekauft?", + "androidSystemAction": "Android-Systemaktion", + "anotherTriggerIsAlreadyAssignedForThisButton": "Für diese Schaltfläche ist bereits ein anderer Auslöser zugewiesen. Wechsel zur Pro-Version, um mehrere Auslösertypen zu verwenden, oder ersetze die bestehende Zuweisung und fahre fort mit {triggerTitle}.", + "appIdActions": "{appId} Aktionen", + "@appIdActions": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "applyNow": "Jetzt anwenden", + "asAFinalStepYoullChooseHowToConnectTo": "Im letzten Schritt wählen Sie die Verbindungsmethode aus für {trainerApp}", + "battery": "Batterie", + "beforeDate": "Vor dem {date}", + "bluetoothAdvertiseAccess": "Bluetooth-Zugriff", + "bluetoothKeyboardExplanation": "So können Sie Ihr Smartphone als Bluetooth-Tastatur zur Steuerung kompatibler Apps verwenden. Nach der Kopplung können Sie die Tasten Ihres Fahrradcontrollers nutzen, um Tastatureingaben an die App zu senden.", + "bluetoothTurnedOn": "Bluetooth ist eingeschaltet", + "browserNotSupported": "Dieser Browser unterstützt kein Web-Bluetooth und die Plattform wird nicht unterstützt :(", + "button": "Taste.", + "buyFullVersion": "Vollversion kaufen", + "bySigningInYouAgreeToOur": "Mit der Anmeldung stimmst Du unseren {privacyPolicy} zu", + "@bySigningInYouAgreeToOur": { + "placeholders": { + "privacyPolicy": { + "type": "String" + } + } + }, + "cancel": "Abbrechen", + "changelog": "Änderungsprotokoll", + "checkMyWhooshConnectionScreen": "Überprüfe den Verbindungsbildschirm in MyWhoosh, um zu sehen, ob „Link“ verbunden ist.", + "chooseAnotherScreenshot": "Wähle einen anderen Screenshot aus", + "chooseBikeControlInConnectionScreen": "Wähle im Verbindungsbildschirm BikeControl aus.", + "clickAButtonOnYourController": "Klicke eine Controller-Taste, um deren Aktion zu bearbeiten, oder tippe auf das Bearbeitungssymbol.", + "clickV2EventInfo": "Dein Click V2 sendet möglicherweise keine Tastenereignisse mehr. Probier mal ein paar Tasten aus und schau, ob sie in BikeControl angezeigt werden.", + "clickV2Instructions": "Damit dein Zwift Click V2 optimal funktioniert, solltest du das Gerät vor jeder Trainings-Session in der Zwift-App verbinden.\nAnsonsten funktioniert der Click V2 nach einer Minute nicht mehr.\n\n1. Öffne die Zwift-App (nicht Companion!).\n2. Melde dich an (kein Abonnement nötig) und öffne den Bildschirm für die Geräteverbindung.\n3. Verbinde deinen Trainer und dann den Zwift Click V2.\n4. Schließe die Zwift-App wieder und verbinde dich erneut in BikeControl.", + "close": "Schließen", + "commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} verbleibende Befehle heute", + "configuration": "Konfiguration", + "connectControllerToPreview": "Schließe ein Controller-Gerät an, um die Tastaturbelegung in der Vorschau anzuzeigen und anzupassen.", + "connectControllers": "Controller verbinden", + "connectDirectlyOverNetwork": "Direkte Verbindung über das Netzwerk", + "connectToTrainerApp": "Mit der Trainer-App verbinden", + "connectUsingBluetooth": "Verbindung über Bluetooth herstellen", + "connectUsingMyWhooshLink": "Verbinden über MyWhoosh „Link“", + "connected": "Verbunden", + "connectedControllers": "Verbundene Controller", + "connectedTo": "Verbunden mit {appId}", + "@connectedTo": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "connection": "Verbindung", + "continueAction": "Weiter", + "controlAppUsingModes": "Steuere {appName} über {modes}", + "@controlAppUsingModes": { + "placeholders": { + "appName": { + "type": "String" + }, + "modes": { + "type": "String" + } + } + }, + "controllerConnectedClickButton": "Super! Dein Controller ist verbunden. Drücke eine Taste auf deinem Controller, um fortzufahren.", + "controllers": "Controller", + "couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "{button} konnte nicht ausgeführt werden: Keine Konfiguration festgelegt", + "create": "Erstellen", + "createNewKeymap": "Neue Tastaturbelegung erstellen", + "createNewProfileByDuplicating": "Erstelle ein neues benutzerdefiniertes Profil, indem „{profileName} “ dupliziert wird.", + "@createNewProfileByDuplicating": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "createdNewCustomProfile": "Ein neues benutzerdefiniertes Profil wurde erstellt: {profileName}", + "@createdNewCustomProfile": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "currentDeviceIsNotRegistered": "Das aktuelle Gerät ist nicht registriert", + "currentPlan": "Aktueller Plan", + "customizeControllerButtons": "Controller-Tasten anpassen für {appName}", + "@customizeControllerButtons": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "customizeKeymapHint": "Passe die Tastaturbelegung an, falls Probleme auftreten (z. B. falsche Tastatureingaben oder falsch ausgerichtete Touch-Positionen).", + "dailyCommandLimitReachedNotification": "Das tägliche Befehlslimit wurde für heute erreicht. Führe ein Upgrade durch, um die Vollversion mit unbegrenzten Befehlen freizuschalten.", + "dailyLimitReached": "Tageslimit erreicht ({dailyCommandCount} / {dailyCommandLimit} verbraucht)", + "delete": "Löschen", + "deleteProfile": "Profil löschen", + "deleteProfileConfirmation": "„{profileName} “ wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "@deleteProfileConfirmation": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "deny": "Verweigern", + "deviceButton": "{deviceName} Taste", + "@deviceButton": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "deviceLimitReached": "Gerätelimit erreicht für {platform}. Um ein neues Gerät zu registrieren, müssen andere Geräte widerrufen werden.", + "devicesActive": "{active} aktiv", + "disconnectDevices": "Geräte trennen", + "disconnected": "Getrennt", + "donateByBuyingFromPlayStore": "App im Play Store kaufen", + "donateViaCreditCard": "per Kreditkarte, Google Pay, Apple Pay und anderen Zahlungsarten", + "donateViaPaypal": "via PayPal", + "download": "Herunterladen", + "downloadLatestSettings": "Neueste Einstellungen herunterladen", + "dragToReposition": "Zum Verschieben ziehen", + "duplicate": "Duplikat", + "enableAutoRotation": "Aktiviere die automatische Drehung auf Ihrem Gerät, um sicherzustellen, dass die App ordnungsgemäß funktioniert.", + "enableBluetooth": "Bluetooth aktivieren", + "enableItInTheConnectionSettingsFirst": "Aktiviere es zuerst in den Verbindungseinstellungen.", + "enableKeyboardAccessMessage": "Aktiviere im folgenden Bildschirm die Tastatursteuerung für BikeControl. Falls BikeControl nicht angezeigt wird, füge es bitte manuell hinzu.", + "enableKeyboardMouseControl": "BikeControl sendet Maus- oder Tastaturkommandos an {appName}", + "@enableKeyboardMouseControl": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "enableLocalConnectionMethodFirst": "Aktivieren Sie zuerst die Methode „Lokale Verbindung“.", + "enableMediaKeyDetection": "Medientastenerkennung aktivieren", + "enableMywhooshLinkInTheConnectionSettingsFirst": "Aktiviere zuerst MyWhoosh Link in den Verbindungseinstellungen.", + "enablePairingProcess": "Als Bluetooth-Maus fungieren", + "enablePermissions": "Berechtigungen aktivieren", + "enableSteeringWithPhone": "Lenkung über Handy-Sensoren aktivieren", + "enableVibrationFeedback": "Vibrationsfeedback beim Gangwechsel aktivieren", + "enableZwiftControllerBluetooth": "Zwift Controller aktivieren (Bluetooth)", + "enableZwiftControllerNetwork": "Zwift Controller aktivieren (Netzwerk)", + "errorStartingMyWhooshLink": "Fehler beim Starten des MyWhoosh Link-Servers. Bitte stelle sicher, dass die App „MyWhoosh Link“ nicht bereits auf diesem Gerät ausgeführt wird.", + "errorStartingOpenBikeControlBluetoothServer": "Fehler beim Starten des OpenBikeControl Bluetooth-Servers.", + "errorStartingOpenBikeControlServer": "Fehler beim Starten des OpenBikeControl-Servers.", + "exportAction": "Export", + "failedToImportProfile": "Profilimport fehlgeschlagen. Ungültiges Format.", + "failedToUpdate": "Aktualisierung fehlgeschlagen: {error}", + "@failedToUpdate": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "firmware": "Firmware", + "forceCloseToUpdate": "Schließen die App, um die neue Version zu verwenden.", + "full": "VOLL", + "fullVersion": "Vollversion", + "fullVersionDescription": "Die Vollversion beinhaltet: \n- Unbegrenzte Befehle pro Tag \n- Zugriff auf alle zukünftigen Updates \n- Kein Abonnement! Nur eine einmalige Gebühr :)", + "getSupport": "Unterstützung erhalten", + "goPro": "Pro werden", + "gotIt": "Verstanden!", + "grant": "Gewähren", + "granted": "Gewährt", + "helpRequested": "Hilfe für BikeControl v{version} angefordert", + "@helpRequested": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "howBikeControlUsesPermission": "Wie nutzt BikeControl diese Berechtigung?", + "ignoredDevices": "Ignorierte Geräte", + "importAction": "Import", + "importProfile": "Profil importieren", + "instructions": "Anleitung", + "jsonData": "JSON-Daten", + "justNow": "Soeben", + "keyboardAccess": "Tastaturzugriff", + "lastSeen": "Zuletzt gesehen:", + "lastSynced": "Zuletzt synchronisiert:", + "latestVersion": "aktuell: {version}", + "@latestVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "leaveAReview": "Hinterlasse eine Bewertung", + "letsAppConnectOverBluetooth": "Lässt {appName} die Verbindung zu BikeControl über Bluetooth herstellen.", + "@letsAppConnectOverBluetooth": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsAppConnectOverNetwork": "{appName} direkt über das Netzwerk verbinden. Wähle BikeControl im Verbindungsbildschirm aus.", + "@letsAppConnectOverNetwork": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsGetYouSetUp": "Starten wir die Einrichtung", + "license": "Lizenz", + "licenseStatus": "Lizenzstatus", + "loadScreenshotForPlacement": "Lade einen Screenshot aus dem Spiel zur Platzierung hoch.", + "logViewer": "Protokollanzeige", + "loggedInAsMail": "Angemeldet als {email}", + "logout": "Abmelden", + "logs": "Protokolle", + "logsAreAlsoAt": "Die Protokolle befinden sich auch unter", + "logsHaveBeenCopiedToClipboard": "Die Protokolle wurden in die Zwischenablage kopiert.", + "longPress": "langes\nDrücken", + "longPressMode": "Modus „Langes Drücken“ (statt Wiederholen)", + "longTapExplanation": "Klick 1: Gedrückt \nKlick 2: Losgelassen", + "mailSupportExplanation": "Die individuelle Unterstützung per E-Mail ist für mich sehr aufwendig.\n\nBitte nutze daher Reddit, Facebook oder GitHub für Fragen und Probleme, damit die gesamte Community davon profitieren kann.", + "manageIgnoredDevices": "Ignorierte Geräte verwalten", + "manageProfile": "Profil verwalten", + "manageSubscription": "Abonnement verwalten", + "manageYourDevices": "Geräte verwalten", + "manualyControllingButton": "{trainerApp} manuell steuern!", + "mediaKeyDetectionTooltip": "Aktiviere diese Option, damit BikeControl Bluetooth-Fernbedienungen erkennt. \nDazu muss BikeControl als Mediaplayer fungieren.", + "miuiDeviceDetected": "MIUI-Gerät erkannt", + "miuiDisableBatteryOptimization": "• Batterieoptimierung für BikeControl deaktivieren", + "miuiEnableAutostart": "• Aktiviere den automatischen Start für BikeControl.", + "miuiEnsureProperWorking": "Um sicherzustellen, dass BikeControl ordnungsgemäß funktioniert:", + "miuiLockInRecentApps": "• Sperre die App in den zuletzt verwendeten Apps", + "miuiWarningDescription": "Auf Ihrem Gerät läuft MIUI, das dafür bekannt ist, Hintergrunddienste und Bedienungshilfen aggressiv zu beenden.", + "moreInformation": "Weitere Informationen", + "mustChooseAllowOrDeny": "Diese Berechtigung muss entweder erteilt oder verweigert werden, um fortfahren zu können.", + "myWhooshDirectConnectAction": "MyWhoosh „Link”-Aktion", + "myWhooshDirectConnection": " z. B. mit MyWhoosh „Link”.", + "myWhooshLinkConnected": "MyWhoosh „Link“ verbunden", + "myWhooshLinkDescriptionLocal": "Direkte Verbindung zu MyWhoosh über die „Link“-Methode. Beachte die Anleitung, um sicherzustellen, dass eine Verbindung möglich ist. Die „MyWhoosh Link“-App darf nicht gleichzeitig aktiv sein.", + "myWhooshLinkInfo": "Schau mal im Abschnitt zur Fehlerbehebung nach, wenn du Probleme hast. Eine deutlich zuverlässigere Verbindungsmethode kommt bald!", + "needHelpClickHelp": "Hilfe benötigt? Klicke auf", + "needHelpDontHesitate": "den Button oben und zögere nicht, uns zu kontaktieren.", + "never": "Niemals", + "newConnectionMethodAnnouncement": "{trainerApp} wird in Kürze deutlich bessere und zuverlässigere Verbindungsmethoden unterstützen!", + "newCustomProfile": "Neues benutzerdefiniertes Profil", + "newProfileName": "Neuer Profilname", + "newVersionAvailable": "Neue Version verfügbar", + "newVersionAvailableWithVersion": "Neue Version verfügbar: {version}", + "@newVersionAvailableWithVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "newer": "NEUER", + "newerSettingsAvailable": "Neuere Einstellungen verfügbar", + "next": "Nächste", + "no": "Nein", + "noActionAssigned": "Keine Maßnahmen zugewiesen", + "noActionAssignedForButton": "{button} konnte nicht ausgeführt werden: Keine Aktion zugewiesen", + "noConnectionMethodIsConnectedOrActive": "Es ist keine Verbindungsmethode verbunden oder aktiv.", + "noConnectionMethodSelected": "Keine Verbindungsmethode ausgewählt", + "noControllerConnected": "Keine Verbindung", + "noControllerUseCompanionMode": "Kein Controller? Nutze den Companion Mode", + "noDevicesRegistered": "Keine Geräte registriert", + "noIgnoredDevices": "Keine ignorierten Geräte.", + "noTrainerSelected": "Kein Trainer ausgewählt", + "notConnected": "Nicht verbunden", + "notificationDescription": "Dadurch bleibt die App im Hintergrund aktiv und informiert, wenn sich die Verbindung zu Geräten ändert.", + "ok": "OK", + "only": "Nur", + "openBikeControlActions": "OpenBikeControl-Aktionen", + "openBikeControlAnnouncement": "Tolle Neuigkeiten! {trainerApp} unterstützt das OpenBikeControl-Protokoll für eine bestmögliche Benutzererfahrung.", + "openBikeControlConnection": " z. B. durch Verwendung einer OpenBikeControl-Verbindung", + "otherConnectionMethods": "Andere Verbindungsmethoden", + "pairingDescription": "So können Sie Ihr Smartphone als Bluetooth-Maus zur Steuerung von Apps verwenden. Nach der Kopplung können Sie die Tasten Ihres Fahrradcontrollers nutzen, um Mausbewegungen an die App zu senden.", + "pairingInstructions": "Gehe auf Deinem {targetName} in die Bluetooth-Einstellungen und suche nach BikeControl oder dem Namen Ihres Geräts. Wenn Du die Fernbedienungsfunktion nutzen möchtest, ist eine Kopplung erforderlich.", + "@pairingInstructions": { + "placeholders": { + "targetName": { + "type": "String" + } + } + }, + "pairingInstructionsIOS": "Gehe auf Deinem iPad zu „Einstellungen“ > „Bedienungshilfen“ > „Berühren“ > „AssistiveTouch“ > „Zeigergeräte“ > „Geräte“ und koppel Dein Gerät. Stelle sicher, dass AssistiveTouch aktiviert ist.", + "pasteExportedJsonData": "Füge die exportierten JSON-Daten unten ein:", + "pathCopiedToClipboard": "Der Pfad wurde in die Zwischenablage kopiert.", + "paywall_amountOfActions": "Anzahl der Aktionen", + "paywall_billedAtPricemo": "Kostenpunkt {price} /Monat.", + "paywall_billedAtYearly": "Kostenpunkt {cost} /Jahr.", + "paywall_configure3ActionsPerButton": "3 Aktionen pro Schaltfläche", + "paywall_connectToYourTrainer": "Verbinden mit Trainer", + "paywall_controlYourDeviceMusic": "Steuere dein Gerät / deine Musik", + "paywall_createScreenshots": "Screenshots erstellen", + "paywall_monthly": "Monatlich", + "paywall_startAnyCommandShortcutWithAnyButton": "Beliebigen Befehl mit jeder Taste ausführen", + "paywall_supportDevelopmentOfNewFeaturesDevicesAndMore": "Unterstützung der Entwicklung neuer Funktionen/Geräte und mehr", + "paywall_synchronizeAndBackup": "Synchronisieren und Sichern", + "paywall_useBikecontrolOnAllPlatforms": "BikeControl auf allen Plattformen nutzen", + "paywall_yearly": "Jährlich", + "permissionsRequired": "Damit BikeControl nach Geräten in der Nähe suchen und bei Verbindungsänderungen informieren kann, aktiviere bitte die folgenden Berechtigungen:", + "platformNotSupported": "{platform} wird nicht unterstützt :(", + "@platformNotSupported": { + "placeholders": { + "platform": { + "type": "String" + } + } + }, + "platformRestrictionNotSupported": "Aufgrund von Plattformbeschränkungen wird dieses Szenario nicht unterstützt.", + "platformRestrictionOtherDevicesOnly": "Aufgrund von Plattformbeschränkungen wird das Kontrollieren von {appName} nur auf anderen Geräten unterstützt.", + "@platformRestrictionOtherDevicesOnly": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "playPause": "Wiedergabe/Pause", + "pleaseSelectAConnectionMethodFirst": "Wähle bitte zuerst in den Trainereinstellungen eine Verbindungsmethode aus.", + "predefinedAction": "Vordefinierte {appName} Aktion", + "@predefinedAction": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "pressButtonOnClickDevice": "Drücke eine Taste auf Ihrem Click-Gerät.", + "pressKeyToAssign": "Drücke eine Taste auf Ihrer Tastatur, um sie {buttonName} zuzuweisen.", + "@pressKeyToAssign": { + "placeholders": { + "buttonName": { + "type": "String" + } + } + }, + "previous": "Vorherige", + "privacyPolicy": "Datenschutzrichtlinie", + "profileExportedToClipboard": "Profil „{profileName} “ in die Zwischenablage kopiert", + "@profileExportedToClipboard": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "profileImportedSuccessfully": "Profil erfolgreich importiert", + "profileName": "Profilname", + "purchase": "Kaufen", + "recommendedConnectionMethods": "Empfohlene Verbindungsmethoden", + "registerCurrentDevice": "Aktuelles Gerät registrieren", + "registeredDevices": "Registrierte Geräte", + "removeFromIgnoredList": "Aus der Ignorierliste entfernen", + "removeOnePressAction": "Der {device} unterstützt kein langes Drücken nativ. Um diesen Typ zu verwenden, muss die Funktion für langes Drücken entfernt werden.", + "rename": "Umbenennen", + "renameProfile": "Profil umbenennen", + "replaceExisting": "Vorhandene ersetzen", + "requirement": "Anforderung", + "reset": "Zurücksetzen", + "restart": "Neustart", + "restorePurchaseInfo": "Klicke auf den Knopf oben und anschließend auf „Kauf wiederherstellen“. Bei Problemen kontaktiere mich bitte direkt.", + "restorePurchases": "Käufe wiederherstellen", + "revoke": "Widerrufen", + "revoked": "Widerrufen", + "runAppOnPlatformRemotely": "{appName} auf {platform} laufen lassen und es von diesem Gerät aus fernsteuern via {preferredConnection}.", + "@runAppOnPlatformRemotely": { + "placeholders": { + "appName": { + "type": "String" + }, + "platform": { + "type": "String" + }, + "preferredConnection": { + "type": "String" + } + } + }, + "runAppOnThisDevice": "{appName} läuft auf diesem Gerät.", + "@runAppOnThisDevice": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "save": "Speichern", + "scan": "SCAN", + "scanningForDevices": "Suche nach Geräten... Stelle sicher, dass diese eingeschaltet und in Reichweite sind und nicht mit einem anderen Gerät verbunden sind.", + "selectADeviceToSyncFrom": "Wählen Sie ein Gerät aus, von dem synchronisiert werden soll.", + "selectKeymap": "Tastaturbelegung auswählen", + "selectTargetWhereAppRuns": "Ziel auswählen, auf dem {appName} läuft", + "@selectTargetWhereAppRuns": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "selectTrainerApp": "Trainer-App auswählen", + "selectTrainerAppAndTarget": "Trainer-App und Zielgerät auswählen", + "selectTrainerAppPlaceholder": "Trainer-App auswählen", + "setting": "Einstellung", + "settingsAppliedFromId": "Einstellungen angewendet von {deviceId} ...", + "settingsDownloadedFromServer": "Einstellungen vom Server heruntergeladen", + "settingsSyncedSuccessfully": "Einstellungen erfolgreich synchronisiert", + "settingsSynchronization": "Einstellungen Synchronisierung", + "setupComplete": "Einrichtung abgeschlossen!", + "setupTrainer": "Trainer einrichten", + "share": "Teilen", + "showDonation": "Zeige Deine Wertschätzung durch eine Spende.", + "showSupportedControllers": "Unterstützte Controller anzeigen", + "signInToSyncYourSubscriptionAndManageDevices": "Anmelden, um Ihr Abonnement zu synchronisieren und Geräte zu verwalten.", + "signal": "Signal", + "simulateButtons": "Trainersteuerung", + "simulateKeyboardShortcut": "Tastenkombination simulieren", + "simulateMediaKey": "Medientaste simulieren", + "simulateTouch": "Berührung simulieren", + "skip": "Überspringen", + "stop": "Stoppen", + "supportedActions": "Unterstützte Aktionen", + "syncSettings": "Synchronisierungseinstellungen", + "syncStatus": "Synchronisierungsstatus", + "synchronizeAcrossDevices": "Synchronisierung über verschiedene Geräte hinweg", + "synchronizeYourAppSettingsAcrossAllYourDevicesThisIncludes": "Synchronisiere die App-Einstellungen auf allen Geräten. Dies umfasst Ihre Tastaturbelegungen, Tastenkonfigurationen und Präferenzen.", + "targetOtherDevice": "Anderes Gerät", + "targetThisDevice": "Dieses Gerät", + "theFollowingPermissionsRequired": "Folgende Berechtigungen sind erforderlich:", + "touchAreaInstructions": "1. Erstelle einen Screenshot Ihrer App (z. B. innerhalb von MyWhoosh) im Querformat.\n2. Lade den Screenshot über die Schaltfläche unten.\n3. Die App wird automatisch auf Querformat eingestellt, um eine genaue Zuordnung zu gewährleisten.\n4. Drücke eine Taste auf Ihrem Click-Gerät, um einen Touch-Bereich zu erstellen.\n5. Ziehe die Touch-Bereiche an die gewünschte Position auf dem Screenshot.\n6. Speicher und schließe diesen Bildschirm.", + "touchSimulationForegroundMessage": "Um Berührungen zu simulieren, muss die App im Vordergrund bleiben.", + "trainer": "Trainer", + "trialDaysRemaining": "{trialDaysRemaining} verbleibende Tage", + "trialExpired": "Testphase abgelaufen. Befehle beschränkt auf {dailyCommandLimit} pro Tag.", + "trialPeriodActive": "Testphase aktiv - {trialDaysRemaining} verbleibende Tage", + "trialPeriodDescription": "Während der Testphase stehen unbegrenzt viele Befehle zur Verfügung. Nach Ablauf der Testphase sind die Befehle auf {dailyCommandLimit} pro Tag eingeschränkt.", + "troubleshootingGuide": "Fragen und Antworten", + "tryingToConnectAgain": "Verbinde erneut...", + "unassignAction": "Zuweisung aufheben", + "unlimited": "Unbegrenzt", + "unlockAllFeaturesWithPro": "Schalten Sie alle Funktionen mit Pro frei.", + "unlockFullVersion": "Vollversion freischalten", + "unlockTheFullVersionOrGoPro": "Schalte die Vollversion frei – oder wechsle zu Pro", + "unlock_bikecontrolAndZwiftNetwork": "BikeControl und Zwift müssen sich im selben Netzwerk oder auf demselben Gerät befinden. Es kann einige Sekunden dauern, bis sie angezeigt werden.", + "unlock_confirmByPressingAButtonOnYourDevice": "Bestätigen Sie durch Drücken einer Taste auf Ihrem Gerät.", + "unlock_connectToBikecontrol": "Verbinde dich mit „BikeControl“, z.B. als Stromquelle.", + "unlock_deviceIsCurrentlyLocked": "Das Gerät ist derzeit gesperrt.", + "unlock_isnowunlocked": "{device} ist jetzt freigeschaltet", + "unlock_markAsUnlocked": "Als entsperrt markieren", + "unlock_notWorking": "Funktioniert nicht?", + "unlock_openZwift": "Öffnen Sie Zwift (nicht die Companion-App) auf diesem oder einem anderen Gerät.", + "unlock_unlockManually": "Manuelles Entsperren", + "unlock_unlockNow": "Jetzt freischalten", + "unlock_unlockedUntilAroundDate": "Entsperrt bis etwa {date}", + "unlock_waitingForZwift": "Warten auf die Entsperrung Ihres Geräts durch Zwift...", + "unlock_youCanNowCloseZwift": "Zwift kann jetzt geschlossen werden.", + "unlock_yourTrialPhaseHasExpired": "Testphase ist abgelaufen. Bitte erwerbe die Vollversion, um die komfortable Entsperrfunktion freizuschalten.", + "unlock_yourZwiftClickMightBeUnlockedAlready": "Ihr Zwift Click ist möglicherweise bereits freigeschaltet.", + "unlockingNotPossible": "Eine Freischaltung ist derzeit noch nicht möglich, daher ist die App vorerst uneingeschränkt nutzbar!", + "update": "Aktualisieren", + "uploadSettings": "Upload-Einstellungen", + "useCustomKeymapForButton": "Verwende eine benutzerdefinierte Tastaturbelegung, um die", + "version": "Version {version}", + "@version": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "viewDetailedInstructions": "Detaillierte Anweisungen ansehen", + "volumeDown": "Lautstärke verringern", + "volumeUp": "Lautstärke erhöhen", + "waiting": "Warten...", + "waitingForConnectionKickrBike": "Verbindung wird hergestellt. Wähle KICKR BIKE PRO im {appName} Verbindungs-Menü.", + "@waitingForConnectionKickrBike": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "whatsNew": "Was ist neu", + "whyPermissionNeeded": "Wozu wird diese Berechtigung benötigt?", + "windowsSubscriptionsRequireYouToBeLoggedIn": "Für Windows-Abonnements ist eine Anmeldung erforderlich.", + "yes": "Ja", + "yourDevices": "Deine Geräte", + "yourSettingsAreAutomaticallySyncedWhenYouMakeChangesTap": "Einstellungen werden automatisch synchronisiert, sobald Änderungen vorgenommen werdem. Tippe auf ein Gerät, um dessen Einstellungen anzuwenden.", + "zwiftControllerAction": "Zwift Controller-Aktion", + "zwiftControllerDescription": "Ermöglicht es BikeControl, als Zwift-kompatibler Controller zu fungieren." +} \ No newline at end of file diff --git a/lib/i10n/intl_en.arb b/lib/i10n/intl_en.arb new file mode 100644 index 000000000..2c69f6dc3 --- /dev/null +++ b/lib/i10n/intl_en.arb @@ -0,0 +1,532 @@ +{ + "accessibilityDescription": "BikeControl needs accessibility permission to control your training apps.", + "accessibilityDisclaimer": "BikeControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.", + "accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, TrainingPeaks, and others using your Zwift devices", + "accessibilityReasonTouch": "• To simulate touch gestures on your screen for controlling trainer apps", + "accessibilityReasonWindow": "• To detect which training app window is currently active", + "accessibilityServiceExplanation": "BikeControl needs to use Android's AccessibilityService API to function properly.", + "accessibilityServiceNotRunning": "Accessibility Service is not running.\nFollow instructions at", + "accessibilityServicePermissionRequired": "Accessibility Service Permission Required", + "accessibilityUsageGestures": "• When you press buttons on your Zwift Click, Zwift Ride, or Zwift Play devices, BikeControl simulates touch gestures at specific screen locations", + "accessibilityUsageMonitor": "• The app monitors which training app window is active to ensure gestures are sent to the correct app", + "accessibilityUsageNoData": "• No personal data is accessed or collected through this service", + "accessories": "Accessories", + "account": "Account", + "actAsBluetoothKeyboard": "Act as Bluetooth Keyboard", + "action": "Action", + "additionalTriggerAssignment": "Additional trigger assignment", + "adjustControllerButtons": "Adjust Controller Buttons", + "afterDate": "After {date}", + "allow": "Allow", + "allowAccessibilityService": "Allow Accessibility Service", + "allowBluetoothConnections": "Allow Bluetooth Connections", + "allowBluetoothScan": "Allow Bluetooth Scan", + "allowLocationForBluetooth": "Allow Location so Bluetooth scan works", + "allowPersistentNotification": "Allow Notifications", + "allowsRunningInBackground": "Allows BikeControl to keep running in background", + "alreadyBoughtTheApp": "Already bought the app? You don't need to pay for BikeControl, again. Due to technical difficulties, it's not possible to determine if the app was bought already.\n\nEnter your Play Store purchase ID (e.g. GPA.3356-1337-1338-1339) to unlock the full version. If you can't find it, please get in touch with me directly.", + "alreadyBoughtTheAppPreviously": "Already bought the app previously?", + "androidSystemAction": "Android System Action", + "anotherTriggerIsAlreadyAssignedForThisButton": "Another trigger is already assigned for this button. Go Pro to keep multiple trigger types, or replace the existing trigger assignment and continue with {triggerTitle}.", + "appIdActions": "{appId} actions", + "@appIdActions": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "applyNow": "Apply now", + "asAFinalStepYoullChooseHowToConnectTo": "As a final step you'll choose how to connect to {trainerApp}.", + "battery": "Battery", + "beforeDate": "Before {date}", + "bluetoothAdvertiseAccess": "Bluetooth Advertise access", + "bluetoothKeyboardExplanation": "This will allow you to use your phone as a Bluetooth keyboard to control compatible apps. Once paired, you can use the buttons on your bike controller to send keyboard inputs to the app.", + "bluetoothTurnedOn": "Bluetooth turned on", + "browserNotSupported": "This Browser does not support Web Bluetooth and platform is not supported :(", + "button": "button.", + "buyFullVersion": "Buy Full Version", + "bySigningInYouAgreeToOur": "By signing in, you agree to our {privacyPolicy}.", + "@bySigningInYouAgreeToOur": { + "placeholders": { + "privacyPolicy": { + "type": "String" + } + } + }, + "cancel": "Cancel", + "changelog": "Changelog", + "checkMyWhooshConnectionScreen": "Check the connection screen in MyWhoosh to see if \"Link\" is connected.", + "chooseAnotherScreenshot": "Choose another screenshot", + "chooseBikeControlInConnectionScreen": "Choose BikeControl in the connection screen.", + "clickAButtonOnYourController": "Click a button on your controller to edit its action or tap the edit icon.", + "clickV2EventInfo": "Your Click V2 may no longer send button events. Please check by tapping a few buttons and see if they are visible in BikeControl.", + "clickV2Instructions": "To make your Zwift Click V2 work best, you should connect it in the Zwift app before each training session.\nIf you don't do that, the Click V2 will stop working after a minute.\n\n1. Open Zwift app (not the Companion!)\n2. Log in (subscription not required) and open the device connection screen\n3. Connect your Trainer, then connect the Zwift Click V2\n4. Close the Zwift app again and connect again in BikeControl", + "close": "Close", + "commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} commands remaining today", + "configuration": "Configuration", + "connectControllerToPreview": "Connect a controller device to preview and customize the keymap.", + "connectControllers": "Connect Controllers", + "connectDirectlyOverNetwork": "Connect directly over Network", + "connectToTrainerApp": "Connect to Trainer app", + "connectUsingBluetooth": "Connect using Bluetooth", + "connectUsingMyWhooshLink": "Connect using MyWhoosh \"Link\"", + "connected": "Connected", + "connectedControllers": "Connected Controllers", + "connectedTo": "Connected to {appId}", + "@connectedTo": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "connection": "Connection", + "continueAction": "Continue", + "controlAppUsingModes": "Control {appName} using {modes}", + "@controlAppUsingModes": { + "placeholders": { + "appName": { + "type": "String" + }, + "modes": { + "type": "String" + } + } + }, + "controllerConnectedClickButton": "Great! Your controller is connected. Click a button on your controller to continue.", + "controllers": "Controllers", + "couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Could not perform {button}: No keymap set", + "create": "Create", + "createNewKeymap": "Create new keymap", + "createNewProfileByDuplicating": "Create new custom profile by duplicating \"{profileName}\"", + "@createNewProfileByDuplicating": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "createdNewCustomProfile": "Created a new custom profile: {profileName}", + "@createdNewCustomProfile": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "currentDeviceIsNotRegistered": "Current Device is not registered", + "currentPlan": "Current Plan", + "customizeControllerButtons": "Customize Controller buttons for {appName}", + "@customizeControllerButtons": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "customizeKeymapHint": "Customize the keymap if you experience any issues (e.g. wrong keyboard output, or misaligned touch placements)", + "dailyCommandLimitReachedNotification": "Daily command limit reached for today. Upgrade to unlock the full version with unlimited commands.", + "dailyLimitReached": "Daily limit reached ({dailyCommandCount}/{dailyCommandLimit} used)", + "delete": "Delete", + "deleteProfile": "Delete Profile", + "deleteProfileConfirmation": "Are you sure you want to delete \"{profileName}\"? This action cannot be undone.", + "@deleteProfileConfirmation": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "deny": "Deny", + "deviceButton": "{deviceName} button", + "@deviceButton": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "deviceLimitReached": "Device limit reached for {platform}. Revoke other devices to register a new one.", + "devicesActive": "{active} active", + "disconnectDevices": "Disconnect Devices", + "disconnected": "Disconnected", + "donateByBuyingFromPlayStore": "by buying the app from Play Store", + "donateViaCreditCard": "via Credit Card, Google Pay, Apple Pay and others", + "donateViaPaypal": "via PayPal", + "download": "Download", + "downloadLatestSettings": "Download Latest Settings", + "dragToReposition": "Drag to reposition", + "duplicate": "Duplicate", + "enableAutoRotation": "Enable auto-rotation on your device to make sure the app works correctly.", + "enableBluetooth": "Enable Bluetooth", + "enableItInTheConnectionSettingsFirst": "Enable it in the connection settings first.", + "enableKeyboardAccessMessage": "Enable keyboard access in the following screen for BikeControl. If you don't see BikeControl, please add it manually.", + "enableKeyboardMouseControl": "BikeControl will send mouse or keyboard actions to control {appName}.", + "@enableKeyboardMouseControl": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "enableLocalConnectionMethodFirst": "Enable Local Connection method, first.", + "enableMediaKeyDetection": "Enable Media Key Detection", + "enableMywhooshLinkInTheConnectionSettingsFirst": "Enable MyWhoosh Link in the connection settings first.", + "enablePairingProcess": "Act as Bluetooth Mouse", + "enablePermissions": "Enable Permissions", + "enableSteeringWithPhone": "Enable Steering with your phone's sensors", + "enableVibrationFeedback": "Enable vibration feedback when shifting gears", + "enableZwiftControllerBluetooth": "Enable Zwift Controller (Bluetooth)", + "enableZwiftControllerNetwork": "Enable Zwift Controller (Network)", + "errorStartingMyWhooshLink": "Error starting MyWhoosh Link server. Please make sure the \"MyWhoosh Link\" app is not already running on this device.", + "errorStartingOpenBikeControlBluetoothServer": "Error starting OpenBikeControl Bluetooth server.", + "errorStartingOpenBikeControlServer": "Error starting OpenBikeControl server.", + "exportAction": "Export", + "failedToImportProfile": "Failed to import profile. Invalid format.", + "failedToUpdate": "Failed to update: {error}", + "@failedToUpdate": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "firmware": "Firmware", + "forceCloseToUpdate": "Force-close the app to use the new version", + "full": "FULL", + "fullVersion": "Full Version", + "fullVersionDescription": "The full version includes:\n- Unlimited commands per day\n- Access to all future updates\n- No subscription! A one-time fee only :)", + "getSupport": "Get Support", + "goPro": "Go Pro", + "gotIt": "Got it!", + "grant": "Grant", + "granted": "Granted", + "helpRequested": "Help requested for BikeControl v{version}", + "@helpRequested": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "howBikeControlUsesPermission": "How does BikeControl use this permission?", + "ignoredDevices": "Ignored Devices", + "importAction": "Import", + "importProfile": "Import Profile", + "instructions": "Instructions", + "jsonData": "JSON Data", + "justNow": "Just now", + "keyboardAccess": "Keyboard access", + "lastSeen": "Last seen:", + "lastSynced": "Last synced:", + "latestVersion": "latest: {version}", + "@latestVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "leaveAReview": "Leave a Review", + "letsAppConnectOverBluetooth": "Lets {appName} connect to BikeControl over Bluetooth.", + "@letsAppConnectOverBluetooth": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsAppConnectOverNetwork": "Lets {appName} connect directly over the Network. Choose BikeControl in the connection screen.", + "@letsAppConnectOverNetwork": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsGetYouSetUp": "Let's get you set up!", + "license": "License", + "licenseStatus": "License Status", + "loadScreenshotForPlacement": "Load in-game screenshot for placement", + "logViewer": "Log Viewer", + "loggedInAsMail": "Logged in as {email}", + "logout": "Logout", + "logs": "Logs", + "logsAreAlsoAt": "Logs are also at", + "logsHaveBeenCopiedToClipboard": "Logs have been copied to clipboard", + "longPress": "long\npress", + "longPressMode": "Long Press Mode (vs. repeating)", + "longTapExplanation": "tap 1: key down\ntap 2: key up", + "mailSupportExplanation": "Providing individual support via email is a lot of work for me.\n\nPlease consider using Reddit, Facebook or GitHub for questions and issues so that the whole community can benefit from it.", + "manageIgnoredDevices": "Manage Ignored Devices", + "manageProfile": "Manage Profile", + "manageSubscription": "Manage Subscription", + "manageYourDevices": "Manage your devices", + "manualyControllingButton": "Control {trainerApp} manually!", + "mediaKeyDetectionTooltip": "Enable this option to allow BikeControl to detect bluetooth remotes.\nIn order to do so BikeControl needs to act as a media player.", + "miuiDeviceDetected": "MIUI Device Detected", + "miuiDisableBatteryOptimization": "• Disable battery optimization for BikeControl", + "miuiEnableAutostart": "• Enable autostart for BikeControl", + "miuiEnsureProperWorking": "To ensure BikeControl works properly:", + "miuiLockInRecentApps": "• Lock the app in recent apps", + "miuiWarningDescription": "Your device is running MIUI, which is known to aggressively kill background services and accessibility services.", + "moreInformation": "More Information", + "mustChooseAllowOrDeny": "You must choose to either Allow or Deny this permission to continue.", + "myWhooshDirectConnectAction": "MyWhoosh \"Link\" Action", + "myWhooshDirectConnection": " e.g. by using MyWhoosh \"Link\"", + "myWhooshLinkConnected": "MyWhoosh \"Link\" connected", + "myWhooshLinkDescriptionLocal": "Connect directly to MyWhoosh via the \"Link\" method. Check the instructions to ensure a connection is possible. The \"MyWhoosh Link\" app must not be active at the same time.", + "myWhooshLinkInfo": "Please check the troubleshooting section if you encounter any issues. A much more reliable connection method is coming soon!", + "needHelpClickHelp": "Need help? Click on the", + "needHelpDontHesitate": "button on top and don't hesitate to contact us.", + "never": "Never", + "newConnectionMethodAnnouncement": "{trainerApp} will soon support much better, reliable connection methods - stay tuned for updates!", + "newCustomProfile": "New Custom Profile", + "newProfileName": "New Profile Name", + "newVersionAvailable": "New version available", + "newVersionAvailableWithVersion": "New version available: {version}", + "@newVersionAvailableWithVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "newer": "NEWER", + "newerSettingsAvailable": "Newer settings available", + "next": "Next", + "no": "No", + "noActionAssigned": "No action assigned", + "noActionAssignedForButton": "Could not perform {button}: No action assigned", + "noConnectionMethodIsConnectedOrActive": "No connection method is connected or active.", + "noConnectionMethodSelected": "No Connection Method selected", + "noControllerConnected": "None connected", + "noControllerUseCompanionMode": "No Controller? Use Companion Mode.", + "noDevicesRegistered": "No devices registered", + "noIgnoredDevices": "No ignored devices.", + "noTrainerSelected": "No Trainer selected", + "notConnected": "Not connected", + "notificationDescription": "This keeps the app alive in background and updates you when the connection to your devices changes.", + "ok": "OK", + "only": "Only", + "openBikeControlActions": "OpenBikeControl actions", + "openBikeControlAnnouncement": "Great news - {trainerApp} supports the OpenBikeControl Protocol, so you'll have the best possible experience!", + "openBikeControlConnection": " e.g. by using OpenBikeControl connection", + "otherConnectionMethods": "Other Connection Methods", + "pairingDescription": "This will allow you to use your phone as a Bluetooth mouse to control apps. Once paired, you can use the buttons on your bike controller to send mouse inputs to the app.", + "pairingInstructions": "On your {targetName} go into Bluetooth settings and look for BikeControl or your machines name. Pairing is required if you want to use the remote control feature.", + "@pairingInstructions": { + "placeholders": { + "targetName": { + "type": "String" + } + } + }, + "pairingInstructionsIOS": "On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.", + "pasteExportedJsonData": "Paste the exported JSON data below:", + "pathCopiedToClipboard": "Path has been copied to clipboard", + "paywall_amountOfActions": "Amount of actions", + "paywall_billedAtPricemo": "Billed at {price}/mo.", + "paywall_billedAtYearly": "Billed at {cost}/yr.", + "paywall_configure3ActionsPerButton": "Configure 3 actions per button", + "paywall_connectToYourTrainer": "Connect to your trainer", + "paywall_controlYourDeviceMusic": "Control your device / music", + "paywall_createScreenshots": "Create screenshots", + "paywall_monthly": "Monthly", + "paywall_startAnyCommandShortcutWithAnyButton": "Start any command / shortcut with any button", + "paywall_supportDevelopmentOfNewFeaturesDevicesAndMore": "Support development of new features / devices and more", + "paywall_synchronizeAndBackup": "Synchronize and backup", + "paywall_useBikecontrolOnAllPlatforms": "Use BikeControl on all platforms", + "paywall_yearly": "Yearly", + "permissionsRequired": "In order for BikeControl to search for nearby devices and update you when the connection changes, please enable the following permissions:", + "platformNotSupported": "This {platform} is not supported :(", + "@platformNotSupported": { + "placeholders": { + "platform": { + "type": "String" + } + } + }, + "platformRestrictionNotSupported": "Due to platform restrictions this scenario is not supported.", + "platformRestrictionOtherDevicesOnly": "Due to platform restrictions only controlling {appName} on other devices is supported.", + "@platformRestrictionOtherDevicesOnly": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "playPause": "Play/Pause", + "pleaseSelectAConnectionMethodFirst": "Please select a connection method in the Trainer settings, first.", + "predefinedAction": "Predefined {appName} action", + "@predefinedAction": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "pressButtonOnClickDevice": "Press a button on your Click device", + "pressKeyToAssign": "Press a key on your keyboard to assign to {buttonName}", + "@pressKeyToAssign": { + "placeholders": { + "buttonName": { + "type": "String" + } + } + }, + "previous": "Previous", + "privacyPolicy": "Privacy Policy", + "profileExportedToClipboard": "Profile \"{profileName}\" exported to clipboard", + "@profileExportedToClipboard": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "profileImportedSuccessfully": "Profile imported successfully", + "profileName": "Profile name", + "purchase": "Purchase", + "recommendedConnectionMethods": "Recommended Connection Methods", + "registerCurrentDevice": "Register current device", + "registeredDevices": "Registered Devices", + "removeFromIgnoredList": "Remove from ignored list", + "removeOnePressAction": "The {device} does not support long press natively. To use this type you the long press action needs to be removed.", + "rename": "Rename", + "renameProfile": "Rename Profile", + "replaceExisting": "Replace Existing", + "requirement": "Requirement", + "reset": "Reset", + "restart": "Restart", + "restorePurchaseInfo": "Click on the button above, then on \"Restore Purchase\". Please contact me directly if you have any issues.", + "restorePurchases": "Restore purchases", + "revoke": "Revoke", + "revoked": "Revoked", + "runAppOnPlatformRemotely": "Run {appName} on {platform} and control it remotely from this device{preferredConnection}.", + "@runAppOnPlatformRemotely": { + "placeholders": { + "appName": { + "type": "String" + }, + "platform": { + "type": "String" + }, + "preferredConnection": { + "type": "String" + } + } + }, + "runAppOnThisDevice": "Run {appName} on this device.", + "@runAppOnThisDevice": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "save": "Save", + "scan": "SCAN", + "scanningForDevices": "Scanning for devices... Make sure they are powered on and in range and not connected to another device.", + "selectADeviceToSyncFrom": "Select a device to sync from", + "selectKeymap": "Select Keymap", + "selectTargetWhereAppRuns": "Select Target where {appName} runs on", + "@selectTargetWhereAppRuns": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "selectTrainerApp": "Select Trainer App", + "selectTrainerAppAndTarget": "Select Trainer App & Target Device", + "selectTrainerAppPlaceholder": "Select Trainer app", + "setting": "Setting", + "settingsAppliedFromId": "Settings applied from {deviceId}...", + "settingsDownloadedFromServer": "Settings downloaded from server", + "settingsSyncedSuccessfully": "Settings synced successfully", + "settingsSynchronization": "Settings Synchronization", + "setupComplete": "Setup Complete!", + "setupTrainer": "Setup Trainer", + "share": "Share", + "showDonation": "Show your appreciation by donating", + "showSupportedControllers": "Show Supported Controllers", + "signInToSyncYourSubscriptionAndManageDevices": "Sign in to sync your subscription and manage devices", + "signal": "Signal", + "simulateButtons": "Trainer Controls", + "simulateKeyboardShortcut": "Press a keyboard key", + "simulateMediaKey": "Control Music playback", + "simulateTouch": "Click any button on screen", + "skip": "Skip", + "stop": "Stop", + "supportedActions": "Supported Actions", + "syncSettings": "Sync Settings", + "syncStatus": "Sync Status", + "synchronizeAcrossDevices": "Synchronize across devices", + "synchronizeYourAppSettingsAcrossAllYourDevicesThisIncludes": "Synchronize your app settings across all your devices. This includes your keymaps, button configurations, and preferences.", + "targetOtherDevice": "Other Device", + "targetThisDevice": "This Device", + "theFollowingPermissionsRequired": "The following permissions are required:", + "touchAreaInstructions": "1. Create an in-game screenshot of your app (e.g. within MyWhoosh) in landscape orientation\n2. Load the screenshot with the button below\n3. The app is automatically set to landscape orientation for accurate mapping\n4. Press a button on your Click device to create a touch area\n5. Drag the touch areas to the desired position on the screenshot\n6. Save and close this screen", + "touchSimulationForegroundMessage": "To simulate touches the app needs to stay in the foreground.", + "trainer": "Trainer", + "trialDaysRemaining": "{trialDaysRemaining} days remaining", + "trialExpired": "Trial expired. Commands limited to {dailyCommandLimit} per day.", + "trialPeriodActive": "Trial Period Active - {trialDaysRemaining} days remaining", + "trialPeriodDescription": "Enjoy unlimited commands during your trial period. After the trial, commands will be limited to {dailyCommandLimit} per day.", + "troubleshootingGuide": "Questions & Answers", + "tryingToConnectAgain": "Trying to connect again...", + "unassignAction": "Unassign action", + "unlimited": "Unlimited", + "unlockAllFeaturesWithPro": "Unlock all features with Pro", + "unlockFullVersion": "Unlock Full Version", + "unlockTheFullVersionOrGoPro": "Unlock the full version - or Go Pro", + "unlock_bikecontrolAndZwiftNetwork": "BikeControl and Zwift need to be on the same network or device. It may take a few seconds to appear.", + "unlock_confirmByPressingAButtonOnYourDevice": "Confirm by pressing a button on your device.", + "unlock_connectToBikecontrol": "Connect to \"BikeControl\" e.g. as Power source", + "unlock_deviceIsCurrentlyLocked": "Device is currently locked", + "unlock_isnowunlocked": "{device} is now unlocked", + "unlock_markAsUnlocked": "Mark as Unlocked", + "unlock_notWorking": "Not working?", + "unlock_openZwift": "Open Zwift (not the Companion) on this or another device", + "unlock_unlockManually": "Unlock manually", + "unlock_unlockNow": "Unlock now", + "unlock_unlockedUntilAroundDate": "Unlocked until around {date}", + "unlock_waitingForZwift": "Waiting for Zwift to unlock your device...", + "unlock_youCanNowCloseZwift": "You can now close Zwift and return to BikeControl.", + "unlock_yourTrialPhaseHasExpired": "Your trial phase has expired. Please purchase the full version to unlock the comfortable unlocking feature :)", + "unlock_yourZwiftClickMightBeUnlockedAlready": "Your Zwift Click might be unlocked already.", + "unlockingNotPossible": "Unlocking is currently not yet possible, so enjoy unlimited usage for the time being!", + "update": "Update", + "uploadSettings": "Upload Settings", + "useCustomKeymapForButton": "Use a custom keymap to support the", + "version": "Version {version}", + "@version": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "viewDetailedInstructions": "View Detailed Instructions", + "volumeDown": "Volume Down", + "volumeUp": "Volume Up", + "waiting": "Waiting...", + "waitingForConnectionKickrBike": "Waiting for connection. Choose KICKR BIKE PRO in {appName}'s controller pairing menu.", + "@waitingForConnectionKickrBike": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "whatsNew": "What's New", + "whyPermissionNeeded": "Why is this permission needed?", + "windowsSubscriptionsRequireYouToBeLoggedIn": "Windows subscriptions require you to be logged in", + "yes": "Yes", + "yourDevices": "Your Devices", + "yourSettingsAreAutomaticallySyncedWhenYouMakeChangesTap": "Your settings are automatically synced when you make changes. Tap on a device to apply its settings.", + "zwiftControllerAction": "Zwift Controller Action", + "zwiftControllerDescription": "Enables BikeControl to act as a Zwift-compatible controller." +} \ No newline at end of file diff --git a/lib/i10n/intl_es.arb b/lib/i10n/intl_es.arb new file mode 100644 index 000000000..a5acd25c3 --- /dev/null +++ b/lib/i10n/intl_es.arb @@ -0,0 +1,532 @@ +{ + "accessibilityDescription": "BikeControl necesita permiso de accesibilidad para controlar tus apps de entrenamiento.", + "accessibilityDisclaimer": "BikeControl solo accederá a tu pantalla para realizar los gestos que configures. No accederá a ninguna otra función de accesibilidad ni a información personal.", + "accessibilityReasonControl": "• Para que puedas controlar apps como MyWhoosh, TrainingPeaks y otras con tus dispositivos Zwift", + "accessibilityReasonTouch": "• Para simular gestos táctiles en tu pantalla y controlar apps de entrenamiento", + "accessibilityReasonWindow": "• Para detectar qué ventana de la app de entrenamiento está activa en ese momento", + "accessibilityServiceExplanation": "BikeControl necesita usar la API AccessibilityService de Android para funcionar correctamente.", + "accessibilityServiceNotRunning": "El servicio de accesibilidad no está en funcionamiento. \nSiga las instrucciones en", + "accessibilityServicePermissionRequired": "Se requiere permiso del servicio de accesibilidad", + "accessibilityUsageGestures": "• Cuando pulsas botones en tus dispositivos Zwift Click, Zwift Ride o Zwift Play, BikeControl simula gestos táctiles en ubicaciones concretas de la pantalla", + "accessibilityUsageMonitor": "• La app supervisa qué ventana de entrenamiento está activa para asegurarse de que los gestos se envíen a la app correcta", + "accessibilityUsageNoData": "• No se accede ni se recopilan datos personales a través de este servicio", + "accessories": "Accesorios", + "account": "Cuenta", + "actAsBluetoothKeyboard": "Actuar como teclado Bluetooth", + "action": "Acción", + "additionalTriggerAssignment": "Asignación de disparador adicional", + "adjustControllerButtons": "Ajustar los botones del controlador", + "afterDate": "Después de {date}", + "allow": "Permitir", + "allowAccessibilityService": "Permitir servicio de accesibilidad", + "allowBluetoothConnections": "Permitir conexiones Bluetooth", + "allowBluetoothScan": "Permitir escaneo Bluetooth", + "allowLocationForBluetooth": "Permite la ubicación para que funcione el escaneo Bluetooth", + "allowPersistentNotification": "Permitir notificaciones", + "allowsRunningInBackground": "Permite que BikeControl siga ejecutándose en segundo plano", + "alreadyBoughtTheApp": "¿Ya compraste la app? No necesitas volver a pagar por BikeControl. Por limitaciones técnicas no es posible saber si la app ya se compró.\n\nIntroduce tu ID de compra de Play Store (por ejemplo, GPA.3356-1337-1338-1339) para desbloquear la versión completa. Si no lo encuentras, ponte en contacto conmigo directamente.", + "alreadyBoughtTheAppPreviously": "¿Ya habías comprado la app antes?", + "androidSystemAction": "Acción del sistema Android", + "anotherTriggerIsAlreadyAssignedForThisButton": "Ya hay otro disparador asignado a este botón. Acceda a la versión Pro para conservar varios tipos de disparadores o reemplace la asignación de disparador existente y continúe con {triggerTitle} .", + "appIdActions": "Acciones de {appId}", + "@appIdActions": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "applyNow": "Aplicar ahora", + "asAFinalStepYoullChooseHowToConnectTo": "Como último paso, elegirás cómo conectarte a {trainerApp}.", + "battery": "Batería", + "beforeDate": "Antes de {date}", + "bluetoothAdvertiseAccess": "Acceso para anunciar por Bluetooth", + "bluetoothKeyboardExplanation": "Esto te permitirá usar tu teléfono como teclado Bluetooth para controlar apps compatibles. Una vez emparejado, podrás usar los botones del controlador de tu bici para enviar pulsaciones de teclado a la app.", + "bluetoothTurnedOn": "Bluetooth activado", + "browserNotSupported": "Este navegador no es compatible con Web Bluetooth y la plataforma no es compatible :(", + "button": "botón.", + "buyFullVersion": "Comprar versión completa", + "bySigningInYouAgreeToOur": "Al iniciar sesión, aceptas nuestros {privacyPolicy} .", + "@bySigningInYouAgreeToOur": { + "placeholders": { + "privacyPolicy": { + "type": "String" + } + } + }, + "cancel": "Cancelar", + "changelog": "Registro de cambios", + "checkMyWhooshConnectionScreen": "Verifique la pantalla de conexión en MyWhoosh para ver si \"Link\" está conectado.", + "chooseAnotherScreenshot": "Elegir otra captura de pantalla", + "chooseBikeControlInConnectionScreen": "Seleccione BikeControl en la pantalla de conexión.", + "clickAButtonOnYourController": "Pulsa un botón de tu controlador para editar su acción o toca el icono de edición.", + "clickV2EventInfo": "Es posible que tu Click V2 ya no esté enviando eventos de botón. Compruébalo pulsando algunos botones y viendo si aparecen en BikeControl.", + "clickV2Instructions": "Para que tu Zwift Click V2 funcione lo mejor posible, deberías conectarlo en la app de Zwift antes de cada sesión de entrenamiento.\nSi no lo haces, el Click V2 dejará de funcionar al cabo de un minuto.\n\n1. Abre la app de Zwift (no Companion)\n2. Inicia sesión (no hace falta suscripción) y abre la pantalla de conexión de dispositivos\n3. Conecta tu rodillo y luego conecta el Zwift Click V2\n4. Vuelve a cerrar la app de Zwift y conéctate otra vez en BikeControl", + "close": "Cerrar", + "commandsRemainingToday": "Te quedan {commandsRemainingToday}/{dailyCommandLimit} comandos hoy", + "configuration": "Configuración", + "connectControllerToPreview": "Conecte un dispositivo controlador para obtener una vista previa y personalizar el mapa de teclas.", + "connectControllers": "Conectar dispositivos de control", + "connectDirectlyOverNetwork": "Conectar directamente a través de la red", + "connectToTrainerApp": "Conectar la app de entrenamiento", + "connectUsingBluetooth": "Conectar mediante Bluetooth", + "connectUsingMyWhooshLink": "Conenctar mediante MyWhoosh \"Link\"", + "connected": "Conectado", + "connectedControllers": "Controles conectados", + "connectedTo": "Conectado a{appId}", + "@connectedTo": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "connection": "Conexión", + "continueAction": "Continuar", + "controlAppUsingModes": "Controlar {appName} usando {modes}", + "@controlAppUsingModes": { + "placeholders": { + "appName": { + "type": "String" + }, + "modes": { + "type": "String" + } + } + }, + "controllerConnectedClickButton": "Perfecto. Tu controlador está conectado. Pulsa un botón en tu controlador para continuar.", + "controllers": "Dispositivos de control", + "couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "No se pudo ejecutar {button}: no hay un mapa de teclas configurado", + "create": "Crear", + "createNewKeymap": "Crear un nuevo mapa de teclas", + "createNewProfileByDuplicating": "Crear un perfil personalizado nuevo duplicando \"{profileName}\"", + "@createNewProfileByDuplicating": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "createdNewCustomProfile": "Se creó un perfil personalizado nuevo: {profileName}", + "@createdNewCustomProfile": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "currentDeviceIsNotRegistered": "El dispositivo actual no está registrado", + "currentPlan": "Plan actual", + "customizeControllerButtons": "Personaliza los botones del controlador para {appName}", + "@customizeControllerButtons": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "customizeKeymapHint": "Personalice el mapa de teclas si experimenta algún problema (por ejemplo, teclas pulsadas incorrectas o pulsaciones táctiles mal localizadas)", + "dailyCommandLimitReachedNotification": "Se alcanzó el límite diario de comandos de hoy. Actualiza para desbloquear la versión completa con comandos ilimitados.", + "dailyLimitReached": "Límite diario alcanzado ({dailyCommandCount}/{dailyCommandLimit} usados)", + "delete": "Eliminar", + "deleteProfile": "Eliminar perfil", + "deleteProfileConfirmation": "¿Seguro que quieres eliminar \"{profileName}\"? Esta acción no se puede deshacer.", + "@deleteProfileConfirmation": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "deny": "Denegar", + "deviceButton": "{deviceName} botón", + "@deviceButton": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "deviceLimitReached": "Se alcanzó el límite de dispositivos para {platform}. Revoca otros dispositivos para registrar uno nuevo.", + "devicesActive": "{active} activos", + "disconnectDevices": "Desconectar dispositivos", + "disconnected": "Desconectado", + "donateByBuyingFromPlayStore": "comprando la app en Play Store", + "donateViaCreditCard": "con tarjeta de crédito, Google Pay, Apple Pay y otros métodos", + "donateViaPaypal": "con PayPal", + "download": "Descargar", + "downloadLatestSettings": "Descargar los ajustes más recientes", + "dragToReposition": "Arrastra para recolocar", + "duplicate": "Duplicar", + "enableAutoRotation": "Habilite el giro automático de pantalla en su dispositivo para asegurar que la aplicación funcione correctamente.", + "enableBluetooth": "Activar Bluetooth", + "enableItInTheConnectionSettingsFirst": "Actívalo primero en los ajustes de conexión.", + "enableKeyboardAccessMessage": "Activa el acceso al teclado en la siguiente pantalla para BikeControl. Si no ves BikeControl, añádelo manualmente.", + "enableKeyboardMouseControl": "Habilita el control del teclado y el mouse para una mejor interacción con {appName} Una vez activo, no se requiere ninguna acción ni conexión adicional. BikeControl enviará directamente la entrada del ratón o del teclado a {appName} .", + "@enableKeyboardMouseControl": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "enableLocalConnectionMethodFirst": "Activa primero el método de conexión local.", + "enableMediaKeyDetection": "Habilitar la detección de claves multimedia", + "enableMywhooshLinkInTheConnectionSettingsFirst": "Activa primero MyWhoosh Link en los ajustes de conexión.", + "enablePairingProcess": "Habilita el proceso de emparejamiento", + "enablePermissions": "Habilitar permisos", + "enableSteeringWithPhone": "Activar dirección con los sensores de tu teléfono", + "enableVibrationFeedback": "Habilitar la vibración al cambiar de marcha", + "enableZwiftControllerBluetooth": "Habilitar el Zwift controller (Bluetooth)", + "enableZwiftControllerNetwork": "Habilitar el Zwift Controller (red)", + "errorStartingMyWhooshLink": "Error al iniciar el servidor MyWhoosh Link. Asegúrate de que la aplicación \"MyWhoosh Link\" no esté ejecutándose en este dispositivo.", + "errorStartingOpenBikeControlBluetoothServer": "Error al iniciar el servidor Bluetooth de OpenBikeControl.", + "errorStartingOpenBikeControlServer": "Error al iniciar el servidor OpenBikeControl.", + "exportAction": "Exportar", + "failedToImportProfile": "No se pudo importar el perfil. Formato no válido.", + "failedToUpdate": "No se pudo actualizar: {error}", + "@failedToUpdate": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "firmware": "Firmware", + "forceCloseToUpdate": "Fuerza el cierre de la app para usar la nueva versión", + "full": "LLENO", + "fullVersion": "Versión completa", + "fullVersionDescription": "La versión completa incluye:\n- Comandos ilimitados al día\n- Acceso a todas las actualizaciones futuras\n- ¡Sin suscripción! Solo un pago único :)", + "getSupport": "Obtener ayuda", + "goPro": "Hazte Pro", + "gotIt": "Entendido", + "grant": "Conceder", + "granted": "Concedido", + "helpRequested": "Ayuda solicitada para BikeControl v{version}", + "@helpRequested": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "howBikeControlUsesPermission": "¿Cómo usa BikeControl este permiso?", + "ignoredDevices": "Dispositivos ignorados", + "importAction": "Importar", + "importProfile": "Importar perfil", + "instructions": "Instrucciones", + "jsonData": "Datos JSON", + "justNow": "Justo ahora", + "keyboardAccess": "Acceso al teclado", + "lastSeen": "Última vez visto:", + "lastSynced": "Última sincronización:", + "latestVersion": "última: {version}", + "@latestVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "leaveAReview": "Dejar una reseña", + "letsAppConnectOverBluetooth": "Permite a {appName} conectarse a BikeControl a través de Bluetooth.", + "@letsAppConnectOverBluetooth": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsAppConnectOverNetwork": "Permite a {appName} conectarse directamente a través de la red. Seleccione BikeControl en la pantalla de conexión.", + "@letsAppConnectOverNetwork": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsGetYouSetUp": "Vamos a configurarlo", + "license": "Licencia", + "licenseStatus": "Estado de la licencia", + "loadScreenshotForPlacement": "Cargar captura del juego para colocar", + "logViewer": "Visor de registros", + "loggedInAsMail": "Has iniciado sesión como {email}", + "logout": "Cerrar sesión", + "logs": "Registros", + "logsAreAlsoAt": "Los registros también están en", + "logsHaveBeenCopiedToClipboard": "Los registros se han copiado al portapapeles", + "longPress": "pulsación\nlarga", + "longPressMode": "Modo de pulsación larga (en vez de repetir)", + "longTapExplanation": "toque 1: tecla abajo\ntoque 2: tecla arriba", + "mailSupportExplanation": "Dar soporte individual por correo me lleva mucho trabajo.\n\nSi puedes, usa Reddit, Facebook o GitHub para dudas y problemas, así toda la comunidad puede beneficiarse.", + "manageIgnoredDevices": "Administrar dispositivos ignorados", + "manageProfile": "Gestionar perfil", + "manageSubscription": "Gestionar suscripción", + "manageYourDevices": "Gestiona tus dispositivos", + "manualyControllingButton": "Controla {trainerApp} manualmente", + "mediaKeyDetectionTooltip": "Habilite esta opción para que BikeControl detecte controles remotos Bluetooth. \nPara ello, BikeControl debe funcionar como reproductor multimedia.", + "miuiDeviceDetected": "Dispositivo MIUI detectado", + "miuiDisableBatteryOptimization": "• Deshabilitar la optimización de la batería para BikeControl", + "miuiEnableAutostart": "• Habilitar el inicio automático para BikeControl", + "miuiEnsureProperWorking": "Para garantizar que BikeControl funcione correctamente:", + "miuiLockInRecentApps": "• Bloquear la aplicación en aplicaciones recientes", + "miuiWarningDescription": "Su dispositivo ejecuta MIUI, conocido por cerrar agresivamente los servicios en segundo plano y los servicios de accesibilidad.", + "moreInformation": "Más información", + "mustChooseAllowOrDeny": "Debes elegir entre Permitir o Denegar este permiso para continuar.", + "myWhooshDirectConnectAction": "Acción de MyWhoosh \"Link\"", + "myWhooshDirectConnection": " por ejemplo, usando MyWhoosh \"Link\"", + "myWhooshLinkConnected": "MyWhoosh \"Link\" conectado", + "myWhooshLinkDescriptionLocal": "Conectar directamente a MyWhoosh mediante el método MyWoosh \"Link\". Las acciones compatibles incluyen cambios de marcha, reacciones y dirección virtual, entre otras. La aplicación complementaria MyWhoosh Link NO debe estar ejecutándose simultáneamente.", + "myWhooshLinkInfo": "Si tienes algún problema, revisa la sección de solución de problemas. Muy pronto llegará un método de conexión mucho más fiable.", + "needHelpClickHelp": "¿Necesitas ayuda? Haz clic en el", + "needHelpDontHesitate": "botón de arriba y no dudes en ponerte en contacto con nosotros.", + "never": "Nunca", + "newConnectionMethodAnnouncement": "{trainerApp} pronto admitirá métodos de conexión mucho mejores y más fiables. Estate atento a las actualizaciones.", + "newCustomProfile": "Nuevo perfil personalizado", + "newProfileName": "Nombre del nuevo perfil", + "newVersionAvailable": "Nueva versión disponible", + "newVersionAvailableWithVersion": "Nueva versión disponible: {version}", + "@newVersionAvailableWithVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "newer": "MÁS NUEVO", + "newerSettingsAvailable": "Hay ajustes más recientes disponibles", + "next": "Siguiente", + "no": "No", + "noActionAssigned": "Sin acción asignada", + "noActionAssignedForButton": "No se pudo ejecutar {button}: no hay ninguna acción asignada", + "noConnectionMethodIsConnectedOrActive": "No hay ningún método de conexión conectado ni activo.", + "noConnectionMethodSelected": "No se ha seleccionado ningún método de conexión", + "noControllerConnected": "Ninguno conectado", + "noControllerUseCompanionMode": "¿No tienes controlador? Usa el modo Companion.", + "noDevicesRegistered": "No hay dispositivos registrados", + "noIgnoredDevices": "No hay dispositivos ignorados.", + "noTrainerSelected": "No has seleccionado ninguna app de entrenamiento", + "notConnected": "No conectado", + "notificationDescription": "Esto mantiene la app viva en segundo plano y te avisa cuando cambia la conexión con tus dispositivos.", + "ok": "Aceptar", + "only": "Solo", + "openBikeControlActions": "Acciones de OpenBikeControl", + "openBikeControlAnnouncement": "Buenas noticias: {trainerApp} es compatible con el protocolo OpenBikeControl, así que tendrás la mejor experiencia posible.", + "openBikeControlConnection": " por ejemplo, usando la conexión OpenBikeControl", + "otherConnectionMethods": "Otros métodos de conexión", + "pairingDescription": "El emparejamiento permite una personalización completa, pero es posible que no funcione en todos los dispositivos.", + "pairingInstructions": "En tu {targetName} accede a la configuración de Bluetooth y busca BikeControl o el nombre de tu máquina. Es necesario emparejarla para usar el control remoto.", + "@pairingInstructions": { + "placeholders": { + "targetName": { + "type": "String" + } + } + }, + "pairingInstructionsIOS": "En tu iPad, ve a Ajustes > Accesibilidad > Táctil > AssistiveTouch > Dispositivos de puntero > Dispositivos y vincula tu dispositivo. Asegúrate de que AssistiveTouch esté activado.", + "pasteExportedJsonData": "Pega abajo los datos JSON exportados:", + "pathCopiedToClipboard": "La ruta se ha copiado al portapapeles", + "paywall_amountOfActions": "Cantidad de acciones", + "paywall_billedAtPricemo": "Facturado en {price} /mes.", + "paywall_billedAtYearly": "Facturado en{cost} /año.", + "paywall_configure3ActionsPerButton": "Configurar 3 acciones por botón", + "paywall_connectToYourTrainer": "Conéctate con tu entrenador", + "paywall_controlYourDeviceMusic": "Controla tu dispositivo/música", + "paywall_createScreenshots": "Crear capturas de pantalla", + "paywall_monthly": "Mensual", + "paywall_startAnyCommandShortcutWithAnyButton": "Inicie cualquier comando/acceso directo con cualquier botón", + "paywall_supportDevelopmentOfNewFeaturesDevicesAndMore": "Apoyar el desarrollo de nuevas funciones/dispositivos y más", + "paywall_synchronizeAndBackup": "Sincronizar y realizar copias de seguridad", + "paywall_useBikecontrolOnAllPlatforms": "Utilice BikeControl en todas las plataformas", + "paywall_yearly": "Anual", + "permissionsRequired": "Para que BikeControl busque dispositivos cercanos y le notifique cuando cambie la conexión, habilite los siguientes permisos:", + "platformNotSupported": "Esta plataforma {platform} no es compatible :(", + "@platformNotSupported": { + "placeholders": { + "platform": { + "type": "String" + } + } + }, + "platformRestrictionNotSupported": "Debido a restricciones de la plataforma, esta opción no es compatible.", + "platformRestrictionOtherDevicesOnly": "Debido a las restricciones de la plataforma, solo está separado el control de{appName} en otros dispositivos.", + "@platformRestrictionOtherDevicesOnly": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "playPause": "Reproducir/Pausar", + "pleaseSelectAConnectionMethodFirst": "Primero selecciona un método de conexión en los ajustes de la app de entrenamiento.", + "predefinedAction": "Acción predefinida de {appName}", + "@predefinedAction": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "pressButtonOnClickDevice": "Pulsa un botón en tu dispositivo Click", + "pressKeyToAssign": "Pulsa una tecla en tu teclado para asignarla a {buttonName}", + "@pressKeyToAssign": { + "placeholders": { + "buttonName": { + "type": "String" + } + } + }, + "previous": "Anterior", + "privacyPolicy": "política de privacidad", + "profileExportedToClipboard": "El perfil \"{profileName}\" se ha exportado al portapapeles", + "@profileExportedToClipboard": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "profileImportedSuccessfully": "Perfil importado correctamente", + "profileName": "Nombre del perfil", + "purchase": "Comprar", + "recommendedConnectionMethods": "Métodos de conexión recomendados", + "registerCurrentDevice": "Registrar dispositivo actual", + "registeredDevices": "Dispositivos registrados", + "removeFromIgnoredList": "Eliminar de la lista de ignorados", + "removeOnePressAction": "El {device} no es compatible con la pulsación prolongada de forma nativa. Para usar este tipo, es necesario eliminar la acción de pulsación prolongada.", + "rename": "Renombrar", + "renameProfile": "Renombrar perfil", + "replaceExisting": "Reemplazar existente", + "requirement": "Requisito", + "reset": "Restablecer", + "restart": "Reiniciar", + "restorePurchaseInfo": "Haz clic en el botón de arriba y luego en \"Restore Purchase\". Si tienes algún problema, ponte en contacto conmigo directamente.", + "restorePurchases": "Restaurar compras", + "revoke": "Revocar", + "revoked": "Revocado", + "runAppOnPlatformRemotely": "Ejecutar{appName} en{platform} y controlarlo remotamente desde este dispositivo{preferredConnection} .", + "@runAppOnPlatformRemotely": { + "placeholders": { + "appName": { + "type": "String" + }, + "platform": { + "type": "String" + }, + "preferredConnection": { + "type": "String" + } + } + }, + "runAppOnThisDevice": "Ejecutar {appName} en este dispositivo.", + "@runAppOnThisDevice": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "save": "Guardar", + "scan": "ESCANEAR", + "scanningForDevices": "Buscando dispositivos... Asegúrese de que estén encendidos, dentro del alcance y no conectados a otro dispositivo.", + "selectADeviceToSyncFrom": "Selecciona un dispositivo del que sincronizar", + "selectKeymap": "Seleccionar mapa de teclas", + "selectTargetWhereAppRuns": "Seleccione en qué dispositivo se usa {appName}", + "@selectTargetWhereAppRuns": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "selectTrainerApp": "Seleccionar la app de entrenamiento", + "selectTrainerAppAndTarget": "Seleccionar app de entrenamiento y dispositivo de destino", + "selectTrainerAppPlaceholder": "Seleccionar la app de entrenamiento", + "setting": "Ajuste", + "settingsAppliedFromId": "Ajustes aplicados desde {deviceId}...", + "settingsDownloadedFromServer": "Ajustes descargados del servidor", + "settingsSyncedSuccessfully": "Ajustes sincronizados correctamente", + "settingsSynchronization": "Sincronización de ajustes", + "setupComplete": "Configuración completada", + "setupTrainer": "Configurar el rodillo", + "share": "Compartir", + "showDonation": "Muestra tu apoyo con una donación", + "showSupportedControllers": "Mostrar controles compatibles", + "signInToSyncYourSubscriptionAndManageDevices": "Inicia sesión para sincronizar tu suscripción y gestionar dispositivos", + "signal": "Señal", + "simulateButtons": "Controles de entrenamiento", + "simulateKeyboardShortcut": "Pulsa una tecla del teclado", + "simulateMediaKey": "Controlar reproducción de música", + "simulateTouch": "Haz clic en cualquier botón de la pantalla", + "skip": "Omitir", + "stop": "Detener", + "supportedActions": "Acciones compatibles", + "syncSettings": "Sincronizar ajustes", + "syncStatus": "Estado de sincronización", + "synchronizeAcrossDevices": "Sincronizar entre dispositivos", + "synchronizeYourAppSettingsAcrossAllYourDevicesThisIncludes": "Sincroniza los ajustes de la app entre todos tus dispositivos. Esto incluye tus mapas de teclas, configuraciones de botones y preferencias.", + "targetOtherDevice": "Otro dispositivo", + "targetThisDevice": "Este dispositivo", + "theFollowingPermissionsRequired": "Se requieren los siguientes permisos:", + "touchAreaInstructions": "1. Haz una captura de pantalla dentro del juego de tu app (por ejemplo, en MyWhoosh) en orientación horizontal\n2. Carga la captura con el botón de abajo\n3. La app se pondrá automáticamente en horizontal para que el mapeo sea preciso\n4. Pulsa un botón en tu dispositivo Click para crear una zona táctil\n5. Arrastra las zonas táctiles a la posición deseada en la captura\n6. Guarda y cierra esta pantalla", + "touchSimulationForegroundMessage": "Para simular toques, la app necesita permanecer en primer plano.", + "trainer": "Rodillo", + "trialDaysRemaining": "Quedan {trialDaysRemaining} días", + "trialExpired": "El periodo de prueba ha caducado. Los comandos están limitados a {dailyCommandLimit} por día.", + "trialPeriodActive": "Periodo de prueba activo: quedan {trialDaysRemaining} días", + "trialPeriodDescription": "Disfruta de comandos ilimitados durante el periodo de prueba. Después, los comandos estarán limitados a {dailyCommandLimit} al día.", + "troubleshootingGuide": "Guía de solución de problemas", + "tryingToConnectAgain": "Intentando conectar de nuevo...", + "unassignAction": "Quitar asignación de la acción", + "unlimited": "Ilimitado", + "unlockAllFeaturesWithPro": "Desbloquea todas las funciones con Pro", + "unlockFullVersion": "Desbloquear versión completa", + "unlockTheFullVersionOrGoPro": "Desbloquea la versión completa o hazte Pro", + "unlock_bikecontrolAndZwiftNetwork": "BikeControl y Zwift deben estar en la misma red o en el mismo dispositivo. Puede tardar unos segundos en aparecer.", + "unlock_confirmByPressingAButtonOnYourDevice": "Confirma pulsando un botón en tu dispositivo.", + "unlock_connectToBikecontrol": "Conéctate a \"BikeControl\", por ejemplo como fuente de potencia", + "unlock_deviceIsCurrentlyLocked": "El dispositivo está bloqueado en este momento", + "unlock_isnowunlocked": "{device} ya está desbloqueado", + "unlock_markAsUnlocked": "Marcar como desbloqueado", + "unlock_notWorking": "¿No funciona?", + "unlock_openZwift": "Abre Zwift (no Companion) en este u otro dispositivo", + "unlock_unlockManually": "Desbloquear manualmente", + "unlock_unlockNow": "Desbloquear ahora", + "unlock_unlockedUntilAroundDate": "Desbloqueado hasta aproximadamente {date}", + "unlock_waitingForZwift": "Esperando a que Zwift desbloquee tu dispositivo...", + "unlock_youCanNowCloseZwift": "Ya puedes cerrar Zwift y volver a BikeControl.", + "unlock_yourTrialPhaseHasExpired": "Tu periodo de prueba ha terminado. Compra la versión completa para desbloquear la función cómoda de desbloqueo :)", + "unlock_yourZwiftClickMightBeUnlockedAlready": "Puede que tu Zwift Click ya esté desbloqueado.", + "unlockingNotPossible": "De momento todavía no es posible desbloquearlo, así que disfruta del uso ilimitado mientras tanto.", + "update": "Actualizar", + "uploadSettings": "Subir ajustes", + "useCustomKeymapForButton": "Usa un mapa de teclas personalizado para que sea compatible con el", + "version": "Versión {version}", + "@version": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "viewDetailedInstructions": "Ver instrucciones detalladas", + "volumeDown": "Bajar volumen", + "volumeUp": "Subir volumen", + "waiting": "Esperando...", + "waitingForConnectionKickrBike": "Esperando conexión. Elige KICKR BIKE PRO en el menú de emparejamiento del controlador de {appName}.", + "@waitingForConnectionKickrBike": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "whatsNew": "Novedades", + "whyPermissionNeeded": "¿Por qué se necesita este permiso?", + "windowsSubscriptionsRequireYouToBeLoggedIn": "Las suscripciones de Windows requieren que inicies sesión", + "yes": "Sí", + "yourDevices": "Tus dispositivos", + "yourSettingsAreAutomaticallySyncedWhenYouMakeChangesTap": "Tus ajustes se sincronizan automáticamente cuando haces cambios. Toca un dispositivo para aplicar sus ajustes.", + "zwiftControllerAction": "Acción del controlador Zwift", + "zwiftControllerDescription": "Hace que BikeControl actúe como un controlador compatible con Zwift." +} \ No newline at end of file diff --git a/lib/i10n/intl_fr.arb b/lib/i10n/intl_fr.arb new file mode 100644 index 000000000..96c013876 --- /dev/null +++ b/lib/i10n/intl_fr.arb @@ -0,0 +1,532 @@ +{ + "accessibilityDescription": "BikeControl a besoin d'une autorisation d'accessibilité pour contrôler vos applications d'entraînement.", + "accessibilityDisclaimer": "BikeControl n'accédera à votre écran que pour effectuer les gestes que vous aurez configurés. Aucune autre fonctionnalité d'accessibilité ni aucune autre information personnelle ne sera accessible.", + "accessibilityReasonControl": "• Pour vous permettre de contrôler des applications telles que MyWhoosh, TrainingPeaks et d'autres à l'aide de vos appareils Zwift.", + "accessibilityReasonTouch": "• Pour simuler des gestes tactiles sur votre écran afin de contrôler les applications d'entraînement", + "accessibilityReasonWindow": "• Pour détecter quelle fenêtre de l'application d'entraînement est actuellement active", + "accessibilityServiceExplanation": "BikeControl doit utiliser l'API AccessibilityService d'Android pour fonctionner correctement.", + "accessibilityServiceNotRunning": "Le service d'accessibilité ne fonctionne pas.\nSuivez les instructions à l'adresse", + "accessibilityServicePermissionRequired": "Autorisation requise pour le service d'accessibilité", + "accessibilityUsageGestures": "• Lorsque vous appuyez sur les boutons de vos appareils Zwift Click, Zwift Ride ou Zwift Play, BikeControl simule des gestes tactiles à des emplacements spécifiques de l'écran.", + "accessibilityUsageMonitor": "• L'application surveille quelle fenêtre de l'application d'entraînement est active afin de s'assurer que les gestes sont envoyés à la bonne application.", + "accessibilityUsageNoData": "• Aucune donnée personnelle n'est consultée ou collectée par le biais de ce service.", + "accessories": "Accessoires", + "account": "Compte", + "actAsBluetoothKeyboard": "Fonctionne comme un clavier Bluetooth", + "action": "Action", + "additionalTriggerAssignment": "Affectation de déclencheur supplémentaire", + "adjustControllerButtons": "Ajuster les boutons de la manette", + "afterDate": "Après {date}", + "allow": "Permettre", + "allowAccessibilityService": "Autoriser le service d'accessibilité", + "allowBluetoothConnections": "Autoriser les connexions Bluetooth", + "allowBluetoothScan": "Autoriser la recherche Bluetooth", + "allowLocationForBluetooth": "Autoriser la localisation pour que la recherche Bluetooth fonctionne", + "allowPersistentNotification": "Autoriser les notifications", + "allowsRunningInBackground": "Permet à BikeControl de continuer à fonctionner en arrière-plan", + "alreadyBoughtTheApp": "Vous avez déjà acheté l'application ? Vous n'avez pas besoin de payer BikeControl une seconde fois. En raison de difficultés techniques, il est impossible de déterminer si l'application a déjà été achetée. \n\nSaisissez votre identifiant d'achat Play Store (par exemple : GPA.3356-1337-1338-1339) pour débloquer la version complète. Si vous ne le trouvez pas, veuillez me contacter directement.", + "alreadyBoughtTheAppPreviously": "Vous avez déjà acheté l'application ?", + "androidSystemAction": "Actions système Android", + "anotherTriggerIsAlreadyAssignedForThisButton": "Un autre déclencheur est déjà associé à ce bouton. Passez à la version Pro pour conserver plusieurs types de déclencheurs, ou remplacez l'attribution de déclencheur existante et poursuivez {triggerTitle} .", + "appIdActions": "{appId} actions", + "@appIdActions": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "applyNow": "Postulez maintenant", + "asAFinalStepYoullChooseHowToConnectTo": "Dernière étape : vous choisirez comment vous connecter à {trainerApp} .", + "battery": "Batterie", + "beforeDate": "Avant {date}", + "bluetoothAdvertiseAccess": "Accès à la publicité Bluetooth", + "bluetoothKeyboardExplanation": "Vous pourrez ainsi utiliser votre téléphone comme clavier Bluetooth pour contrôler les applications compatibles. Une fois l'appairage effectué, vous pourrez utiliser les boutons de votre contrôleur de vélo pour envoyer des commandes à l'application.", + "bluetoothTurnedOn": "Bluetooth activé", + "browserNotSupported": "Ce navigateur ne prend pas en charge Web Bluetooth et la plateforme n'est pas prise en charge :(", + "button": "bouton.", + "buyFullVersion": "Acheter la version complète", + "bySigningInYouAgreeToOur": "En vous connectant, vous acceptez nos conditions générales {privacyPolicy} .", + "@bySigningInYouAgreeToOur": { + "placeholders": { + "privacyPolicy": { + "type": "String" + } + } + }, + "cancel": "Annuler", + "changelog": "Journal des modifications", + "checkMyWhooshConnectionScreen": "Vérifiez l'écran de connexion dans MyWhoosh pour voir si « Link » est connecté.", + "chooseAnotherScreenshot": "Choisissez une autre capture d'écran", + "chooseBikeControlInConnectionScreen": "Sélectionnez BikeControl dans l'écran de connexion.", + "clickAButtonOnYourController": "Cliquez sur un bouton de votre manette pour modifier son action ou appuyez sur l'icône de modification.", + "clickV2EventInfo": "Votre Click V2 n'envoie peut-être plus les événements liés aux boutons. Vérifiez en appuyant sur quelques boutons et voyez s'ils apparaissent dans BikeControl.", + "clickV2Instructions": "Pour que ton Zwift Click V2 marche super bien, tu dois le connecter à l'appli Zwift une fois par jour.\nSi tu ne le fais pas, le Click V2 s'arrêtera de fonctionner au bout d'une minute.\n\n1. Ouvre l'appli Zwift (pas Companion!)\n2. Connecte-toi (pas besoin d'abonnement) et ouvre l'écran de connexion des appareils.\n3. Connecte ton home trainer, puis connecte le Zwift Click V2.\n4. Ferme l'appli Zwift et reconnecte-toi dans BikeControl.", + "close": "Fermer", + "commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} commandes restantes aujourd'hui", + "configuration": "Configuration", + "connectControllerToPreview": "Connectez un périphérique de contrôle pour prévisualiser et personnaliser la configuration des touches.", + "connectControllers": "Connectez les contrôleurs", + "connectDirectlyOverNetwork": "Connexion directe via le réseau", + "connectToTrainerApp": "Se connecter à l'application Trainer", + "connectUsingBluetooth": "Se connecter via Bluetooth", + "connectUsingMyWhooshLink": "Connectez-vous à l'aide de MyWhoosh « Link »", + "connected": "Connecté", + "connectedControllers": "Contrôleurs connectés", + "connectedTo": "Connecté à {appId}", + "@connectedTo": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "connection": "Connexion", + "continueAction": "Continuer", + "controlAppUsingModes": "Contrôle {appName} en utilisant{modes}", + "@controlAppUsingModes": { + "placeholders": { + "appName": { + "type": "String" + }, + "modes": { + "type": "String" + } + } + }, + "controllerConnectedClickButton": "Super ! Votre manette est connectée. Cliquez sur un bouton de votre manette pour continuer.", + "controllers": "Contrôleurs", + "couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Impossible d'effectuer {button}: Aucun clavier défini", + "create": "Créer", + "createNewKeymap": "Créer un nouveau clavier", + "createNewProfileByDuplicating": "Créer un nouveau profil personnalisé en dupliquant «{profileName} ».", + "@createNewProfileByDuplicating": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "createdNewCustomProfile": "Création d'un nouveau profil personnalisé : {profileName}", + "@createdNewCustomProfile": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "currentDeviceIsNotRegistered": "L'appareil actuel n'est pas enregistré.", + "currentPlan": "Plan actuel", + "customizeControllerButtons": "Personnalisez les boutons de la manette pour {appName}", + "@customizeControllerButtons": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "customizeKeymapHint": "Personnalisez la configuration des touches si vous rencontrez des problèmes (par exemple, une sortie clavier incorrecte ou un placement des touches mal aligné).", + "dailyCommandLimitReachedNotification": "Limite de commandes journalières atteinte pour aujourd'hui. Passez à la version supérieure pour débloquer la version complète avec commandes illimitées.", + "dailyLimitReached": "Limite journalière atteinte ({dailyCommandCount} /{dailyCommandLimit} utilisé)", + "delete": "Supprimer", + "deleteProfile": "Supprimer le profil", + "deleteProfileConfirmation": "Êtes-vous sûr de vouloir supprimer «{profileName} » ? Cette action ne peut pas être annulée.", + "@deleteProfileConfirmation": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "deny": "Refuser", + "deviceButton": "{deviceName} bouton", + "@deviceButton": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "deviceLimitReached": "Limite d'appareils atteinte pour {platform}. Révoquez les autres appareils pour en enregistrer un nouveau.", + "devicesActive": "{active} actif", + "disconnectDevices": "Déconnecter les appareils", + "disconnected": "Déconnecté", + "donateByBuyingFromPlayStore": "en achetant l'application sur le Play Store", + "donateViaCreditCard": "par carte bancaire, Google Pay, Apple Pay et autres", + "donateViaPaypal": "via PayPal", + "download": "Télécharger", + "downloadLatestSettings": "Télécharger les derniers paramètres", + "dragToReposition": "Faites glisser pour repositionner", + "duplicate": "Double", + "enableAutoRotation": "Activez la rotation automatique sur votre appareil pour vous assurer que l'application fonctionne correctement.", + "enableBluetooth": "Activer le Bluetooth", + "enableItInTheConnectionSettingsFirst": "Activez-le d'abord dans les paramètres de connexion.", + "enableKeyboardAccessMessage": "Activez l'accès au clavier dans l'écran suivant pour BikeControl. Si vous ne voyez pas BikeControl, veuillez l'ajouter manuellement.", + "enableKeyboardMouseControl": "BikeControl enverra des actions de souris ou de clavier pour contrôler {appName} .", + "@enableKeyboardMouseControl": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "enableLocalConnectionMethodFirst": "Activez d'abord le mode de connexion locale.", + "enableMediaKeyDetection": "Activer la détection des touches multimédias", + "enableMywhooshLinkInTheConnectionSettingsFirst": "Activez d'abord MyWhoosh Link dans les paramètres de connexion.", + "enablePairingProcess": "Fonctionne comme une souris Bluetooth", + "enablePermissions": "Activer les autorisations", + "enableSteeringWithPhone": "Activez la direction avec les capteurs de votre téléphone", + "enableVibrationFeedback": "Activer le retour haptique par vibration lors du changement de vitesse", + "enableZwiftControllerBluetooth": "Activer le contrôleur Zwift (Bluetooth)", + "enableZwiftControllerNetwork": "Activer le contrôleur Zwift (réseau)", + "errorStartingMyWhooshLink": "Erreur lors du démarrage du serveur MyWhoosh Link. Veuillez vous assurer que l'application « MyWhoosh Link » n'est pas déjà en cours d'exécution sur cet appareil.", + "errorStartingOpenBikeControlBluetoothServer": "Erreur lors du démarrage du serveur Bluetooth OpenBikeControl.", + "errorStartingOpenBikeControlServer": "Erreur lors du démarrage du serveur OpenBikeControl.", + "exportAction": "Exporter", + "failedToImportProfile": "Échec de l'importation du profil. Format non valide.", + "failedToUpdate": "Échec de la mise à jour: {error}", + "@failedToUpdate": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "firmware": "Firmware", + "forceCloseToUpdate": "Fermez de force l'application pour utiliser la nouvelle version.", + "full": "COMPLET", + "fullVersion": "Version complète", + "fullVersionDescription": "La version complète comprend:\n- Commandes illimitées par jour\n- Accès à toutes les mises à jour futures \n- Aucun abonnement ! Un paiement unique :)", + "getSupport": "Obtenir de l'aide", + "goPro": "Allez Pro", + "gotIt": "Compris !", + "grant": "Accorder", + "granted": "Accordé", + "helpRequested": "Aide demandée pour BikeControl v{version}", + "@helpRequested": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "howBikeControlUsesPermission": "Comment BikeControl utilise-t-il cette autorisation ?", + "ignoredDevices": "Appareils ignorés", + "importAction": "Importer", + "importProfile": "Profil d'importation", + "instructions": "Mode d'emploi", + "jsonData": "Données JSON", + "justNow": "En ce moment", + "keyboardAccess": "Accès clavier", + "lastSeen": "Dernière connexion :", + "lastSynced": "Dernière synchronisation :", + "latestVersion": "dernier: {version}", + "@latestVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "leaveAReview": "Laisser un avis", + "letsAppConnectOverBluetooth": "Connectons {appName} à BikeControl via Bluetooth.", + "@letsAppConnectOverBluetooth": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsAppConnectOverNetwork": "Permet à {appName} de se connecter directement via le réseau. Sélectionnez BikeControl dans l'écran de connexion.", + "@letsAppConnectOverNetwork": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsGetYouSetUp": "On va vous installer !", + "license": "Licence", + "licenseStatus": "Statut de la licence", + "loadScreenshotForPlacement": "Charger une capture d'écran du jeu pour le placement", + "logViewer": "Visionneuse de journaux", + "loggedInAsMail": "Connecté en tant que {email}", + "logout": "Déconnexion", + "logs": "Journaux", + "logsAreAlsoAt": "Les journaux sont également à", + "logsHaveBeenCopiedToClipboard": "Les journaux ont été copiés dans le presse-papiers.", + "longPress": "long\nappui", + "longPressMode": "Mode appui long (par rapport à la répétition)", + "longTapExplanation": "Appuyer 1: enfoncée\nAppuyer 2: relâchée", + "mailSupportExplanation": "Répondre à tout le monde individuellement par e-mail, ça me prend beaucoup de temps.\n\nSi t'as des questions ou des problèmes, pense à utiliser Reddit, Facebook ou GitHub pour que tout le monde puisse en profiter.", + "manageIgnoredDevices": "Gérer les périphériques ignorés", + "manageProfile": "Gérer mon profil", + "manageSubscription": "Gérer mon abonnement", + "manageYourDevices": "Gérez vos appareils", + "manualyControllingButton": "Contrôle {trainerApp} manuellement!", + "mediaKeyDetectionTooltip": "Activez cette option pour permettre à BikeControl de détecter les télécommandes Bluetooth. Pour ce faire, BikeControl doit fonctionner comme un lecteur multimédia.", + "miuiDeviceDetected": "Appareil MIUI détecté", + "miuiDisableBatteryOptimization": "• Désactivez l'optimisation de la batterie pour BikeControl.", + "miuiEnableAutostart": "• Activer le démarrage automatique pour BikeControl", + "miuiEnsureProperWorking": "Pour garantir le bon fonctionnement de BikeControl :", + "miuiLockInRecentApps": "• Verrouiller l'application dans les applications récentes", + "miuiWarningDescription": "Votre appareil fonctionne sous MIUI, qui est connu pour supprimer de manière agressive les services d'arrière-plan et les services d'accessibilité.", + "moreInformation": "Plus d'informations", + "mustChooseAllowOrDeny": "Vous devez choisir d'autoriser ou de refuser cette autorisation pour continuer.", + "myWhooshDirectConnectAction": "Action «Link» de MyWhoosh", + "myWhooshDirectConnection": " par exemple en utilisant MyWhoosh «Link».", + "myWhooshLinkConnected": "MyWhoosh « Link » connecté", + "myWhooshLinkDescriptionLocal": "Connectez-vous directement à MyWhoosh via la méthode « Link ». Consultez les instructions pour vérifier la compatibilité de la connexion. L’application « MyWhoosh Link » ne doit pas être active simultanément.", + "myWhooshLinkInfo": "Si tu rencontres des problèmes, jette un œil à la section dépannage. Une méthode de connexion bien plus fiable sera bientôt disponible !", + "needHelpClickHelp": "Besoin d'aide ? Cliquez sur le", + "needHelpDontHesitate": "bouton en haut et n'hésitez pas à nous contacter.", + "never": "Jamais", + "newConnectionMethodAnnouncement": "{trainerApp} prendra bientôt en charge des méthodes de connexion bien meilleures et plus fiables - restez à l'écoute pour les mises à jour !", + "newCustomProfile": "Nouveau profil personnalisé", + "newProfileName": "Nouveau nom de profil", + "newVersionAvailable": "Nouvelle version disponible", + "newVersionAvailableWithVersion": "Nouvelle version disponible: {version}", + "@newVersionAvailableWithVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "newer": "PLUS RÉCENT", + "newerSettingsAvailable": "Paramètres plus récents disponibles", + "next": "Suivant", + "no": "Non", + "noActionAssigned": "Aucune action assignée", + "noActionAssignedForButton": "Impossible d'effectuer {button}: Aucune action assignée", + "noConnectionMethodIsConnectedOrActive": "Aucune méthode de connexion n'est établie ou active.", + "noConnectionMethodSelected": "Aucune méthode de connexion choisie", + "noControllerConnected": "Aucun connecté", + "noControllerUseCompanionMode": "Pas de manette ? Utilisez le mode compagnon.", + "noDevicesRegistered": "Aucun appareil enregistré", + "noIgnoredDevices": "Aucun appareil ignoré.", + "noTrainerSelected": "Aucun Trainer sélectionné", + "notConnected": "Non connecté", + "notificationDescription": "Cela permet à l'application de rester active en arrière-plan et de vous informer lorsque la connexion à vos appareils change.", + "ok": "OK", + "only": "Seulement", + "openBikeControlActions": "Actions OpenBikeControl", + "openBikeControlAnnouncement": "Excellente nouvelle! {trainerApp} Il prend en charge le protocole OpenBikeControl, vous bénéficierez donc de la meilleure expérience possible !", + "openBikeControlConnection": " par exemple en utilisant la connexion OpenBikeControl", + "otherConnectionMethods": "Autres méthodes de connexion", + "pairingDescription": "Cela vous permettra d'utiliser votre téléphone comme une souris Bluetooth pour contrôler des applications. Une fois l'appairage effectué, vous pourrez utiliser les boutons de votre contrôleur de vélo pour envoyer des commandes de souris à l'application.", + "pairingInstructions": "Sur votre {targetName}, accédez aux paramètres Bluetooth et recherchez BikeControl ou le nom de votre appareil. L'appairage est nécessaire si vous souhaitez utiliser la fonction de commande à distance.", + "@pairingInstructions": { + "placeholders": { + "targetName": { + "type": "String" + } + } + }, + "pairingInstructionsIOS": "Sur votre iPad, allez dans Réglages > Accessibilité > Toucher > AssistiveTouch > Périphériques de pointage > Périphériques et appairez votre appareil. Assurez-vous que la fonction AssistiveTouch est activée.", + "pasteExportedJsonData": "Collez ci-dessous les données JSON exportées :", + "pathCopiedToClipboard": "Le chemin a été copié dans le presse-papiers.", + "paywall_amountOfActions": "Nombre d'actions", + "paywall_billedAtPricemo": "Facturé à {price} /mois.", + "paywall_billedAtYearly": "Facturé à {cost} /an.", + "paywall_configure3ActionsPerButton": "Configurer 3 actions par bouton", + "paywall_connectToYourTrainer": "Connectez avec votre entraîneur", + "paywall_controlYourDeviceMusic": "Contrôlez votre appareil / musique", + "paywall_createScreenshots": "Créer des captures d'écran", + "paywall_monthly": "Mensuel", + "paywall_startAnyCommandShortcutWithAnyButton": "Démarrez n'importe quelle commande/raccourci avec n'importe quel bouton", + "paywall_supportDevelopmentOfNewFeaturesDevicesAndMore": "Soutenir le développement de nouvelles fonctionnalités/appareils et plus encore", + "paywall_synchronizeAndBackup": "Synchronisation et sauvegarde", + "paywall_useBikecontrolOnAllPlatforms": "Utilisez BikeControl sur toutes les plateformes", + "paywall_yearly": "Annuel", + "permissionsRequired": "Pour que BikeControl puisse rechercher les appareils à proximité et vous informer en cas de changement de connexion, veuillez activer les autorisations suivantes:", + "platformNotSupported": "Cette {platform} n'est pas prise en charge :(", + "@platformNotSupported": { + "placeholders": { + "platform": { + "type": "String" + } + } + }, + "platformRestrictionNotSupported": "En raison des restrictions de la plateforme, ce scénario n'est pas pris en charge.", + "platformRestrictionOtherDevicesOnly": "En raison des restrictions de la plateforme, seul le contrôle de l'{appName} s sur d'autres appareils est pris en charge.", + "@platformRestrictionOtherDevicesOnly": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "playPause": "Lecture/Pause", + "pleaseSelectAConnectionMethodFirst": "Commence par choisir une méthode de connexion dans les paramètres du Trainer.", + "predefinedAction": "Action prédéfinie d'{appName}", + "@predefinedAction": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "pressButtonOnClickDevice": "Appuyez sur un bouton de votre appareil Click", + "pressKeyToAssign": "Appuyez sur une touche de votre clavier pour l'attribuer à {buttonName}", + "@pressKeyToAssign": { + "placeholders": { + "buttonName": { + "type": "String" + } + } + }, + "previous": "Précédent", + "privacyPolicy": "Politique de Confidentialité", + "profileExportedToClipboard": "Profil «{profileName} » exporté vers le presse-papiers", + "@profileExportedToClipboard": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "profileImportedSuccessfully": "Profil importé avec succès", + "profileName": "Nom du profil", + "purchase": "Achat", + "recommendedConnectionMethods": "Méthodes de connexion recommandées", + "registerCurrentDevice": "Enregistrer l'appareil actuel", + "registeredDevices": "Appareils enregistrés", + "removeFromIgnoredList": "Supprimer de la liste ignorée", + "removeOnePressAction": "Le{device} La fonction d'appui long n'est pas prise en charge nativement. Pour utiliser ce type d'appareil, il est nécessaire de désactiver cette fonction.", + "rename": "Rebaptiser", + "renameProfile": "Renommer le profil", + "replaceExisting": "Remplacer l'existant", + "requirement": "Exigence", + "reset": "Réinitialiser", + "restart": "Redémarrage", + "restorePurchaseInfo": "Cliquez sur le bouton ci-dessus, puis sur « Restaurer l’achat ». Veuillez me contacter directement en cas de problème.", + "restorePurchases": "Restaurer les achats", + "revoke": "Révoquer", + "revoked": "Révoqué", + "runAppOnPlatformRemotely": "Exécutez {appName} sur {platform} et contrôlez-le à distance depuis cet appareil{preferredConnection}.", + "@runAppOnPlatformRemotely": { + "placeholders": { + "appName": { + "type": "String" + }, + "platform": { + "type": "String" + }, + "preferredConnection": { + "type": "String" + } + } + }, + "runAppOnThisDevice": "Exécutez {appName} sur cet appareil.", + "@runAppOnThisDevice": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "save": "Sauvegarder", + "scan": "BALAYAGE", + "scanningForDevices": "Recherche d'appareils en cours... Assurez-vous qu'ils sont allumés, à portée et non connectés à un autre appareil.", + "selectADeviceToSyncFrom": "Sélectionnez un appareil à synchroniser depuis", + "selectKeymap": "Sélectionner le clavier", + "selectTargetWhereAppRuns": "Sélectionnez la cible où {appName} fonctionne sur", + "@selectTargetWhereAppRuns": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "selectTrainerApp": "Sélectionner l'application Trainer", + "selectTrainerAppAndTarget": "Sélectionnez l'application Trainer et l'appareil cible", + "selectTrainerAppPlaceholder": "Sélectionner l'application Trainer", + "setting": "Paramètre", + "settingsAppliedFromId": "Paramètres appliqués depuis {deviceId} ...", + "settingsDownloadedFromServer": "Paramètres téléchargés depuis le serveur", + "settingsSyncedSuccessfully": "Paramètres synchronisés avec succès", + "settingsSynchronization": "Synchronisation des paramètres", + "setupComplete": "Installation terminée !", + "setupTrainer": "Configurer le Trainer", + "share": "Partager", + "showDonation": "Exprimez votre reconnaissance en faisant un don", + "showSupportedControllers": "Afficher les manettes compatibles", + "signInToSyncYourSubscriptionAndManageDevices": "Connectez-vous pour synchroniser votre abonnement et gérer vos appareils.", + "signal": "Signal", + "simulateButtons": "Commandes de l'entraîneur", + "simulateKeyboardShortcut": "Appuyez sur une touche du clavier", + "simulateMediaKey": "Contrôler la lecture de musique", + "simulateTouch": "Cliquez sur n'importe quel bouton à l'écran.", + "skip": "Sauter", + "stop": "Arrêt", + "supportedActions": "Actions prises en charge", + "syncSettings": "Paramètres de synchronisation", + "syncStatus": "État de la synchronisation", + "synchronizeAcrossDevices": "Synchroniser entre les appareils", + "synchronizeYourAppSettingsAcrossAllYourDevicesThisIncludes": "Synchronisez les paramètres de votre application sur tous vos appareils. Cela inclut vos raccourcis clavier, la configuration des boutons et vos préférences.", + "targetOtherDevice": "Autre appareil", + "targetThisDevice": "Cet appareil", + "theFollowingPermissionsRequired": "Les autorisations suivantes sont requises :", + "touchAreaInstructions": "1. Créez une capture d'écran de votre application (par exemple dans MyWhoosh) en mode paysage.\n2. Chargez la capture d'écran à l'aide du bouton ci-dessous.\n3. L'application est automatiquement réglée en mode paysage pour un mappage précis.\n4. Appuyez sur un bouton de votre appareil Click pour créer une zone tactile.\n5. Faites glisser les zones tactiles vers la position souhaitée sur la capture d'écran.\n6. Enregistrez et fermez cet écran.", + "touchSimulationForegroundMessage": "Pour simuler les touches, l'application doit rester au premier plan.", + "trainer": "Trainer", + "trialDaysRemaining": "{trialDaysRemaining} jours restants", + "trialExpired": "Période d'essai expirée. Commandes limitées à {dailyCommandLimit} par jour.", + "trialPeriodActive": "Période d'essai active -{trialDaysRemaining} jours restants", + "trialPeriodDescription": "Profitez de commandes illimitées pendant votre période d'essai. Après la période d'essai, le nombre de commandes sera limité à {dailyCommandLimit} par jour.", + "troubleshootingGuide": "Questions et réponses", + "tryingToConnectAgain": "Tentative de reconnexion...", + "unassignAction": "Action de désaffectation", + "unlimited": "Illimité", + "unlockAllFeaturesWithPro": "Débloquez toutes les fonctionnalités avec la version Pro", + "unlockFullVersion": "Débloquer la version complète", + "unlockTheFullVersionOrGoPro": "Débloquez la version complète – ou passez à la version Pro", + "unlock_bikecontrolAndZwiftNetwork": "BikeControl et Zwift doivent être connectés au même réseau ou appareil. L'affichage peut prendre quelques secondes.", + "unlock_confirmByPressingAButtonOnYourDevice": "Confirmez en appuyant sur un bouton de votre appareil.", + "unlock_connectToBikecontrol": "Se connecter à « BikeControl », par exemple comme source d'alimentation", + "unlock_deviceIsCurrentlyLocked": "L'appareil est actuellement verrouillé.", + "unlock_isnowunlocked": "{device} est maintenant déverrouillé", + "unlock_markAsUnlocked": "Marquer comme déverrouillé", + "unlock_notWorking": "Ça ne fonctionne pas ?", + "unlock_openZwift": "Ouvrez Zwift (et non l'application Companion) sur cet appareil ou un autre.", + "unlock_unlockManually": "Déverrouiller manuellement", + "unlock_unlockNow": "Déverrouiller maintenant", + "unlock_unlockedUntilAroundDate": "Déverrouillé jusqu'à environ {date}", + "unlock_waitingForZwift": "En attente du déverrouillage de votre appareil par Zwift...", + "unlock_youCanNowCloseZwift": "Vous pouvez maintenant fermer Zwift et revenir à BikeControl.", + "unlock_yourTrialPhaseHasExpired": "Votre période d'essai est terminée. Veuillez acheter la version complète pour débloquer la fonction de déverrouillage simplifiée :)", + "unlock_yourZwiftClickMightBeUnlockedAlready": "Votre Zwift Click est peut-être déjà déverrouillé.", + "unlockingNotPossible": "Le déverrouillage n'est pas encore possible pour le moment, alors profitez d'une utilisation illimitée pour l'instant !", + "update": "Mise à jour", + "uploadSettings": "Paramètres de téléchargement", + "useCustomKeymapForButton": "Utilisez une configuration de touches personnalisée pour prendre en charge", + "version": "Version {version}", + "@version": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "viewDetailedInstructions": "Consultez les instructions détaillées", + "volumeDown": "Baisser le volume", + "volumeUp": "Augmenter le volume", + "waiting": "En attendant...", + "waitingForConnectionKickrBike": "En attente de connexion. Sélectionnez KICKR BIKE PRO dans le menu de couplage du contrôleur de l'{appName}.", + "@waitingForConnectionKickrBike": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "whatsNew": "Quoi de neuf", + "whyPermissionNeeded": "Pourquoi cette autorisation est-elle nécessaire ?", + "windowsSubscriptionsRequireYouToBeLoggedIn": "Les abonnements Windows nécessitent une connexion.", + "yes": "Oui", + "yourDevices": "Vos appareils", + "yourSettingsAreAutomaticallySyncedWhenYouMakeChangesTap": "Vos paramètres sont automatiquement synchronisés lorsque vous effectuez des modifications. Appuyez sur un appareil pour appliquer ses paramètres.", + "zwiftControllerAction": "Action du contrôleur Zwift", + "zwiftControllerDescription": "Permet à BikeControl de fonctionner comme une manette compatible avec Zwift." +} \ No newline at end of file diff --git a/lib/i10n/intl_it.arb b/lib/i10n/intl_it.arb new file mode 100644 index 000000000..84074914b --- /dev/null +++ b/lib/i10n/intl_it.arb @@ -0,0 +1,532 @@ +{ + "accessibilityDescription": "BikeControl necessita dell'autorizzazione di accessibilità per controllare le tue app di allenamento.", + "accessibilityDisclaimer": "BikeControl accederà al tuo schermo solo per eseguire i gesti da te configurati. Non accederà ad altre funzioni di accessibilità o a informazioni personali.", + "accessibilityReasonControl": "• Per consentirti di controllare app come MyWhoosh, TrainingPeaks e altre utilizzando i tuoi dispositivi Zwift", + "accessibilityReasonTouch": "• Per simulare i gesti touch sullo schermo per controllare le app di allenamento", + "accessibilityReasonWindow": "• Per rilevare quale finestra dell'app di allenamento è attualmente attiva", + "accessibilityServiceExplanation": "Per funzionare correttamente, BikeControl deve utilizzare l'API AccessibilityService di Android.", + "accessibilityServiceNotRunning": "Il servizio di accessibilità non è in esecuzione. Seguire le istruzioni su", + "accessibilityServicePermissionRequired": "Autorizzazione al servizio di accessibilità richiesta", + "accessibilityUsageGestures": "• Quando premi i pulsanti sui tuoi dispositivi Zwift Click, Zwift Ride o Zwift Play, BikeControl simula i gesti touch in posizioni specifiche dello schermo", + "accessibilityUsageMonitor": "• L'applicazione monitora quale finestra dell'app di allenamento è attiva per garantire che i gesti vengano inviati all'app corretta", + "accessibilityUsageNoData": "• Nessun dato personale viene raccolto o consultato tramite questo servizio", + "accessories": "Accessori", + "account": "Account", + "actAsBluetoothKeyboard": "Funziona come tastiera Bluetooth", + "action": "Azione", + "additionalTriggerAssignment": "Assegnazione di trigger aggiuntiva", + "adjustControllerButtons": "Regola i pulsanti del controller", + "afterDate": "Dopo il {date}", + "allow": "Consenti", + "allowAccessibilityService": "Consenti servizio di accessibilità", + "allowBluetoothConnections": "Consenti connessioni Bluetooth", + "allowBluetoothScan": "Consenti scansione Bluetooth", + "allowLocationForBluetooth": "Consentire in modo che la scansione Bluetooth funzioni", + "allowPersistentNotification": "Consenti notifiche", + "allowsRunningInBackground": "Consente a BikeControl di continuare a funzionare in background", + "alreadyBoughtTheApp": "Hai già acquistato l'app? Non devi più pagare per BikeControl. A causa di problemi tecnici, non è possibile determinare se l'app è già stata acquistata.\n\nInserisci il tuo ID di acquisto sul Play Store (ad esempio GPA.3356-1337-1338-1339) per sbloccare la versione completa. Se non riesci a trovarlo, contattami direttamente.", + "alreadyBoughtTheAppPreviously": "Hai già acquistato l'app in precedenza?", + "androidSystemAction": "Azione del sistema Android", + "anotherTriggerIsAlreadyAssignedForThisButton": "Un altro trigger è già assegnato a questo pulsante. Passa a Pro per mantenere più tipi di trigger o sostituisci l'assegnazione del trigger esistente e continua con {triggerTitle} .", + "appIdActions": "{appId} azioni", + "@appIdActions": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "applyNow": "Candidati ora", + "asAFinalStepYoullChooseHowToConnectTo": "Come ultimo passaggio sceglierai come connetterti a {trainerApp} .", + "battery": "Batteria", + "beforeDate": "Prima del {date}", + "bluetoothAdvertiseAccess": "Accesso pubblicitario Bluetooth", + "bluetoothKeyboardExplanation": "Questo ti permetterà di usare il tuo telefono come tastiera Bluetooth per controllare le app compatibili. Una volta associato, potrai usare i pulsanti del controller della tua bici per inviare input dalla tastiera all'app.", + "bluetoothTurnedOn": "Bluetooth attivato", + "browserNotSupported": "Questo browser non supporta il Web Bluetooth e la piattaforma non è supportata :(", + "button": "pulsante.", + "buyFullVersion": "Acquista la versione completa", + "bySigningInYouAgreeToOur": "Accedendo, accetti i nostri {privacyPolicy} .", + "@bySigningInYouAgreeToOur": { + "placeholders": { + "privacyPolicy": { + "type": "String" + } + } + }, + "cancel": "Cancella", + "changelog": "Registro delle modifiche", + "checkMyWhooshConnectionScreen": "Controlla la schermata di connessione in MyWhoosh per vedere se è connesso.", + "chooseAnotherScreenshot": "Scegli un altro screenshot", + "chooseBikeControlInConnectionScreen": "Selezionare BikeControl nella schermata di connessione.", + "clickAButtonOnYourController": "Fai clic su un pulsante del controller per modificarne l'azione oppure tocca l'icona di modifica.", + "clickV2EventInfo": "Il tuo Click V2 potrebbe non inviare più eventi tramite i pulsanti. Verifica toccando alcuni pulsanti e verifica se sono visibili in BikeControl.", + "clickV2Instructions": "Per far funzionare al meglio il tuo Zwift Click V2, dovresti collegarlo all'app Zwift prima di ogni sessione di allenamento. In caso contrario, il Click V2 smetterà di funzionare dopo un minuto. \n\n1. Apri l'app Zwift (non il Companion!)\n2. Accedi (non richiesto un abbonamento attivo) e apri la schermata di connessione del dispositivo \n3. Collega il tuo trainer, quindi collega lo Zwift Click V2, verifica che i comandi del controller funzionino \n4. Chiudi nuovamente l'app Zwift e riconnettiti a BikeControl", + "close": "Chiudi", + "commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} comandi rimanenti oggi", + "configuration": "Configurazione", + "connectControllerToPreview": "Collega un dispositivo controller per visualizzare in anteprima e personalizzare la mappa dei tasti.", + "connectControllers": "Connetti i controller", + "connectDirectlyOverNetwork": "Connettiti direttamente tramite la rete", + "connectToTrainerApp": "Connettiti all'app di allenamento", + "connectUsingBluetooth": "Connettiti tramite Bluetooth", + "connectUsingMyWhooshLink": "Connettiti tramite MyWhoosh \"Link\"", + "connected": "Connesso", + "connectedControllers": "Controller connessi", + "connectedTo": "Connesso a{appId}", + "@connectedTo": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "connection": "Connessione", + "continueAction": "Continua", + "controlAppUsingModes": "Controllare{appName} usando{modes}", + "@controlAppUsingModes": { + "placeholders": { + "appName": { + "type": "String" + }, + "modes": { + "type": "String" + } + } + }, + "controllerConnectedClickButton": "Ottimo! Il tuo controller è connesso. Clicca su un pulsante del controller per continuare.", + "controllers": "Controller", + "couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Non è stato possibile eseguire{button} : Nessuna mappa dei tasti impostata", + "create": "Crea", + "createNewKeymap": "Crea una nuova mappa dei tasti", + "createNewProfileByDuplicating": "Crea un nuovo profilo personalizzato duplicando \"{profileName}\"", + "@createNewProfileByDuplicating": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "createdNewCustomProfile": "Creato un nuovo profilo personalizzato:{profileName}", + "@createdNewCustomProfile": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "currentDeviceIsNotRegistered": "Il dispositivo corrente non è registrato", + "currentPlan": "Piano attuale", + "customizeControllerButtons": "Personalizza i pulsanti del controller per{appName}", + "@customizeControllerButtons": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "customizeKeymapHint": "Personalizza la mappa dei tasti, se riscontri problemi (ad esempio, output della tastiera errato o posizionamenti del tocco non allineati)", + "dailyCommandLimitReachedNotification": "Limite giornaliero di comandi raggiunto per oggi. Esegui l'upgrade per sbloccare la versione completa con comandi illimitati.", + "dailyLimitReached": "Limite giornaliero raggiunto ({dailyCommandCount} /{dailyCommandLimit} usato)", + "delete": "Cancella", + "deleteProfile": "Elimina profilo", + "deleteProfileConfirmation": "Sei sicuro di voler eliminare \"{profileName}\" ? Questa azione non può essere annullata.", + "@deleteProfileConfirmation": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "deny": "Nega", + "deviceButton": "{deviceName}pulsante", + "@deviceButton": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "deviceLimitReached": "Limite di dispositivi raggiunto per {platform}. Revoca altri dispositivi per registrarne uno nuovo.", + "devicesActive": "{active} attivo", + "disconnectDevices": "Dispositivi disconnessi", + "disconnected": "Disconnesso", + "donateByBuyingFromPlayStore": "acquistando l'app dal Play Store", + "donateViaCreditCard": "tramite carta di credito, Google Pay, Apple Pay e altri", + "donateViaPaypal": "tramite PayPal", + "download": "Download", + "downloadLatestSettings": "Scarica le ultime impostazioni", + "dragToReposition": "Trascina per riposizionare", + "duplicate": "Duplica", + "enableAutoRotation": "Abilita la rotazione automatica sul tuo dispositivo per assicurarti che l'app funzioni correttamente.", + "enableBluetooth": "Abilita Bluetooth", + "enableItInTheConnectionSettingsFirst": "Per prima cosa abilitalo nelle impostazioni di connessione.", + "enableKeyboardAccessMessage": "Abilita l'accesso tramite tastiera nella schermata seguente per BikeControl. Se BikeControl non è presente, aggiungilo manualmente.", + "enableKeyboardMouseControl": "BikeControl invierà azioni del mouse o della tastiera per controllare {appName} .", + "@enableKeyboardMouseControl": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "enableLocalConnectionMethodFirst": "Abilitare prima il metodo di connessione locale.", + "enableMediaKeyDetection": "Abilita rilevamento tasti multimediali", + "enableMywhooshLinkInTheConnectionSettingsFirst": "Per prima cosa, abilita MyWhoosh Link nelle impostazioni di connessione.", + "enablePairingProcess": "Funziona come un mouse Bluetooth", + "enablePermissions": "Abilita autorizzazioni", + "enableSteeringWithPhone": "Abilita lo sterzo con i sensori del tuo telefono", + "enableVibrationFeedback": "Abilita il feedback delle vibrazioni durante il cambio marcia", + "enableZwiftControllerBluetooth": "Abilita il controller Zwift (Bluetooth)", + "enableZwiftControllerNetwork": "Abilita Zwift Controller (rete)", + "errorStartingMyWhooshLink": "Errore durante l'avvio del server MyWhoosh Link. Assicurati che l'app \"MyWhoosh Link\" non sia già in esecuzione su questo dispositivo.", + "errorStartingOpenBikeControlBluetoothServer": "Errore durante l'avvio del server Bluetooth OpenBikeControl.", + "errorStartingOpenBikeControlServer": "Errore durante l'avvio del server OpenBikeControl.", + "exportAction": "Esporta", + "failedToImportProfile": "Impossibile importare il profilo. Formato non valido.", + "failedToUpdate": "Aggiornamento non riuscito:{error}", + "@failedToUpdate": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "firmware": "Firmware", + "forceCloseToUpdate": "Forza la chiusura dell'app per utilizzare la nuova versione", + "full": "PIENO", + "fullVersion": "Versione completa", + "fullVersionDescription": "La versione completa include: \n- Comandi illimitati al giorno \n- Accesso a tutti gli aggiornamenti futuri \n- Nessun abbonamento! Solo un canone una tantum :)", + "getSupport": "Ottieni supporto", + "goPro": "Diventa Pro", + "gotIt": "Fatto!", + "grant": "Concedi", + "granted": "Concesso", + "helpRequested": "Aiuto richiesto per BikeControl v{version}", + "@helpRequested": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "howBikeControlUsesPermission": "In che modo BikeControl utilizza questa autorizzazione?", + "ignoredDevices": "Dispositivi ignorati", + "importAction": "Importa", + "importProfile": "Importa profilo", + "instructions": "Istruzioni", + "jsonData": "Dati JSON", + "justNow": "Proprio adesso", + "keyboardAccess": "Accesso tramite tastiera", + "lastSeen": "Ultimo accesso:", + "lastSynced": "Ultima sincronizzazione:", + "latestVersion": "ultima:{version}", + "@latestVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "leaveAReview": "Lascia una recensione", + "letsAppConnectOverBluetooth": "Lascia che {appName} si connetta a BikeControl tramite Bluetooth.", + "@letsAppConnectOverBluetooth": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsAppConnectOverNetwork": "Lascia che {appName} si connetta direttamente tramite la rete. Seleziona BikeControl nella schermata di connessione.", + "@letsAppConnectOverNetwork": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsGetYouSetUp": "Ti aiuteremo a prepararti!", + "license": "Licenza", + "licenseStatus": "Stato della licenza", + "loadScreenshotForPlacement": "Carica lo screenshot del gioco per il posizionamento", + "logViewer": "Visualizzatore di registri", + "loggedInAsMail": "Accedi come {email}", + "logout": "Esci", + "logs": "Registri", + "logsAreAlsoAt": "I registri sono anche a", + "logsHaveBeenCopiedToClipboard": "I registri sono stati copiati negli appunti", + "longPress": "pressione prolungata", + "longPressMode": "Modalità pressione prolungata (rispetto alla ripetizione)", + "longTapExplanation": "tocco 1: tasto giù \ntocco 2: tasto su", + "mailSupportExplanation": "Fornire supporto individuale via email è un impegno notevole per me. \n\nVi invito a utilizzare Reddit, Facebook o GitHub per domande e problemi, in modo che l'intera comunità possa trarne beneficio.", + "manageIgnoredDevices": "Gestisci dispositivi ignorati", + "manageProfile": "Gestisci profilo", + "manageSubscription": "Gestisci abbonamento", + "manageYourDevices": "Gestisci i tuoi dispositivi", + "manualyControllingButton": "Controlla {trainerApp} manualmente!", + "mediaKeyDetectionTooltip": "Abilita questa opzione per consentire a BikeControl di rilevare i telecomandi Bluetooth. \nPer fare ciò, BikeControl deve funzionare come lettore multimediale.", + "miuiDeviceDetected": "Dispositivo MIUI rilevato", + "miuiDisableBatteryOptimization": "• Disattivare l'ottimizzazione della batteria per BikeControl", + "miuiEnableAutostart": "• Abilita l'avvio automatico per BikeControl", + "miuiEnsureProperWorking": "Per garantire il corretto funzionamento di BikeControl:", + "miuiLockInRecentApps": "• Blocca l'app nelle app recenti", + "miuiWarningDescription": "Il tuo dispositivo utilizza MIUI, che è noto per disattivare in modo aggressivo i servizi in background e i servizi di accessibilità.", + "moreInformation": "Ulteriori informazioni", + "mustChooseAllowOrDeny": "Per continuare, devi scegliere se consentire o negare questa autorizzazione.", + "myWhooshDirectConnectAction": "Azione MyWhoosh \"Link\"", + "myWhooshDirectConnection": " ad esempio utilizzando MyWhoosh \"Link\"", + "myWhooshLinkConnected": "MyWhoosh \"Link\" connesso", + "myWhooshLinkDescriptionLocal": "Connettiti direttamente a MyWhoosh tramite il metodo \"Link\". Controlla le istruzioni per assicurarti che la connessione sia possibile. L'app \"MyWhoosh Link\" non deve essere attiva contemporaneamente.", + "myWhooshLinkInfo": "In caso di problemi, consulta la sezione relativa alla risoluzione dei problemi. Presto sarà disponibile un metodo di connessione molto più affidabile!", + "needHelpClickHelp": "Hai bisogno di aiuto? Clicca sul", + "needHelpDontHesitate": "pulsante in alto e non esitare a contattarci.", + "never": "Mai", + "newConnectionMethodAnnouncement": "{trainerApp}supporterà presto metodi di connessione molto migliori e affidabili: restate sintonizzati per gli aggiornamenti!", + "newCustomProfile": "Nuovo profilo personalizzato", + "newProfileName": "Nuovo nome del profilo", + "newVersionAvailable": "Nuova versione disponibile", + "newVersionAvailableWithVersion": "Nuova versione disponibile:{version}", + "@newVersionAvailableWithVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "newer": "PIÙ RECENTE", + "newerSettingsAvailable": "Nuove impostazioni disponibili", + "next": "Successivo", + "no": "No", + "noActionAssigned": "Nessuna azione assegnata", + "noActionAssignedForButton": "Non è stato possibile eseguire{button} : Nessuna azione assegnata", + "noConnectionMethodIsConnectedOrActive": "Nessun metodo di connessione è connesso o attivo.", + "noConnectionMethodSelected": "Nessun metodo di connessione selezionato", + "noControllerConnected": "Nessuno connesso", + "noControllerUseCompanionMode": "Non hai un controller? Usa la Companion Mode", + "noDevicesRegistered": "Nessun dispositivo registrato", + "noIgnoredDevices": "Nessun dispositivo ignorato.", + "noTrainerSelected": "Nessun Trainer selezionato", + "notConnected": "Non connesso", + "notificationDescription": "In questo modo l'app rimane attiva in background e ti aggiorna quando cambia la connessione ai tuoi dispositivi.", + "ok": "Ok", + "only": "Soltanto", + "openBikeControlActions": "Azioni OpenBikeControl", + "openBikeControlAnnouncement": "Ottime notizie -{trainerApp} supporta il protocollo OpenBikeControl, così avrai la migliore esperienza possibile!", + "openBikeControlConnection": " ad esempio utilizzando la connessione OpenBikeControl", + "otherConnectionMethods": "Altri metodi di connessione", + "pairingDescription": "Questo ti permetterà di usare il tuo telefono come mouse Bluetooth per controllare le app. Una volta associato, potrai usare i pulsanti del controller della tua bici per inviare input del mouse all'app.", + "pairingInstructions": "Sul tuo{targetName} Accedi alle impostazioni Bluetooth e cerca BikeControl o il nome del tuo dispositivo. L'associazione è necessaria se vuoi utilizzare la funzione di controllo remoto.", + "@pairingInstructions": { + "placeholders": { + "targetName": { + "type": "String" + } + } + }, + "pairingInstructionsIOS": "Sul tuo iPad, vai su Impostazioni > Accessibilità > Tocco > Assistenza Tocco > Dispositivi di puntamento > Dispositivi e associa il tuo dispositivo. Assicurati che Assistenza Tocco sia abilitato.", + "pasteExportedJsonData": "Incolla i dati JSON esportati qui sotto:", + "pathCopiedToClipboard": "Il percorso è stato copiato negli appunti", + "paywall_amountOfActions": "Quantità di azioni", + "paywall_billedAtPricemo": "Fatturato a {price} /mese.", + "paywall_billedAtYearly": "Fatturato a {cost} /anno", + "paywall_configure3ActionsPerButton": "Configura 3 azioni per pulsante", + "paywall_connectToYourTrainer": "Connettiti al tuo trainer", + "paywall_controlYourDeviceMusic": "Controlla il tuo dispositivo/musica", + "paywall_createScreenshots": "Crea screenshot", + "paywall_monthly": "Mensile", + "paywall_startAnyCommandShortcutWithAnyButton": "Avvia qualsiasi comando/scorciatoia con qualsiasi pulsante", + "paywall_supportDevelopmentOfNewFeaturesDevicesAndMore": "Supportare lo sviluppo di nuove funzionalità/dispositivi e altro ancora", + "paywall_synchronizeAndBackup": "Sincronizzazione ed esecuzione del backup", + "paywall_useBikecontrolOnAllPlatforms": "Utilizza BikeControl su tutte le piattaforme", + "paywall_yearly": "Annuale", + "permissionsRequired": "Per consentire a BikeControl di cercare i dispositivi nelle vicinanze e di aggiornarti quando cambia la connessione, abilita le seguenti autorizzazioni:", + "platformNotSupported": "Questo{platform} non è supportato :(", + "@platformNotSupported": { + "placeholders": { + "platform": { + "type": "String" + } + } + }, + "platformRestrictionNotSupported": "A causa delle restrizioni della piattaforma, questo scenario non è supportato.", + "platformRestrictionOtherDevicesOnly": "A causa delle restrizioni della piattaforma, solo il controllo{appName} su altri dispositivi è supportato.", + "@platformRestrictionOtherDevicesOnly": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "playPause": "Riproduci/Pausa", + "pleaseSelectAConnectionMethodFirst": "Per prima cosa seleziona un metodo di connessione nelle impostazioni del Trainer.", + "predefinedAction": "Predefinito{appName} azione", + "@predefinedAction": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "pressButtonOnClickDevice": "Premi un pulsante sul tuo dispositivo Click", + "pressKeyToAssign": "Premi un tasto sulla tastiera per assegnarlo a{buttonName}", + "@pressKeyToAssign": { + "placeholders": { + "buttonName": { + "type": "String" + } + } + }, + "previous": "Precedente", + "privacyPolicy": "politica sulla riservatezza", + "profileExportedToClipboard": "Profilo \"{profileName}\" esportato negli appunti", + "@profileExportedToClipboard": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "profileImportedSuccessfully": "Profilo importato con successo", + "profileName": "Nome del profilo", + "purchase": "Acquista", + "recommendedConnectionMethods": "Metodi di connessione consigliati", + "registerCurrentDevice": "Registra il dispositivo corrente", + "registeredDevices": "Dispositivi registrati", + "removeFromIgnoredList": "Rimuovi dall'elenco degli ignorati", + "removeOnePressAction": "Il {device} non supporta nativamente la pressione prolungata. Per utilizzare questa tipologia è necessario rimuovere l'azione di pressione prolungata.", + "rename": "Rinomina", + "renameProfile": "Rinomina profilo", + "replaceExisting": "Sostituisci esistente", + "requirement": "Requisiti", + "reset": "Reset", + "restart": "Riavvia", + "restorePurchaseInfo": "Clicca sul pulsante qui sopra, poi su \"Ripristina acquisto\". In caso di problemi, contattami direttamente.", + "restorePurchases": "Ripristina gli acquisti", + "revoke": "Revocare", + "revoked": "Revoca", + "runAppOnPlatformRemotely": "Avvia {appName} su{platform} e controllarlo da remoto da questo dispositivo{preferredConnection} .", + "@runAppOnPlatformRemotely": { + "placeholders": { + "appName": { + "type": "String" + }, + "platform": { + "type": "String" + }, + "preferredConnection": { + "type": "String" + } + } + }, + "runAppOnThisDevice": "Avvia {appName} su questo dispositivo.", + "@runAppOnThisDevice": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "save": "Salva", + "scan": "Scansione", + "scanningForDevices": "Ricerca dispositivi in corso... Assicurarsi che siano accesi e nel raggio d'azione e che non siano connessi a un altro dispositivo.", + "selectADeviceToSyncFrom": "Seleziona un dispositivo da cui sincronizzare", + "selectKeymap": "Seleziona la mappa dei tasti", + "selectTargetWhereAppRuns": "Seleziona la destinazione dove{appName} è in esecuzione", + "@selectTargetWhereAppRuns": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "selectTrainerApp": "Seleziona l'app Trainer", + "selectTrainerAppAndTarget": "Seleziona l'app Trainer e il dispositivo di destinazione", + "selectTrainerAppPlaceholder": "Seleziona l'app Trainer", + "setting": "Impostazioni", + "settingsAppliedFromId": "Impostazioni applicate da {deviceId} ...", + "settingsDownloadedFromServer": "Impostazioni scaricate dal server", + "settingsSyncedSuccessfully": "Impostazioni sincronizzate correttamente", + "settingsSynchronization": "Sincronizzazione delle impostazioni", + "setupComplete": "Installazione completata!", + "setupTrainer": "Impostazioni Trainer", + "share": "Condividi", + "showDonation": "Mostra il tuo apprezzamento donando", + "showSupportedControllers": "Mostra controller supportati", + "signInToSyncYourSubscriptionAndManageDevices": "Accedi per sincronizzare il tuo abbonamento e gestire i dispositivi", + "signal": "Segnale", + "simulateButtons": "Controlli del Trainer", + "simulateKeyboardShortcut": "Premi un tasto della tastiera", + "simulateMediaKey": "Controllo della riproduzione musicale", + "simulateTouch": "Premi un qualunque pulsante sullo scgermo", + "skip": "Saltare", + "stop": "Stop", + "supportedActions": "Azioni supportate", + "syncSettings": "Impostazioni di sincronizzazione", + "syncStatus": "Stato di sincronizzazione", + "synchronizeAcrossDevices": "Sincronizzazione tra dispositivi", + "synchronizeYourAppSettingsAcrossAllYourDevicesThisIncludes": "Sincronizza le impostazioni della tua app su tutti i tuoi dispositivi. Questo include le mappe dei tasti, le configurazioni dei pulsanti e le preferenze.", + "targetOtherDevice": "Altro dispositivo", + "targetThisDevice": "Questo dispositivo", + "theFollowingPermissionsRequired": "Sono richieste le seguenti autorizzazioni:", + "touchAreaInstructions": "1. Crea uno screenshot in-game della tua app (ad esempio all'interno di MyWhoosh) in orientamento orizzontale \n2. Carica lo screenshot con il pulsante qui sotto \n3. L'app viene automaticamente impostata in orientamento orizzontale per una mappatura accurata \n4. Premi un pulsante sul tuo dispositivo Click per creare un'area touch \n5. Trascina le aree touch nella posizione desiderata sullo screenshot \n6. Salva e chiudi questa schermata", + "touchSimulationForegroundMessage": "Per simulare i tocchi, l'app deve rimanere in primo piano.", + "trainer": "Trainer", + "trialDaysRemaining": "{trialDaysRemaining}giorni rimanenti", + "trialExpired": "Prova scaduta. Comandi limitati a{dailyCommandLimit} al giorno.", + "trialPeriodActive": "Periodo di prova attivo -{trialDaysRemaining} giorni rimanenti", + "trialPeriodDescription": "Goditi comandi illimitati durante il periodo di prova. Dopo la prova, i comandi saranno limitati a{dailyCommandLimit} al giorno.", + "troubleshootingGuide": "Domande e risposte", + "tryingToConnectAgain": "Sto provando a connettermi di nuovo...", + "unassignAction": "Annulla assegnazione azione", + "unlimited": "Illimitato", + "unlockAllFeaturesWithPro": "Sblocca tutte le funzionalità con Pro", + "unlockFullVersion": "Sblocca la versione completa", + "unlockTheFullVersionOrGoPro": "Sblocca la versione completa o passa a Pro", + "unlock_bikecontrolAndZwiftNetwork": "BikeControl e Zwift devono essere sulla stessa rete o sullo stesso dispositivo. Potrebbero essere necessari alcuni secondi per visualizzarli.", + "unlock_confirmByPressingAButtonOnYourDevice": "Conferma premendo un pulsante sul tuo dispositivo.", + "unlock_connectToBikecontrol": "Connetti a \"BikeControl\" ad esempio come fonte di alimentazione", + "unlock_deviceIsCurrentlyLocked": "Il dispositivo è attualmente bloccato", + "unlock_isnowunlocked": "{device} è ora sbloccato", + "unlock_markAsUnlocked": "Segna come sbloccato", + "unlock_notWorking": "Non funziona?", + "unlock_openZwift": "Apri Zwift (non il Companion) su questo o un altro dispositivo", + "unlock_unlockManually": "Sblocca manualmente", + "unlock_unlockNow": "Sblocca ora", + "unlock_unlockedUntilAroundDate": "Sbloccato fino a circa {date}", + "unlock_waitingForZwift": "In attesa che Zwift sblocchi il tuo dispositivo...", + "unlock_youCanNowCloseZwift": "Ora puoi chiudere Zwift e tornare a BikeControl.", + "unlock_yourTrialPhaseHasExpired": "La tua fase di prova è scaduta. Acquista la versione completa per sbloccare la comoda funzione di sblocco :)", + "unlock_yourZwiftClickMightBeUnlockedAlready": "Il tuo Zwift Click potrebbe essere già sbloccato.", + "unlockingNotPossible": "Al momento non è ancora possibile sbloccarlo, quindi per il momento goditi un utilizzo illimitato!", + "update": "Aggiorna", + "uploadSettings": "Impostazioni di caricamento", + "useCustomKeymapForButton": "Utilizzare una mappa dei tasti personalizzata per supportare", + "version": "Versione{version}", + "@version": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "viewDetailedInstructions": "Visualizza le istruzioni dettagliate", + "volumeDown": "Abbassa il volume", + "volumeUp": "Aumenta il volume", + "waiting": "In attesa...", + "waitingForConnectionKickrBike": "In attesa di connessione. Scegli KICKR BIKE PRO in{appName} menu di associazione del controller.", + "@waitingForConnectionKickrBike": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "whatsNew": "Novità", + "whyPermissionNeeded": "Perché è necessaria questa autorizzazione?", + "windowsSubscriptionsRequireYouToBeLoggedIn": "Gli abbonamenti Windows richiedono l'accesso", + "yes": "SÌ", + "yourDevices": "I tuoi dispositivi", + "yourSettingsAreAutomaticallySyncedWhenYouMakeChangesTap": "Le tue impostazioni vengono sincronizzate automaticamente quando apporti modifiche. Tocca un dispositivo per applicare le sue impostazioni.", + "zwiftControllerAction": "Azione del controller Zwift", + "zwiftControllerDescription": "Consente a BikeControl di funzionare come controller compatibile con Zwift." +} \ No newline at end of file diff --git a/lib/i10n/intl_pl.arb b/lib/i10n/intl_pl.arb new file mode 100644 index 000000000..b3679451b --- /dev/null +++ b/lib/i10n/intl_pl.arb @@ -0,0 +1,532 @@ +{ + "accessibilityDescription": "BikeControl potrzebuje uprawnień dostępu, aby móc sterować aplikacjami treningowymi.", + "accessibilityDisclaimer": "BikeControl będzie miał dostęp do Twojego ekranu wyłącznie w celu wykonania skonfigurowanych przez Ciebie gestów. Nie będzie miał dostępu do żadnych innych funkcji ułatwień dostępu ani danych osobowych.", + "accessibilityReasonControl": "• Aby umożliwić Ci sterowanie aplikacjami takimi jak MyWhoosh, TrainingPeaks i innymi za pomocą urządzeń Zwift", + "accessibilityReasonTouch": "• Aby symulować gesty dotykowe na ekranie w celu sterowania aplikacją treningową", + "accessibilityReasonWindow": "• Aby wykryć, które okno aplikacji treningowej jest aktualnie aktywne", + "accessibilityServiceExplanation": "Aby działać prawidłowo, BikeControl musi korzystać z interfejsu API AccessibilityService systemu Android.", + "accessibilityServiceNotRunning": "Usługa ułatwień dostępu nie działa.\nPostępuj zgodnie z instrukcjami na", + "accessibilityServicePermissionRequired": "Wymagane uprawnienie usługi ułatwień dostępu", + "accessibilityUsageGestures": "• Gdy naciskasz przyciski na urządzeniach Zwift Click, Zwift Ride lub Zwift Play, BikeControl symuluje gesty dotykowe w określonych miejscach ekranu", + "accessibilityUsageMonitor": "• Aplikacja monitoruje, które okno aplikacji treningowej jest aktywne, aby zapewnić, że gesty są wysyłane do właściwej aplikacji", + "accessibilityUsageNoData": "• Ta usługa nie uzyskuje dostępu do danych osobowych, ani ich nie gromadzi", + "accessories": "Akcesoria", + "account": "Konto", + "actAsBluetoothKeyboard": "Działa jako klawiatura Bluetooth", + "action": "Działanie", + "additionalTriggerAssignment": "Dodatkowe przypisanie wyzwalacza", + "adjustControllerButtons": "Dostosuj przyciski kontrolera", + "afterDate": "Po {date}", + "allow": "Zezwól", + "allowAccessibilityService": "Zezwól na usługę ułatwień dostępu", + "allowBluetoothConnections": "Zezwól na połączenia Bluetooth", + "allowBluetoothScan": "Zezwól na skanowanie Bluetooth", + "allowLocationForBluetooth": "Zezwól na dostęp do lokalizacji, aby umożliwić skanowanie Bluetooth", + "allowPersistentNotification": "Zezwól na powiadomienia", + "allowsRunningInBackground": "Umożliwia działanie BikeControl w tle", + "alreadyBoughtTheApp": "Kupiłeś już aplikację? Nie musisz płacić za BikeControl. Z powodu problemów technicznych nie można ustalić, czy aplikacja została już zakupiona. \n\nWprowadź swój identyfikator zakupu w Sklepie Play (np. GPA.3356-1337-1338-1339), aby odblokować pełną wersję. Jeśli nie możesz jej znaleźć, skontaktuj się ze mną bezpośrednio.", + "alreadyBoughtTheAppPreviously": "Już wcześniej kupiłeś aplikację?", + "androidSystemAction": "Akcja systemu Android", + "anotherTriggerIsAlreadyAssignedForThisButton": "Do tego przycisku przypisano już inny wyzwalacz. Wybierz wersję Pro, aby zachować wiele typów wyzwalaczy, lub zastąp istniejące przypisanie wyzwalacza i kontynuuj {triggerTitle} .", + "appIdActions": "{appId} działania", + "@appIdActions": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "applyNow": "Złóż wniosek teraz", + "asAFinalStepYoullChooseHowToConnectTo": "W ostatnim kroku wybierzesz sposób połączenia {trainerApp} .", + "battery": "Bateria", + "beforeDate": "Zanim {date}", + "bluetoothAdvertiseAccess": "Dostęp do reklamy Bluetooth", + "bluetoothKeyboardExplanation": "Umożliwi to używanie telefonu jako klawiatury Bluetooth do sterowania kompatybilnymi aplikacjami. Po sparowaniu możesz używać przycisków na kontrolerze rowerowym do wysyłania poleceń z klawiatury do aplikacji.", + "bluetoothTurnedOn": "Włączono Bluetooth", + "browserNotSupported": "Ta przeglądarka nie obsługuje technologii Web Bluetooth i platforma nie jest obsługiwana :(", + "button": "przycisk.", + "buyFullVersion": "Kup pełną wersję", + "bySigningInYouAgreeToOur": "Logując się, wyrażasz zgodę na nasze {privacyPolicy} .", + "@bySigningInYouAgreeToOur": { + "placeholders": { + "privacyPolicy": { + "type": "String" + } + } + }, + "cancel": "Anuluj", + "changelog": "Dziennik zmian", + "checkMyWhooshConnectionScreen": "Sprawdź ekran połączenia w MyWhoosh, aby zobaczyć, czy „Link” jest połączony.", + "chooseAnotherScreenshot": "Wybierz inny zrzut ekranu", + "chooseBikeControlInConnectionScreen": "Wybierz BikeControl na ekranie połączenia.", + "clickAButtonOnYourController": "Kliknij przycisk na kontrolerze, aby edytować jego akcję lub naciśnij ikonę edycji.", + "clickV2EventInfo": "Twóje Click V2 może już nie wysyłać zdarzeń przycisków. Sprawdź, naciskając kilka przycisków, czy są one widoczne w BikeControl.", + "clickV2Instructions": "Aby Twoje Zwift Click V2 działały optymalnie, należy połączyć je z aplikacją Zwift przed każdym treningiem.\nJeśli tego nie zrobisz, Click V2 przestanie działać po minucie.\n\n1. Otwórz aplikację Zwift (nie Companion!)\n2. Zaloguj się (nie wymaga subskrypcji) i otwórz ekran połączeń z urządzeniami\n3. Połącz się z trenażerem, a następnie z Zwift Click V2\n4. Zamknij aplikację Zwift i ponownie połącz się z BikeControl", + "close": "Zamknij", + "commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} dzisiejsze pozostałe polecenia", + "configuration": "Konfiguracja", + "connectControllerToPreview": "Podłącz urządzenie sterujące, aby wyświetlić podgląd i dostosować mapę klawiszy.", + "connectControllers": "Połącz kontrolery", + "connectDirectlyOverNetwork": "Połącz bezpośrednio przez sieć", + "connectToTrainerApp": "Połącz z aplikacją treningową", + "connectUsingBluetooth": "Połącz przez Bluetooth", + "connectUsingMyWhooshLink": "Połącz się za pomocą MyWhoosh „Link”", + "connected": "Połączono", + "connectedControllers": "Połączone kontrolery", + "connectedTo": "Połączono z {appId}", + "@connectedTo": { + "placeholders": { + "appId": { + "type": "String" + } + } + }, + "connection": "Połączenie", + "continueAction": "Kontynuuj", + "controlAppUsingModes": "Kontroluj {appName} za pomocą {modes}", + "@controlAppUsingModes": { + "placeholders": { + "appName": { + "type": "String" + }, + "modes": { + "type": "String" + } + } + }, + "controllerConnectedClickButton": "Świetnie! Twój kontroler jest połączony. Kliknij przycisk na kontrolerze, aby kontynuować.", + "controllers": "Kontrolery", + "couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Nie można wykonać {button}: Nie zmapowano klawisza", + "create": "Utwórz", + "createNewKeymap": "Utwórz nową mapę klawiszy", + "createNewProfileByDuplicating": "Utwórz nowy profil niestandardowy, kopiując „{profileName}\"", + "@createNewProfileByDuplicating": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "createdNewCustomProfile": "Utworzono nowy profil niestandardowy: {profileName}", + "@createdNewCustomProfile": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "currentDeviceIsNotRegistered": "Obecne urządzenie nie jest zarejestrowane", + "currentPlan": "Aktualny plan", + "customizeControllerButtons": "Dostosuj przyciski kontrolera dla {appName}", + "@customizeControllerButtons": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "customizeKeymapHint": "Jeśli występują jakiekolwiek problemy (np. nieprawidłowe działanie klawiatury lub nieprawidłowe rozmieszczenie przycisków dotykowych), dostosuj mapę klawiszy.", + "dailyCommandLimitReachedNotification": "Dzienny limit poleceń został osiągnięty. Zaktualizuj do pełnej wersji z nieograniczoną liczbą poleceń.", + "dailyLimitReached": "Osiągnięto dzienny limit ({dailyCommandCount} /{dailyCommandLimit} wykorzystanych)", + "delete": "Usuń", + "deleteProfile": "Usuń profil", + "deleteProfileConfirmation": "Czy na pewno chcesz usunąć „{profileName}\"? Tej czynności nie można cofnąć.", + "@deleteProfileConfirmation": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "deny": "Odmów", + "deviceButton": "{deviceName} przycisk", + "@deviceButton": { + "placeholders": { + "deviceName": { + "type": "String" + } + } + }, + "deviceLimitReached": "Osiągnięto limit urządzeń {platform}. Anuluj inne urządzenia, aby zarejestrować nowe.", + "devicesActive": "{active} aktywny", + "disconnectDevices": "Odłącz urządzenia", + "disconnected": "Rozłączony", + "donateByBuyingFromPlayStore": "kupując aplikację w Play Store", + "donateViaCreditCard": "za pomocą karty kredytowej, Google Pay, Apple Pay i innych", + "donateViaPaypal": "przez PayPal", + "download": "Pobierz", + "downloadLatestSettings": "Pobierz najnowsze ustawienia", + "dragToReposition": "Przeciągnij, aby zmienić położenie", + "duplicate": "Duplikuj", + "enableAutoRotation": "Włącz funkcję automatycznego obracania na urządzeniu, aby mieć pewność, że aplikacja będzie działać prawidłowo.", + "enableBluetooth": "Włącz Bluetooth", + "enableItInTheConnectionSettingsFirst": "Najpierw włącz tę opcję w ustawieniach połączenia.", + "enableKeyboardAccessMessage": "Włącz dostęp do klawiatury dla BikeControl na poniższym ekranie. Jeśli nie widzisz BikeControl, dodaj go ręcznie.", + "enableKeyboardMouseControl": "BikeControl będzie wysyłał akcje myszy lub klawiatury do kontrolera {appName} .", + "@enableKeyboardMouseControl": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "enableLocalConnectionMethodFirst": "Najpierw włącz metodę połączenia lokalnego.", + "enableMediaKeyDetection": "Włącz rozpoznawanie klawiszy multimedialnych", + "enableMywhooshLinkInTheConnectionSettingsFirst": "Najpierw włącz MyWhoosh Link w ustawieniach połączenia.", + "enablePairingProcess": "Działa jako mysz Bluetooth", + "enablePermissions": "Nadaj uprawnienia", + "enableSteeringWithPhone": "Włącz sterowanie za pomocą czujników telefonu", + "enableVibrationFeedback": "Włącz wibracje podczas zmiany biegów", + "enableZwiftControllerBluetooth": "Włącz kontroler Zwift (Bluetooth)", + "enableZwiftControllerNetwork": "Włącz kontroler Zwift (sieć)", + "errorStartingMyWhooshLink": "Błąd podczas uruchamiania serwera MyWhoosh Link. Upewnij się, że aplikacja „MyWhoosh Link” nie jest już uruchomiona na tym urządzeniu.", + "errorStartingOpenBikeControlBluetoothServer": "Błąd podczas uruchamiania serwera Bluetooth OpenBikeControl.", + "errorStartingOpenBikeControlServer": "Błąd podczas uruchamiania serwera OpenBikeControl.", + "exportAction": "Eksport", + "failedToImportProfile": "Nie udało się zaimportować profilu. Nieprawidłowy format.", + "failedToUpdate": "Nie udało się zaktualizować: {error}", + "@failedToUpdate": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "firmware": "Firmware", + "forceCloseToUpdate": "Wymuś zamknięcie aplikacji, aby móc korzystać z nowej wersji", + "full": "PEŁNY", + "fullVersion": "Pełna wersja", + "fullVersionDescription": "Pełna wersja zawiera: \n- Nielimitowane polecenia dziennie \n- Dostęp do wszystkich przyszłych aktualizacji \n- Brak subskrypcji! Opłata jednorazowa :)", + "getSupport": "Uzyskaj wsparcie", + "goPro": "Go Pro", + "gotIt": "Zrozumiałem!", + "grant": "Nadaj", + "granted": "Nadano", + "helpRequested": "Prośba o pomoc dotyczącą BikeControl v{version}", + "@helpRequested": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "howBikeControlUsesPermission": "W jaki sposób BikeControl wykorzystuje to uprawnienie?", + "ignoredDevices": "Zignorowane urządzenia", + "importAction": "Import", + "importProfile": "Importuj profil", + "instructions": "Instrukcje", + "jsonData": "Dane JSON", + "justNow": "Właśnie", + "keyboardAccess": "Dostęp do klawiatury", + "lastSeen": "Ostatnio widziany:", + "lastSynced": "Ostatnia synchronizacja:", + "latestVersion": "najnowszy: {version}", + "@latestVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "leaveAReview": "Zostaw recenzję", + "letsAppConnectOverBluetooth": "Pozwala {appName} połączyć się z BikeControl przez Bluetooth.", + "@letsAppConnectOverBluetooth": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsAppConnectOverNetwork": "Pozwala {appName} połączyć się bezpośrednio przez sieć. Wybierz BikeControl na ekranie połączenia.", + "@letsAppConnectOverNetwork": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "letsGetYouSetUp": "Przygotujmy Cię!", + "license": "Licencja", + "licenseStatus": "Status licencji", + "loadScreenshotForPlacement": "Prześlij zrzut ekranu z gry w celu ustalenia miejsca", + "logViewer": "Podgląd dziennika zdarzeń", + "loggedInAsMail": "Zalogowany jako {email}", + "logout": "Wyloguj", + "logs": "Dziennik zdarzeń", + "logsAreAlsoAt": "Dziennik zdarzeń jest dostępny również na", + "logsHaveBeenCopiedToClipboard": "Dziennik zdarzeń został skopiowany do schowka", + "longPress": "długie\nnaciśnięcie", + "longPressMode": "Tryb długiego naciśnięcia (zamiast powtarzania)", + "longTapExplanation": "stuknięcie 1: press down \nstuknięcie 2: released", + "mailSupportExplanation": "Udzielanie indywidualnego wsparcia przez e-mail to dla mnie dużo pracy.\n\nProszę rozważyć korzystanie z Reddita, Facebooka lub GitHuba w celu zadawania pytań i zgłaszania problemów, aby cała społeczność mogła z tego skorzystać.", + "manageIgnoredDevices": "Zarządzaj ignorowanymi urządzeniami", + "manageProfile": "Zarządzaj profilem", + "manageSubscription": "Zarządzaj subskrypcją", + "manageYourDevices": "Zarządzaj swoimi urządzeniami", + "manualyControllingButton": "Steruj {trainerApp} manualnie!", + "mediaKeyDetectionTooltip": "Włącz tę opcję, aby umożliwić BikeControl wykrywanie pilotów Bluetooth.\nW tym celu BikeControl musi działać jako odtwarzacz multimedialny.", + "miuiDeviceDetected": "Wykryto urządzenie MIUI", + "miuiDisableBatteryOptimization": "• Wyłącz optymalizację baterii dla BikeControl", + "miuiEnableAutostart": "• Włącz autostart dla BikeControl", + "miuiEnsureProperWorking": "Aby mieć pewność, że BikeControl działa prawidłowo:", + "miuiLockInRecentApps": "• Zablokuj aplikację w ostatnio używanych aplikacjach", + "miuiWarningDescription": "Na Twoim urządzeniu działa oprogramowanie MIUI, który słynie z agresywnego wyłączania usług działających w tle i usług ułatwień dostępu.", + "moreInformation": "Więcej informacji", + "mustChooseAllowOrDeny": "Aby kontynuować, musisz zezwolić lub odmówić tego uprawnienia.", + "myWhooshDirectConnectAction": "Akcja „Link” MyWhoosh", + "myWhooshDirectConnection": " np. za pomocą MyWhoosh „Link”", + "myWhooshLinkConnected": "Połączono MyWhoosh „Link”", + "myWhooshLinkDescriptionLocal": "Połącz się bezpośrednio z MyWhoosh za pomocą metody „Link”. Sprawdź instrukcje, aby upewnić się, że połączenie jest możliwe. Aplikacja „MyWhoosh Link” nie może być jednocześnie aktywna.", + "myWhooshLinkInfo": "W razie napotkania błędów prosimy o sprawdzenie sekcji rozwiązywania problemów. Wkrótce pojawi się znacznie bardziej niezawodna metoda połączenia!", + "needHelpClickHelp": "Potrzebujesz pomocy? Kliknij", + "needHelpDontHesitate": "przycisk na górze i skontaktuj się z nami.", + "never": "Nigdy", + "newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będzie wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądź na bieżąco z aktualizacjami!", + "newCustomProfile": "Nowy profil niestandardowy", + "newProfileName": "Nowa nazwa profilu", + "newVersionAvailable": "Dostępna jest nowa wersja", + "newVersionAvailableWithVersion": "Dostępna jest nowa wersja: {version}", + "@newVersionAvailableWithVersion": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "newer": "NOWSZY", + "newerSettingsAvailable": "Dostępne są nowsze ustawienia", + "next": "Dalej", + "no": "Nie", + "noActionAssigned": "Brak przypisanej akcji", + "noActionAssignedForButton": "Nie można wykonać {button}: Brak przypisanej akcji", + "noConnectionMethodIsConnectedOrActive": "Żadna metoda połączenia nie jest podłączona lub aktywna.", + "noConnectionMethodSelected": "Nie wybrano metody połączenia", + "noControllerConnected": "Brak połączenia", + "noControllerUseCompanionMode": "Nie masz kontrolera? Użyj Companion Mode.", + "noDevicesRegistered": "Brak zarejestrowanych urządzeń", + "noIgnoredDevices": "Brak ignorowanych urządzeń.", + "noTrainerSelected": "Nie wybrano trenażera", + "notConnected": "Nie połączono", + "notificationDescription": "Dzięki temu aplikacja działa w tle i powiadamia Cię o każdej zmianie połączenia z Twoim urządzeniem.", + "ok": "OK", + "only": "Tylko", + "openBikeControlActions": "Akcje OpenBikeControl", + "openBikeControlAnnouncement": "Świetna wiadomość - {trainerApp} obsługuje protokół OpenBikeControl, dzięki czemu uzyskasz najlepsze doświadczenia!", + "openBikeControlConnection": " np. za pomocą połączenia OpenBikeControl", + "otherConnectionMethods": "Inne metody połączenia", + "pairingDescription": "Umożliwi to używanie telefonu jako myszy Bluetooth do sterowania aplikacjami. Po sparowaniu możesz używać przycisków na kontrolerze rowerowym do wysyłania poleceń myszy do aplikacji.", + "pairingInstructions": "Przejdź do ustawień Bluetooth na twoim {targetName} i wyszukaj BikeControl lub nazwę swojego urządzenia. Parowanie jest wymagane, jeśli chcesz korzystać z funkcji zdalnego sterowania.", + "@pairingInstructions": { + "placeholders": { + "targetName": { + "type": "String" + } + } + }, + "pairingInstructionsIOS": "Na iPadzie przejdź do Ustawienia > Ułatwienia dostępu > Dotyk > AssistiveTouch > Urządzenia wskazujące > Urządzenia i sparuj twoje urządzenie. Upewnij się, że funkcja AssistiveTouch jest włączona.", + "pasteExportedJsonData": "Wklej wyeksportowane dane JSON poniżej:", + "pathCopiedToClipboard": "Ścieżka została skopiowana do schowka", + "paywall_amountOfActions": "Ilość działań", + "paywall_billedAtPricemo": "Wystawiono fakturę{price} /miesiąc", + "paywall_billedAtYearly": "Wystawiono fakturę{cost} /rok", + "paywall_configure3ActionsPerButton": "Skonfiguruj 3 akcje na przycisk", + "paywall_connectToYourTrainer": "Połącz się ze swoim trenerem", + "paywall_controlYourDeviceMusic": "Kontroluj swoje urządzenie / muzykę", + "paywall_createScreenshots": "Twórz zrzuty ekranu", + "paywall_monthly": "Miesięczny", + "paywall_startAnyCommandShortcutWithAnyButton": "Uruchom dowolne polecenie/skrót dowolnym przyciskiem", + "paywall_supportDevelopmentOfNewFeaturesDevicesAndMore": "Wsparcie rozwoju nowych funkcji/urządzeń i nie tylko", + "paywall_synchronizeAndBackup": "Synchronizuj i twórz kopie zapasowe", + "paywall_useBikecontrolOnAllPlatforms": "Używaj BikeControl na wszystkich platformach", + "paywall_yearly": "Rocznie", + "permissionsRequired": "Aby aplikacja BikeControl mogła wyszukiwać urządzenia w pobliżu i informować o zmianie połączenia, włącz następujące uprawnienia:", + "platformNotSupported": "Platforma {platform} nie jest obsługiwana :(", + "@platformNotSupported": { + "placeholders": { + "platform": { + "type": "String" + } + } + }, + "platformRestrictionNotSupported": "Ze względu na ograniczenia platformy ten scenariusz nie jest obsługiwany.", + "platformRestrictionOtherDevicesOnly": "Ze względu na ograniczenia platformy obsługiwane jest tylko sterowanie {appName} na innych urządzeniach.", + "@platformRestrictionOtherDevicesOnly": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "playPause": "Start/Pauza", + "pleaseSelectAConnectionMethodFirst": "Najpierw wybierz metodę połączenia w ustawieniach trenażera.", + "predefinedAction": "Predefiniowana akcja {appName}", + "@predefinedAction": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "pressButtonOnClickDevice": "Wciśnij przycisk na swoim urządzeniu Click", + "pressKeyToAssign": "Naciśnij klawisz na klawiaturze, aby go przypisać do {buttonName}", + "@pressKeyToAssign": { + "placeholders": { + "buttonName": { + "type": "String" + } + } + }, + "previous": "Poprzedni", + "privacyPolicy": "Polityka prywatności", + "profileExportedToClipboard": "Wyeksportowano profil „{profileName}\" do schowka", + "@profileExportedToClipboard": { + "placeholders": { + "profileName": { + "type": "String" + } + } + }, + "profileImportedSuccessfully": "Pomyślnie zaimportowano profil", + "profileName": "Nazwa profilu", + "purchase": "Zakup", + "recommendedConnectionMethods": "Zalecane metody połączenia", + "registerCurrentDevice": "Zarejestruj aktualne urządzenie", + "registeredDevices": "Zarejestrowane urządzenia", + "removeFromIgnoredList": "Usuń z listy ignorowanych", + "removeOnePressAction": "Ten {device} Nie obsługuje natywnie długiego naciśnięcia. Aby korzystać z tego typu, należy usunąć akcję długiego naciśnięcia.", + "rename": "Zmiana nazwy", + "renameProfile": "Zmień nazwę profilu", + "replaceExisting": "Zastąp istniejące", + "requirement": "Wymóg", + "reset": "Reset", + "restart": "Uruchom ponownie", + "restorePurchaseInfo": "Kliknij przycisk powyżej, a następnie „Przywróć zakup”. W razie jakichkolwiek problemów skontaktuj się ze mną bezpośrednio.", + "restorePurchases": "Przywróć zakupy", + "revoke": "Unieważnić", + "revoked": "Odwołany", + "runAppOnPlatformRemotely": "Uruchom {appName} na {platform} i steruj nim zdalnie z tego urządzenia {preferredConnection}.", + "@runAppOnPlatformRemotely": { + "placeholders": { + "appName": { + "type": "String" + }, + "platform": { + "type": "String" + }, + "preferredConnection": { + "type": "String" + } + } + }, + "runAppOnThisDevice": "Uruchom {appName} na tym urządzeniu.", + "@runAppOnThisDevice": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "save": "Zapisz", + "scan": "SKANUJ", + "scanningForDevices": "Skanowanie urządzeń... Upewnij się, że są włączone, znajdują się w zasięgu i nie są podłączone do innego urządzenia.", + "selectADeviceToSyncFrom": "Wybierz urządzenie, z którego chcesz przeprowadzić synchronizację", + "selectKeymap": "Wybierz mapę klawiszy", + "selectTargetWhereAppRuns": "Wybierz urządzenie docelowe, na którym działa {appName}", + "@selectTargetWhereAppRuns": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "selectTrainerApp": "Wybierz aplikację treningową", + "selectTrainerAppAndTarget": "Wybierz aplikację treningową i docelowe urządzenie", + "selectTrainerAppPlaceholder": "Wybierz aplikację treningową", + "setting": "Ustawienie", + "settingsAppliedFromId": "Ustawienia zastosowane z{deviceId} ...", + "settingsDownloadedFromServer": "Ustawienia pobrane z serwera", + "settingsSyncedSuccessfully": "Ustawienia zostały pomyślnie zsynchronizowane", + "settingsSynchronization": "Synchronizacja ustawień", + "setupComplete": "Konfiguracja ukończona!", + "setupTrainer": "Konfiguracja trenażera", + "share": "Udostępnij", + "showDonation": "Wyraź swoją wdzięczność poprzez darowiznę", + "showSupportedControllers": "Pokaż wspierane kontrolery", + "signInToSyncYourSubscriptionAndManageDevices": "Zaloguj się, aby zsynchronizować swoją subskrypcję i zarządzać urządzeniami", + "signal": "Sygnał", + "simulateButtons": "Sterowanie trenażerem", + "simulateKeyboardShortcut": "Naciśnij przycisk na klawiaturze", + "simulateMediaKey": "Kontroluj odtwarzanie muzyki", + "simulateTouch": "Naciśnij dowolny przycisk na ekranie", + "skip": "Pominąć", + "stop": "Stop", + "supportedActions": "Obsługiwane działania", + "syncSettings": "Ustawienia synchronizacji", + "syncStatus": "Status synchronizacji", + "synchronizeAcrossDevices": "Synchronizuj na różnych urządzeniach", + "synchronizeYourAppSettingsAcrossAllYourDevicesThisIncludes": "Synchronizuj ustawienia aplikacji na wszystkich urządzeniach. Dotyczy to map klawiszy, konfiguracji przycisków i preferencji.", + "targetOtherDevice": "Inne urządzenie", + "targetThisDevice": "To urządzenie", + "theFollowingPermissionsRequired": "Wymagane są następujące uprawnienia:", + "touchAreaInstructions": "1. Utwórz zrzut ekranu w grze swojej aplikacji (np. w MyWhoosh) w orientacji poziomej\n2. Załaduj zrzut ekranu za pomocą poniższego przycisku\n3. Aplikacja automatycznie ustawi się w orientacji poziomej, aby zapewnić dokładne odwzorowanie\n4. Naciśnij przycisk na urządzeniu Click, aby utworzyć obszar dotykowy\n5. Przeciągnij obszary dotykowe do żądanej pozycji na zrzucie ekranu\n6. Zapisz i zamknij ten ekran.", + "touchSimulationForegroundMessage": "Aby symulować dotyk, aplikacja musi pozostać na pierwszym planie.", + "trainer": "Trenażer", + "trialDaysRemaining": "Pozostało {trialDaysRemaining} dni", + "trialExpired": "Wersja próbna wygasła. Liczba poleceń ograniczona do {dailyCommandLimit} na dzień.", + "trialPeriodActive": "Okres próbny jest aktywny - pozostało {trialDaysRemaining} dni", + "trialPeriodDescription": "Korzystaj z nieograniczonej liczby poleceń w okresie próbnym. Po zakończeniu okresu próbnego polecenia będą ograniczone do {dailyCommandLimit} na dzień.", + "troubleshootingGuide": "Pytania i odpowiedzi", + "tryingToConnectAgain": "Próba ponownego połączenia...", + "unassignAction": "Anuluj przypisanie akcji", + "unlimited": "Nieograniczony", + "unlockAllFeaturesWithPro": "Odblokuj wszystkie funkcje w wersji Pro", + "unlockFullVersion": "Odblokuj pełną wersję", + "unlockTheFullVersionOrGoPro": "Odblokuj pełną wersję – lub przejdź na wersję Pro", + "unlock_bikecontrolAndZwiftNetwork": "BikeControl i Zwift muszą być podłączone do tej samej sieci lub urządzenia. Pojawienie się aplikacji może potrwać kilka sekund.", + "unlock_confirmByPressingAButtonOnYourDevice": "Potwierdź, naciskając przycisk na swoim urządzeniu.", + "unlock_connectToBikecontrol": "Połącz się z „BikeControl”, np. jako źródło zasilania", + "unlock_deviceIsCurrentlyLocked": "Urządzenie jest obecnie zablokowane", + "unlock_isnowunlocked": "{device} jest teraz odblokowany", + "unlock_markAsUnlocked": "Oznacz jako odblokowane", + "unlock_notWorking": "Nie działa?", + "unlock_openZwift": "Otwórz aplikację Zwift (nie Companion) na tym lub innym urządzeniu", + "unlock_unlockManually": "Odblokuj ręcznie", + "unlock_unlockNow": "Odblokuj teraz", + "unlock_unlockedUntilAroundDate": "Odblokowane do około {date}", + "unlock_waitingForZwift": "Czekamy, aż Zwift odblokuje Twoje urządzenie...", + "unlock_youCanNowCloseZwift": "Możesz teraz zamknąć aplikację Zwift i wrócić do BikeControl.", + "unlock_yourTrialPhaseHasExpired": "Twój okres próbny wygasł. Kup pełną wersję, aby odblokować wygodną funkcję odblokowywania :)", + "unlock_yourZwiftClickMightBeUnlockedAlready": "Twoje urządzenie Zwift Click może być już odblokowane.", + "unlockingNotPossible": "Odblokowanie nie jest obecnie możliwe, więc ciesz się nieograniczonym użytkowaniem!", + "update": "Aktualizacja", + "uploadSettings": "Ustawienia przesyłania", + "useCustomKeymapForButton": "Użyj niestandardowej mapy klawiszy, aby obsługiwać", + "version": "Wersja {version}", + "@version": { + "placeholders": { + "version": { + "type": "String" + } + } + }, + "viewDetailedInstructions": "Zobacz szczegółowe instrukcje", + "volumeDown": "Zmniejsz głośność", + "volumeUp": "Zwiększ głośność", + "waiting": "Czekam...", + "waitingForConnectionKickrBike": "Czekam na połączenie. Wybierz KICKR BIKE PRO w menu parowania kontrolera w {appName}.", + "@waitingForConnectionKickrBike": { + "placeholders": { + "appName": { + "type": "String" + } + } + }, + "whatsNew": "Co nowego", + "whyPermissionNeeded": "Dlaczego to uprawnienie jest potrzebne?", + "windowsSubscriptionsRequireYouToBeLoggedIn": "Subskrypcje systemu Windows wymagają zalogowania się", + "yes": "Tak", + "yourDevices": "Twoje urządzenia", + "yourSettingsAreAutomaticallySyncedWhenYouMakeChangesTap": "Twoje ustawienia są automatycznie synchronizowane po wprowadzeniu zmian. Dotknij urządzenia, aby zastosować ustawienia.", + "zwiftControllerAction": "Akcja kontrolera Zwift", + "zwiftControllerDescription": "Umożliwia BikeControl działanie jako kontroler kompatybilny ze Zwift." +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 07c232e4b..17cfce11d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,32 +1,351 @@ -import 'package:accessibility/accessibility.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:swift_control/pages/requirements.dart'; -import 'package:swift_control/theme.dart'; -import 'package:swift_control/utils/connection.dart'; +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; +import 'package:app_links/app_links.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/pages/onboarding.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/actions/desktop.dart'; +import 'package:bike_control/utils/actions/remote.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/requirements/windows.dart'; +import 'package:bike_control/widgets/menu.dart'; +import 'package:bike_control/widgets/testbed.dart'; +import 'package:bike_control/widgets/ui/colors.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'pages/navigation.dart'; import 'utils/actions/base_actions.dart'; +import 'utils/core.dart'; + +final navigatorKey = GlobalKey(); +var screenshotMode = false; + +void main() async { + // setup crash reporting + + // Catch errors that happen in other isolates + if (!kIsWeb) { + Isolate.current.addErrorListener( + RawReceivePort((dynamic pair) { + final List errorAndStack = pair as List; + final error = errorAndStack.first; + final stack = errorAndStack.last as StackTrace?; + recordError(error, stack, context: 'Isolate'); + }).sendPort, + ); + } + + runZonedGuarded>( + () async { + // Catch Flutter framework errors (build/layout/paint) + FlutterError.onError = (FlutterErrorDetails details) { + _recordFlutterError(details); + // Optionally forward to default behavior in debug: + FlutterError.presentError(details); + }; + + // Catch errors from platform dispatcher (async) + PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { + recordError(error, stack, context: 'PlatformDispatcher'); + // Return true means "handled" + return true; + }; -final connection = Connection(); -final actionHandler = ActionHandler(); -final accessibilityHandler = Accessibility(); -final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + WidgetsFlutterBinding.ensureInitialized(); -void main() { - runApp(const SwiftPlayApp()); + final error = await core.settings.init(); + + if (error != null) { + recordError(error, null, context: 'SettingsInit'); + } + + runApp(BikeControlApp(error: error)); + }, + (Object error, StackTrace stack) { + if (kDebugMode) { + print('App crashed: $error'); + debugPrintStack(stackTrace: stack); + } + recordError(error, stack, context: 'Zone'); + }, + ); +} + +Future _recordFlutterError(FlutterErrorDetails details) async { + await _persistCrash( + type: 'flutter', + error: details.exceptionAsString(), + stack: details.stack, + information: details.informationCollector?.call().join('\n'), + ); +} + +Future recordError( + Object error, + StackTrace? stack, { + required String context, +}) async { + if (kDebugMode) { + print('Error in $context: $error'); + if (stack != null) { + debugPrintStack(stackTrace: stack); + } + } + await _persistCrash( + type: 'dart', + error: error.toString(), + stack: stack, + information: 'Context: $context', + ); +} + +Future _persistCrash({ + required String type, + required String error, + StackTrace? stack, + String? information, +}) async { + try { + final timestamp = DateTime.now().toIso8601String(); + final crashData = StringBuffer() + ..writeln('--- $timestamp ---') + ..writeln('Type: $type') + ..writeln('Error: $error') + ..writeln('Stack: ${stack ?? 'no stack'}') + ..writeln('Info: ${information ?? ''}') + ..writeln(await debugText()) + ..writeln() + ..writeln(); + + final directory = await _getLogDirectory(); + final file = File('${directory.path}/app.log'); + if (file.existsSync()) { + final fileLength = await file.length(); + if (fileLength > 5 * 1024 * 1024) { + // If log file exceeds 5MB, truncate it + try { + final lines = file.readAsLinesSync(); + final half = lines.length ~/ 2; + final truncatedLines = lines.sublist(half); + await file.writeAsString(truncatedLines.join('\n')); + } catch (e) { + if (kDebugMode) { + print('Failed to truncate log file: $e'); + } + file.deleteSync(); + } + } + } + + await file.writeAsString(crashData.toString(), mode: FileMode.append); + core.connection.signalNotification(LogNotification('App crashed: $error')); + } catch (error) { + if (kDebugMode) { + print('Failed to write crash log: $error'); + } + // Avoid throwing from the crash logger + } +} + +// Minimal implementation; customize per platform if needed. +Future _getLogDirectory() async { + // On mobile, you might choose applicationDocumentsDirectory via platform channel, + // but staying pure Dart, use currentDirectory as a placeholder. + return Directory.current; +} + +enum ConnectionType { + unknown, + local, + remote, +} + +void initializeActions(ConnectionType connectionType) { + if (kIsWeb) { + core.actionHandler = StubActions(); + } else if (Platform.isAndroid) { + core.actionHandler = switch (connectionType) { + ConnectionType.local => AndroidActions(), + ConnectionType.remote => RemoteActions(), + ConnectionType.unknown => StubActions(), + }; + } else if (Platform.isIOS) { + core.actionHandler = switch (connectionType) { + ConnectionType.local => StubActions(), + ConnectionType.remote => RemoteActions(), + ConnectionType.unknown => StubActions(), + }; + } else { + core.actionHandler = switch (connectionType) { + ConnectionType.local => DesktopActions(), + ConnectionType.remote => RemoteActions(), + ConnectionType.unknown => StubActions(), + }; + } + core.actionHandler.init(core.settings.getKeyMap()); +} + +class BikeControlApp extends StatefulWidget { + final Widget? customChild; + final BCPage page; + final String? error; + const BikeControlApp({super.key, this.error, this.page = BCPage.devices, this.customChild}); + + @override + State createState() => _BikeControlAppState(); } -class SwiftPlayApp extends StatelessWidget { - const SwiftPlayApp({super.key}); +class _BikeControlAppState extends State { + BCPage? _showPage; @override Widget build(BuildContext context) { - return MaterialApp( - title: 'SwiftControl', - theme: AppTheme.light, - darkTheme: AppTheme.dark, - themeMode: ThemeMode.system, - home: const RequirementsPage(), + final isMobile = MediaQuery.sizeOf(context).width < 600; + return ShadcnApp( + navigatorKey: navigatorKey, + debugShowCheckedModeBanner: false, + menuHandler: OverlayHandler.popover, + popoverHandler: OverlayHandler.popover, + localizationsDelegates: [ + ...ShadcnLocalizations.localizationsDelegates, + OtherLocalizationsDelegate(), + AppLocalizations.delegate, + ], + supportedLocales: AppLocalizations.delegate.supportedLocales, + title: 'BikeControl', + darkTheme: ThemeData( + colorScheme: ColorSchemes.darkNeutral.copyWith( + card: () => Color(0xFF001A29), + background: () => Color(0xFF232323), + muted: () => Color(0xFF3A3A3A), + border: () => Color(0xFF3A3A3A), + ), + ), + locale: screenshotMode ? Locale('en') : null, + theme: ThemeData( + colorScheme: ColorSchemes.lightNeutral.copyWith( + card: () => BKColor.backgroundLight, + ), + ), + //themeMode: ThemeMode.light, + home: widget.error != null + ? Center( + child: Text( + 'There was an error starting the App. Please contact support:\n${widget.error}', + style: TextStyle(color: Colors.white), + ), + ) + : ToastLayer( + key: ValueKey('Test'), + padding: isMobile ? EdgeInsets.only(bottom: 60, left: 24, right: 24, top: 60) : null, + child: _Starter( + child: Stack( + children: [ + widget.customChild ?? + (AnimatedSwitcher( + duration: Duration(milliseconds: 600), + child: core.settings.getShowOnboarding() + ? OnboardingPage( + onComplete: () { + setState(() { + if (core.obpMdnsEmulator.connectedApp.value == null) { + _showPage = BCPage.trainer; + } else { + _showPage = BCPage.devices; + } + }); + }, + ) + : Navigation(page: _showPage ?? widget.page), + )), + Positioned.fill(child: Testbed()), + ], + ), + ), + ), ); } } + +class _Starter extends StatefulWidget { + final Widget child; + const _Starter({super.key, required this.child}); + + @override + State<_Starter> createState() => _StarterState(); +} + +class _StarterState extends State<_Starter> with WidgetsBindingObserver { + final _appLinks = AppLinks(); + + StreamSubscription? _deeplinkSubscription; + + @override + void initState() { + super.initState(); + + core.connection.initialize(); + WindowsProtocolHandler().registerForOutsideStoreBuild('bikecontrol'); + WidgetsBinding.instance.addObserver(this); + if (!kIsWeb && !screenshotMode) { + // It will handle app links while the app is already started - be it in + // the foreground or in the background. + _deeplinkSubscription = _appLinks.uriLinkStream.listen( + (Uri? uri) { + if (uri != null) { + if (uri.scheme == "bikecontrol") { + IAPManager.instance.refreshEntitlementsOnAppStart(); + } + } + }, + onError: (Object err, StackTrace stackTrace) { + if (kDebugMode) { + print('Error handling deep link: $err'); + debugPrintStack(stackTrace: stackTrace); + } + recordError(err, stackTrace, context: 'DeepLink'); + }, + ); + } + unawaited(IAPManager.instance.refreshEntitlementsOnAppStart()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _deeplinkSubscription?.cancel(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(IAPManager.instance.refreshEntitlementsOnResume()); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +class OtherLocalizationsDelegate extends LocalizationsDelegate { + const OtherLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) => + AppLocalizations.delegate.supportedLocales.map((e) => e.languageCode).contains(locale.languageCode); + + @override + Future load(Locale locale) async { + return SynchronousFuture(lookupShadcnLocalizations(Locale('en'))); + } + + @override + bool shouldReload(covariant LocalizationsDelegate old) => false; +} diff --git a/lib/models/device_limit_reached_error.dart b/lib/models/device_limit_reached_error.dart new file mode 100644 index 000000000..878f12cfc --- /dev/null +++ b/lib/models/device_limit_reached_error.dart @@ -0,0 +1,35 @@ +import 'package:bike_control/models/user_device.dart'; + +class DeviceLimitReachedError implements Exception { + final String platform; + final int maxDevices; + final List devices; + + const DeviceLimitReachedError({ + required this.platform, + required this.maxDevices, + required this.devices, + }); + + factory DeviceLimitReachedError.fromJson(Map json) { + final rawDevices = json['devices']; + final parsedDevices = rawDevices is List + ? rawDevices + .whereType() + .map((item) => Map.from(item)) + .map(UserDevice.fromJson) + .toList(growable: false) + : const []; + + return DeviceLimitReachedError( + platform: (json['platform'] as String?) ?? '', + maxDevices: (json['max_devices'] as num?)?.toInt() ?? 0, + devices: parsedDevices, + ); + } + + @override + String toString() { + return 'Device limit reached for $platform ($maxDevices)'; + } +} diff --git a/lib/models/entitlement.dart b/lib/models/entitlement.dart new file mode 100644 index 000000000..38ea117a9 --- /dev/null +++ b/lib/models/entitlement.dart @@ -0,0 +1,40 @@ +class Entitlement { + final String productKey; + final String status; + final DateTime? activeUntil; + final String source; + + const Entitlement({ + required this.productKey, + required this.status, + required this.activeUntil, + required this.source, + }); + + bool get isActive => status == 'active'; // || status == 'grace'; + + factory Entitlement.fromJson(Map json) { + return Entitlement( + productKey: json['product_key'] as String? ?? '', + status: json['status'] as String? ?? 'expired', + activeUntil: _parseDateTime(json['active_until']), + source: json['source'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'product_key': productKey, + 'status': status, + 'active_until': activeUntil?.toIso8601String(), + 'source': source, + }; + } + + static DateTime? _parseDateTime(dynamic value) { + if (value is! String || value.isEmpty) { + return null; + } + return DateTime.tryParse(value); + } +} diff --git a/lib/models/register_device_result.dart b/lib/models/register_device_result.dart new file mode 100644 index 000000000..a684b015e --- /dev/null +++ b/lib/models/register_device_result.dart @@ -0,0 +1,25 @@ +class RegisterDeviceResult { + final bool ok; + final String platform; + final String deviceId; + final int maxDevices; + final int activeDeviceCount; + + const RegisterDeviceResult({ + required this.ok, + required this.platform, + required this.deviceId, + required this.maxDevices, + required this.activeDeviceCount, + }); + + factory RegisterDeviceResult.fromJson(Map json) { + return RegisterDeviceResult( + ok: (json['ok'] as bool?) ?? false, + platform: (json['platform'] as String?) ?? '', + deviceId: (json['device_id'] as String?) ?? '', + maxDevices: (json['max_devices'] as num?)?.toInt() ?? 0, + activeDeviceCount: (json['active_device_count'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/lib/models/user_device.dart b/lib/models/user_device.dart new file mode 100644 index 000000000..119a52701 --- /dev/null +++ b/lib/models/user_device.dart @@ -0,0 +1,47 @@ +class UserDevice { + final String id; + final String userId; + final String platform; + final String deviceId; + final String? deviceName; + final String? appVersion; + final DateTime? lastSeenAt; + final DateTime? createdAt; + final DateTime? revokedAt; + + const UserDevice({ + required this.id, + required this.userId, + required this.platform, + required this.deviceId, + required this.deviceName, + required this.appVersion, + required this.lastSeenAt, + required this.createdAt, + required this.revokedAt, + }); + + bool get isRevoked => revokedAt != null; + bool get isActive => !isRevoked; + + factory UserDevice.fromJson(Map json) { + return UserDevice( + id: (json['id'] as String?) ?? '', + userId: (json['user_id'] as String?) ?? '', + platform: (json['platform'] as String?) ?? '', + deviceId: (json['device_id'] as String?) ?? '', + deviceName: json['device_name'] as String?, + appVersion: json['app_version'] as String?, + lastSeenAt: _parseDateTime(json['last_seen_at']), + createdAt: _parseDateTime(json['created_at']), + revokedAt: _parseDateTime(json['revoked_at']), + ); + } + + static DateTime? _parseDateTime(dynamic value) { + if (value is! String || value.isEmpty) { + return null; + } + return DateTime.tryParse(value); + } +} diff --git a/lib/models/user_settings.dart b/lib/models/user_settings.dart new file mode 100644 index 000000000..9cd16e871 --- /dev/null +++ b/lib/models/user_settings.dart @@ -0,0 +1,107 @@ +/// Model representing user settings synced across devices. +/// Corresponds to the user_settings table in Supabase. +class UserSettings { + final String? userId; + final String? deviceId; + final Map? keymaps; + final List? ignoredDeviceIds; + final List? ignoredDeviceNames; + final int version; + final DateTime? updatedAt; + final DateTime? createdAt; + + const UserSettings({ + this.userId, + this.deviceId, + this.keymaps, + this.ignoredDeviceIds, + this.ignoredDeviceNames, + this.version = 0, + this.updatedAt, + this.createdAt, + }); + + factory UserSettings.fromJson(Map json) { + return UserSettings( + userId: json['user_id'] as String?, + deviceId: json['device_id'] as String?, + keymaps: json['keymaps'] as Map?, + ignoredDeviceIds: _parseStringList(json['ignored_device_ids']), + ignoredDeviceNames: _parseStringList(json['ignored_device_names']), + version: json['version'] as int? ?? 0, + updatedAt: json['updated_at'] != null + ? DateTime.parse(json['updated_at'] as String) + : null, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : null, + ); + } + + Map toJson() { + return { + 'user_id': userId, + 'device_id': deviceId, + 'keymaps': keymaps, + 'ignored_device_ids': _stringifyList(ignoredDeviceIds), + 'ignored_device_names': _stringifyList(ignoredDeviceNames), + 'version': version, + }; + } + + UserSettings copyWith({ + String? userId, + String? deviceId, + Map? keymaps, + List? ignoredDeviceIds, + List? ignoredDeviceNames, + int? version, + DateTime? updatedAt, + DateTime? createdAt, + }) { + return UserSettings( + userId: userId ?? this.userId, + deviceId: deviceId ?? this.deviceId, + keymaps: keymaps ?? this.keymaps, + ignoredDeviceIds: ignoredDeviceIds ?? this.ignoredDeviceIds, + ignoredDeviceNames: ignoredDeviceNames ?? this.ignoredDeviceNames, + version: version ?? this.version, + updatedAt: updatedAt ?? this.updatedAt, + createdAt: createdAt ?? this.createdAt, + ); + } + + static List? _parseStringList(dynamic value) { + if (value == null) return null; + if (value is List) { + return value.map((e) => e.toString()).toList(); + } + if (value is String) { + return value.isEmpty ? [] : value.split(RegExp(r'[,\n]+')).map((e) => e.trim()).where((e) => e.isNotEmpty).toList(); + } + return null; + } + + static String? _stringifyList(List? list) { + if (list == null) return null; + return list.join(','); + } + + /// Returns true if this settings instance has newer data than [other]. + /// Compares version first, then falls back to updated_at timestamp. + bool isNewerThan(UserSettings? other) { + if (other == null) return true; + if (version != other.version) { + return version > other.version; + } + if (updatedAt != null && other.updatedAt != null) { + return updatedAt!.isAfter(other.updatedAt!); + } + return updatedAt != null; + } + + @override + String toString() { + return 'UserSettings(deviceId: $deviceId, version: $version, updatedAt: $updatedAt, keymaps: ${keymaps?.length ?? 0} items)'; + } +} diff --git a/lib/pages/button_edit.dart b/lib/pages/button_edit.dart new file mode 100644 index 000000000..bc47fc55e --- /dev/null +++ b/lib/pages/button_edit.dart @@ -0,0 +1,1042 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/pages/touch_area.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/apps/custom_app.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/widgets/custom_keymap_selector.dart'; +import 'package:bike_control/widgets/go_pro_dialog.dart'; +import 'package:bike_control/widgets/ui/button_widget.dart'; +import 'package:bike_control/widgets/ui/colored_title.dart'; +import 'package:bike_control/widgets/ui/colors.dart'; +import 'package:bike_control/widgets/ui/pro_badge.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:dartx/dartx.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../bluetooth/devices/base_device.dart'; + +class ButtonEditPage extends StatefulWidget { + final Keymap keymap; + final BaseDevice device; + final KeyPair keyPair; + final ButtonTrigger trigger; + final VoidCallback onUpdate; + const ButtonEditPage({ + super.key, + required this.keyPair, + required this.device, + required this.onUpdate, + required this.keymap, + required this.trigger, + }); + + @override + State createState() => _ButtonEditPageState(); +} + +class _ButtonEditPageState extends State { + late KeyPair _keyPair; + late final ScrollController _scrollController = ScrollController(); + final double baseHeight = 46; + bool _bumped = false; + + void _triggerBump() async { + setState(() { + _bumped = true; + }); + + await Future.delayed(const Duration(milliseconds: 150)); + + if (mounted) { + setState(() { + _bumped = false; + }); + } + } + + late StreamSubscription _actionSubscription; + + bool get _usesFallbackLongPressMode { + final button = _keyPair.buttons.firstOrNull; + if (button == null || widget.trigger != ButtonTrigger.longPress) { + return false; + } + return widget.device.supportsLongPress == false; + } + + @override + void initState() { + super.initState(); + _keyPair = widget.keyPair; + _keyPair.trigger = widget.trigger; + _actionSubscription = core.connection.actionStream.listen((data) async { + if (!mounted) { + return; + } + if (data is ButtonNotification && data.buttonsClicked.length == 1) { + final clickedButton = data.buttonsClicked.first; + final keyPair = widget.keymap.getOrCreateKeyPair(clickedButton, trigger: widget.trigger); + setState(() { + _keyPair = keyPair; + }); + _triggerBump(); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _actionSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + child: Container( + constraints: BoxConstraints(maxWidth: 300), + padding: const EdgeInsets.only(right: 26.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + SizedBox(height: 16), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 8, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 600), + curve: Curves.easeOut, + width: _keyPair.buttons.first.color != null ? baseHeight : null, + height: _keyPair.buttons.first.color != null ? baseHeight : null, + padding: EdgeInsets.all(_bumped ? 0 : 6.0), + constraints: BoxConstraints(maxWidth: 120), + child: ButtonWidget(button: _keyPair.buttons.first), + ), + Expanded(child: SizedBox()), + IconButton( + icon: Icon(Icons.close), + variance: ButtonVariance.ghost, + onPressed: () { + closeDrawer(context); + }, + ), + ], + ), + Text('Editing ${widget.trigger.title}').xSmall.muted, + if (_usesFallbackLongPressMode) + Warning( + important: false, + children: [ + Text( + 'This device uses long press toggle mode: first click sends key down, second click sends key up.', + ).small, + ], + ), + if (core.logic.hasNoConnectionMethod) + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 300), + child: Warning( + children: [ + Text(AppLocalizations.of(context).pleaseSelectAConnectionMethodFirst), + ], + ), + ), + if (core.logic.showObpActions) ...[ + ColoredTitle(text: context.i18n.openBikeControlActions), + if (core.logic.obpConnectedApp == null) + Warning( + children: [ + Text( + core.logic.obpConnectedApp == null + ? 'Please connect to ${core.settings.getTrainerApp()?.name}, first.' + : context.i18n.appIdActions(core.logic.obpConnectedApp!.appId), + ), + ], + ) + else + ..._buildTrainerConnectionActions(core.logic.obpConnectedApp!.supportedActions), + ], + + if (core.logic.showMyWhooshLink && (Platform.isIOS || core.settings.getMyWhooshLinkEnabled())) ...[ + SizedBox(height: 8), + ColoredTitle(text: context.i18n.myWhooshDirectConnectAction), + if (!core.settings.getMyWhooshLinkEnabled()) + Warning( + important: false, + children: [ + Text(AppLocalizations.of(context).enableMywhooshLinkInTheConnectionSettingsFirst), + ], + ) + else + ..._buildTrainerConnectionActions(core.whooshLink.supportedActions), + ], + if (core.logic.showZwiftBleEmulator || core.logic.showZwiftMsdnEmulator) ...[ + SizedBox(height: 8), + ColoredTitle(text: context.i18n.zwiftControllerAction), + if (!core.settings.getZwiftBleEmulatorEnabled() && !core.settings.getZwiftMdnsEmulatorEnabled()) + Warning( + important: false, + children: [ + Text(AppLocalizations.of(context).enableItInTheConnectionSettingsFirst), + ], + ) + else + ..._buildTrainerConnectionActions(core.zwiftEmulator.supportedActions), + ], + + if (core.logic.showLocalRemoteOptions) ...[ + SizedBox(height: 8), + ColoredTitle(text: 'Local / Remote Setting'), + + if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard) && + (core.settings.getLocalEnabled() || core.settings.getRemoteKeyboardControlEnabled())) + Builder( + builder: (context) { + return SelectableCard( + icon: RadixIcons.keyboard, + title: Text(context.i18n.simulateKeyboardShortcut), + isActive: + _keyPair.physicalKey != null && + !_keyPair.isSpecialKey && + (core.settings.getLocalEnabled() || core.settings.getRemoteKeyboardControlEnabled()), + value: _keyPair.toString(), + onPressed: () async { + await _showModeDropdown(context, SupportedMode.keyboard); + }, + ); + }, + ), + if (core.actionHandler.supportedModes.contains(SupportedMode.touch) && + (core.settings.getLocalEnabled() || core.settings.getRemoteControlEnabled())) + Builder( + builder: (context) { + return SelectableCard( + title: Text(context.i18n.simulateTouch), + icon: core.actionHandler is AndroidActions ? Icons.touch_app_outlined : BootstrapIcons.mouse, + isActive: + ((core.actionHandler is AndroidActions || _keyPair.physicalKey == null) && + _keyPair.touchPosition != Offset.zero) && + (core.settings.getLocalEnabled() || core.settings.getRemoteControlEnabled()), + value: _keyPair.toString(), + trailing: IconButton.secondary( + icon: Icon(Icons.ondemand_video), + onPressed: () { + launchUrlString('https://youtube.com/shorts/SvLOQqu2Dqg?feature=share'); + }, + ), + onPressed: () async { + await _showModeDropdown(context, SupportedMode.touch); + }, + ); + }, + ), + + if (core.actionHandler.supportedModes.contains(SupportedMode.media)) + Builder( + builder: (context) => SelectableCard( + icon: Icons.music_note_outlined, + isActive: _keyPair.isSpecialKey && core.settings.getLocalEnabled(), + title: Text(context.i18n.simulateMediaKey), + value: _keyPair.toString(), + trailing: IconButton.secondary( + icon: Icon(Icons.ondemand_video), + onPressed: () { + launchUrlString('https://youtube.com/shorts/ClY1eTnmAv0?feature=share'); + }, + ), + onPressed: () { + if (!core.settings.getLocalEnabled()) { + buildToast( + title: AppLocalizations.of(context).enableLocalConnectionMethodFirst, + ); + } else { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + MenuButton( + leading: Icon(Icons.play_arrow_outlined), + onPressed: (c) async { + if (!await _ensureProForFeature(context)) { + return; + } + _keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause; + _keyPair.touchPosition = Offset.zero; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + + setState(() {}); + widget.onUpdate(); + }, + child: _buildProMenuItemLabel(context.i18n.playPause), + ), + MenuButton( + leading: Icon(Icons.stop_outlined), + onPressed: (c) async { + if (!await _ensureProForFeature(context)) { + return; + } + _keyPair.physicalKey = PhysicalKeyboardKey.mediaStop; + _keyPair.touchPosition = Offset.zero; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + + setState(() {}); + widget.onUpdate(); + }, + child: _buildProMenuItemLabel(context.i18n.stop), + ), + MenuButton( + leading: Icon(Icons.skip_previous_outlined), + onPressed: (c) async { + if (!await _ensureProForFeature(context)) { + return; + } + _keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious; + _keyPair.touchPosition = Offset.zero; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + + setState(() {}); + widget.onUpdate(); + }, + child: _buildProMenuItemLabel(context.i18n.previous), + ), + MenuButton( + leading: Icon(Icons.skip_next_outlined), + onPressed: (c) async { + if (!await _ensureProForFeature(context)) { + return; + } + _keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext; + _keyPair.touchPosition = Offset.zero; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + + setState(() {}); + widget.onUpdate(); + }, + child: _buildProMenuItemLabel(context.i18n.next), + ), + MenuButton( + leading: Icon(Icons.volume_up_outlined), + onPressed: (c) async { + if (!await _ensureProForFeature(context)) { + return; + } + _keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp; + _keyPair.touchPosition = Offset.zero; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + + setState(() {}); + widget.onUpdate(); + }, + child: _buildProMenuItemLabel(context.i18n.volumeUp), + ), + MenuButton( + leading: Icon(Icons.volume_down_outlined), + child: _buildProMenuItemLabel(context.i18n.volumeDown), + onPressed: (c) async { + if (!await _ensureProForFeature(context)) { + return; + } + _keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown; + _keyPair.touchPosition = Offset.zero; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + + setState(() {}); + widget.onUpdate(); + }, + ), + ], + ), + ); + } + }, + ), + ), + if (core.logic.showLocalControl && core.actionHandler is AndroidActions) + Builder( + builder: (context) => SelectableCard( + icon: Icons.settings_remote_outlined, + isActive: + _keyPair.androidAction != null && + _keyPair.androidAction != AndroidSystemAction.assistant && + core.settings.getLocalEnabled(), + title: Text(AppLocalizations.of(context).androidSystemAction), + value: _keyPair.androidAction != AndroidSystemAction.assistant + ? _keyPair.androidAction?.title + : null, + trailing: IconButton.secondary( + icon: Icon(Icons.ondemand_video), + onPressed: () { + launchUrlString('https://youtube.com/shorts/zqD5ARGIVmE?feature=share'); + }, + ), + onPressed: () { + if (!core.settings.getLocalEnabled()) { + buildToast(title: 'Enable Local Connection method, first.'); + } else { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: AndroidSystemAction.values + .where((action) => action != AndroidSystemAction.assistant) + .map( + (action) => MenuButton( + leading: Icon(action.icon), + onPressed: (_) async { + if (!await _ensureProForFeature(context)) { + return; + } + _keyPair.androidAction = action; + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.modifiers = []; + _keyPair.touchPosition = Offset.zero; + _keyPair.inGameAction = null; + _keyPair.inGameActionValue = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + setState(() {}); + widget.onUpdate(); + }, + child: _buildProMenuItemLabel(action.title), + ), + ) + .toList(), + ), + ); + } + }, + ), + ), + if (core.logic.showLocalControl && core.actionHandler is AndroidActions) + Builder( + builder: (context) => SelectableCard( + icon: Icons.assistant_outlined, + isActive: + _keyPair.androidAction == AndroidSystemAction.assistant && core.settings.getLocalEnabled(), + title: Text(AndroidSystemAction.assistant.title), + value: _keyPair.androidAction == AndroidSystemAction.assistant + ? _keyPair.androidAction?.title + : null, + isProOnly: true, + onPressed: () { + _keyPair.androidAction = AndroidSystemAction.assistant; + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.modifiers = []; + _keyPair.touchPosition = Offset.zero; + _keyPair.inGameAction = null; + _keyPair.inGameActionValue = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + setState(() {}); + widget.onUpdate(); + }, + ), + ), + ], + + if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isIOS)) ...[ + SizedBox(height: 8), + ColoredTitle(text: 'Other Actions'), + SelectableCard( + isProOnly: true, + title: Text(Platform.isMacOS || Platform.isIOS ? 'Launch Shortcut' : 'Run Command'), + icon: Platform.isMacOS || Platform.isIOS ? Icons.rocket_launch_outlined : Icons.terminal, + isActive: _keyPair.command?.trim().isNotEmpty == true, + value: _keyPair.command, + onPressed: () async { + await _showCommandDialog(context); + }, + ), + if (Platform.isMacOS || Platform.isWindows) + SelectableCard( + isProOnly: true, + title: Text('Take Screenshot'), + icon: Icons.image_outlined, + isActive: _keyPair.screenshotPath?.trim().isNotEmpty == true, + value: _keyPair.screenshotPath, + onPressed: () async { + await _showScreenshotDialog(); + }, + ), + ], + + if (core.connection.accessories.isNotEmpty) ...[ + SizedBox(height: 8), + ColoredTitle(text: 'Accessory Actions'), + Builder( + builder: (context) => SelectableCard( + icon: Icons.air, + title: Text('KICKR Headwind'), + isActive: + _keyPair.inGameAction != null && + (_keyPair.inGameAction == InGameAction.headwindSpeed || + _keyPair.inGameAction == InGameAction.headwindHeartRateMode), + value: _keyPair.inGameAction != null + ? '${_keyPair.inGameAction} ${_keyPair.inGameActionValue ?? ""}'.trim() + : null, + onPressed: () { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + MenuButton( + subMenu: [0, 25, 50, 75, 100] + .map( + (value) => MenuButton( + child: Text('Set Speed to $value%'), + onPressed: (_) { + _keyPair.inGameAction = InGameAction.headwindSpeed; + _keyPair.inGameActionValue = value; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + widget.onUpdate(); + setState(() {}); + }, + ), + ) + .toList(), + child: Text('Set Speed'), + ), + MenuButton( + child: Text('Set to Heart Rate Mode'), + onPressed: (_) { + _keyPair.inGameAction = InGameAction.headwindHeartRateMode; + _keyPair.inGameActionValue = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + widget.onUpdate(); + setState(() {}); + }, + ), + ], + ), + ); + }, + ), + ), + ], + + SizedBox(height: 8), + ColoredTitle(text: context.i18n.setting), + SizedBox(height: 8), + DestructiveButton( + onPressed: () { + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.modifiers = []; + _keyPair.touchPosition = Offset.zero; + _keyPair.inGameAction = null; + _keyPair.inGameActionValue = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + widget.onUpdate(); + setState(() {}); + }, + child: Text(context.i18n.unassignAction), + ), + SizedBox(height: 16), + ], + ), + ), + ), + ), + ); + } + + List _buildTrainerConnectionActions(List supportedActions) { + return supportedActions.map((action) { + return Builder( + builder: (context) { + return SelectableCard( + icon: action.icon, + title: Text(action.title), + subtitle: (action.possibleValues != null && action == _keyPair.inGameAction) + ? Text(_keyPair.inGameActionValue!.toString()) + : action.alternativeTitle != null + ? Text(action.alternativeTitle!) + : null, + isActive: _keyPair.inGameAction == action && supportedActions.contains(_keyPair.inGameAction), + onPressed: () { + if (action.possibleValues?.isNotEmpty == true) { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: action.possibleValues!.map( + (ingame) { + return MenuButton( + child: Text(ingame.toString()), + onPressed: (_) { + _keyPair.touchPosition = Offset.zero; + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + _keyPair.inGameAction = action; + _keyPair.inGameActionValue = ingame; + widget.onUpdate(); + setState(() {}); + }, + ); + }, + ).toList(), + ), + ); + } else { + _keyPair.touchPosition = Offset.zero; + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + _keyPair.inGameAction = action; + _keyPair.inGameActionValue = null; + widget.onUpdate(); + setState(() {}); + } + }, + ); + }, + ); + }).toList(); + } + + Future _showCommandDialog(BuildContext context) async { + if (Platform.isWindows) { + final result = await FilePicker.platform.pickFiles( + dialogTitle: 'Select command to run', + type: FileType.any, + allowMultiple: false, + ); + if (result == null) { + return; + } + final selectedPath = result.files.single.path?.trim(); + if (selectedPath == null || selectedPath.isEmpty) { + buildToast(title: 'No executable file selected'); + return; + } + _setCommand(selectedPath); + return; + } + + final controller = TextEditingController(text: _keyPair.command ?? ''); + final result = await showDialog( + context: context, + builder: (context) => SafeArea( + child: AlertDialog( + title: Text('Launch Shortcut'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + TextField( + controller: controller, + hintText: 'Shortcut name', + autofocus: true, + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, + ), + if (Platform.isMacOS) + Text('Runs a macOS Shortcuts shortcut by its exact name when this button is pressed.').small + else + Text( + 'Note that Shortcuts on iOS are very limited: BikeControl needs to be in the foreground when you want to run the command, and your shortcut should have "Open BikeControl" as its first action so BikeControl can continue to trigger shortcuts.', + ).xSmall, + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.i18n.cancel), + ), + if (_keyPair.command?.trim().isNotEmpty == true) + TextButton( + onPressed: () => Navigator.pop(context, ''), + child: Text('Clear'), + ), + TextButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text('Save'), + ), + ], + ), + ), + ); + + if (result == null) { + return; + } + + final shortcutName = result.trim(); + _setCommand(shortcutName.isEmpty ? null : shortcutName); + } + + void _setCommand(String? value) { + _keyPair.command = value; + + if (_keyPair.command != null) { + _keyPair.screenshotPath = null; + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.modifiers = []; + _keyPair.touchPosition = Offset.zero; + _keyPair.inGameAction = null; + _keyPair.inGameActionValue = null; + _keyPair.androidAction = null; + } + + widget.onUpdate(); + setState(() {}); + } + + Future _showScreenshotDialog() async { + final selectedPath = Directory.current.path; + + final path = selectedPath.trim(); + if (path.isEmpty) { + buildToast(title: 'No path selected'); + return; + } + + final hasWriteAccess = await _ensureScreenshotDirectoryWritable(path); + if (!hasWriteAccess) { + buildToast(title: 'Cannot write to this folder. Please grant write permission and try again.'); + return; + } + + _setScreenshotPath(path); + } + + Future _ensureScreenshotDirectoryWritable(String directoryPath) async { + try { + final directory = Directory(directoryPath); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + final testFile = File( + '${directory.path}${Platform.pathSeparator}.bikecontrol-write-test-${DateTime.now().microsecondsSinceEpoch}', + ); + await testFile.writeAsString('ok', flush: true); + if (await testFile.exists()) { + await testFile.delete(); + } + return true; + } catch (e) { + return false; + } + } + + void _setScreenshotPath(String? value) { + _keyPair.screenshotPath = value; + + if (_keyPair.screenshotPath != null) { + _keyPair.command = null; + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.modifiers = []; + _keyPair.touchPosition = Offset.zero; + _keyPair.inGameAction = null; + _keyPair.inGameActionValue = null; + _keyPair.androidAction = null; + } + + widget.onUpdate(); + setState(() {}); + } + + Future _ensureProForFeature(BuildContext context) async { + if (IAPManager.instance.hasActiveSubscription) { + return true; + } + await showGoProDialog(context); + return IAPManager.instance.hasActiveSubscription; + } + + Widget _buildProMenuItemLabel(String text) { + final isPro = IAPManager.instance.hasActiveSubscription; + if (isPro) { + return Text(text); + } + + return Row( + children: [ + Expanded(child: Text(text)), + const ProBadge( + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2), + fontSize: 9, + ), + ], + ); + } + + Future _showModeDropdown(BuildContext context, SupportedMode supportedMode) async { + final trainerApp = core.settings.getTrainerApp(); + + final triggerForPredefined = widget.trigger == ButtonTrigger.doubleClick + ? ButtonTrigger.singleClick + : widget.trigger; + final actionsWithInGameAction = trainerApp?.keymap.keyPairs + .where( + (kp) => + kp.trigger == triggerForPredefined && + kp.inGameAction != null && + switch (supportedMode) { + SupportedMode.keyboard => kp.physicalKey != null, + SupportedMode.touch => kp.touchPosition != Offset.zero, + SupportedMode.media => kp.isSpecialKey, + }, + ) + .distinctBy((kp) => kp.inGameAction) + .toList(); + + final isEnabled = + supportedMode == SupportedMode.keyboard && + (core.settings.getLocalEnabled() || core.settings.getRemoteKeyboardControlEnabled()) || + supportedMode == SupportedMode.touch && + (core.settings.getLocalEnabled() || core.settings.getRemoteControlEnabled()) || + supportedMode == SupportedMode.media && core.settings.getLocalEnabled(); + + if (!isEnabled) { + return buildToast( + title: AppLocalizations.of(context).enableLocalConnectionMethodFirst, + ); + } else if (actionsWithInGameAction != null && actionsWithInGameAction.isNotEmpty) { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + MenuLabel(child: Text(context.i18n.predefinedAction(trainerApp?.name ?? 'App'))), + ...actionsWithInGameAction.map((keyPairAction) { + return MenuButton( + leading: keyPairAction.inGameAction?.icon != null ? Icon(keyPairAction.inGameAction!.icon) : null, + onPressed: (_) { + // Copy all properties from the selected predefined action + if (core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) { + _keyPair.physicalKey = keyPairAction.physicalKey; + _keyPair.logicalKey = keyPairAction.logicalKey; + _keyPair.modifiers = List.of(keyPairAction.modifiers); + } else { + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.modifiers = []; + } + if (core.actionHandler.supportedModes.contains(SupportedMode.touch)) { + _keyPair.touchPosition = keyPairAction.touchPosition; + } else { + _keyPair.touchPosition = Offset.zero; + } + _keyPair.inGameAction = keyPairAction.inGameAction; + _keyPair.inGameActionValue = keyPairAction.inGameActionValue; + _keyPair.androidAction = null; + _keyPair.command = keyPairAction.command; + _keyPair.screenshotPath = keyPairAction.screenshotPath; + setState(() {}); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(keyPairAction.inGameAction?.title ?? ''), + Text(switch (supportedMode) { + SupportedMode.keyboard => keyPairAction.logicalKey?.keyLabel ?? 'Not assigned', + SupportedMode.touch => + 'X:${keyPairAction.touchPosition.dx.toInt()}, Y:${keyPairAction.touchPosition.dy.toInt()}', + SupportedMode.media => throw UnimplementedError(), + }).muted.small, + ], + ), + ); + }), + MenuDivider(), + MenuLabel(child: Text('Custom ${supportedMode.name.capitalize()} action')), + MenuButton( + leading: Icon(Icons.edit_outlined), + onPressed: (_) { + _editAction(supportedMode); + }, + child: Text('Custom'), + ), + ], + ), + ); + } else { + _editAction(supportedMode); + } + } + + Future _editAction(SupportedMode supportedMode) async { + if (supportedMode == SupportedMode.keyboard) { + await showDialog( + context: context, + barrierDismissible: false, // enable Escape key + builder: (c) => HotKeyListenerDialog( + customApp: core.actionHandler.supportedApp! as CustomApp, + keyPair: _keyPair, + trigger: widget.trigger, + ), + ); + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + setState(() {}); + widget.onUpdate(); + } else if (supportedMode == SupportedMode.touch) { + if (_keyPair.touchPosition == Offset.zero) { + _keyPair.touchPosition = Offset(50, 50); + } + _keyPair.physicalKey = null; + _keyPair.logicalKey = null; + _keyPair.androidAction = null; + _keyPair.command = null; + _keyPair.screenshotPath = null; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (c) => TouchAreaSetupPage( + keyPair: _keyPair, + ), + ), + ); + setState(() {}); + widget.onUpdate(); + } + } +} + +class SelectableCard extends StatelessWidget { + final Widget title; + final Widget? subtitle; + final Widget? trailing; + final IconData? icon; + final bool isActive; + final String? value; + final VoidCallback? onPressed; + final bool isProOnly; + + const SelectableCard({ + super.key, + required this.title, + this.icon, + this.subtitle, + this.trailing, + required this.isActive, + this.value, + required this.onPressed, + this.isProOnly = false, + }); + + @override + Widget build(BuildContext context) { + final isPro = IAPManager.instance.hasActiveSubscription; + + return Stack( + children: [ + Button.outline( + style: + ButtonStyle( + variance: ButtonVariance.outline, + ) + .withBorder( + border: isActive + ? Border.all(color: BKColor.main, width: 2) + : Border.all(color: Theme.of(context).colorScheme.border, width: 2), + hoverBorder: Border.all(color: BKColor.mainEnd, width: 2), + focusBorder: Border.all(color: BKColor.main, width: 2), + ) + .withBackgroundColor( + color: isActive + ? Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.card + : Theme.of(context).colorScheme.card.withLuminance(0.9) + : Theme.of(context).colorScheme.background, + hoverColor: Theme.of(context).colorScheme.card, + ), + onPressed: () async { + if (isProOnly && !isPro) { + await showGoProDialog(context); + } else { + onPressed?.call(); + } + }, + alignment: Alignment.topLeft, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Basic( + leading: icon != null + ? Padding( + padding: const EdgeInsets.only(top: 3.0), + child: Icon( + icon, + color: icon == Icons.delete_outline ? Theme.of(context).colorScheme.destructive : null, + ), + ) + : null, + title: title, + subtitle: value != null && isActive ? Text(value!) : subtitle, + trailing: trailing, + ), + ), + ), + if (isProOnly && !isPro) + Positioned( + top: 0, + right: 0, + child: const ProBadge( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/button_simulator.dart b/lib/pages/button_simulator.dart new file mode 100644 index 000000000..dbe58d5e9 --- /dev/null +++ b/lib/pages/button_simulator.dart @@ -0,0 +1,823 @@ +import 'dart:math'; + +import 'package:bike_control/bluetooth/devices/mywhoosh/link.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart'; +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_emulator.dart'; +import 'package:bike_control/bluetooth/remote_keyboard_pairing.dart'; +import 'package:bike_control/bluetooth/remote_pairing.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/touch_area.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/actions/desktop.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart'; +import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart'; +import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.dart'; +import 'package:bike_control/widgets/apps/zwift_mdns_tile.dart'; +import 'package:bike_control/widgets/apps/zwift_tile.dart'; +import 'package:bike_control/widgets/keyboard_pair_widget.dart'; +import 'package:bike_control/widgets/mouse_pair_widget.dart'; +import 'package:bike_control/widgets/ui/gradient_text.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart' show BackButton; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class ButtonSimulator extends StatefulWidget { + const ButtonSimulator({super.key}); + + @override + State createState() => _ButtonSimulatorState(); +} + +class _ButtonSimulatorState extends State { + late final FocusNode _focusNode; + Map _hotkeys = {}; + Map> _recentValues = {}; + + // Default hotkeys for actions + static const List _defaultHotkeyOrder = [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'q', + 'w', + 'e', + 'r', + 't', + 'y', + 'u', + 'i', + 'o', + 'p', + 'a', + 's', + 'd', + 'f', + 'g', + 'h', + 'j', + 'k', + 'l', + 'z', + 'x', + 'c', + 'v', + 'b', + 'n', + 'm', + ]; + + static const Duration _keyPressDuration = Duration(milliseconds: 200); + + InGameAction? _pressedAction; + + DateTime? _lastDown; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: 'ButtonSimulatorFocus', canRequestFocus: true); + _loadHotkeys(); + _focusNode.requestFocus(); + } + + @override + void dispose() { + _focusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadHotkeys() async { + _loadRecentValues(); + final savedHotkeys = core.settings.getButtonSimulatorHotkeys(); + + // If no saved hotkeys, initialize with defaults + if (savedHotkeys.isEmpty) { + final connectedTrainers = core.logic.enabledTrainerConnections; + final allActions = []; + + for (final connection in connectedTrainers) { + allActions.addAll(connection.supportedActions); + } + + // Assign default hotkeys to actions + final Map defaultHotkeys = {}; + int hotkeyIndex = 0; + for (final action in allActions.distinct()) { + if (hotkeyIndex < _defaultHotkeyOrder.length) { + defaultHotkeys[action] = _defaultHotkeyOrder[hotkeyIndex]; + hotkeyIndex++; + } + } + + await core.settings.setButtonSimulatorHotkeys(defaultHotkeys); + if (mounted) { + setState(() { + _hotkeys = defaultHotkeys; + }); + } + } else { + setState(() { + _hotkeys = savedHotkeys; + }); + } + } + + void _loadRecentValues() { + final map = >{}; + for (final action in InGameAction.values) { + if (action.possibleValues != null) { + map[action] = action.possibleValues!.take(2).toList(); + } + } + setState(() { + _recentValues = map; + }); + } + + Future _sendQuickValue(InGameAction action, int value, TrainerConnection connection) async { + if (!connection.isConnected.value) { + buildToast(title: 'No connected trainer.'); + return; + } + await connection.sendAction( + KeyPair( + buttons: [], + physicalKey: null, + logicalKey: null, + inGameAction: action, + inGameActionValue: value, + ), + isKeyDown: true, + isKeyUp: true, + ); + _updateRecentValue(action, value); + } + + void _updateRecentValue(InGameAction action, int value) { + final list = List.from(_recentValues[action] ?? []); + list.remove(value); + list.insert(0, value); + setState(() { + _recentValues[action] = list.take(2).toList(); + }); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final key = event.logicalKey.keyLabel.toLowerCase(); + + // Find the action associated with this key + final action = _hotkeys.entries.firstOrNullWhere((entry) => entry.value == key)?.key; + + if (action == null) return KeyEventResult.ignored; + + _pressedAction = action; + setState(() {}); + + // Find the connection that supports this action + final connectedTrainers = core.logic.connectedTrainerConnections; + final connection = connectedTrainers.firstOrNullWhere((c) => c.supportedActions.contains(action)); + + if (connection != null) { + _sendKey(context, down: true, action: action, connection: connection); + // Schedule key up event + Future.delayed( + _keyPressDuration, + () { + if (mounted) { + _pressedAction = null; + setState(() {}); + _sendKey(context, down: false, action: action, connection: connection); + } + }, + ); + return KeyEventResult.handled; + } else { + _pressedAction = null; + setState(() {}); + buildToast(title: 'No connected trainer.'); + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final connectedTrainers = core.logic.enabledTrainerConnections; + + final isMobile = MediaQuery.sizeOf(context).width < 600; + + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKey, + child: Scaffold( + headers: [ + AppBar( + leading: [BackButton()], + title: Text(context.i18n.simulateButtons), + trailing: [ + PrimaryButton( + child: Icon(Icons.settings), + onPressed: () => _showHotkeySettings(context, connectedTrainers), + ), + ], + ), + ], + child: Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + if (connectedTrainers.isEmpty) + Warning( + children: [ + Text('No suitable connection method activated. Connect a trainer to simulate button presses.'), + ], + ), + for (final connectedTrainer in connectedTrainers) + if (!screenshotMode) + switch (connectedTrainer.title) { + WhooshLink.connectionTitle => MyWhooshLinkTile(), + ZwiftEmulator.connectionTitle => ZwiftTile( + onUpdate: () { + if (mounted) setState(() {}); + }, + ), + FtmsMdnsEmulator.connectionTitle => ZwiftMdnsTile( + onUpdate: () { + setState(() {}); + }, + ), + OpenBikeControlMdnsEmulator.connectionTitle => OpenBikeControlMdnsTile(), + OpenBikeControlBluetoothEmulator.connectionTitle => OpenBikeControlBluetoothTile(), + RemotePairing.connectionTitle => RemoteMousePairingWidget(), + RemoteKeyboardPairing.connectionTitle => RemoteKeyboardPairingWidget(), + _ => SizedBox.shrink(), + }, + ...connectedTrainers.map( + (connection) { + final supportedActions = connection.supportedActions == InGameAction.values + ? core.settings + .getTrainerApp()! + .keymap + .keyPairs + .mapNotNull((k) => k.inGameAction) + .distinct() + .toList() + : connection.supportedActions; + + final actionGroups = { + if (supportedActions.contains(InGameAction.shiftUp) && + supportedActions.contains(InGameAction.shiftDown)) + 'Shifting': [InGameAction.shiftDown, InGameAction.shiftUp], + 'Other': supportedActions + .where( + (action) => + action != InGameAction.shiftUp && + action != InGameAction.shiftDown && + action != InGameAction.steerLeft && + action != InGameAction.steerRight, + ) + .toList(), + if (supportedActions.contains(InGameAction.steerLeft) && + supportedActions.contains(InGameAction.steerRight)) + 'Steering': [InGameAction.steerLeft, InGameAction.steerRight], + }; + + return [ + GradientText(connection.title).bold.large, + ConstrainedBox( + constraints: BoxConstraints(maxWidth: 800), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + for (final group in actionGroups.entries) ...[ + Text(group.key.toUpperCase()).bold.muted, + if (group.value.length == 2) + Row( + spacing: 8, + children: group.value.map( + (action) { + final hotkey = _hotkeys[action]; + return Expanded( + child: Stack( + children: [ + SizedBox( + height: 150, + width: double.infinity, + child: _buildButton(action, group, connection, isMobile), + ), + if (hotkey != null) + Positioned( + top: -4, + right: -4, + child: KeyWidget( + label: hotkey.toUpperCase(), + invert: true, + ), + ), + ], + ), + ); + }, + ).toList(), + ) + else + _buildActionGrid(group, connection, isMobile), + SizedBox(height: 12), + ], + ], + ), + ), + ]; + }, + ).flatten(), + // local control doesn't make much sense - it would send the key events to BikeControl itself + if (false && + core.logic.showLocalControl && + core.settings.getLocalEnabled() && + core.actionHandler.supportedApp != null) ...[ + GradientText('Local Control'), + Wrap( + spacing: 12, + runSpacing: 12, + children: core.actionHandler.supportedApp!.keymap.keyPairs + .map( + (keyPair) => PrimaryButton( + child: Text(keyPair.toString()), + onPressed: () async { + if (core.actionHandler is AndroidActions) { + await (core.actionHandler as AndroidActions).performAction( + keyPair.buttons.first, + isKeyDown: true, + isKeyUp: false, + ); + await (core.actionHandler as AndroidActions).performAction( + keyPair.buttons.first, + isKeyDown: false, + isKeyUp: true, + ); + } else { + await (core.actionHandler as DesktopActions).performAction( + keyPair.buttons.first, + isKeyDown: true, + isKeyUp: false, + ); + await (core.actionHandler as DesktopActions).performAction( + keyPair.buttons.first, + isKeyDown: false, + isKeyUp: true, + ); + } + }, + ), + ) + .toList(), + ), + ], + ], + ), + ), + ), + ), + ); + } + + Widget _buildActionGrid( + MapEntry> group, + TrainerConnection connection, + bool isMobile, + ) { + final hasQuickAccess = group.value.any((a) => a.possibleValues != null); + + if (!hasQuickAccess) { + return GridView.count( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + crossAxisCount: min(group.value.length, 3), + childAspectRatio: isMobile ? 1 : 2.4, + children: group.value.map((action) { + final hotkey = _hotkeys[action]; + return Stack( + fit: StackFit.expand, + children: [ + _buildButton(action, group, connection, isMobile), + if (hotkey != null) + Positioned( + top: -4, + right: -4, + child: KeyWidget(label: hotkey.toUpperCase()), + ), + ], + ); + }).toList(), + ); + } + + // Use Wrap layout when quick-access buttons are present + final crossAxisCount = min(group.value.length, 3); + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + return Wrap( + spacing: 8, + runSpacing: 8, + children: group.value.map((action) { + final hotkey = _hotkeys[action]; + final buttonHeight = isMobile ? 120.0 : 80.0; + return SizedBox( + width: crossAxisCount == 1 + ? availableWidth + : (availableWidth - 8 * (crossAxisCount - 1)) / crossAxisCount, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + SizedBox( + height: buttonHeight, + width: double.infinity, + child: _buildButton(action, group, connection, isMobile), + ), + if (hotkey != null) + Positioned( + top: -4, + right: -4, + child: KeyWidget(label: hotkey.toUpperCase()), + ), + ], + ), + if (action.possibleValues != null) _buildQuickAccessButtons(action, connection), + ], + ), + ); + }).toList(), + ); + }, + ); + } + + Widget _buildQuickAccessButtons(InGameAction action, TrainerConnection connection) { + final recent = _recentValues[action] ?? action.possibleValues!.take(2).toList(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + spacing: 4, + children: [ + for (int i = 0; i < 2; i++) + Expanded( + child: SizedBox( + height: 42, + child: i < recent.length + ? OutlineButton( + size: ButtonSize.small, + density: ButtonDensity.compact, + onPressed: () => _sendQuickValue(action, recent[i], connection), + child: Center(child: Text(recent[i].toString())), + ) + : SizedBox.shrink(), + ), + ), + ], + ), + ); + } + + Widget _buildButton( + InGameAction action, + MapEntry> group, + TrainerConnection connection, + bool isMobile, + ) { + return Builder( + builder: (context) { + return Button( + style: _pressedAction == action + ? ButtonStyle.outline() + : group.key == 'Other' + ? ButtonStyle.outline() + : ButtonStyle.primary(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (action.icon != null) ...[ + Icon(action.icon), + SizedBox(height: 8), + ], + Text( + action.title, + textAlign: TextAlign.center, + style: TextStyle(height: 1), + maxLines: 2, + ).bold, + if (action.alternativeTitle != null) + Text( + action.alternativeTitle!.toUpperCase(), + style: TextStyle(fontSize: 10, color: Colors.gray), + ), + ], + ), + onPressed: () {}, + onTapDown: (c) async { + _sendKey(context, down: true, action: action, connection: connection); + /*final device = HidDevice('Simulator'); + final button = ControllerButton('action', action: InGameAction.openActionBar); + device.getOrAddButton(button.name, () => button); + device.handleButtonsClickedWithoutLongPressSupport([button]);*/ + }, + onTapUp: (c) async { + _sendKey(context, down: false, action: action, connection: connection); + }, + ); + }, + ); + } + + Future _sendKey( + BuildContext context, { + required bool down, + required InGameAction action, + required TrainerConnection connection, + }) async { + if (!connection.isConnected.value) { + if (down) { + buildToast(title: 'No connected trainer.'); + } + + return; + } + if (action.possibleValues != null) { + if (down) return; + showDropdown( + context: context, + builder: (context) => DropdownMenu( + children: action.possibleValues! + .map( + (e) => MenuButton( + child: Text(e.toString()), + onPressed: (c) async { + await connection.sendAction( + KeyPair( + buttons: [], + physicalKey: null, + logicalKey: null, + inGameAction: action, + inGameActionValue: e, + ), + isKeyDown: true, + isKeyUp: true, + ); + _updateRecentValue(action, e); + }, + ), + ) + .toList(), + ), + ); + return; + } else { + if (!down && _lastDown != null && action.isLongPress) { + final timeSinceLastDown = DateTime.now().difference(_lastDown!); + if (timeSinceLastDown < Duration(milliseconds: 400)) { + // wait a bit so actions actually get applied correctly for some trainer apps + await Future.delayed(Duration(milliseconds: 800) - timeSinceLastDown); + } + } else if (down) { + _lastDown = DateTime.now(); + } + + final result = await connection.sendAction( + KeyPair( + buttons: [], + physicalKey: null, + logicalKey: null, + inGameAction: action, + ), + isKeyDown: down, + isKeyUp: !down, + ); + await IAPManager.instance.incrementCommandCount(); + if (result is! Success && result is! Ignored) { + buildToast(title: result.message); + } + } + } + + void _showHotkeySettings(BuildContext context, List connections) { + showDialog( + context: context, + builder: (context) => _HotkeySettingsDialog( + connections: connections, + currentHotkeys: _hotkeys, + onSave: (newHotkeys) { + setState(() { + _hotkeys = newHotkeys; + }); + }, + ), + ); + } +} + +class _HotkeySettingsDialog extends StatefulWidget { + final List connections; + final Map currentHotkeys; + final Function(Map) onSave; + + const _HotkeySettingsDialog({ + required this.connections, + required this.currentHotkeys, + required this.onSave, + }); + + @override + State<_HotkeySettingsDialog> createState() => _HotkeySettingsDialogState(); +} + +class _HotkeySettingsDialogState extends State<_HotkeySettingsDialog> { + late Map _editableHotkeys; + InGameAction? _editingAction; + late FocusNode _focusNode; + + static final _validHotkeyPattern = RegExp(r'[0-9a-z]'); + + @override + void initState() { + super.initState(); + _editableHotkeys = Map.from(widget.currentHotkeys); + _focusNode = FocusNode(debugLabel: 'HotkeySettingsFocus', canRequestFocus: true); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + KeyEventResult _onKey(FocusNode node, KeyEvent event) { + if (_editingAction == null || event is! KeyDownEvent) return KeyEventResult.ignored; + + final key = event.logicalKey.keyLabel.toLowerCase(); + + // Only allow single character 1-9 and a-z + if (key.length == 1 && _validHotkeyPattern.hasMatch(key)) { + setState(() { + _editableHotkeys[_editingAction!] = key; + _editingAction = null; + }); + return KeyEventResult.handled; + } + + // Escape to cancel + if (event.logicalKey == LogicalKeyboardKey.escape) { + setState(() { + _editingAction = null; + }); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final allActions = []; + for (final connection in widget.connections) { + allActions.addAll(connection.supportedActions); + } + final uniqueActions = allActions.distinct().toList(); + + return Focus( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKey, + child: AlertDialog( + title: Text('Configure Keyboard Hotkeys'), + content: SizedBox( + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text('Assign keyboard shortcuts to simulator buttons').muted, + SizedBox(height: 8), + Flexible( + child: SingleChildScrollView( + child: Column( + spacing: 8, + children: uniqueActions.map((action) { + final hotkey = _editableHotkeys[action]; + final isEditing = _editingAction == action; + + return Card( + child: Container( + padding: EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: Text(action.title), + ), + if (isEditing) + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(4), + ), + child: Text('Press a key...', style: TextStyle(color: Colors.blue)), + ) + else if (hotkey != null) + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.gray.withOpacity(0.3), + borderRadius: BorderRadius.circular(4), + ), + child: Text(hotkey.toUpperCase(), style: TextStyle(fontWeight: FontWeight.bold)), + ) + else + Text('No hotkey', style: TextStyle(color: Colors.gray)), + SizedBox(width: 8), + OutlineButton( + size: ButtonSize.small, + child: Text(isEditing ? 'Cancel' : 'Set'), + onPressed: () { + setState(() { + _editingAction = isEditing ? null : action; + }); + }, + ), + if (hotkey != null && !isEditing) ...[ + SizedBox(width: 4), + OutlineButton( + size: ButtonSize.small, + child: Text('Clear'), + onPressed: () { + setState(() { + _editableHotkeys.remove(action); + }); + }, + ), + ], + ], + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ), + actions: [ + SecondaryButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + PrimaryButton( + child: Text('Save'), + onPressed: () async { + await core.settings.setButtonSimulatorHotkeys(_editableHotkeys); + widget.onSave(_editableHotkeys); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ], + ), + ); + } +} diff --git a/lib/pages/configuration.dart b/lib/pages/configuration.dart new file mode 100644 index 000000000..240b0f74f --- /dev/null +++ b/lib/pages/configuration.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/button_edit.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/keymap/apps/custom_app.dart'; +import 'package:bike_control/utils/keymap/apps/my_whoosh.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:bike_control/widgets/ui/colored_title.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class ConfigurationPage extends StatefulWidget { + final bool onboardingMode; + final VoidCallback onUpdate; + const ConfigurationPage({super.key, required this.onUpdate, this.onboardingMode = false}); + + @override + State createState() => _ConfigurationPageState(); +} + +class _ConfigurationPageState extends State { + @override + Widget build(BuildContext context) { + return Column( + spacing: 12, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ColoredTitle(text: context.i18n.setupTrainer), + Card( + fillColor: Theme.of(context).colorScheme.background, + filled: true, + borderWidth: 1, + borderColor: Theme.of(context).colorScheme.border, + child: Builder( + builder: (context) { + return StatefulBuilder( + builder: (c, setState) => Column( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Select( + constraints: BoxConstraints(maxWidth: 400, minWidth: 400), + itemBuilder: (c, app) => Row( + spacing: 4, + children: [ + Text(screenshotMode ? 'Trainer app' : app.name), + if (app.supportsOpenBikeProtocol.isNotEmpty) Icon(Icons.star), + ], + ), + popup: SelectPopup( + items: SelectItemList( + children: SupportedApp.supportedApps.map((app) { + return SelectItemButton( + value: app, + child: Row( + spacing: 4, + children: [ + Text(app.name), + if (app.supportsOpenBikeProtocol.isNotEmpty) Icon(Icons.star), + ], + ), + ); + }).toList(), + ), + ).call, + placeholder: Text(context.i18n.selectTrainerAppPlaceholder), + value: core.settings.getTrainerApp(), + onChanged: (selectedApp) async { + if (selectedApp is! MyWhoosh) { + if (core.whooshLink.isStarted.value) { + core.whooshLink.stopServer(); + } + } + if (!selectedApp!.supportsZwiftEmulation) { + if (core.zwiftMdnsEmulator.isStarted.value) { + core.zwiftMdnsEmulator.stop(); + } + if (core.zwiftEmulator.isStarted.value) { + core.zwiftEmulator.stopAdvertising(); + } + } + if (selectedApp.supportsOpenBikeProtocol.isEmpty) { + if (core.obpMdnsEmulator.isStarted.value) { + core.obpMdnsEmulator.stopServer(); + } + if (core.obpBluetoothEmulator.isStarted.value) { + core.obpBluetoothEmulator.stopServer(); + } + } + + core.settings.setTrainerApp(selectedApp); + if (core.settings.getLastTarget() == null && Target.thisDevice.isCompatible) { + await _setTarget(context, Target.thisDevice); + } else if (core.settings.getLastTarget() == null && Target.otherDevice.isCompatible) { + await _setTarget(context, Target.otherDevice); + } + if (core.actionHandler.supportedApp == null || + (core.actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) { + core.actionHandler.init(selectedApp); + core.settings.setKeyMap(selectedApp); + } + widget.onUpdate(); + setState(() {}); + }, + ), + if (core.settings.getTrainerApp() != null) ...[ + if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol.isNotEmpty && + !screenshotMode && + !widget.onboardingMode) + Text( + AppLocalizations.of(context).openBikeControlAnnouncement(core.settings.getTrainerApp()!.name), + ).xSmall, + SizedBox(height: 0), + Text( + context.i18n.selectTargetWhereAppRuns( + screenshotMode ? 'Trainer app' : core.settings.getTrainerApp()?.name ?? 'the Trainer app', + ), + ).small, + Row( + spacing: 8, + children: [Target.thisDevice, Target.otherDevice] + .map( + (target) => Expanded( + child: SelectableCard( + title: Center(child: Icon(target.icon)), + isActive: target == core.settings.getLastTarget(), + subtitle: Center( + child: Column( + children: [ + Text(target.getTitle(context)), + if (!target.isCompatible) Text(context.i18n.platformRestrictionNotSupported), + ], + ), + ), + onPressed: !target.isCompatible + ? null + : () async { + await _setTarget(context, target); + setState(() {}); + widget.onUpdate(); + }, + ), + ), + ) + .toList(), + ), + ], + + if (core.settings.getLastTarget() == Target.otherDevice && + !core.logic.hasRecommendedConnectionMethods) ...[ + SizedBox(height: 8), + Warning( + children: [ + Text( + 'BikeControl is available on iOS, Android, Windows and macOS. For proper support for ${core.settings.getTrainerApp()?.name} please download BikeControl on that device.', + ).small, + ], + ), + ], + if (core.settings.getTrainerApp()?.star == true && !screenshotMode && !widget.onboardingMode) + Row( + spacing: 8, + children: [ + Icon(Icons.star), + Expanded( + child: Text( + AppLocalizations.of( + context, + ).newConnectionMethodAnnouncement(core.settings.getTrainerApp()!.name), + style: TextStyle(fontWeight: FontWeight.bold), + ).xSmall, + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ); + } + + Future _setTarget(BuildContext context, Target target) async { + await core.settings.setLastTarget(target); + + if ((core.settings.getTrainerApp()?.supportsOpenBikeProtocol.isNotEmpty ?? false) && !core.logic.emulatorEnabled) { + core.settings.setObpMdnsEnabled(true); + } + + // enable local connection on Windows if the app doesn't support OBP + if (target == Target.thisDevice && + core.settings.getTrainerApp()?.supportsOpenBikeProtocol.isEmpty == true && + !kIsWeb && + Platform.isWindows) { + core.settings.setLocalEnabled(true); + } + core.logic.startEnabledConnectionMethod(); + } +} diff --git a/lib/pages/customize.dart b/lib/pages/customize.dart new file mode 100644 index 000000000..abdd73b69 --- /dev/null +++ b/lib/pages/customize.dart @@ -0,0 +1,195 @@ +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/apps/custom_app.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/keymap/manager.dart'; +import 'package:bike_control/widgets/iap_status_widget.dart'; +import 'package:bike_control/widgets/keymap_explanation.dart'; +import 'package:bike_control/widgets/ui/beta_pill.dart'; +import 'package:bike_control/widgets/ui/colored_title.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class CustomizePage extends StatefulWidget { + final bool isMobile; + const CustomizePage({super.key, required this.isMobile}); + + @override + State createState() => _CustomizeState(); +} + +class _CustomizeState extends State { + @override + void initState() { + IAPManager.instance.entitlements.addListener(_onIAPChange); + super.initState(); + } + + @override + void dispose() { + IAPManager.instance.entitlements.removeListener(_onIAPChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: EdgeInsets.only(bottom: widget.isMobile ? 146 : 16, left: 16, right: 16, top: 16), + child: Column( + spacing: 12, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: IAPManager.instance.isPurchased, + builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: true), + ), + Container( + margin: const EdgeInsets.only(bottom: 8.0), + padding: const EdgeInsets.symmetric(vertical: 8.0), + width: double.infinity, + child: ColoredTitle( + text: context.i18n.customizeControllerButtons( + screenshotMode ? 'Trainer app' : (core.settings.getTrainerApp()?.name ?? ''), + ), + ), + ), + + Row( + spacing: 8, + children: [ + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: 300), + child: Select( + value: core.actionHandler.supportedApp, + popup: SelectPopup( + items: SelectItemList( + children: [ + ..._getAllApps().map( + (a) => SelectItemButton( + value: a, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(a.name)), + if (a is CustomApp) + BetaPill(text: 'CUSTOM') + else if (a.supportsOpenBikeProtocol.isNotEmpty) + Icon(Icons.star, size: 16), + ], + ), + ), + ), + SelectItemButton( + value: CustomApp(profileName: 'New'), + child: Row( + spacing: 6, + children: [ + Icon(Icons.add, color: Theme.of(context).colorScheme.mutedForeground), + Expanded(child: Text(context.i18n.createNewKeymap).normal.muted), + ], + ), + ), + ], + ), + ).call, + itemBuilder: (c, app) => Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: Text(screenshotMode ? 'Trainer app' : app!.name)), + if (app is CustomApp) BetaPill(text: 'CUSTOM'), + ], + ), + placeholder: Text(context.i18n.selectKeymap), + + onChanged: (app) async { + if (app == null) { + return; + } else if (app.name == 'New') { + final profileName = await KeymapManager().showNewProfileDialog(context); + if (profileName != null && profileName.isNotEmpty) { + final customApp = CustomApp(profileName: profileName); + core.actionHandler.init(customApp); + await core.settings.setKeyMap(customApp); + + setState(() {}); + } + } else { + core.actionHandler.init(app); + await core.settings.setKeyMap(app); + setState(() {}); + } + }, + ), + ), + ), + KeymapManager().getManageProfileDialog( + context, + core.actionHandler.supportedApp is CustomApp ? core.actionHandler.supportedApp?.name : null, + onDone: () { + setState(() {}); + }, + ), + ], + ), + + if (core.actionHandler.supportedApp is! CustomApp && !screenshotMode) + Text( + context.i18n.customizeKeymapHint, + style: TextStyle(fontSize: 12), + ), + if (!screenshotMode) Gap(12), + if (core.actionHandler.supportedApp != null && core.connection.controllerDevices.isNotEmpty) + KeymapExplanation( + key: Key(core.actionHandler.supportedApp!.keymap.runtimeType.toString()), + keymap: core.actionHandler.supportedApp!.keymap, + onUpdate: () { + setState(() {}); + + if (core.actionHandler.supportedApp is CustomApp) { + core.settings.setKeyMap(core.actionHandler.supportedApp!); + } + }, + ) + else if (core.connection.controllerDevices.isEmpty) + Warning( + children: [Text(context.i18n.connectControllerToPreview).small], + ), + ], + ), + ); + } + + List _getAllApps() { + final baseApp = core.settings.getTrainerApp(); + final customProfiles = core.settings.getCustomAppProfiles(); + + final customApps = customProfiles.map((profile) { + final customApp = CustomApp(profileName: profile); + final savedKeymap = core.settings.getCustomAppKeymap(profile); + if (savedKeymap != null) { + try { + customApp.decodeKeymap(savedKeymap); + } catch (e, s) { + recordError(e, s, context: 'getAllApps'); + } + } + return customApp; + }).toList(); + + // If no custom profiles exist, add the default "Custom" one + if (customApps.isEmpty) { + customApps.add(CustomApp()); + } + + return [if (baseApp != null) baseApp, ...customApps]; + } + + void _onIAPChange() { + setState(() {}); + } +} diff --git a/lib/pages/device.dart b/lib/pages/device.dart index cbe298c13..88f9758d0 100644 --- a/lib/pages/device.dart +++ b/lib/pages/device.dart @@ -1,39 +1,41 @@ import 'dart:async'; +import 'dart:io'; -import 'package:dartx/dartx.dart'; -import 'package:flutter/material.dart'; -import 'package:swift_control/main.dart'; -import 'package:swift_control/utils/devices/ble_device.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/button_simulator.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/iap_status_widget.dart'; +import 'package:bike_control/widgets/ignored_devices_dialog.dart'; +import 'package:bike_control/widgets/scan.dart'; +import 'package:bike_control/widgets/ui/colored_title.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; -import '../utils/messages/notification.dart'; +import '../bluetooth/devices/base_device.dart'; +import '../utils/keymap/buttons.dart'; +import 'button_edit.dart'; class DevicePage extends StatefulWidget { - const DevicePage({super.key}); + final bool isMobile; + final VoidCallback onUpdate; + const DevicePage({super.key, required this.onUpdate, required this.isMobile}); @override State createState() => _DevicePageState(); } class _DevicePageState extends State { - List _actions = []; - - late StreamSubscription _connectionStateSubscription; - - late StreamSubscription _actionSubscription; + late StreamSubscription _connectionStateSubscription; @override void initState() { super.initState(); - _actionSubscription = connection.actionStream.listen((data) { - if (mounted) { - setState(() { - _actions.add('${DateTime.now().toString().split(" ").last}: $data'); - _actions = _actions.takeLast(30).toList(); - }); - } - }); - _connectionStateSubscription = connection.connectionStream.listen((state) async { + _connectionStateSubscription = core.connection.connectionStream.listen((state) async { setState(() {}); }); } @@ -41,63 +43,222 @@ class _DevicePageState extends State { @override void dispose() { _connectionStateSubscription.cancel(); - _actionSubscription.cancel(); super.dispose(); } - final _snackBarMessengerKey = GlobalKey(); - @override Widget build(BuildContext context) { - return ScaffoldMessenger( - key: _snackBarMessengerKey, - child: PopScope( - onPopInvokedWithResult: (hello, _) { - connection.reset(); - }, - child: Scaffold( - appBar: AppBar( - title: Text('SwiftControl'), - actions: [ - IconButton( + return Scrollbar( + child: SingleChildScrollView( + primary: true, + padding: EdgeInsets.only(bottom: widget.isMobile ? 166 : 16, left: 16, right: 16, top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: IAPManager.instance.isPurchased, + builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: false), + ), + + if (core.connection.controllerDevices.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ColoredTitle(text: context.i18n.connectControllers), + ), + + // leave it in for the extra scanning options + ScanWidget(), + + Gap(12), + if (core.connection.controllerDevices.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ColoredTitle(text: context.i18n.connectedControllers), + ), + + Gap(12), + ...core.connection.controllerDevices.map( + (device) => Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Card( + filled: true, + fillColor: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.card + : Theme.of(context).colorScheme.card.withLuminance(0.95), + child: device.showInformation(context), + ), + ), + ), + + Gap(12), + if (core.connection.accessories.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ColoredTitle(text: AppLocalizations.of(context).accessories), + ), + ...core.connection.accessories.map( + (device) => Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Card( + filled: true, + fillColor: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.card + : Theme.of(context).colorScheme.card.withLuminance(0.95), + child: device.showInformation(context), + ), + ), + ), + ], + + Gap(12), + if (!screenshotMode) + Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlineButton( + onPressed: () { + launchUrlString( + 'https://github.com/OpenBikeControl/bikecontrol/?tab=readme-ov-file#supported-devices', + ); + }, + leading: Icon(Icons.gamepad_outlined), + child: Text(context.i18n.showSupportedControllers), + ), + if (core.settings.getIgnoredDevices().isNotEmpty) + OutlineButton( + leading: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.destructive, + borderRadius: BorderRadius.circular(12), + ), + padding: EdgeInsets.symmetric(horizontal: 6), + margin: EdgeInsets.only(right: 4), + child: Text( + core.settings.getIgnoredDevices().length.toString(), + style: TextStyle( + color: Theme.of(context).colorScheme.primaryForeground, + ), + ), + ), + onPressed: () async { + await showDialog( + context: context, + builder: (context) => IgnoredDevicesDialog(), + ); + setState(() {}); + }, + child: Text(context.i18n.manageIgnoredDevices), + ), + + if (core.connection.controllerDevices.isEmpty) + PrimaryButton( + leading: Icon(Icons.computer_outlined), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (c) => ButtonSimulator(), + ), + ); + }, + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: "${AppLocalizations.of(context).noControllerUseCompanionMode.split("?").first}?\n", + ), + TextSpan( + text: AppLocalizations.of(context).noControllerUseCompanionMode.split("? ").last, + style: TextStyle(color: Theme.of(context).colorScheme.muted, fontSize: 12), + ), + ], + ), + ), + ), + ], + ), + Gap(24), + + if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isIOS)) + ValueListenableBuilder( + valueListenable: core.mediaKeyHandler.isMediaKeyDetectionEnabled, + builder: (context, value, child) { + return SelectableCard( + isActive: value, + icon: value ? Icons.check_box : Icons.check_box_outline_blank, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text(context.i18n.enableMediaKeyDetection), + Text( + context.i18n.mediaKeyDetectionTooltip, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + ], + ), + onPressed: () { + final newValue = !core.mediaKeyHandler.isMediaKeyDetectionEnabled.value; + core.mediaKeyHandler.isMediaKeyDetectionEnabled.value = newValue; + core.settings.setMediaKeyDetectionEnabled(newValue); + }, + ); + }, + ), + Gap(8), + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && !core.settings.getShowOnboarding()) + SelectableCard( + isActive: core.settings.getPhoneSteeringEnabled(), + icon: core.settings.getPhoneSteeringEnabled() ? Icons.check_box : Icons.check_box_outline_blank, + isProOnly: true, + title: Row( + spacing: 4, + children: [ + Icon(InGameAction.navigateRight.icon!, size: 16), + Icon(InGameAction.navigateLeft.icon!, size: 16), + SizedBox(), + Expanded(child: Text(AppLocalizations.of(context).enableSteeringWithPhone)), + IconButton.secondary( + icon: Icon(Icons.ondemand_video), + onPressed: () { + launchUrlString('https://youtube.com/shorts/zqD5ARGIVmE?feature=share'); + }, + ), + ], + ), onPressed: () { - _actions.clear(); + final enable = !core.settings.getPhoneSteeringEnabled(); + core.settings.setPhoneSteeringEnabled(enable); + core.connection.toggleGyroscopeSteering(enable); setState(() {}); }, - icon: Icon(Icons.clear), ), - ], - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - ), - body: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Devices:\n${connection.devices.joinToString(separator: '\n', transform: (it) { - return "${it.device.platformName}: ${it.device.isConnected ? 'Connected' : 'Not connected'}"; - })}', - ), - Divider(color: Theme.of(context).colorScheme.primary, height: 30), - Expanded( - child: ListView( - children: - _actions - .map( - (action) => Text( - action, - style: TextStyle(fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]), - ), - ) - .toList(), + + SizedBox(height: 16), + if (core.connection.controllerDevices.isNotEmpty) + Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + PrimaryButton( + child: Text(context.i18n.connectToTrainerApp), + onPressed: () { + widget.onUpdate(); + }, ), - ), - ], - ), - ), + ], + ), + ], ), ), ); } } + +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 000000000..aeff7365b --- /dev/null +++ b/lib/pages/markdown.dart @@ -0,0 +1,213 @@ +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/rouvy.dart'; +import 'package:bike_control/utils/keymap/apps/zwift.dart'; +import 'package:bike_control/widgets/ui/gradient_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:http/http.dart' as http; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package: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 { + static const String _troubleshootingPath = 'TROUBLESHOOTING.md'; + static const String _myWhooshLinkPath = 'INSTRUCTIONS_MYWHOOSH_LINK.md'; + static const String _remoteControlPath = 'INSTRUCTIONS_REMOTE_CONTROL.md'; + static const String _localPath = 'INSTRUCTIONS_LOCAL.md'; + static const String _rouvyPath = 'INSTRUCTIONS_ROUVY.md'; + static const String _zwiftPath = 'INSTRUCTIONS_ZWIFT.md'; + + List<_Group>? _groups; + String? _error; + late String _selectedAssetPath; + late final List<_InstructionOption> _instructionOptions; + + bool get _showInstructionSwitcher => widget.assetPath == _troubleshootingPath; + + @override + void initState() { + super.initState(); + _selectedAssetPath = widget.assetPath; + _instructionOptions = _buildInstructionOptions(); + _loadMarkdown(_selectedAssetPath); + } + + @override + void didUpdateWidget(covariant MarkdownPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.assetPath != widget.assetPath) { + _selectedAssetPath = widget.assetPath; + _loadMarkdown(_selectedAssetPath); + } + } + + Future _loadMarkdown(String assetPath) async { + setState(() { + _groups = null; + _error = null; + }); + + try { + final md = await rootBundle.loadString(assetPath); + _parseMarkdown(md); + } catch (e) { + setState(() { + _error = 'Failed to load markdown: $e'; + }); + } finally { + _loadOnlineVersion(assetPath); + } + } + + @override + Widget build(BuildContext context) { + final content = _error != null + ? Center(child: Text(_error!)) + : _groups == null + ? Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 500, + child: Accordion( + items: _groups! + .map( + (group) => AccordionItem( + trigger: AccordionTrigger(child: GradientText(group.title).bold), + content: MarkdownWidget( + markdown: group.markdown, + theme: MarkdownThemeData( + textStyle: TextStyle( + fontSize: 14.0, + color: Theme.of(context).colorScheme.brightness == Brightness.dark + ? Colors.white.withAlpha(255 * 70) + : Colors.black.withAlpha(87 * 255), + ), + onLinkTap: (title, url) { + launchUrlString(url); + }, + ), + ), + ), + ) + .toList(), + ), + ), + ); + + if (!_showInstructionSwitcher) { + return content; + } + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: ButtonGroup( + children: _instructionOptions + .map( + (option) => Button( + style: option.assetPath == _selectedAssetPath ? ButtonStyle.primary() : ButtonStyle.secondary(), + onPressed: () { + final newAssetPath = option.assetPath; + if (newAssetPath == _selectedAssetPath) { + return; + } + setState(() { + _selectedAssetPath = newAssetPath; + }); + _loadMarkdown(newAssetPath); + }, + child: Text(option.label), + ), + ) + .toList(), + ), + ), + Expanded(child: content), + ], + ); + } + + void _parseMarkdown(String md) { + setState(() { + _error = null; + _groups = md + .split('## ') + .map((section) { + final lines = section.split('\n'); + final title = lines.first.replaceFirst('# ', '').trim(); + final content = lines.skip(1).join('\n').trim(); + return _Group( + title: title, + markdown: Markdown.fromString('## $content'), + ); + }) + .where((group) => group.title.isNotEmpty) + .toList(); + }); + } + + Future _loadOnlineVersion(String assetPath) async { + // load latest version + final response = await http.get( + Uri.parse('https://raw.githubusercontent.com/OpenBikeControl/bikecontrol/refs/heads/main/$assetPath'), + ); + if (response.statusCode == 200) { + final latestMd = response.body; + if (assetPath == _selectedAssetPath) { + _parseMarkdown(latestMd); + } + } + } + + List<_InstructionOption> _buildInstructionOptions() { + final options = <_InstructionOption>[ + _InstructionOption(label: 'Q&A', assetPath: _troubleshootingPath), + _InstructionOption(label: 'MyWhoosh Link', assetPath: _myWhooshLinkPath), + _InstructionOption(label: 'Remote Control', assetPath: _remoteControlPath), + ]; + + final platformSupportsLocal = + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows); + + if (platformSupportsLocal) { + options.add(_InstructionOption(label: 'Local', assetPath: _localPath)); + } + + final trainerApp = core.settings.getTrainerApp(); + if (trainerApp is Rouvy) { + options.add(_InstructionOption(label: 'Rouvy', assetPath: _rouvyPath)); + } else if (trainerApp is Zwift) { + options.add(_InstructionOption(label: 'Zwift', assetPath: _zwiftPath)); + } + + return options; + } +} + +class _Group { + final String title; + final Markdown markdown; + + _Group({required this.title, required this.markdown}); +} + +class _InstructionOption { + final String label; + final String assetPath; + + const _InstructionOption({required this.label, required this.assetPath}); +} diff --git a/lib/pages/navigation.dart b/lib/pages/navigation.dart new file mode 100644 index 000000000..f5602469f --- /dev/null +++ b/lib/pages/navigation.dart @@ -0,0 +1,445 @@ +import 'dart:async'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/customize.dart'; +import 'package:bike_control/pages/device.dart'; +import 'package:bike_control/pages/trainer.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/logviewer.dart'; +import 'package:bike_control/widgets/menu.dart'; +import 'package:bike_control/widgets/title.dart'; +import 'package:bike_control/widgets/ui/colors.dart'; +import 'package:bike_control/widgets/ui/help_button.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../widgets/changelog_dialog.dart'; + +enum BCPage { + devices(Icons.gamepad), + trainer(Icons.pedal_bike), + customization(Icons.videogame_asset_outlined), + logs(Icons.article); + + final IconData icon; + + const BCPage(this.icon); + + String getTitle(BuildContext context) { + return switch (this) { + BCPage.devices => context.i18n.controllers, + BCPage.trainer => context.i18n.trainer, + BCPage.customization => context.i18n.configuration, + BCPage.logs => context.i18n.logs, + }; + } +} + +class Navigation extends StatefulWidget { + final BCPage page; + const Navigation({super.key, this.page = BCPage.devices}); + + @override + State createState() => _NavigationState(); +} + +class _NavigationState extends State { + bool _isMobile = false; + late BCPage _selectedPage; + + final Map _pageKeys = { + BCPage.devices: Key('devices_page'), + BCPage.trainer: Key('trainer_page'), + BCPage.customization: Key('customization_page'), + BCPage.logs: Key('logs_page'), + }; + + @override + void initState() { + super.initState(); + + _selectedPage = widget.page; + + core.logic.startEnabledConnectionMethod(); + + _actionListener = core.connection.actionStream.listen((_) { + _updateTrainerConnectionStatus(); + if (mounted) { + setState(() {}); + } + }); + _updateTrainerConnectionStatus(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + SystemChrome.setSystemUIOverlayStyle( + Theme.of(context).colorScheme.brightness == Brightness.light + ? SystemUiOverlayStyle.dark + : SystemUiOverlayStyle.light, + ); + _checkAndShowChangelog(); + }); + } + + @override + void dispose() { + _actionListener.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant Navigation oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.page != oldWidget.page) { + setState(() { + _selectedPage = widget.page; + }); + } + } + + void _updateTrainerConnectionStatus() async { + final isConnected = await core.logic.isTrainerConnected(); + if (mounted) { + setState(() { + _isTrainerConnected = isConnected; + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _isMobile = MediaQuery.sizeOf(context).width < 600; + } + + Future _checkAndShowChangelog() async { + try { + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = packageInfo.version; + final lastSeenVersion = core.settings.getLastSeenVersion(); + + if (mounted) { + await ChangelogDialog.showIfNeeded(context, currentVersion, lastSeenVersion); + } + + // Update last seen version + await core.settings.setLastSeenVersion(currentVersion); + } catch (e) { + print('Failed to check changelog: $e'); + } + } + + final List _tabs = BCPage.values.whereNot((e) => e == BCPage.logs).toList(); + + bool _isTrainerConnected = false; + + late StreamSubscription _actionListener; + + @override + Widget build(BuildContext context) { + return Scaffold( + headers: [ + Stack( + children: [ + AppBar( + padding: + const EdgeInsets.only(top: 12, bottom: 8, left: 12, right: 12) * + (screenshotMode ? 2 : Theme.of(context).scaling), + title: AppTitle(), + backgroundColor: Theme.of(context).colorScheme.background, + trailing: buildMenuButtons( + context, + _selectedPage, + _isMobile + ? () { + setState(() { + _selectedPage = BCPage.logs; + }); + } + : null, + ), + ), + if (!_isMobile) + Container( + alignment: Alignment.topCenter, + child: HelpButton(isMobile: false), + ), + ], + ), + Divider(), + ], + footers: _isMobile + ? [ + if (_isMobile) Center(child: HelpButton(isMobile: true)), + Divider(), + _buildNavigationBar(), + ] + : [], + floatingFooter: true, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!_isMobile) ...[ + _buildNavigationMenu(), + VerticalDivider(), + ], + Expanded( + child: AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: switch (_selectedPage) { + BCPage.devices => Align( + alignment: Alignment.topLeft, + child: DevicePage( + isMobile: _isMobile, + onUpdate: () { + setState(() { + _selectedPage = BCPage.trainer; + }); + }, + ), + ), + BCPage.trainer => Align( + alignment: Alignment.topLeft, + child: TrainerPage( + onUpdate: () { + setState(() {}); + }, + goToNextPage: () { + setState(() { + _selectedPage = BCPage.customization; + }); + }, + isMobile: _isMobile, + ), + ), + BCPage.customization => Align( + alignment: Alignment.topLeft, + child: CustomizePage(isMobile: _isMobile), + ), + BCPage.logs => Padding( + padding: EdgeInsets.only(bottom: _isMobile ? 146 : 16, left: 16, right: 16, top: 16), + child: LogViewer( + key: _pageKeys[BCPage.logs], + ), + ), + }, + ), + ), + ], + ), + ); + } + + Widget _buildNavigationMenu() { + return NavigationSidebar( + backgroundColor: Theme.of(context).brightness == Brightness.light + ? BKColor.backgroundLight + : Theme.of(context).colorScheme.card, + onSelected: (Key? key) { + setState(() { + _selectedPage = _pageKeys.entries.firstWhere((entry) => entry.value == key).key; + }); + }, + spacing: 4, + selectedKey: _pageKeys[_selectedPage], + footer: [ + SliverPadding( + padding: const EdgeInsets.all(8.0), + sliver: _buildNavigationItemDesktop(BCPage.logs), + ), + ], + children: _tabs.map((page) => _buildNavigationItemDesktop(page)).toList(), + ); + } + + Widget _buildIcon(BCPage page) { + final needsAttention = _needsAttention(page); + return Stack( + children: [ + Icon( + page.icon, + color: !_isPageEnabled(page) + ? null + : Theme.of(context).colorScheme.brightness == Brightness.dark + ? Colors.white + : null, + ), + if (needsAttention) ...[ + Positioned( + right: 0, + top: 0, + child: RepeatedAnimationBuilder( + duration: Duration(seconds: 1), + reverseDuration: Duration(seconds: 1), + start: 10, + end: 12, + mode: LoopingMode.pingPong, + builder: (context, value, child) { + return Container( + width: value, + height: value, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ); + }, + ), + ), + ], + ], + ); + } + + Widget _buildNavigationBar() { + return NavigationBar( + padding: EdgeInsets.only(top: 6, left: 12, right: 12, bottom: 12) * Theme.of(context).scaling, + labelType: NavigationLabelType.all, + alignment: NavigationBarAlignment.spaceAround, + spacing: 8, + selectedKey: _pageKeys[_selectedPage], + onSelected: (Key? key) { + setState(() { + _selectedPage = _pageKeys.entries.firstWhere((entry) => entry.value == key).key; + }); + }, + children: _tabs.map((page) { + return NavigationItem( + key: _pageKeys[page], + selected: _selectedPage == page, + selectedStyle: ButtonStyle.primary(density: ButtonDensity.dense).copyWith( + decoration: (context, states, value) { + return BoxDecoration( + gradient: const LinearGradient( + colors: [BKColor.main, BKColor.mainEnd], + ), + borderRadius: BorderRadius.circular(8), + ); + }, + ), + style: ButtonStyle.ghost(density: ButtonDensity.dense).copyWith( + decoration: (context, states, value) { + return BoxDecoration( + gradient: states.contains(WidgetState.hovered) + ? const LinearGradient( + colors: [BKColor.main, BKColor.mainEnd], + ) + : null, + borderRadius: BorderRadius.circular(8), + ); + }, + ), + enabled: _isPageEnabled(page), + label: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + page == BCPage.trainer && !screenshotMode + ? core.settings.getTrainerApp()?.name.split(' ').first ?? page.getTitle(context) + : page.getTitle(context), + style: TextStyle( + color: !_isPageEnabled(page) + ? null + : Theme.of(context).colorScheme.brightness == Brightness.dark + ? Colors.white + : null, + ), + ), + ), + child: _buildIcon(page), + ); + }).toList(), + ); + } + + bool _isPageEnabled(BCPage page) { + return switch (page) { + BCPage.customization => core.settings.getTrainerApp() != null, + _ => true, + }; + } + + bool _needsAttention(BCPage page) { + return switch (page) { + BCPage.devices => core.connection.controllerDevices.isEmpty, + BCPage.customization => false, + BCPage.trainer => core.settings.getTrainerApp() == null || !_isTrainerConnected, + BCPage.logs => false, + }; + } + + NavigationItem _buildNavigationItemDesktop(BCPage page) { + return NavigationItem( + key: _pageKeys[page], + selected: _selectedPage == page, + selectedStyle: ButtonStyle.primary(density: ButtonDensity.dense).copyWith( + decoration: (context, states, value) { + return BoxDecoration( + gradient: const LinearGradient( + colors: [BKColor.main, BKColor.mainEnd], + ), + borderRadius: BorderRadius.circular(8), + ); + }, + padding: (context, states, value) { + return EdgeInsets.symmetric(horizontal: 12, vertical: 16); + }, + ), + style: ButtonStyle.ghost(density: ButtonDensity.dense).copyWith( + decoration: (context, states, value) { + return BoxDecoration( + gradient: states.contains(WidgetState.hovered) + ? const LinearGradient( + colors: [BKColor.main, BKColor.mainEnd], + ) + : null, + borderRadius: BorderRadius.circular(8), + ); + }, + padding: (context, states, value) { + return EdgeInsets.symmetric(horizontal: 12, vertical: 16); + }, + ), + enabled: _isPageEnabled(page), + child: SizedBox( + width: screenshotMode ? 180 : 152, + child: Basic( + padding: screenshotMode ? EdgeInsets.all(0) : null, + leading: _buildIcon(page), + leadingAlignment: Alignment.centerLeft, + title: Text( + page == BCPage.trainer && !screenshotMode + ? core.settings.getTrainerApp()?.name.split(' ').first ?? page.getTitle(context) + : page.getTitle(context), + style: TextStyle( + color: !_isPageEnabled(page) + ? null + : Theme.of(context).colorScheme.brightness == Brightness.dark + ? Colors.white + : null, + ), + ), + subtitle: _needsAttention(page) + ? Text( + switch (page) { + BCPage.devices => AppLocalizations.of(context).noControllerConnected, + BCPage.trainer when !_isTrainerConnected => AppLocalizations.of(context).notConnected, + BCPage.trainer when core.settings.getTrainerApp() == null => AppLocalizations.of( + context, + ).noTrainerSelected, + _ => '', + }, + style: _selectedPage == page ? TextStyle(color: Colors.gray.shade300) : null, + ) + : null, + ), + ), + ); + } +} diff --git a/lib/pages/onboarding.dart b/lib/pages/onboarding.dart new file mode 100644 index 000000000..818614400 --- /dev/null +++ b/lib/pages/onboarding.dart @@ -0,0 +1,399 @@ +import 'dart:async'; + +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:bike_control/utils/requirements/platform.dart'; +import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart'; +import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.dart'; +import 'package:bike_control/widgets/scan.dart'; +import 'package:bike_control/widgets/title.dart'; +import 'package:bike_control/widgets/ui/help_button.dart'; +import 'package:bike_control/widgets/ui/permissions_list.dart'; +import 'package:dartx/dartx.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../utils/i18n_extension.dart'; +import '../widgets/ui/colored_title.dart'; +import 'configuration.dart'; + +class OnboardingPage extends StatefulWidget { + final VoidCallback onComplete; + const OnboardingPage({super.key, required this.onComplete}); + + @override + State createState() => _OnboardingPageState(); +} + +enum _OnboardingStep { + permissions, + connect, + trainer, + openbikecontrol, + finish, +} + +class _OnboardingPageState extends State { + var _currentStep = _OnboardingStep.permissions; + + bool _isMobile = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _isMobile = MediaQuery.sizeOf(context).width < 600; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + loadingProgress: _OnboardingStep.values.indexOf(_currentStep) / (_OnboardingStep.values.length - 1), + headers: [ + AppBar( + backgroundColor: Theme.of(context).colorScheme.primaryForeground, + leading: [ + Image.asset('icon.png', height: 40), + SizedBox(width: 10), + AppTitle(), + ], + trailing: [ + Button( + style: ButtonStyle.outline(size: ButtonSize.small), + child: Text(AppLocalizations.of(context).skip), + onPressed: () { + core.settings.setShowOnboarding(false); + widget.onComplete(); + }, + ), + ], + ), + Divider(), + ], + floatingFooter: true, + footers: [ + Center( + child: HelpButton( + isMobile: true, + ), + ), + ], + child: Center( + child: Container( + alignment: Alignment.topCenter, + constraints: !_isMobile ? BoxConstraints(maxWidth: 500) : null, + child: SingleChildScrollView( + padding: EdgeInsets.only(top: !_isMobile ? 42 : 22.0, bottom: !_isMobile ? 42 : 68.0, left: 16, right: 16), + child: AnimatedSwitcher( + duration: Duration(milliseconds: 600), + child: switch (_currentStep) { + _OnboardingStep.permissions => _PermissionsOnboardingStep( + onComplete: () { + setState(() { + _currentStep = _OnboardingStep.connect; + }); + }, + ), + _OnboardingStep.connect => _ConnectOnboardingStep( + onComplete: () { + setState(() { + _currentStep = _OnboardingStep.trainer; + }); + }, + ), + _OnboardingStep.trainer => _TrainerOnboardingStep( + onComplete: () { + setState(() { + if (core.settings.getTrainerApp()?.supportsOpenBikeProtocol.containsAny([ + OpenBikeProtocolSupport.network, + OpenBikeProtocolSupport.dircon, + ]) ?? + false) { + _currentStep = _OnboardingStep.openbikecontrol; + } else { + _currentStep = _OnboardingStep.finish; + } + }); + }, + ), + _OnboardingStep.finish => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 12, + children: [ + SizedBox(height: 30), + Icon(Icons.check_circle, size: 58, color: Colors.green), + ColoredTitle(text: AppLocalizations.of(context).setupComplete), + if (core.obpMdnsEmulator.connectedApp.value == null) + Text( + AppLocalizations.of( + context, + ).asAFinalStepYoullChooseHowToConnectTo(core.settings.getTrainerApp()?.name ?? 'your trainer'), + textAlign: TextAlign.center, + ).small.muted, + + SizedBox(height: 30), + PrimaryButton( + leading: Icon(Icons.check), + onPressed: () { + core.settings.setShowOnboarding(false); + widget.onComplete(); + }, + child: Text(context.i18n.continueAction), + ), + ], + ), + _OnboardingStep.openbikecontrol => _OpenBikeControlConnectPage( + onComplete: () { + setState(() { + _currentStep = _OnboardingStep.finish; + }); + }, + ), + }, + ), + ), + ), + ), + ); + } +} + +class _PermissionsOnboardingStep extends StatefulWidget { + final VoidCallback onComplete; + const _PermissionsOnboardingStep({super.key, required this.onComplete}); + + @override + State<_PermissionsOnboardingStep> createState() => _PermissionsOnboardingStepState(); +} + +class _PermissionsOnboardingStepState extends State<_PermissionsOnboardingStep> { + void _checkRequirements() { + core.permissions.getScanRequirements().then((permissions) { + if (!mounted) return; + setState(() { + _needsPermissions = permissions; + }); + if (permissions.isEmpty) { + widget.onComplete(); + } + }); + } + + List? _needsPermissions; + + @override + void initState() { + super.initState(); + _checkRequirements(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox(height: 8), + Text(AppLocalizations.of(context).letsGetYouSetUp).h3, + if (_needsPermissions != null && _needsPermissions!.isNotEmpty) + PermissionList( + requirements: _needsPermissions!, + onDone: () { + widget.onComplete(); + }, + ), + ], + ); + } +} + +class _ConnectOnboardingStep extends StatefulWidget { + final VoidCallback onComplete; + const _ConnectOnboardingStep({super.key, required this.onComplete}); + + @override + State<_ConnectOnboardingStep> createState() => _ConnectOnboardingStepState(); +} + +class _ConnectOnboardingStepState extends State<_ConnectOnboardingStep> { + late StreamSubscription _connectionStateSubscription; + late StreamSubscription _actionSubscription; + + @override + void initState() { + super.initState(); + + _actionSubscription = core.connection.actionStream.listen((data) async { + setState(() {}); + if (data is ButtonNotification) { + widget.onComplete(); + } + }); + _connectionStateSubscription = core.connection.connectionStream.listen((state) async { + setState(() {}); + }); + } + + @override + void dispose() { + _connectionStateSubscription.cancel(); + _actionSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + ColoredTitle(text: context.i18n.connectControllers), + if (core.connection.controllerDevices.isEmpty) ...[ + ScanWidget(), + + OutlineButton( + onPressed: () { + launchUrlString('https://github.com/OpenBikeControl/bikecontrol/?tab=readme-ov-file#supported-devices'); + }, + leading: Icon(Icons.gamepad_outlined), + child: Text(context.i18n.showSupportedControllers), + ), + PrimaryButton( + leading: Icon(Icons.computer_outlined), + onPressed: () { + widget.onComplete(); + }, + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: "${AppLocalizations.of(context).noControllerUseCompanionMode.split("?").first}?\n"), + TextSpan( + text: AppLocalizations.of(context).noControllerUseCompanionMode.split("? ").last, + style: TextStyle(color: Theme.of(context).colorScheme.muted, fontSize: 12), + ), + ], + ), + ), + ), + ] else ...[ + if (core.connection.controllerDevices.any((d) => d.isConnected && d is ZwiftDevice)) + RepeatedAnimationBuilder( + duration: Duration(seconds: 1), + start: 0.5, + end: 1.0, + curve: Curves.easeInOut, + mode: LoopingMode.pingPong, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Text( + AppLocalizations.of(context).controllerConnectedClickButton, + ).small, + ); + }, + ), + SizedBox(), + ...core.connection.controllerDevices.map( + (device) => device.showInformation(context), + ), + if (core.connection.controllerDevices.any((d) => d.isConnected)) + PrimaryButton( + leading: Icon(Icons.check), + onPressed: () { + widget.onComplete(); + }, + child: Text(context.i18n.continueAction), + ), + SizedBox(), + ], + ], + ); + } +} + +class _TrainerOnboardingStep extends StatefulWidget { + final VoidCallback onComplete; + const _TrainerOnboardingStep({super.key, required this.onComplete}); + + @override + State<_TrainerOnboardingStep> createState() => _TrainerOnboardingStepState(); +} + +class _TrainerOnboardingStepState extends State<_TrainerOnboardingStep> { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + spacing: 12, + children: [ + SizedBox(), + ConfigurationPage( + onboardingMode: true, + onUpdate: () { + setState(() {}); + }, + ), + if (core.settings.getTrainerApp() != null) SizedBox(height: 20), + if (core.settings.getTrainerApp() != null) + PrimaryButton( + leading: Icon(Icons.check), + onPressed: () { + widget.onComplete(); + }, + child: Text(context.i18n.continueAction), + ), + ], + ); + } +} + +class _OpenBikeControlConnectPage extends StatefulWidget { + final VoidCallback onComplete; + const _OpenBikeControlConnectPage({super.key, required this.onComplete}); + + @override + State<_OpenBikeControlConnectPage> createState() => _OpenBikeControlConnectPageState(); +} + +class _OpenBikeControlConnectPageState extends State<_OpenBikeControlConnectPage> { + @override + void initState() { + super.initState(); + if (!core.obpMdnsEmulator.isStarted.value) { + if (!core.logic.isObpMdnsEnabled) { + core.settings.setObpMdnsEnabled(true); + } + core.logic.startEnabledConnectionMethod(); + } + core.obpMdnsEmulator.connectedApp.addListener(() { + if (core.obpMdnsEmulator.connectedApp.value != null) { + widget.onComplete(); + } + }); + } + + @override + Widget build(BuildContext context) { + return Column( + spacing: 22, + children: [ + Text( + AppLocalizations.of(context).openBikeControlAnnouncement(core.settings.getTrainerApp()!.name), + ).small, + OpenBikeControlMdnsTile(), + + if (core.settings.getLastTarget() == Target.otherDevice && + core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.ble) == true) ...[ + SizedBox(height: 20), + Text('If you have issues with your network connection, you can also connect via Bluetooth.').small.muted, + OpenBikeControlBluetoothTile(), + ], + ], + ); + } +} diff --git a/lib/pages/paywall.dart b/lib/pages/paywall.dart new file mode 100644 index 000000000..f21187cf0 --- /dev/null +++ b/lib/pages/paywall.dart @@ -0,0 +1,822 @@ +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/ui/colors.dart'; +import 'package:bike_control/widgets/ui/pro_badge.dart'; +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +enum _PaywallPlan { + yearly, + monthly, + fullVersion, +} + +enum _PaywallCell { + unlimited, + check, + dash, +} + +class _FeatureLine { + final IconData icon; + final String label; + final _PaywallCell full; + final _PaywallCell pro; + + const _FeatureLine({ + required this.icon, + required this.label, + required this.full, + required this.pro, + }); +} + +class _PaywallPricing { + final String yearlyPrice; + final String yearlyBilled; + final String monthlyPrice; + final String monthlyBilled; + final String fullVersionSubtitle; + final String? discountBadge; + + const _PaywallPricing({ + required this.yearlyPrice, + required this.yearlyBilled, + required this.monthlyPrice, + required this.monthlyBilled, + required this.fullVersionSubtitle, + required this.discountBadge, + }); + + static const fallback = _PaywallPricing( + yearlyPrice: 'About 2.25 \$/mo', + yearlyBilled: 'Price calculated at checkout', + monthlyPrice: 'About 2.50 \$/mo', + monthlyBilled: 'Price calculated at checkout', + fullVersionSubtitle: 'About 4.99 \$ - price calculated at checkout', + discountBadge: '10% OFF', + ); +} + +class Paywall extends StatefulWidget { + final bool defaultToFullVersion; + + const Paywall({ + super.key, + this.defaultToFullVersion = false, + }); + + @override + State createState() => _PaywallState(); +} + +class _PaywallState extends State { + late final List<_FeatureLine> _features = [ + _FeatureLine( + icon: Icons.functions, + label: AppLocalizations.current.paywall_amountOfActions, + full: _PaywallCell.unlimited, + pro: _PaywallCell.unlimited, + ), + _FeatureLine( + icon: Icons.public, + label: AppLocalizations.current.paywall_connectToYourTrainer, + full: _PaywallCell.check, + pro: _PaywallCell.check, + ), + _FeatureLine( + icon: Icons.tune, + label: AppLocalizations.current.paywall_configure3ActionsPerButton, + full: _PaywallCell.dash, + pro: _PaywallCell.check, + ), + _FeatureLine( + icon: Icons.devices, + label: AppLocalizations.current.paywall_useBikecontrolOnAllPlatforms, + full: _PaywallCell.dash, + pro: _PaywallCell.check, + ), + _FeatureLine( + icon: Icons.cloud_outlined, + label: AppLocalizations.current.paywall_synchronizeAndBackup, + full: _PaywallCell.dash, + pro: _PaywallCell.check, + ), + _FeatureLine( + icon: Icons.keyboard_command_key, + label: AppLocalizations.current.paywall_startAnyCommandShortcutWithAnyButton, + full: _PaywallCell.dash, + pro: _PaywallCell.check, + ), + _FeatureLine( + icon: Icons.music_note_outlined, + label: AppLocalizations.current.paywall_controlYourDeviceMusic, + full: _PaywallCell.dash, + pro: _PaywallCell.check, + ), + _FeatureLine( + icon: Icons.screenshot_monitor_outlined, + label: AppLocalizations.current.paywall_createScreenshots, + full: _PaywallCell.dash, + pro: _PaywallCell.check, + ), + _FeatureLine( + icon: Icons.volunteer_activism_outlined, + label: AppLocalizations.of(context).paywall_supportDevelopmentOfNewFeaturesDevicesAndMore, + full: _PaywallCell.dash, + pro: _PaywallCell.check, + ), + ]; + + final IAPManager _iapManager = IAPManager.instance; + + late _PaywallPlan _selectedPlan; + _PaywallPricing _pricing = _PaywallPricing.fallback; + + bool _isPurchasing = false; + bool _isRestoring = false; + + @override + void initState() { + super.initState(); + _selectedPlan = widget.defaultToFullVersion ? _PaywallPlan.fullVersion : _PaywallPlan.yearly; + _iapManager.entitlements.addListener(_onEntitlementsChanged); + _iapManager.isPurchased.addListener(_onEntitlementsChanged); + _loadRevenueCatPricing(); + } + + @override + void dispose() { + _iapManager.entitlements.removeListener(_onEntitlementsChanged); + _iapManager.isPurchased.removeListener(_onEntitlementsChanged); + super.dispose(); + } + + void _onEntitlementsChanged() { + if (!mounted) { + return; + } + if (_iapManager.isProEnabled || _iapManager.isPurchased.value) { + closeDrawer(context); + } + } + + Future _onPurchasePressed() async { + if (_isPurchasing) { + return; + } + setState(() { + _isPurchasing = true; + }); + + try { + switch (_selectedPlan) { + case _PaywallPlan.yearly: + await _iapManager.purchaseSubscription( + context, + plan: SubscriptionPlan.yearly, + fromPaywall: true, + ); + break; + case _PaywallPlan.monthly: + await _iapManager.purchaseSubscription( + context, + plan: SubscriptionPlan.monthly, + fromPaywall: true, + ); + break; + case _PaywallPlan.fullVersion: + await _iapManager.purchaseFullVersion( + context, + fromPaywall: true, + ); + break; + } + } finally { + if (mounted) { + setState(() { + _isPurchasing = false; + }); + } + } + } + + Future _onRestorePressed() async { + if (_isRestoring) { + return; + } + + setState(() { + _isRestoring = true; + }); + + try { + await _iapManager.restorePurchases(); + } finally { + if (mounted) { + setState(() { + _isRestoring = false; + }); + } + } + } + + void _selectPlan(_PaywallPlan plan) { + setState(() { + _selectedPlan = plan; + }); + } + + Future _loadRevenueCatPricing() async { + if (defaultTargetPlatform != TargetPlatform.macOS) { + return; + } + + try { + final offerings = await Purchases.getOfferings(); + final pricing = _buildPricingFromOfferings(offerings); + if (pricing != null && mounted) { + setState(() { + _pricing = pricing; + }); + } + } catch (e) { + debugPrint('Could not load RevenueCat offerings for paywall: $e'); + } + } + + _PaywallPricing? _buildPricingFromOfferings(Offerings offerings) { + final allOfferings = offerings.all.values.toList(); + final proOffering = offerings.all[_iapManager.isPurchased.value ? 'proonly-freemonth' : 'pro']; + final defaultOffering = offerings.all['default']; + + final monthlyPackage = + proOffering?.monthly ?? + offerings.current?.monthly ?? + _firstPackageFromOfferings(allOfferings, (offering) => offering.monthly); + + final yearlyPackage = + proOffering?.annual ?? + offerings.current?.annual ?? + _firstPackageFromOfferings(allOfferings, (offering) => offering.annual); + + final lifetimePackage = + defaultOffering?.lifetime ?? + offerings.current?.lifetime ?? + _firstPackageFromOfferings(allOfferings, (offering) => offering.lifetime); + + if (monthlyPackage == null && yearlyPackage == null && lifetimePackage == null) { + return null; + } + + final monthlyStoreProduct = monthlyPackage?.storeProduct; + final yearlyStoreProduct = yearlyPackage?.storeProduct; + final lifetimeStoreProduct = lifetimePackage?.storeProduct; + + final yearlyPrice = yearlyStoreProduct != null + ? '${_formatCurrency(yearlyStoreProduct.price / 12, yearlyStoreProduct.currencyCode)}/mo' + : _pricing.yearlyPrice; + + final yearlyBilled = yearlyStoreProduct != null + ? AppLocalizations.of(context).paywall_billedAtYearly(yearlyStoreProduct.priceString) + : _pricing.yearlyBilled; + + final monthlyPrice = monthlyStoreProduct != null ? '${monthlyStoreProduct.priceString}/mo' : _pricing.monthlyPrice; + + final monthlyBilled = monthlyStoreProduct != null + ? AppLocalizations.of(context).paywall_billedAtPricemo(monthlyStoreProduct.priceString) + : _pricing.monthlyBilled; + + final fullVersionSubtitle = lifetimeStoreProduct != null + ? '${AppLocalizations.of(context).only} ${lifetimeStoreProduct.priceString}' + : _pricing.fullVersionSubtitle; + + String? discountBadge; + if (monthlyStoreProduct != null && yearlyStoreProduct != null && monthlyStoreProduct.price > 0) { + final yearlyEquivalent = yearlyStoreProduct.price / 12; + final savingsFraction = (monthlyStoreProduct.price - yearlyEquivalent) / monthlyStoreProduct.price; + final savingsPercent = (savingsFraction * 100).round(); + if (savingsPercent > 0) { + discountBadge = '$savingsPercent% OFF'; + } + } + + return _PaywallPricing( + yearlyPrice: yearlyPrice, + yearlyBilled: yearlyBilled, + monthlyPrice: monthlyPrice, + monthlyBilled: monthlyBilled, + fullVersionSubtitle: fullVersionSubtitle, + discountBadge: discountBadge, + ); + } + + Package? _firstPackageFromOfferings( + Iterable offerings, + Package? Function(Offering offering) selector, + ) { + for (final offering in offerings) { + final package = selector(offering); + if (package != null) { + return package; + } + } + return null; + } + + String _formatCurrency(double value, String currencyCode) { + final formatter = NumberFormat.currency( + name: currencyCode, + decimalDigits: 2, + ); + return formatter.format(value); + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 500), + child: SafeArea( + top: false, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(18, 20, 18, 26), + child: Column( + spacing: 18, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildComparisonTable(context), + _buildPlansSection(context), + _buildPurchaseButton(context), + Align( + child: Button.ghost( + onPressed: _isRestoring ? null : _onRestorePressed, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isRestoring) ...[ + CircularProgressIndicator( + size: 14, + ), + const SizedBox(width: 8), + ], + Text( + _isRestoring ? 'Restoring purchases...' : AppLocalizations.of(context).restorePurchases, + style: const TextStyle( + fontSize: 16, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildComparisonTable(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final fullColumnWidth = 80.0; + final proColumnWidth = 102.0; + + return ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Container( + color: const Color(0xFFF5F5F8), + child: Stack( + children: [ + Positioned( + top: 0, + right: 0, + bottom: 0, + width: proColumnWidth, + child: Container( + color: const Color(0xFFE6E7F5), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 0, 12), + child: Column( + children: [ + _buildHeaderRow( + fullColumnWidth: fullColumnWidth, + proColumnWidth: proColumnWidth, + ), + const SizedBox(height: 8), + ..._features.map( + (feature) => _buildFeatureRow( + feature: feature, + fullColumnWidth: fullColumnWidth, + proColumnWidth: proColumnWidth, + compact: true, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildHeaderRow({ + required double fullColumnWidth, + required double proColumnWidth, + }) { + return Row( + children: [ + const Expanded(child: SizedBox()), + SizedBox( + width: fullColumnWidth, + child: Center( + child: Text( + AppLocalizations.of(context).full, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + letterSpacing: 0.8, + color: Color(0xFF55565C), + ), + ), + ), + ), + SizedBox( + width: proColumnWidth, + child: Center( + child: ProBadge(fontSize: 14), + ), + ), + ], + ); + } + + Widget _buildFeatureRow({ + required _FeatureLine feature, + required double fullColumnWidth, + required double proColumnWidth, + required bool compact, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + feature.icon, + color: const Color(0xFF94959A), + size: compact ? 18 : 22, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + feature.label, + style: TextStyle( + color: const Color(0xFF4D4E54), + fontWeight: FontWeight.normal, + fontSize: compact ? 16 : 19, + height: 1.2, + ), + ), + ), + ], + ), + ), + SizedBox( + width: fullColumnWidth, + child: Center( + child: _buildCell(feature.full, compact: compact), + ), + ), + SizedBox( + width: proColumnWidth, + child: Center( + child: _buildCell(feature.pro, compact: compact), + ), + ), + ], + ), + ); + } + + Widget _buildCell(_PaywallCell value, {required bool compact}) { + return switch (value) { + _PaywallCell.unlimited => Text( + AppLocalizations.of(context).unlimited, + style: TextStyle( + fontSize: compact ? 14 : 24, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), + _PaywallCell.check => Icon( + Icons.check_rounded, + size: compact ? 28 : 48, + color: Colors.black, + ), + _PaywallCell.dash => Container( + width: compact ? 20 : 40, + height: 3, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(3), + ), + ), + }; + } + + Widget _buildPlansSection(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final stackPlans = constraints.maxWidth < 420; + + return Column( + spacing: 12, + children: [ + if (!stackPlans) + Row( + spacing: 12, + children: [ + Expanded( + child: _buildPlanCard( + plan: _PaywallPlan.yearly, + title: AppLocalizations.of(context).paywall_yearly, + price: _pricing.yearlyPrice, + billed: _pricing.yearlyBilled, + badge: _pricing.discountBadge, + ), + ), + Expanded( + child: _buildPlanCard( + plan: _PaywallPlan.monthly, + title: AppLocalizations.of(context).paywall_monthly, + price: _pricing.monthlyPrice, + billed: _pricing.monthlyBilled, + ), + ), + ], + ) + else ...[ + _buildPlanCard( + plan: _PaywallPlan.yearly, + title: AppLocalizations.of(context).paywall_yearly, + price: _pricing.yearlyPrice, + billed: _pricing.yearlyBilled, + badge: _pricing.discountBadge, + ), + _buildPlanCard( + plan: _PaywallPlan.monthly, + title: AppLocalizations.of(context).paywall_monthly, + price: _pricing.monthlyPrice, + billed: _pricing.monthlyBilled, + ), + ], + if (!_iapManager.isPurchased.value) _buildFullVersionCard(context), + ], + ); + }, + ); + } + + Widget _buildPlanCard({ + required _PaywallPlan plan, + required String title, + required String price, + required String billed, + String? badge, + }) { + final selected = _selectedPlan == plan; + + return Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: () => _selectPlan(plan), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.fromLTRB(16, 20, 16, 18), + decoration: BoxDecoration( + color: const Color(0xFFF1F2F7), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: selected ? const Color(0xFF5A6ED6) : const Color(0xFFC1C2C8), + width: selected ? 2.6 : 2, + ), + ), + child: Column( + spacing: 2, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 8, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Color(0xFF07070A), + ), + ), + ), + _buildRadioIndicator(selected, compact: true), + ], + ), + Text( + price, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Color(0xFF111216), + ), + ), + const SizedBox(height: 10), + Text( + billed, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF7A7B85), + ), + ), + ], + ), + ), + ), + if (badge != null) + Positioned( + top: -10, + left: 0, + right: 0, + child: Align( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF5A6ED6), + borderRadius: BorderRadius.circular(18), + ), + child: Text( + badge, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 15, + letterSpacing: 0.3, + ), + ), + ), + ), + ), + + if (plan == _PaywallPlan.monthly || plan == _PaywallPlan.yearly) + Positioned( + top: 0, + right: 0, + child: ProBadge( + fontSize: 14, + borderRadius: BorderRadius.only(topRight: Radius.circular(16), bottomLeft: Radius.circular(8)), + ), + ), + ], + ); + } + + Widget _buildFullVersionCard(BuildContext context) { + final selected = _selectedPlan == _PaywallPlan.fullVersion; + return GestureDetector( + onTap: () => _selectPlan(_PaywallPlan.fullVersion), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFF1F2F7), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: selected ? const Color(0xFF5A6ED6) : const Color(0xFFC1C2C8), + width: selected ? 2.4 : 2, + ), + ), + child: Row( + children: [ + _buildRadioIndicator(selected, compact: true), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).fullVersion, + style: const TextStyle( + color: Color(0xFF07070A), + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + _pricing.fullVersionSubtitle, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF4E4E53), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildRadioIndicator(bool selected, {bool compact = false}) { + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: compact ? 28 : 38, + height: compact ? 28 : 38, + margin: EdgeInsets.only(top: 8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? const Color(0xFF5A6ED6) : Colors.transparent, + border: Border.all( + color: selected ? const Color(0xFF5A6ED6) : const Color(0xFFB8B9C0), + width: selected ? 3 : 2, + ), + boxShadow: selected + ? [ + BoxShadow( + color: const Color(0xFF5A6ED6).withAlpha(70), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: selected + ? Icon( + Icons.check, + size: compact ? 17 : 20, + color: Colors.white, + ) + : null, + ); + } + + Widget _buildPurchaseButton(BuildContext context) { + return GestureDetector( + onTap: _isPurchasing ? null : _onPurchasePressed, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _isPurchasing ? 0.85 : 1, + child: Container( + height: 52, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + BKColor.main, + BKColor.mainEnd, + ], + ), + borderRadius: BorderRadius.circular(22), + boxShadow: [ + BoxShadow( + color: BKColor.mainEnd.withAlpha(55), + blurRadius: 14, + offset: const Offset(0, 8), + ), + ], + ), + alignment: Alignment.center, + child: _isPurchasing + ? CircularProgressIndicator( + size: 20, + color: Colors.white, + ) + : Text( + AppLocalizations.of(context).purchase, + style: const TextStyle( + fontSize: 20, + color: Colors.white, + fontWeight: FontWeight.w800, + height: 1, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/requirements.dart b/lib/pages/requirements.dart deleted file mode 100644 index 124d6a738..000000000 --- a/lib/pages/requirements.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'dart:io'; - -import 'package:dartx/dartx.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:swift_control/main.dart'; -import 'package:swift_control/utils/requirements/platform.dart'; - -import 'device.dart'; - -class RequirementsPage extends StatefulWidget { - const RequirementsPage({super.key}); - - @override - State createState() => _RequirementsPageState(); -} - -class _RequirementsPageState extends State with WidgetsBindingObserver { - int _currentStep = 0; - - List _requirements = []; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - - // call after first frame - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!kIsWeb && Platform.isMacOS) { - // add more delay due tu CBManagerStateUnknown - Future.delayed(const Duration(seconds: 2), () { - _reloadRequirements(); - }); - } else { - _reloadRequirements(); - } - }); - - connection.hasDevices.addListener(() { - if (connection.hasDevices.value) { - Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage())); - } - }); - } - - @override - dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) { - _reloadRequirements(); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('SwiftControl'), backgroundColor: Theme.of(context).colorScheme.inversePrimary), - body: - _requirements.isEmpty - ? Center(child: CircularProgressIndicator()) - : Stepper( - currentStep: _currentStep, - connectorColor: WidgetStateProperty.resolveWith( - (Set states) => Theme.of(context).colorScheme.primary, - ), - onStepContinue: - _currentStep < _requirements.length - ? () { - setState(() { - _currentStep += 1; - }); - } - : null, - onStepTapped: (step) { - if (_requirements[step].status) { - return; - } - final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step; - if (hasEarlierIncomplete) { - return; - } - setState(() { - _currentStep = step; - }); - }, - controlsBuilder: (context, details) => Container(), - steps: - _requirements - .mapIndexed( - (index, req) => Step( - title: Text(req.name), - content: - (index == _currentStep - ? req.build(context, () { - _reloadRequirements(); - }) - : null) ?? - ElevatedButton( - onPressed: req.status ? null : () => _callRequirement(req), - child: Text(req.name), - ), - state: req.status ? StepState.complete : StepState.indexed, - ), - ) - .toList(), - ), - ); - } - - void _callRequirement(PlatformRequirement req) { - req.call().then((_) { - _reloadRequirements(); - }); - } - - void _reloadRequirements() { - getRequirements().then((req) { - _requirements = req; - _currentStep = req.indexWhere((req) => !req.status); - if (mounted) { - setState(() {}); - } - }); - } -} diff --git a/lib/pages/scan.dart b/lib/pages/scan.dart deleted file mode 100644 index b7d01f0c9..000000000 --- a/lib/pages/scan.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart'; -import 'package:swift_control/main.dart'; -import 'package:swift_control/utils/ble.dart'; -import 'package:swift_control/widgets/small_progress_indicator.dart'; - -class ScanWidget extends StatefulWidget { - const ScanWidget({super.key}); - - @override - State createState() => _ScanWidgetState(); -} - -class _ScanWidgetState extends State { - bool _isScanning = false; - late StreamSubscription _isScanningSubscription; - - @override - void initState() { - super.initState(); - - connection.startScanning(); - - _isScanningSubscription = FlutterBluePlus.isScanning.listen((state) { - _isScanning = state; - if (mounted) { - setState(() {}); - } - }); - - // after the first frame - WidgetsBinding.instance.addPostFrameCallback((_) { - onScanPressed(); - }); - } - - @override - void dispose() { - _isScanningSubscription.cancel(); - super.dispose(); - } - - Future onScanPressed() async { - try { - await FlutterBluePlus.startScan( - timeout: const Duration(seconds: 30), - withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID], - webOptionalServices: kIsWeb ? [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID] : [], - ); - } catch (e, backtrace) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'), duration: const Duration(seconds: 5))); - print(e); - print("backtrace: $backtrace"); - } - if (mounted) { - setState(() {}); - } - } - - Future onStopPressed() async { - try { - FlutterBluePlus.stopScan(); - } catch (e, backtrace) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'), duration: const Duration(seconds: 5))); - print(e); - print("backtrace: $backtrace"); - } - } - - Widget buildScanButton(BuildContext context) { - if (FlutterBluePlus.isScanningNow) { - return ElevatedButton(onPressed: onStopPressed, child: const Icon(Icons.stop)); - } else { - return ElevatedButton(onPressed: onScanPressed, child: const Text("SCAN")); - } - } - - @override - Widget build(BuildContext context) { - return Container( - constraints: BoxConstraints(minHeight: 200), - child: ListView( - padding: EdgeInsets.all(16), - shrinkWrap: true, - children: [if (_isScanning) SmallProgressIndicator() else buildScanButton(context)], - ), - ); - } -} diff --git a/lib/pages/subscription.dart b/lib/pages/subscription.dart new file mode 100644 index 000000000..813f45609 --- /dev/null +++ b/lib/pages/subscription.dart @@ -0,0 +1,441 @@ +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/pages/button_edit.dart'; +import 'package:bike_control/pages/subscriptions/login.dart'; +import 'package:bike_control/pages/subscriptions/registered_devices_view.dart'; +import 'package:bike_control/pages/subscriptions/sync_settings_view.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/go_pro_dialog.dart'; +import 'package:bike_control/widgets/ui/loading_widget.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +enum SubscriptionPageView { + main, + login, + syncSettings, + devices, +} + +class SubscriptionPage extends StatefulWidget { + const SubscriptionPage({super.key}); + + @override + State createState() => _SubscriptionPageState(); +} + +class _SubscriptionPageState extends State { + final IAPManager _iapManager = IAPManager.instance; + SubscriptionPageView _currentView = SubscriptionPageView.main; + bool? _hasStripeCustomer; + + @override + void initState() { + super.initState(); + _checkStripeCustomer(); + _iapManager.entitlements.addListener(_onEntitlementsChanged); + } + + @override + dispose() { + _iapManager.entitlements.removeListener(_onEntitlementsChanged); + super.dispose(); + } + + Future _checkStripeCustomer() async { + if (_iapManager.isWindows && _iapManager.isLoggedIn) { + final hasCustomer = await _iapManager.hasStripeCustomer(); + if (mounted) { + setState(() { + _hasStripeCustomer = hasCustomer; + }); + } + } + } + + Color _getStatusColor() { + if (_iapManager.isProEnabledForCurrentDevice) { + return Colors.green; + } else if (_iapManager.isProEnabled) { + return Colors.orange; + } else if (_iapManager.isPurchased.value) { + return Colors.blue; + } else { + return Colors.red; + } + } + + IconData _getStatusIcon() { + if (_iapManager.isProEnabledForCurrentDevice) { + return Icons.workspace_premium; + } else if (_iapManager.isProEnabled) { + return Icons.pending; + } else if (_iapManager.isPurchased.value) { + return Icons.verified; + } else { + return Icons.hourglass_empty; + } + } + + bool get _isPro => _iapManager.hasActiveSubscription; + + void _navigateTo(SubscriptionPageView view) { + setState(() { + _currentView = view; + }); + } + + void _goBack() { + setState(() { + _currentView = SubscriptionPageView.main; + }); + } + + void _showGoProDialog() { + showGoProDialog(context); + } + + void _handleProFeature(VoidCallback action) { + if (_isPro) { + action(); + } else { + _showGoProDialog(); + } + } + + void _handleLoggedInFeature(VoidCallback action) { + if (_isPro && core.supabase.auth.currentSession != null) { + action(); + } else { + _handleProFeature(() { + _navigateTo(SubscriptionPageView.login); + }); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Breadcrumbs + if (_currentView != SubscriptionPageView.main) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.border, + ), + ), + ), + child: Row( + children: [ + Button.secondary( + onPressed: _goBack, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.arrow_back, size: 16), + const SizedBox(width: 8), + Text('Subscription'), + ], + ), + ), + const SizedBox(width: 8), + Icon(Icons.chevron_right, size: 16, color: Theme.of(context).colorScheme.mutedForeground), + const SizedBox(width: 8), + Text( + switch (_currentView) { + SubscriptionPageView.login => AppLocalizations.of(context).account, + SubscriptionPageView.syncSettings => AppLocalizations.of(context).syncSettings, + SubscriptionPageView.devices => AppLocalizations.of(context).registeredDevices, + _ => '', + }, + ).small, + ], + ), + ), + // Content + Flexible( + child: switch (_currentView) { + SubscriptionPageView.main => _buildMainView(), + SubscriptionPageView.login => _buildLoginView(), + SubscriptionPageView.syncSettings => _buildSyncSettingsView(), + SubscriptionPageView.devices => _buildDevicesView(), + }, + ), + ], + ), + ); + } + + Widget _buildMainView() { + final session = core.supabase.auth.currentSession; + final isOutsideStoreWindowsBuild = _iapManager.isOutsideStoreWindowsBuild; + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Version Status Card + Card( + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _getStatusColor().withAlpha(30), + shape: BoxShape.circle, + ), + child: Icon( + _getStatusIcon(), + size: 28, + color: _getStatusColor(), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context).currentPlan, + ).small.muted, + Text( + IAPManager.instance.getStatusMessage(), + ).large.bold, + ], + ), + ), + ], + ), + if (!_isPro) ...[ + Divider(), + Text( + (!_iapManager.isPurchased.value && !isOutsideStoreWindowsBuild) + ? AppLocalizations.of(context).unlockTheFullVersionOrGoPro + : AppLocalizations.of(context).unlockAllFeaturesWithPro, + ).small.muted, + if (_iapManager.isWindows && !_iapManager.isWindowsLoggedIn) _buildWindowsAuthWarning(), + Row( + spacing: 8, + children: [ + if (!_iapManager.isPurchased.value && !isOutsideStoreWindowsBuild) + Expanded( + child: LoadingWidget( + futureCallback: () => _buyFullVersion(), + renderChild: (isLoading, tap) => Button.secondary( + onPressed: tap, + child: isLoading + ? SmallProgressIndicator() + : Text(AppLocalizations.of(context).buyFullVersion), + ), + ), + ), + Expanded( + child: LoadingWidget( + futureCallback: () => _buyProVersion(), + renderChild: (isLoading, tap) => Button.primary( + onPressed: tap, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + isLoading ? SmallProgressIndicator() : Icon(Icons.workspace_premium, size: 16), + const SizedBox(width: 8), + Text(AppLocalizations.of(context).goPro), + ], + ), + ), + ), + ), + ], + ), + ] else if (_isPro && _iapManager.isWindows) ...[ + // Show manage subscription button for Windows Pro users + if (_hasStripeCustomer == true) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Divider(), + LoadingWidget( + futureCallback: () async { + await _openBillingPortal(); + }, + renderChild: (isLoading, tap) => Button.secondary( + onPressed: tap, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + isLoading ? SmallProgressIndicator() : Icon(Icons.manage_accounts, size: 16), + const SizedBox(width: 8), + Text(AppLocalizations.of(context).manageSubscription), + ], + ), + ), + ), + ], + ), + ], + ], + ), + ), + + // Account Section + _buildProCard( + icon: Icons.account_circle, + title: AppLocalizations.of(context).account, + subtitle: _getAccountSubtitle(session), + onTap: () => _navigateTo(SubscriptionPageView.login), + ), + + // Sync Settings Section + _buildProCard( + icon: Icons.sync, + title: AppLocalizations.of(context).syncSettings, + subtitle: AppLocalizations.of(context).synchronizeAcrossDevices, + onTap: () { + _handleLoggedInFeature(() { + if (IAPManager.instance.isProEnabledForCurrentDevice) { + _navigateTo(SubscriptionPageView.syncSettings); + } else { + buildToast(title: AppLocalizations.of(context).currentDeviceIsNotRegistered); + } + }); + }, + ), + + // Registered Devices Section + _buildProCard( + icon: Icons.devices, + title: AppLocalizations.of(context).registeredDevices, + subtitle: AppLocalizations.of(context).manageYourDevices, + onTap: () => _handleLoggedInFeature(() => _navigateTo(SubscriptionPageView.devices)), + ), + ], + ), + ); + } + + Widget _buildProCard({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return SelectableCard( + onPressed: onTap, + isActive: false, + isProOnly: icon != Icons.account_circle, + title: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Icon(icon, size: 24, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title).small.bold, + const SizedBox(height: 4), + Text(subtitle).small.muted, + ], + ), + ), + Icon(Icons.chevron_right, size: 20, color: Theme.of(context).colorScheme.mutedForeground), + ], + ), + ), + ); + } + + Widget _buildLoginView() { + return LoginPage( + pushed: false, + onBack: _goBack, + ); + } + + Widget _buildDevicesView() { + return RegisteredDevicesView( + onBack: _goBack, + ); + } + + Widget _buildSyncSettingsView() { + return SyncSettingsView(); + } + + Future _buyFullVersion() { + return _iapManager.purchaseFullVersion(context); + } + + Future _buyProVersion() { + return _iapManager.purchaseSubscription(context); + } + + Future _openBillingPortal() async { + final shouldShowButton = await _iapManager.openBillingPortal(context); + if (!shouldShowButton && mounted) { + setState(() { + _hasStripeCustomer = false; + }); + } + } + + /// Get the account subtitle with Windows-specific messaging + String _getAccountSubtitle(Session? session) { + if (session != null) { + return AppLocalizations.of(context).loggedInAsMail(session.user.email ?? '?'); + } + + if (_iapManager.isWindows) { + return 'Not logged in - Required for subscription'; + } + + return 'Not logged in'; + } + + /// Shows a warning on Windows that authentication is required for subscriptions + Widget _buildWindowsAuthWarning() { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.orange.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withAlpha(100)), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.orange, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + AppLocalizations.of(context).windowsSubscriptionsRequireYouToBeLoggedIn, + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + ), + ), + ), + ], + ), + ); + } + + void _onEntitlementsChanged() { + setState(() {}); + } +} diff --git a/lib/pages/subscriptions/login.dart b/lib/pages/subscriptions/login.dart new file mode 100644 index 000000000..9f2eb8c94 --- /dev/null +++ b/lib/pages/subscriptions/login.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/requirements/windows.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:sign_in_button/sign_in_button.dart'; +import 'package:sign_in_with_apple/sign_in_with_apple.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class LoginPage extends StatefulWidget { + final bool pushed; + final VoidCallback? onBack; + const LoginPage({super.key, required this.pushed, this.onBack}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final IAPManager _iapManager = IAPManager.instance; + + @override + Widget build(BuildContext context) { + final session = core.supabase.auth.currentSession; + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 820), + child: session == null ? _buildSignedOut(context) : _buildSignedIn(context, session), + ), + ), + ); + } + + Widget _buildSignedOut(BuildContext context) { + return Column( + spacing: 32, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withAlpha(20), + shape: BoxShape.circle, + ), + child: Icon( + Icons.account_circle, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + ), + Column( + spacing: 8, + children: [ + Text( + 'BikeControl', + ).large, + Text( + AppLocalizations.of(context).signInToSyncYourSubscriptionAndManageDevices, + ).small.muted, + ], + ), + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + spacing: 16, + mainAxisSize: MainAxisSize.min, + children: [ + SignInButton( + Buttons.google, + onPressed: _nativeGoogleSignIn, + ), + SignInButton( + Buttons.apple, + onPressed: _signInWithApple, + ), + SignInButton( + Buttons.gitHub, + onPressed: _signInWithGithub, + ), + SignInButton( + Buttons.facebook, + onPressed: _signInWithFacebook, + ), + ], + ), + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: AppLocalizations.of(context).bySigningInYouAgreeToOur( + AppLocalizations.of(context).privacyPolicy, + ).split(AppLocalizations.of(context).privacyPolicy).first, + ), + TextSpan( + text: AppLocalizations.of(context).privacyPolicy, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrlString('https://bikecontrol.app/privacy-policy'), + ), + TextSpan( + text: AppLocalizations.of(context).bySigningInYouAgreeToOur( + AppLocalizations.of(context).privacyPolicy, + ).split(AppLocalizations.of(context).privacyPolicy).last, + ), + ], + ), + textAlign: TextAlign.center, + ).small.muted, + if (kDebugMode && Platform.isWindows) + Button.secondary( + child: const Text('Register protocol handler'), + onPressed: () { + WindowsProtocolHandler().register('bikecontrol'); + }, + ), + ], + ); + } + + Widget _buildSignedIn(BuildContext context, Session session) { + return Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.withAlpha(30), + shape: BoxShape.circle, + ), + child: Icon(Icons.check_circle, size: 28, color: Colors.green), + ), + const SizedBox(width: 16), + Text( + session.user.email ?? session.user.id, + ).small.bold, + ], + ), + Button.secondary( + child: Text(AppLocalizations.of(context).logout), + onPressed: () async { + await core.supabase.auth.signOut(); + }, + ), + ], + ), + ), + ], + ); + } + + Future _nativeGoogleSignIn() async { + if (Platform.isAndroid || Platform.isIOS) { + const webClientId = '709945926587-bgk7j9qc86t7nuemu100ngvl9c7irv9k.apps.googleusercontent.com'; + final iosClientId = Platform.isAndroid + ? (kDebugMode + ? '709945926587-fr2uodlnea57jc3mr8qannt45hi0tjeq.apps.googleusercontent.com' + : '709945926587-orkcqc71o6i3cf5lkd85k9n93lobfgae.apps.googleusercontent.com') + : '709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2.apps.googleusercontent.com'; + final scopes = ['email']; + final googleSignIn = GoogleSignIn.instance; + await googleSignIn.initialize( + serverClientId: webClientId, + clientId: iosClientId, + ); + GoogleSignInAccount? googleUser = await googleSignIn.attemptLightweightAuthentication(reportAllExceptions: true); + googleUser ??= await googleSignIn.authenticate(); + + final authorization = + await googleUser.authorizationClient.authorizationForScopes(scopes) ?? + await googleUser.authorizationClient.authorizeScopes(scopes); + final idToken = googleUser.authentication.idToken; + if (idToken == null) { + throw AuthException('No ID Token found.'); + } + final response = await core.supabase.auth.signInWithIdToken( + provider: OAuthProvider.google, + idToken: idToken, + accessToken: authorization.accessToken, + ); + + if (widget.pushed) { + Navigator.pop(context); + } else { + widget.onBack?.call(); + } + return response; + } else { + await core.supabase.auth.signInWithOAuth( + OAuthProvider.google, + redirectTo: kIsWeb ? null : 'bikecontrol://login/', + authScreenLaunchMode: kIsWeb ? LaunchMode.platformDefault : LaunchMode.externalApplication, + ); + if (widget.pushed) { + Navigator.pop(context); + } else { + widget.onBack?.call(); + } + return null; + } + } + + Future _signInWithApple() async { + if (Platform.isIOS || Platform.isMacOS) { + final rawNonce = core.supabase.auth.generateRawNonce(); + final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); + + final credential = await SignInWithApple.getAppleIDCredential( + scopes: [AppleIDAuthorizationScopes.email], + nonce: hashedNonce, + ); + final idToken = credential.identityToken; + if (idToken == null) { + throw const AuthException('Could not find ID Token from generated credential.'); + } + final authResponse = await core.supabase.auth.signInWithIdToken( + provider: OAuthProvider.apple, + idToken: idToken, + nonce: rawNonce, + ); + + if (widget.pushed) { + Navigator.pop(context); + } else { + widget.onBack?.call(); + } + return authResponse; + } else { + await core.supabase.auth.signInWithOAuth( + OAuthProvider.apple, + redirectTo: kIsWeb ? null : 'bikecontrol://login/', + authScreenLaunchMode: kIsWeb ? LaunchMode.platformDefault : LaunchMode.externalApplication, + ); + if (widget.pushed) { + Navigator.pop(context); + } else { + widget.onBack?.call(); + } + return null; + } + } + + Future _signInWithGithub() async { + await core.supabase.auth.signInWithOAuth( + OAuthProvider.github, + redirectTo: kIsWeb ? null : 'bikecontrol://login/', + authScreenLaunchMode: kIsWeb ? LaunchMode.platformDefault : LaunchMode.externalApplication, + ); + } + + Future _signInWithFacebook() async { + await core.supabase.auth.signInWithOAuth( + OAuthProvider.facebook, + redirectTo: kIsWeb ? null : 'bikecontrol://login/', + authScreenLaunchMode: kIsWeb ? LaunchMode.platformDefault : LaunchMode.externalApplication, + ); + } +} diff --git a/lib/pages/subscriptions/registered_devices_view.dart b/lib/pages/subscriptions/registered_devices_view.dart new file mode 100644 index 000000000..14ba068ae --- /dev/null +++ b/lib/pages/subscriptions/registered_devices_view.dart @@ -0,0 +1,229 @@ +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/models/device_limit_reached_error.dart'; +import 'package:bike_control/models/user_device.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/ui/loading_widget.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class RegisteredDevicesView extends StatefulWidget { + final VoidCallback onBack; + + const RegisteredDevicesView({super.key, required this.onBack}); + + @override + State createState() => _RegisteredDevicesViewState(); +} + +class _RegisteredDevicesViewState extends State { + final IAPManager _iapManager = IAPManager.instance; + bool _isLoading = false; + Map> _devicesByPlatform = {}; + + @override + void initState() { + super.initState(); + _loadDevices(); + } + + Future _loadDevices() async { + setState(() { + _isLoading = true; + }); + + try { + final devices = await _iapManager.deviceManagement.getMyDevices(); + final grouped = >{}; + for (final device in devices) { + grouped.putIfAbsent(device.platform, () => []).add(device); + } + if (mounted) { + setState(() { + _devicesByPlatform = grouped; + }); + } + } catch (e) { + // Handle error + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_isLoading) + Center( + child: Column( + spacing: 16, + children: [ + CircularProgressIndicator(), + ], + ), + ), + if (!IAPManager.instance.isProEnabledForCurrentDevice) + LoadingWidget( + futureCallback: _registerCurrentDevice, + renderChild: (isLoading, tap) => Button.primary( + onPressed: tap, + child: isLoading + ? SmallProgressIndicator() + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + size: 16, + ), + const SizedBox(width: 8), + Text(AppLocalizations.of(context).registerCurrentDevice), + ], + ), + ), + ), + + if (_devicesByPlatform.isEmpty) + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.muted.withAlpha(20), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Column( + spacing: 12, + children: [ + Icon( + Icons.devices, + size: 48, + color: Theme.of(context).colorScheme.mutedForeground, + ), + Text(AppLocalizations.of(context).noDevicesRegistered).small.muted, + ], + ), + ), + ) + else + ..._devicesByPlatform.entries.map((entry) => _buildPlatformSection(entry.key, entry.value)), + ], + ), + ); + } + + Widget _buildPlatformSection(String platform, List devices) { + return Card( + child: Column( + spacing: 12, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(platform.toUpperCase()).small.bold, + const Spacer(), + Text(AppLocalizations.of(context).devicesActive(devices.where((d) => d.isActive).length)).small.muted, + ], + ), + Divider(), + ...devices.map((device) => _buildDeviceTile(device)), + ], + ), + ); + } + + Widget _buildDeviceTile(UserDevice device) { + final isRevoked = device.isRevoked; + + return Card( + filled: true, + child: Row( + children: [ + Icon( + Icons.device_unknown, + size: 20, + color: isRevoked ? Theme.of(context).colorScheme.mutedForeground : Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + device.deviceName?.isNotEmpty == true ? device.deviceName! : device.deviceId.split("|").first, + ).small.bold, + if (device.deviceName?.isNotEmpty == true) Text('ID: ${device.deviceId.split("|").first}').small.muted, + Text('${AppLocalizations.of(context).lastSeen} ${_formatDate(device.lastSeenAt)}').small.muted, + ], + ), + ), + if (isRevoked) + Text(AppLocalizations.of(context).revoked).small + else + Button.secondary( + onPressed: () => _revokeDevice(device), + child: Text(AppLocalizations.of(context).revoke), + ), + ], + ), + ); + } + + String _formatDate(DateTime? value) { + if (value == null) return 'Never'; + final local = value.toLocal(); + return '${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year} ${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}'; + } + + Future _revokeDevice(UserDevice device) async { + try { + await _iapManager.deviceManagement.revokeDevice( + platform: device.platform, + deviceId: device.deviceId, + ); + await _iapManager.entitlements.refresh(force: true); + await _loadDevices(); + } catch (e) { + buildToast(title: 'Could not revoke device: $e'); + } + } + + Future _registerCurrentDevice() async { + try { + final platform = await _iapManager.deviceManagement.currentPlatform(); + final deviceName = 'BikeControl ${platform?.toUpperCase() ?? ''}'; + + final package = await PackageInfo.fromPlatform(); + final version = package.version; + + await _iapManager.deviceManagement.registerCurrentDevice( + deviceName: deviceName, + appVersion: version, + ); + await _iapManager.entitlements.refresh(force: true); + await _loadDevices(); + if (!mounted) return; + setState(() {}); + } on DeviceLimitReachedError catch (error) { + if (!mounted) return; + buildToast( + title: AppLocalizations.of(context).deviceLimitReached(error.platform.capitalize().replaceAll('os', 'OS')), + ); + } catch (error) { + if (!mounted) return; + buildToast(title: 'Could not register device: $error'); + } + } +} diff --git a/lib/pages/subscriptions/sync_settings_view.dart b/lib/pages/subscriptions/sync_settings_view.dart new file mode 100644 index 000000000..ef2a3f1c7 --- /dev/null +++ b/lib/pages/subscriptions/sync_settings_view.dart @@ -0,0 +1,556 @@ +import 'dart:async'; + +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/models/user_device.dart'; +import 'package:bike_control/models/user_settings.dart'; +import 'package:bike_control/pages/button_edit.dart'; +import 'package:bike_control/repositories/user_settings_repository.dart'; +import 'package:bike_control/services/settings_sync_service.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class SyncSettingsView extends StatefulWidget { + const SyncSettingsView({super.key}); + + @override + State createState() => _SyncSettingsViewState(); +} + +class _SyncSettingsViewState extends State { + late final SettingsSyncService _syncService; + late final UserSettingsRepository _repository; + + UserSettings? _serverSettings; + List _registeredDevices = []; + List _allDeviceSettings = []; + bool _isLoading = false; + bool _hasNewerSettings = false; + String? _lastSyncText; + Timer? _syncStatusTimer; + + @override + void initState() { + super.initState(); + _repository = UserSettingsRepository(core.supabase); + _syncService = SettingsSyncService(repository: _repository); + _syncService.initialize(); + + // Listen to sync status changes + _syncService.lastSyncedAt.addListener(_onSyncStatusChanged); + _syncService.isSyncing.addListener(_onSyncStatusChanged); + _syncService.lastError.addListener(_onSyncStatusChanged); + + // Check for updates periodically + _syncStatusTimer = Timer.periodic(const Duration(seconds: 30), (_) { + _checkForUpdates(); + }); + + // Initial load + _loadData(); + } + + @override + void dispose() { + _syncStatusTimer?.cancel(); + _syncService.lastSyncedAt.removeListener(_onSyncStatusChanged); + _syncService.isSyncing.removeListener(_onSyncStatusChanged); + _syncService.lastError.removeListener(_onSyncStatusChanged); + _syncService.dispose(); + super.dispose(); + } + + void _onSyncStatusChanged() { + if (mounted) { + setState(() { + _updateLastSyncText(); + }); + } + } + + void _updateLastSyncText() { + final lastSynced = _syncService.lastSyncedAt.value; + if (lastSynced == null) { + _lastSyncText = AppLocalizations.of(context).never; + } else { + final now = DateTime.now(); + final diff = now.difference(lastSynced); + + if (diff.inMinutes < 1) { + _lastSyncText = AppLocalizations.of(context).justNow; + } else if (diff.inMinutes < 60) { + _lastSyncText = '${diff.inMinutes}min ago'; + } else if (diff.inHours < 24) { + _lastSyncText = '${diff.inHours}h ago'; + } else { + _lastSyncText = '${diff.inDays}d ago'; + } + } + } + + Future _loadData() async { + setState(() => _isLoading = true); + + try { + // Load current device ID first (needed for filtering) + await _loadCurrentDeviceId(); + + // Load registered devices from DeviceManagementService (same as RegisteredDevicesView) + _registeredDevices = await IAPManager.instance.deviceManagement.getMyDevices(); + + // Load all device settings + _allDeviceSettings = await _repository.getAllDeviceSettings(); + + // Load current server settings + _serverSettings = await _repository.getSettings(); + + _updateLastSyncText(); + await _checkForUpdates(); + } catch (e, s) { + recordError(e, s, context: 'Load Data'); + print('Error loading sync data: $e'); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _checkForUpdates() async { + try { + final hasNewer = await _syncService.checkForUpdates(); + if (mounted) { + setState(() { + _hasNewerSettings = hasNewer; + }); + } + } catch (e, s) { + recordError(e, s, context: 'Check for Updates'); + print('Error checking for updates: $e'); + } + } + + Future _syncToServer() async { + setState(() => _isLoading = true); + + try { + final success = await _syncService.syncToServer(); + + if (mounted) { + if (success) { + buildToast(title: AppLocalizations.of(context).settingsSyncedSuccessfully); + await _loadData(); + } else if (_syncService.lastError.value != null) { + buildToast( + title: _syncService.lastError.value!, + level: LogLevel.LOGLEVEL_ERROR, + ); + } + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _syncFromServer() async { + setState(() => _isLoading = true); + + try { + final success = await _syncService.syncFromServer(); + + if (mounted) { + if (success) { + buildToast(title: AppLocalizations.of(context).settingsDownloadedFromServer); + await _loadData(); + setState(() => _hasNewerSettings = false); + } else if (_syncService.lastError.value != null) { + buildToast( + title: _syncService.lastError.value!, + level: LogLevel.LOGLEVEL_ERROR, + ); + } else { + buildToast( + title: 'No newer settings on server', + level: LogLevel.LOGLEVEL_WARNING, + ); + } + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + String _formatDateTime(DateTime? dateTime) { + if (dateTime == null) return 'Unknown'; + final local = dateTime.toLocal(); + return '${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year} ' + '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}'; + } + + UserSettings? _getSettingsForDevice(String deviceId) { + try { + return _allDeviceSettings.firstWhere( + (s) => s.deviceId == deviceId, + ); + } catch (e) { + return null; + } + } + + String? _getDeviceRemoteId(UserDevice device) { + // Use the remote ID (UUID from user_devices table) + return device.id; + } + + /// Returns only devices that have settings available and are not the current device. + List _getDevicesWithSettings() { + return _registeredDevices.where((device) { + // Skip devices with no settings + final remoteId = _getDeviceRemoteId(device); + if (remoteId == null) return false; + + final settings = _getSettingsForDevice(remoteId); + if (settings == null) return false; + + // Skip the current device + final isCurrentDevice = _isCurrentDevice(device); + if (isCurrentDevice) return kDebugMode; + + return true; + }).toList(); + } + + /// Checks if the given device is the current device. + bool _isCurrentDevice(UserDevice device) { + // Compare the local device ID from the UserDevice with the current device's local ID + return device.deviceId == _currentDeviceId; + } + + /// The current device's local ID (populated during init). + String? _currentDeviceId; + + Future _loadCurrentDeviceId() async { + try { + _currentDeviceId = await IAPManager.instance.deviceManagement.currentDeviceId(); + } catch (e) { + print('Error loading current device ID: $e'); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 24, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Sync Status Card + _buildSyncStatusCard(), + + // Device Selection Card + if (_getDevicesWithSettings().isNotEmpty) _buildDeviceSelectionCard(), + + // Sync Actions + if (_isLoading) + Card( + filled: true, + child: Padding( + padding: const EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + ) + else ...[ + // Download button (only if newer settings available) + if (_hasNewerSettings) + Button.secondary( + onPressed: _syncFromServer, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_download, size: 20), + const SizedBox(width: 12), + Text(AppLocalizations.of(context).downloadLatestSettings), + ], + ), + ), + ], + + // Info Card + Card( + filled: true, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + spacing: 12, + children: [ + Icon(Icons.info, size: 20, color: Theme.of(context).colorScheme.primary), + Expanded( + child: Text( + AppLocalizations.of(context).yourSettingsAreAutomaticallySyncedWhenYouMakeChangesTap, + ).small.muted, + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildSyncStatusCard() { + return Card( + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _hasNewerSettings + ? Colors.orange.withAlpha(30) + : Theme.of(context).colorScheme.primary.withAlpha(30), + shape: BoxShape.circle, + ), + child: Icon( + _hasNewerSettings ? Icons.cloud_download : Icons.cloud_sync, + size: 28, + color: _hasNewerSettings ? Colors.orange : Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppLocalizations.of(context).syncStatus).small.muted, + Text( + _hasNewerSettings + ? AppLocalizations.of(context).newerSettingsAvailable + : AppLocalizations.of(context).settingsSynchronization, + ).large.bold, + ], + ), + ), + ], + ), + Divider(), + Text( + AppLocalizations.of(context).synchronizeYourAppSettingsAcrossAllYourDevicesThisIncludes, + ).small.muted, + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.muted.withAlpha(20), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.schedule, size: 16, color: Theme.of(context).colorScheme.mutedForeground), + const SizedBox(width: 8), + Text('${AppLocalizations.of(context).lastSynced} ${_lastSyncText ?? 'Never'}').small, + ], + ), + if (_serverSettings?.version != null) + Row( + children: [ + Icon(Icons.tag, size: 16, color: Theme.of(context).colorScheme.mutedForeground), + const SizedBox(width: 8), + Text('Version: ${_serverSettings!.version}').small, + ], + ), + // Upload button + Button.primary( + onPressed: _syncToServer, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_upload, size: 20), + const SizedBox(width: 12), + Text(AppLocalizations.of(context).uploadSettings), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDeviceSelectionCard() { + return Card( + child: Column( + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.purple.withAlpha(30), + shape: BoxShape.circle, + ), + child: Icon( + Icons.devices, + size: 28, + color: Colors.purple, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppLocalizations.of(context).yourDevices).small.muted, + Text(AppLocalizations.of(context).selectADeviceToSyncFrom).large.bold, + ], + ), + ), + ], + ), + Divider(), + ..._getDevicesWithSettings().map((device) => _buildDeviceTile(device)), + ], + ), + ); + } + + Widget _buildDeviceTile(UserDevice device) { + final remoteDeviceId = _getDeviceRemoteId(device); + final deviceSettings = remoteDeviceId != null ? _getSettingsForDevice(remoteDeviceId) : null; + final hasSettings = deviceSettings != null; + final isNewer = hasSettings && deviceSettings.isNewerThan(_serverSettings); + + return Builder( + builder: (context) { + return SelectableCard( + onPressed: () => _showDeviceOptions(context, device, remoteDeviceId), + isActive: false, + title: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Icon( + Icons.device_unknown, + size: 24, + color: Theme.of(context).colorScheme.mutedForeground, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (device.deviceName != null) + Text( + device.deviceName!, + ).small.bold, + const SizedBox(height: 4), + Text(device.platform.capitalize().replaceAll('os', 'OS')).small.muted, + if (hasSettings) ...[ + const SizedBox(height: 4), + Text(_formatDateTime(device.lastSeenAt)).xSmall.muted, + Text( + 'Version: ${deviceSettings.version} • Keymaps: ${deviceSettings.keymaps?.length ?? 0}', + ).xSmall.muted, + ], + ], + ), + ), + if (isNewer) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + AppLocalizations.of(context).newer, + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _showDeviceOptions(BuildContext context, UserDevice device, String? remoteDeviceId) { + if (remoteDeviceId == null) return; + + showDropdown( + context: context, + builder: (context) => DropdownMenu( + children: [ + MenuButton( + child: Row( + children: [ + Icon(Icons.download, size: 20), + const SizedBox(width: 8), + Text(AppLocalizations.of(context).applyNow), + ], + ), + onPressed: (context) async { + await _syncFromDevice(remoteDeviceId); + }, + ), + ], + ), + ); + } + + Future _syncFromDevice(String deviceId) async { + setState(() => _isLoading = true); + + try { + final success = await _syncService.syncFromServer(deviceId: deviceId); + + if (mounted) { + if (success) { + buildToast(title: AppLocalizations.of(context).settingsAppliedFromId(deviceId.substring(0, 8))); + await _loadData(); + setState(() => _hasNewerSettings = false); + } else if (_syncService.lastError.value != null) { + buildToast( + title: _syncService.lastError.value!, + level: LogLevel.LOGLEVEL_ERROR, + ); + } else { + buildToast( + title: 'No newer settings available', + level: LogLevel.LOGLEVEL_WARNING, + ); + } + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } +} diff --git a/lib/pages/touch_area.dart b/lib/pages/touch_area.dart new file mode 100644 index 000000000..5c67f7028 --- /dev/null +++ b/lib/pages/touch_area.dart @@ -0,0 +1,438 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/keymap_explanation.dart'; +import 'package:bike_control/widgets/testbed.dart'; +import 'package:bike_control/widgets/ui/button_widget.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../utils/keymap/keymap.dart'; + +final touchAreaSize = 42.0; + +class TouchAreaSetupPage extends StatefulWidget { + final KeyPair keyPair; + const TouchAreaSetupPage({super.key, required this.keyPair}); + + @override + State createState() => _TouchAreaSetupPageState(); +} + +class _TouchAreaSetupPageState extends State { + Uint8List? _backgroundImage; + final TransformationController _transformationController = TransformationController(); + + late Rect _imageRect; + + bool _showFaded = true; + + Future _pickScreenshot() async { + final picker = ImagePicker(); + final result = await picker.pickImage(source: ImageSource.gallery); + if (result != null) { + final image = File(result.path); + final Directory tempDir = await getTemporaryDirectory(); + final tempImage = File('${tempDir.path}/${core.actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png'); + await image.copy(tempImage.path); + _backgroundImage = tempImage.readAsBytesSync(); + await _calculateBounds(); + } + } + + Future _calculateBounds() async { + if (_backgroundImage == null) return; + + // need to decode image to get its size so we can have a percentage mapping + final decodedImage = await decodeImageFromList(_backgroundImage!); + // 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(() {}); + } + + void _saveAndClose() { + Navigator.of(context).pop(true); + } + + @override + void dispose() { + super.dispose(); + // Exit full screen + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); + // Reset orientation preferences to allow all orientations + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + windowManager.setFullScreen(false); + } + } + + @override + void initState() { + super.initState(); + + // initialize _imageRect by using Flutter view size + final flutterView = WidgetsBinding.instance.platformDispatcher.views.first; + final size = flutterView.physicalSize / flutterView.devicePixelRatio; + _imageRect = Rect.fromLTWH(0, 0, size.width, size.height); + + // Force landscape orientation during keymap editing + SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []); + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + windowManager.setFullScreen(true); + } + getTemporaryDirectory().then((tempDir) async { + final tempImage = File('${tempDir.path}/${core.actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png'); + if (tempImage.existsSync()) { + _backgroundImage = tempImage.readAsBytesSync(); + setState(() {}); + + // wait a bit until device rotation is done + SchedulerBinding.instance.addPostFrameCallback((_) { + _calculateBounds(); + }); + } + }); + } + + Widget _buildDraggableArea({ + required bool enableTouch, + required void Function(Offset newPosition) onPositionChanged, + required Color color, + required KeyPair keyPair, + }) { + // map the percentage position to the image rect + final relativeX = min(100.0, keyPair.touchPosition.dx) / 100.0; + final relativeY = min(100.0, keyPair.touchPosition.dy) / 100.0; + //print('Relative position: $relativeX, $relativeY'); + final flutterView = WidgetsBinding.instance.platformDispatcher.views.first; + + // figure out notch height for e.g. macOS. On Windows the display size is not available (0,0). + final differenceInHeight = (!Platform.isWindows && flutterView.display.size.height > 0 && !Platform.isIOS) + ? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio + : 0.0; + + // Store the initial drag position to calculate drag distance + Offset? dragStartPosition; + + if (kDebugMode && false) { + print('Display Size: ${flutterView.display.size}'); + print('View size: ${flutterView.physicalSize}'); + print('Difference: $differenceInHeight'); + } + + //final isOnTheRightEdge = position.dx > (MediaQuery.sizeOf(context).width - 250); + + final iconSize = 40.0; + + final Offset position = Offset( + _imageRect.left + relativeX * _imageRect.width - iconSize / 2, + _imageRect.top + relativeY * _imageRect.height - differenceInHeight - iconSize / 2, + ); + + final icon = Container( + constraints: BoxConstraints(minHeight: iconSize, minWidth: iconSize), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (keyPair.buttons.singleOrNull?.color == null) + Container( + decoration: BoxDecoration( + color: color.withOpacity(0.4), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + width: iconSize, + height: iconSize, + child: Icon( + keyPair.icon, + size: iconSize - 12, + shadows: [ + Shadow(color: Colors.white, offset: Offset(1, 1)), + Shadow(color: Colors.white, offset: Offset(-1, -1)), + Shadow(color: Colors.white, offset: Offset(-1, 1)), + Shadow(color: Colors.white, offset: Offset(-1, 1)), + Shadow(color: Colors.white, offset: Offset(1, -1)), + ], + ), + ), + KeypairExplanation(withKey: true, keyPair: keyPair), + ], + ), + ); + + return Positioned( + left: position.dx, + top: position.dy, + child: Tooltip( + tooltip: (c) => Text(context.i18n.dragToReposition), + child: AnimatedOpacity( + opacity: _showFaded && widget.keyPair != keyPair ? 0.2 : 1.0, + duration: Duration(milliseconds: 300), + 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: Container( + color: Colors.transparent, + child: icon, + ), + childWhenDragging: const SizedBox.shrink(), + onDragStarted: () { + // Capture the starting position to calculate drag distance later + dragStartPosition = position; + if (keyPair != widget.keyPair && _showFaded) { + setState(() { + _showFaded = false; + }); + } + }, + onDragEnd: (details) { + // Calculate drag distance to prevent accidental repositioning from clicks + // while allowing legitimate drags even with low velocity (e.g., when overlapping buttons) + final dragDistance = dragStartPosition != null + ? (details.offset - dragStartPosition!).distance + : double.infinity; + + // Only update position if dragged more than 5 pixels (prevents accidental clicks) + if (dragDistance > 5) { + final matrix = Matrix4.inverted(_transformationController.value); + final height = 0; + final sceneY = details.offset.dy - height; + final viewportPoint = MatrixUtils.transformPoint( + matrix, + Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2), + ); + setState(() => onPositionChanged(viewportPoint)); + } + }, + child: icon, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + child: LayoutBuilder( + builder: (context, constraints) { + if (_backgroundImage == null && constraints.biggest != _imageRect.size) { + _imageRect = Rect.fromLTWH(0, 0, constraints.maxWidth, constraints.maxHeight); + } + final keyPairsToShow = + core.actionHandler.supportedApp?.keymap.keyPairs + .where((kp) => kp.touchPosition != Offset.zero && !kp.isSpecialKey) + .toList() ?? + []; + return InteractiveViewer( + transformationController: _transformationController, + child: Stack( + children: [ + if (_backgroundImage != null) + Positioned.fill( + child: Opacity( + opacity: 0.5, + child: Image.memory( + _backgroundImage!, + fit: BoxFit.contain, + ), + ), + ), + + // draw _imageRect for debugging + if (kDebugMode) + Positioned( + left: _imageRect.left, + top: _imageRect.top, + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.green, width: 2), + ), + child: SizedBox.fromSize(size: _imageRect.size), + ), + ), + + for (final keyPair in keyPairsToShow) + _buildDraggableArea( + enableTouch: true, + keyPair: keyPair, + onPositionChanged: (newPos) { + // convert to percentage + final relativeX = ((newPos.dx - _imageRect.left) / _imageRect.width).clamp(0.0, 1.0); + final relativeY = ((newPos.dy - _imageRect.top) / _imageRect.height).clamp(0.0, 1.0); + keyPair.touchPosition = Offset(relativeX * 100.0, relativeY * 100.0); + keyPair.inGameAction = null; + keyPair.inGameActionValue = null; + setState(() {}); + }, + color: Colors.red, + ), + + Positioned.fill(child: Testbed()), + + if (_backgroundImage == null) + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + IgnorePointer( + child: Text( + context.i18n.touchAreaInstructions, + ), + ), + PrimaryButton( + onPressed: () { + _pickScreenshot(); + }, + child: Text(context.i18n.loadScreenshotForPlacement), + ), + ], + ), + ), + ), + + Positioned( + top: 40, + right: 20, + child: Row( + spacing: 8, + children: [ + IconButton.outline( + onPressed: _saveAndClose, + icon: const Icon(Icons.save), + trailing: Text(context.i18n.save), + ), + Builder( + builder: (context) { + return OutlineButton( + child: Text('Menu'), + onPressed: () { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + if (_backgroundImage != null) + MenuButton( + child: Text(context.i18n.chooseAnotherScreenshot), + onPressed: (c) { + _pickScreenshot(); + }, + ), + MenuButton( + child: Text(context.i18n.reset), + onPressed: (c) { + _backgroundImage = null; + + core.actionHandler.supportedApp?.keymap.reset(); + setState(() {}); + }, + ), + ], + ), + ); + }, + ); + }, + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class KeypairExplanation extends StatelessWidget { + final bool withKey; + final KeyPair keyPair; + + const KeypairExplanation({super.key, required this.keyPair, this.withKey = false}); + + @override + Widget build(BuildContext context) { + return Basic( + leading: withKey + ? Row( + children: keyPair.buttons.map((b) => ButtonWidget(button: b, big: true)).toList(), + ) + : Icon(keyPair.icon), + leadingAlignment: Alignment.centerLeft, + contentSpacing: 10, + subtitle: Text(keyPair.trigger.title).muted.xSmall, + title: Text(keyPair.toString()), + ); + } +} + +class KeyWidget extends StatelessWidget { + final String label; + final bool invert; + const KeyWidget({super.key, required this.label, this.invert = false}); + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + constraints: BoxConstraints(minWidth: 30), + decoration: BoxDecoration( + color: invert ? Colors.white : Colors.black, + border: Border.all(color: Theme.of(context).colorScheme.border, width: 2), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Text( + label.splitByUpperCase(), + style: TextStyle( + fontFamily: screenshotMode ? null : 'monospace', + fontSize: 12, + color: invert ? Colors.black : Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/trainer.dart b/lib/pages/trainer.dart new file mode 100644 index 000000000..f6f7c1275 --- /dev/null +++ b/lib/pages/trainer.dart @@ -0,0 +1,305 @@ +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/button_simulator.dart'; +import 'package:bike_control/pages/configuration.dart'; +import 'package:bike_control/pages/navigation.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/apps/local_tile.dart'; +import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart'; +import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart'; +import 'package:bike_control/widgets/apps/openbikecontrol_mdns_tile.dart'; +import 'package:bike_control/widgets/apps/zwift_mdns_tile.dart'; +import 'package:bike_control/widgets/apps/zwift_tile.dart'; +import 'package:bike_control/widgets/iap_status_widget.dart'; +import 'package:bike_control/widgets/keyboard_pair_widget.dart'; +import 'package:bike_control/widgets/mouse_pair_widget.dart'; +import 'package:bike_control/widgets/ui/colored_title.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../utils/keymap/apps/supported_app.dart'; +import '../utils/keymap/apps/zwift.dart'; + +class TrainerPage extends StatefulWidget { + final bool isMobile; + final VoidCallback onUpdate; + final VoidCallback goToNextPage; + const TrainerPage({super.key, required this.onUpdate, required this.goToNextPage, required this.isMobile}); + + @override + State createState() => _TrainerPageState(); +} + +class _TrainerPageState extends State with WidgetsBindingObserver { + late final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + // keep screen on - this is required for iOS to keep the bluetooth connection alive + if (!screenshotMode) { + WakelockPlus.enable(); + } + + if (!kIsWeb) { + if (core.logic.showForegroundMessage) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // show snackbar to inform user that the app needs to stay in foreground + buildToast(title: AppLocalizations.current.touchSimulationForegroundMessage); + }); + } + + core.whooshLink.isStarted.addListener(() { + if (mounted) setState(() {}); + }); + + core.zwiftEmulator.isConnected.addListener(() { + if (mounted) setState(() {}); + }); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _scrollController.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + if (core.logic.showForegroundMessage) { + UniversalBle.getBluetoothAvailabilityState().then((state) { + if (state == AvailabilityState.poweredOn && mounted) { + core.remotePairing.reconnect(); + buildToast(title: AppLocalizations.current.touchSimulationForegroundMessage); + } + }); + } + } + } + + @override + Widget build(BuildContext context) { + final showLocalAsOther = + //(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && + false && core.logic.showLocalControl && !core.settings.getLocalEnabled(); + final showWhooshLinkAsOther = + (core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showMyWhooshLink; + + final recommendedTiles = [ + if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(), + if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(), + + if (core.logic.showZwiftMsdnEmulator) + ZwiftMdnsTile( + onUpdate: () { + core.connection.signalNotification( + LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'), + ); + }, + ), + if (core.logic.showZwiftBleEmulator) + ZwiftTile( + onUpdate: () { + if (mounted) { + core.connection.signalNotification( + LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'), + ); + setState(() {}); + } + }, + ), + if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(), + if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(), + if (core.logic.showRemote && core.settings.getTrainerApp() is! Zwift) RemoteKeyboardPairingWidget(), + ]; + + final otherTiles = [ + if (core.logic.showRemote) RemoteMousePairingWidget(), + if (core.logic.showLocalControl && showLocalAsOther) LocalTile(), + if (showWhooshLinkAsOther) MyWhooshLinkTile(), + ]; + + return Scrollbar( + controller: _scrollController, + child: SingleChildScrollView( + controller: _scrollController, + padding: EdgeInsets.only(bottom: widget.isMobile ? 166 : 16, left: 16, right: 16, top: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: IAPManager.instance.isPurchased, + builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: true), + ), + SizedBox( + width: double.infinity, + child: Accordion( + items: [ + AccordionItem( + trigger: AccordionTrigger( + child: IgnorePointer( + child: Row( + spacing: 12, + children: [ + Flexible( + child: Select( + itemBuilder: (c, app) => Row( + spacing: 4, + children: [ + Expanded(child: Text(screenshotMode ? 'Trainer app' : app.name)), + if (app.supportsOpenBikeProtocol.isNotEmpty) Icon(Icons.star), + ], + ), + popup: SelectPopup( + items: SelectItemList( + children: SupportedApp.supportedApps.map((app) { + return SelectItemButton( + value: app, + child: Row( + spacing: 4, + children: [ + Text(app.name), + if (app.supportsOpenBikeProtocol.isNotEmpty) Icon(Icons.star), + ], + ), + ); + }).toList(), + ), + ).call, + placeholder: Text(context.i18n.selectTrainerAppPlaceholder), + value: core.settings.getTrainerApp(), + onChanged: (selectedApp) async {}, + ), + ), + if (core.settings.getLastTarget() != null) ...[ + if (!widget.isMobile) Icon(core.settings.getLastTarget()!.icon), + Text(core.settings.getLastTarget()!.getTitle(context)), + ], + ], + ), + ), + ), + content: ConfigurationPage( + onUpdate: () { + setState(() {}); + widget.onUpdate(); + }, + ), + ), + ], + ), + ), + if (core.settings.getTrainerApp() != null) ...[ + if (recommendedTiles.isNotEmpty) ...[ + Gap(22), + ColoredTitle(text: context.i18n.recommendedConnectionMethods), + Gap(12), + ], + + for (final grouped in recommendedTiles.chunked(widget.isMobile ? 1 : 2)) ...[ + IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: grouped.map((tile) => Expanded(child: tile)).toList(), + ), + ), + ), + ], + Gap(12), + if (otherTiles.isNotEmpty) ...[ + SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: Accordion( + items: [ + AccordionItem( + expanded: + recommendedTiles.isEmpty || (core.logic.showRemote && core.remotePairing.isStarted.value), + trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)), + content: Column( + children: [ + for (final grouped in otherTiles.chunked(widget.isMobile ? 1 : 2)) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: IntrinsicHeight( + child: Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: grouped.map((tile) => Expanded(child: tile)).toList(), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ], + Gap(12), + + SizedBox(height: 4), + Flex( + direction: widget.isMobile || MediaQuery.sizeOf(context).width < 750 ? Axis.vertical : Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + PrimaryButton( + leading: Icon(Icons.computer_outlined), + child: Text( + AppLocalizations.of( + context, + ).manualyControllingButton(core.settings.getTrainerApp()?.name ?? 'your trainer'), + ), + onPressed: () { + if (core.settings.getTrainerApp() == null) { + buildToast( + level: LogLevel.LOGLEVEL_WARNING, + title: context.i18n.selectTrainerApp, + ); + widget.onUpdate(); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (c) => ButtonSimulator(), + ), + ); + } + }, + ), + PrimaryButton( + leading: Icon(BCPage.customization.icon), + onPressed: () { + widget.goToNextPage(); + }, + child: Text(context.i18n.adjustControllerButtons), + ), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/pages/unlock.dart b/lib/pages/unlock.dart new file mode 100644 index 000000000..c496be8ee --- /dev/null +++ b/lib/pages/unlock.dart @@ -0,0 +1,251 @@ +import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/markdown.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/src/scheduler/ticker.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../widgets/ui/small_progress_indicator.dart'; + +class UnlockPage extends StatefulWidget { + final ZwiftClickV2 device; + const UnlockPage({super.key, required this.device}); + + @override + State createState() => _UnlockPageState(); +} + +class _UnlockPageState extends State with SingleTickerProviderStateMixin { + late final bool _wasZwiftMdnsEmulatorActive; + late final bool _wasObpMdnsEmulatorActive; + bool _showManualSteps = false; + + late final bool _isInTrialPhase; + + late final Ticker _ticker; + + int _secondsRemaining = 60; + + void _isConnectedUpdate() { + setState(() {}); + if (ftmsEmulator.isUnlocked.value) { + _close(); + } + } + + @override + void initState() { + super.initState(); + _isInTrialPhase = !IAPManager.instance.isPurchased.value && IAPManager.instance.isTrialExpired; + + _ticker = createTicker((_) { + if (ftmsEmulator.waiting.value) { + final waitUntil = ftmsEmulator.connectionDate!.add(Duration(minutes: 1)); + final secondsUntil = waitUntil.difference(DateTime.now()).inSeconds; + + if (mounted) { + _secondsRemaining = secondsUntil; + setState(() {}); + } + } + })..start(); + + /*Future.delayed(Duration(seconds: 5), () { + emulator.waiting.value = true; + + Future.delayed(Duration(seconds: 3), () { + propPrefs.setZwiftClickV2LastUnlock(widget.device.device.deviceId, DateTime.now()); + emulator.isUnlocked.value = true; + }); + });*/ + + _wasZwiftMdnsEmulatorActive = core.zwiftMdnsEmulator.isStarted.value; + _wasObpMdnsEmulatorActive = core.obpMdnsEmulator.isStarted.value; + if (!_isInTrialPhase) { + if (_wasZwiftMdnsEmulatorActive) { + core.zwiftMdnsEmulator.stop(); + core.settings.setZwiftMdnsEmulatorEnabled(false); + } + if (_wasObpMdnsEmulatorActive) { + core.obpMdnsEmulator.stopServer(); + core.settings.setObpMdnsEnabled(false); + } + + ftmsEmulator.isUnlocked.value = false; + ftmsEmulator.alreadyUnlocked.value = false; + ftmsEmulator.waiting.value = false; + ftmsEmulator.isConnected.addListener(_isConnectedUpdate); + ftmsEmulator.isUnlocked.addListener(_isConnectedUpdate); + ftmsEmulator.alreadyUnlocked.addListener(_isConnectedUpdate); + ftmsEmulator.startServer().then((_) {}).catchError((e, s) { + recordError(e, s, context: 'Emulator'); + core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString())); + }); + } + } + + @override + void dispose() { + _ticker.dispose(); + if (!_isInTrialPhase) { + ftmsEmulator.isConnected.removeListener(_isConnectedUpdate); + ftmsEmulator.isUnlocked.removeListener(_isConnectedUpdate); + ftmsEmulator.alreadyUnlocked.removeListener(_isConnectedUpdate); + ftmsEmulator.stop(); + + if (_wasZwiftMdnsEmulatorActive) { + core.zwiftMdnsEmulator.startServer(); + core.settings.setZwiftMdnsEmulatorEnabled(true); + } + if (_wasObpMdnsEmulatorActive) { + core.obpMdnsEmulator.startServer(); + core.settings.setObpMdnsEnabled(true); + } + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints(maxWidth: 400), + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_isInTrialPhase && !_showManualSteps) + Text( + IAPManager.instance.isOutsideStoreWindowsBuild + ? AppLocalizations.of(context).trialExpired(IAPManager.dailyCommandLimit) + : AppLocalizations.of(context).unlock_yourTrialPhaseHasExpired, + ) + else if (_showManualSteps) ...[ + Warning( + children: [ + Text( + 'Important Setup Information', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.destructive, + ), + ).small, + Text( + AppLocalizations.of(context).clickV2Instructions, + style: TextStyle( + color: Theme.of(context).colorScheme.destructive, + ), + ).xSmall, + if (kDebugMode) + GhostButton( + onPressed: () { + widget.device.sendCommand(Opcode.RESET, null); + }, + child: Text('Reset now'), + ), + + Button.secondary( + onPressed: () { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'), + ); + }, + leading: const Icon(Icons.help_outline_outlined), + child: Text(context.i18n.instructions), + ), + ], + ), + SizedBox(height: 32), + Button.primary( + child: Text(AppLocalizations.of(context).unlock_markAsUnlocked), + onPressed: () { + propPrefs.setZwiftClickV2LastUnlock(widget.device.scanResult.deviceId, DateTime.now()); + closeDrawer(context); + }, + ), + ] else if (!ftmsEmulator.isConnected.value) ...[ + Text(AppLocalizations.of(context).unlock_openZwift).li, + Text(AppLocalizations.of(context).unlock_connectToBikecontrol).li, + GhostButton( + leading: Icon(Icons.play_circle_outline), + onPressed: () { + launchUrlString( + 'https://www.reddit.com/r/BikeControl/comments/1qt9cg5/great_news_for_zwift_click_v2_owners_introducing/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button', + ); + }, + child: Text('Example Video'), + ), + SizedBox(height: 32), + Text(AppLocalizations.of(context).unlock_bikecontrolAndZwiftNetwork).small, + ] else if (ftmsEmulator.alreadyUnlocked.value) ...[ + Text(AppLocalizations.of(context).unlock_yourZwiftClickMightBeUnlockedAlready), + SizedBox(height: 8), + Text(AppLocalizations.of(context).unlock_confirmByPressingAButtonOnYourDevice).small, + ] else if (!ftmsEmulator.isUnlocked.value) + Text(AppLocalizations.of(context).unlock_waitingForZwift) + else + Text('Zwift Click is unlocked! You can now close this page.'), + SizedBox(height: 32), + if (!_showManualSteps && !_isInTrialPhase) ...[ + if (ftmsEmulator.waiting.value && _secondsRemaining >= 0) + Center(child: CircularProgressIndicator(value: 1 - (_secondsRemaining / 60), size: 20)) + else if (ftmsEmulator.alreadyUnlocked.value) + Center(child: Icon(Icons.lock_clock)) + else + SmallProgressIndicator(), + SizedBox(height: 20), + ], + if (!ftmsEmulator.isUnlocked.value && !_showManualSteps) ...[ + if (!_isInTrialPhase) ...[ + SizedBox(height: 32), + Center(child: Text(AppLocalizations.of(context).unlock_notWorking).small), + ], + SizedBox(height: 6), + Center( + child: Button.secondary( + onPressed: () { + setState(() { + _showManualSteps = !_showManualSteps; + }); + }, + child: Text(AppLocalizations.of(context).unlock_unlockManually), + ), + ), + ], + SizedBox(height: 20), + ], + ), + ); + } + + void _close() { + final title = AppLocalizations.of(context).unlock_isnowunlocked(widget.device.toString()); + + final subtitle = AppLocalizations.of(context).unlock_youCanNowCloseZwift; + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_INFO, title), + ); + + core.flutterLocalNotificationsPlugin.show( + 1339, + title, + subtitle, + NotificationDetails( + android: AndroidNotificationDetails('Unlocked', 'Device unlocked notification'), + iOS: DarwinNotificationDetails(presentAlert: true), + ), + ); + closeDrawer(context); + } +} diff --git a/lib/protocol/zwift.pbjson.dart b/lib/protocol/zwift.pbjson.dart deleted file mode 100644 index e67347665..000000000 --- a/lib/protocol/zwift.pbjson.dart +++ /dev/null @@ -1,277 +0,0 @@ -// -// Generated code. Do not modify. -// source: zwift.proto -// -// @dart = 2.12 - -// ignore_for_file: annotate_overrides, camel_case_types, comment_references -// ignore_for_file: constant_identifier_names, library_prefixes -// ignore_for_file: non_constant_identifier_names, prefer_final_fields -// ignore_for_file: unnecessary_import, unnecessary_this, unused_import - -import 'dart:convert' as $convert; -import 'dart:core' as $core; -import 'dart:typed_data' as $typed_data; - -@$core.Deprecated('Use playButtonStatusDescriptor instead') -const PlayButtonStatus$json = { - '1': 'PlayButtonStatus', - '2': [ - {'1': 'ON', '2': 0}, - {'1': 'OFF', '2': 1}, - ], -}; - -/// Descriptor for `PlayButtonStatus`. Decode as a `google.protobuf.EnumDescriptorProto`. -final $typed_data.Uint8List playButtonStatusDescriptor = $convert.base64Decode( - 'ChBQbGF5QnV0dG9uU3RhdHVzEgYKAk9OEAASBwoDT0ZGEAE='); - -@$core.Deprecated('Use rideButtonMaskDescriptor instead') -const RideButtonMask$json = { - '1': 'RideButtonMask', - '2': [ - {'1': 'LEFT_BTN', '2': 1}, - {'1': 'UP_BTN', '2': 2}, - {'1': 'RIGHT_BTN', '2': 4}, - {'1': 'DOWN_BTN', '2': 8}, - {'1': 'A_BTN', '2': 16}, - {'1': 'B_BTN', '2': 32}, - {'1': 'Y_BTN', '2': 64}, - {'1': 'Z_BTN', '2': 256}, - {'1': 'SHFT_UP_L_BTN', '2': 512}, - {'1': 'SHFT_DN_L_BTN', '2': 1024}, - {'1': 'POWERUP_L_BTN', '2': 2048}, - {'1': 'ONOFF_L_BTN', '2': 4096}, - {'1': 'SHFT_UP_R_BTN', '2': 8192}, - {'1': 'SHFT_DN_R_BTN', '2': 16384}, - {'1': 'POWERUP_R_BTN', '2': 65536}, - {'1': 'ONOFF_R_BTN', '2': 131072}, - ], -}; - -/// Descriptor for `RideButtonMask`. Decode as a `google.protobuf.EnumDescriptorProto`. -final $typed_data.Uint8List rideButtonMaskDescriptor = $convert.base64Decode( - 'Cg5SaWRlQnV0dG9uTWFzaxIMCghMRUZUX0JUThABEgoKBlVQX0JUThACEg0KCVJJR0hUX0JUTh' - 'AEEgwKCERPV05fQlROEAgSCQoFQV9CVE4QEBIJCgVCX0JUThAgEgkKBVlfQlROEEASCgoFWl9C' - 'VE4QgAISEgoNU0hGVF9VUF9MX0JUThCABBISCg1TSEZUX0ROX0xfQlROEIAIEhIKDVBPV0VSVV' - 'BfTF9CVE4QgBASEAoLT05PRkZfTF9CVE4QgCASEgoNU0hGVF9VUF9SX0JUThCAQBITCg1TSEZU' - 'X0ROX1JfQlROEICAARITCg1QT1dFUlVQX1JfQlROEICABBIRCgtPTk9GRl9SX0JUThCAgAg='); - -@$core.Deprecated('Use rideAnalogLocationDescriptor instead') -const RideAnalogLocation$json = { - '1': 'RideAnalogLocation', - '2': [ - {'1': 'LEFT', '2': 0}, - {'1': 'RIGHT', '2': 1}, - {'1': 'UP', '2': 2}, - {'1': 'DOWN', '2': 3}, - ], -}; - -/// Descriptor for `RideAnalogLocation`. Decode as a `google.protobuf.EnumDescriptorProto`. -final $typed_data.Uint8List rideAnalogLocationDescriptor = $convert.base64Decode( - 'ChJSaWRlQW5hbG9nTG9jYXRpb24SCAoETEVGVBAAEgkKBVJJR0hUEAESBgoCVVAQAhIICgRET1' - 'dOEAM='); - -@$core.Deprecated('Use playKeyPadStatusDescriptor instead') -const PlayKeyPadStatus$json = { - '1': 'PlayKeyPadStatus', - '2': [ - {'1': 'RightPad', '3': 1, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'RightPad'}, - {'1': 'Button_Y_Up', '3': 2, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonYUp'}, - {'1': 'Button_Z_Left', '3': 3, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonZLeft'}, - {'1': 'Button_A_Right', '3': 4, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonARight'}, - {'1': 'Button_B_Down', '3': 5, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonBDown'}, - {'1': 'Button_On', '3': 6, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonOn'}, - {'1': 'Button_Shift', '3': 7, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonShift'}, - {'1': 'Analog_LR', '3': 8, '4': 1, '5': 17, '10': 'AnalogLR'}, - {'1': 'Analog_UD', '3': 9, '4': 1, '5': 17, '10': 'AnalogUD'}, - ], -}; - -/// Descriptor for `PlayKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List playKeyPadStatusDescriptor = $convert.base64Decode( - 'ChBQbGF5S2V5UGFkU3RhdHVzEjoKCFJpZ2h0UGFkGAEgASgOMh4uZGUuam9uYXNiYXJrLlBsYX' - 'lCdXR0b25TdGF0dXNSCFJpZ2h0UGFkEj4KC0J1dHRvbl9ZX1VwGAIgASgOMh4uZGUuam9uYXNi' - 'YXJrLlBsYXlCdXR0b25TdGF0dXNSCUJ1dHRvbllVcBJCCg1CdXR0b25fWl9MZWZ0GAMgASgOMh' - '4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0dXNSC0J1dHRvblpMZWZ0EkQKDkJ1dHRvbl9B' - 'X1JpZ2h0GAQgASgOMh4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0dXNSDEJ1dHRvbkFSaW' - 'dodBJCCg1CdXR0b25fQl9Eb3duGAUgASgOMh4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0' - 'dXNSC0J1dHRvbkJEb3duEjsKCUJ1dHRvbl9PbhgGIAEoDjIeLmRlLmpvbmFzYmFyay5QbGF5Qn' - 'V0dG9uU3RhdHVzUghCdXR0b25PbhJBCgxCdXR0b25fU2hpZnQYByABKA4yHi5kZS5qb25hc2Jh' - 'cmsuUGxheUJ1dHRvblN0YXR1c1ILQnV0dG9uU2hpZnQSGwoJQW5hbG9nX0xSGAggASgRUghBbm' - 'Fsb2dMUhIbCglBbmFsb2dfVUQYCSABKBFSCEFuYWxvZ1VE'); - -@$core.Deprecated('Use playCommandParametersDescriptor instead') -const PlayCommandParameters$json = { - '1': 'PlayCommandParameters', - '2': [ - {'1': 'param1', '3': 1, '4': 1, '5': 13, '10': 'param1'}, - {'1': 'param2', '3': 2, '4': 1, '5': 13, '10': 'param2'}, - {'1': 'HapticPattern', '3': 3, '4': 1, '5': 13, '10': 'HapticPattern'}, - ], -}; - -/// Descriptor for `PlayCommandParameters`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List playCommandParametersDescriptor = $convert.base64Decode( - 'ChVQbGF5Q29tbWFuZFBhcmFtZXRlcnMSFgoGcGFyYW0xGAEgASgNUgZwYXJhbTESFgoGcGFyYW' - '0yGAIgASgNUgZwYXJhbTISJAoNSGFwdGljUGF0dGVybhgDIAEoDVINSGFwdGljUGF0dGVybg=='); - -@$core.Deprecated('Use playCommandContentsDescriptor instead') -const PlayCommandContents$json = { - '1': 'PlayCommandContents', - '2': [ - {'1': 'CommandParameters', '3': 1, '4': 1, '5': 11, '6': '.de.jonasbark.PlayCommandParameters', '10': 'CommandParameters'}, - ], -}; - -/// Descriptor for `PlayCommandContents`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List playCommandContentsDescriptor = $convert.base64Decode( - 'ChNQbGF5Q29tbWFuZENvbnRlbnRzElEKEUNvbW1hbmRQYXJhbWV0ZXJzGAEgASgLMiMuZGUuam' - '9uYXNiYXJrLlBsYXlDb21tYW5kUGFyYW1ldGVyc1IRQ29tbWFuZFBhcmFtZXRlcnM='); - -@$core.Deprecated('Use playCommandDescriptor instead') -const PlayCommand$json = { - '1': 'PlayCommand', - '2': [ - {'1': 'CommandContents', '3': 2, '4': 1, '5': 11, '6': '.de.jonasbark.PlayCommandContents', '10': 'CommandContents'}, - ], -}; - -/// Descriptor for `PlayCommand`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List playCommandDescriptor = $convert.base64Decode( - 'CgtQbGF5Q29tbWFuZBJLCg9Db21tYW5kQ29udGVudHMYAiABKAsyIS5kZS5qb25hc2JhcmsuUG' - 'xheUNvbW1hbmRDb250ZW50c1IPQ29tbWFuZENvbnRlbnRz'); - -@$core.Deprecated('Use idleDescriptor instead') -const Idle$json = { - '1': 'Idle', - '2': [ - {'1': 'Unknown2', '3': 2, '4': 1, '5': 13, '10': 'Unknown2'}, - ], -}; - -/// Descriptor for `Idle`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List idleDescriptor = $convert.base64Decode( - 'CgRJZGxlEhoKCFVua25vd24yGAIgASgNUghVbmtub3duMg=='); - -@$core.Deprecated('Use rideAnalogKeyPressDescriptor instead') -const RideAnalogKeyPress$json = { - '1': 'RideAnalogKeyPress', - '2': [ - {'1': 'Location', '3': 1, '4': 1, '5': 14, '6': '.de.jonasbark.RideAnalogLocation', '10': 'Location'}, - {'1': 'AnalogValue', '3': 2, '4': 1, '5': 17, '10': 'AnalogValue'}, - ], -}; - -/// Descriptor for `RideAnalogKeyPress`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List rideAnalogKeyPressDescriptor = $convert.base64Decode( - 'ChJSaWRlQW5hbG9nS2V5UHJlc3MSPAoITG9jYXRpb24YASABKA4yIC5kZS5qb25hc2JhcmsuUm' - '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'}, - ], -}; - -/// Descriptor for `RideKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List rideKeyPadStatusDescriptor = $convert.base64Decode( - 'ChBSaWRlS2V5UGFkU3RhdHVzEhwKCUJ1dHRvbk1hcBgBIAEoDVIJQnV0dG9uTWFwEkYKDUFuYW' - 'xvZ0J1dHRvbnMYAiABKAsyIC5kZS5qb25hc2JhcmsuUmlkZUFuYWxvZ0tleUdyb3VwUg1BbmFs' - 'b2dCdXR0b25z'); - -@$core.Deprecated('Use clickKeyPadStatusDescriptor instead') -const ClickKeyPadStatus$json = { - '1': 'ClickKeyPadStatus', - '2': [ - {'1': 'Button_Plus', '3': 1, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonPlus'}, - {'1': 'Button_Minus', '3': 2, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonMinus'}, - ], -}; - -/// Descriptor for `ClickKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List clickKeyPadStatusDescriptor = $convert.base64Decode( - 'ChFDbGlja0tleVBhZFN0YXR1cxI/CgtCdXR0b25fUGx1cxgBIAEoDjIeLmRlLmpvbmFzYmFyay' - '5QbGF5QnV0dG9uU3RhdHVzUgpCdXR0b25QbHVzEkEKDEJ1dHRvbl9NaW51cxgCIAEoDjIeLmRl' - 'LmpvbmFzYmFyay5QbGF5QnV0dG9uU3RhdHVzUgtCdXR0b25NaW51cw=='); - -@$core.Deprecated('Use deviceInformationContentDescriptor instead') -const DeviceInformationContent$json = { - '1': 'DeviceInformationContent', - '2': [ - {'1': 'Unknown1', '3': 1, '4': 1, '5': 13, '10': 'Unknown1'}, - {'1': 'SoftwareVersion', '3': 2, '4': 3, '5': 13, '10': 'SoftwareVersion'}, - {'1': 'DeviceName', '3': 3, '4': 1, '5': 9, '10': 'DeviceName'}, - {'1': 'Unknown4', '3': 4, '4': 1, '5': 13, '10': 'Unknown4'}, - {'1': 'Unknown5', '3': 5, '4': 1, '5': 13, '10': 'Unknown5'}, - {'1': 'SerialNumber', '3': 6, '4': 1, '5': 9, '10': 'SerialNumber'}, - {'1': 'HardwareVersion', '3': 7, '4': 1, '5': 9, '10': 'HardwareVersion'}, - {'1': 'ReplyData', '3': 8, '4': 3, '5': 13, '10': 'ReplyData'}, - {'1': 'Unknown9', '3': 9, '4': 1, '5': 13, '10': 'Unknown9'}, - {'1': 'Unknown10', '3': 10, '4': 1, '5': 13, '10': 'Unknown10'}, - {'1': 'Unknown13', '3': 13, '4': 1, '5': 13, '10': 'Unknown13'}, - ], -}; - -/// Descriptor for `DeviceInformationContent`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List deviceInformationContentDescriptor = $convert.base64Decode( - 'ChhEZXZpY2VJbmZvcm1hdGlvbkNvbnRlbnQSGgoIVW5rbm93bjEYASABKA1SCFVua25vd24xEi' - 'gKD1NvZnR3YXJlVmVyc2lvbhgCIAMoDVIPU29mdHdhcmVWZXJzaW9uEh4KCkRldmljZU5hbWUY' - 'AyABKAlSCkRldmljZU5hbWUSGgoIVW5rbm93bjQYBCABKA1SCFVua25vd240EhoKCFVua25vd2' - '41GAUgASgNUghVbmtub3duNRIiCgxTZXJpYWxOdW1iZXIYBiABKAlSDFNlcmlhbE51bWJlchIo' - 'Cg9IYXJkd2FyZVZlcnNpb24YByABKAlSD0hhcmR3YXJlVmVyc2lvbhIcCglSZXBseURhdGEYCC' - 'ADKA1SCVJlcGx5RGF0YRIaCghVbmtub3duORgJIAEoDVIIVW5rbm93bjkSHAoJVW5rbm93bjEw' - 'GAogASgNUglVbmtub3duMTASHAoJVW5rbm93bjEzGA0gASgNUglVbmtub3duMTM='); - -@$core.Deprecated('Use subContentDescriptor instead') -const SubContent$json = { - '1': 'SubContent', - '2': [ - {'1': 'Content', '3': 1, '4': 1, '5': 11, '6': '.de.jonasbark.DeviceInformationContent', '10': 'Content'}, - {'1': 'Unknown2', '3': 2, '4': 1, '5': 13, '10': 'Unknown2'}, - {'1': 'Unknown4', '3': 4, '4': 1, '5': 13, '10': 'Unknown4'}, - {'1': 'Unknown5', '3': 5, '4': 1, '5': 13, '10': 'Unknown5'}, - {'1': 'Unknown6', '3': 6, '4': 1, '5': 13, '10': 'Unknown6'}, - ], -}; - -/// Descriptor for `SubContent`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List subContentDescriptor = $convert.base64Decode( - 'CgpTdWJDb250ZW50EkAKB0NvbnRlbnQYASABKAsyJi5kZS5qb25hc2JhcmsuRGV2aWNlSW5mb3' - 'JtYXRpb25Db250ZW50UgdDb250ZW50EhoKCFVua25vd24yGAIgASgNUghVbmtub3duMhIaCghV' - 'bmtub3duNBgEIAEoDVIIVW5rbm93bjQSGgoIVW5rbm93bjUYBSABKA1SCFVua25vd241EhoKCF' - 'Vua25vd242GAYgASgNUghVbmtub3duNg=='); - -@$core.Deprecated('Use deviceInformationDescriptor instead') -const DeviceInformation$json = { - '1': 'DeviceInformation', - '2': [ - {'1': 'InformationId', '3': 1, '4': 1, '5': 13, '10': 'InformationId'}, - {'1': 'SubContent', '3': 2, '4': 1, '5': 11, '6': '.de.jonasbark.SubContent', '10': 'SubContent'}, - ], -}; - -/// Descriptor for `DeviceInformation`. Decode as a `google.protobuf.DescriptorProto`. -final $typed_data.Uint8List deviceInformationDescriptor = $convert.base64Decode( - 'ChFEZXZpY2VJbmZvcm1hdGlvbhIkCg1JbmZvcm1hdGlvbklkGAEgASgNUg1JbmZvcm1hdGlvbk' - 'lkEjgKClN1YkNvbnRlbnQYAiABKAsyGC5kZS5qb25hc2JhcmsuU3ViQ29udGVudFIKU3ViQ29u' - 'dGVudA=='); - diff --git a/lib/protocol/zwift.pbserver.dart b/lib/protocol/zwift.pbserver.dart deleted file mode 100644 index 8178db618..000000000 --- a/lib/protocol/zwift.pbserver.dart +++ /dev/null @@ -1,14 +0,0 @@ -// -// Generated code. Do not modify. -// source: zwift.proto -// -// @dart = 2.12 - -// ignore_for_file: annotate_overrides, camel_case_types, comment_references -// ignore_for_file: constant_identifier_names -// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes -// ignore_for_file: non_constant_identifier_names, prefer_final_fields -// ignore_for_file: unnecessary_import, unnecessary_this, unused_import - -export 'zwift.pb.dart'; - diff --git a/lib/protocol/zwift.proto b/lib/protocol/zwift.proto deleted file mode 100644 index 709db86fc..000000000 --- a/lib/protocol/zwift.proto +++ /dev/null @@ -1,126 +0,0 @@ -syntax = "proto2"; -package de.jonasbark; - -// derived from qdomyos-zwift project - -//---------------- Zwift Play messages - -enum PlayButtonStatus { - ON = 0; - OFF = 1; -} -// The command code prepending this message is 0x07 -message PlayKeyPadStatus { - optional PlayButtonStatus RightPad = 1; - optional PlayButtonStatus Button_Y_Up = 2; - optional PlayButtonStatus Button_Z_Left = 3; - optional PlayButtonStatus Button_A_Right = 4; - optional PlayButtonStatus Button_B_Down = 5; - optional PlayButtonStatus Button_On = 6; - optional PlayButtonStatus Button_Shift = 7; - optional sint32 Analog_LR = 8; - optional sint32 Analog_UD = 9; -} - - -message PlayCommandParameters { - optional uint32 param1 = 1; - optional uint32 param2 = 2; - optional uint32 HapticPattern = 3; -} - -message PlayCommandContents { - optional PlayCommandParameters CommandParameters = 1; -} - -// The command code prepending this message is 0x12 -// This is sent to the control point to configure and make the controller vibrate -message PlayCommand { - optional PlayCommandContents CommandContents = 2; -} - -// The command code prepending this message is 0x19 -// This is sent periodically when there are no button presses -message Idle { - optional uint32 Unknown2 = 2; -} - -//----------------- Zwift Ride messages -enum RideButtonMask { - LEFT_BTN = 0x00001; - UP_BTN = 0x00002; - RIGHT_BTN = 0x00004; - DOWN_BTN = 0x00008; - A_BTN = 0x00010; - B_BTN = 0x00020; - Y_BTN = 0x00040; - - Z_BTN = 0x00100; - SHFT_UP_L_BTN = 0x00200; - SHFT_DN_L_BTN = 0x00400; - POWERUP_L_BTN = 0x00800; - ONOFF_L_BTN = 0x01000; - SHFT_UP_R_BTN = 0x02000; - SHFT_DN_R_BTN = 0x04000; - - POWERUP_R_BTN = 0x10000; - ONOFF_R_BTN = 0x20000; -} - -enum RideAnalogLocation { - LEFT = 0; - RIGHT = 1; - UP = 2; - DOWN = 3; -} - -message RideAnalogKeyPress { - optional RideAnalogLocation Location = 1; - optional sint32 AnalogValue = 2; -} - -message RideAnalogKeyGroup { - repeated RideAnalogKeyPress GroupStatus = 1; -} - -// The command code prepending this message is 0x23 -message RideKeyPadStatus { - optional uint32 ButtonMap = 1; - optional RideAnalogKeyGroup AnalogButtons = 2; -} - -//------------------ Zwift Click messages -// The command code prepending this message is 0x37 -message ClickKeyPadStatus { - optional PlayButtonStatus Button_Plus = 1; - optional PlayButtonStatus Button_Minus = 2; -} - -//------------------ Device Information requested after connection -// The command code prepending this message is 0x3c -message DeviceInformationContent { - optional uint32 Unknown1 = 1; - repeated uint32 SoftwareVersion = 2; - optional string DeviceName = 3; - optional uint32 Unknown4 = 4; - optional uint32 Unknown5 =5; - optional string SerialNumber = 6; - optional string HardwareVersion = 7; - repeated uint32 ReplyData = 8; - optional uint32 Unknown9 = 9; - optional uint32 Unknown10 = 10; - optional uint32 Unknown13 = 13; -} - -message SubContent { - optional DeviceInformationContent Content = 1; - optional uint32 Unknown2 = 2; - optional uint32 Unknown4 = 4; - optional uint32 Unknown5 = 5; - optional uint32 Unknown6 = 6; -} - -message DeviceInformation { - optional uint32 InformationId = 1; - optional SubContent SubContent = 2; -} diff --git a/lib/repositories/user_settings_repository.dart b/lib/repositories/user_settings_repository.dart new file mode 100644 index 000000000..f4f8c4602 --- /dev/null +++ b/lib/repositories/user_settings_repository.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:bike_control/models/user_device.dart'; +import 'package:bike_control/models/user_settings.dart'; +import 'package:bike_control/services/device_identity_service.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Repository for managing user settings synchronization with Supabase. +class UserSettingsRepository { + final SupabaseClient _supabase; + final DeviceIdentityService _deviceIdentity; + + UserSettingsRepository(this._supabase, {DeviceIdentityService? deviceIdentity}) + : _deviceIdentity = deviceIdentity ?? DeviceIdentityService(); + + /// Gets the current user's settings from Supabase. + /// Returns null if no settings exist or user is not logged in. + Future getSettings() async { + final userId = _supabase.auth.currentUser?.id; + if (userId == null) return null; + + final remoteId = await _deviceIdentity.getRemoteId(_supabase); + if (remoteId == null) return null; + + try { + final response = await _supabase + .from('user_settings') + .select() + .eq('user_id', userId) + .eq('device_id', remoteId) + .maybeSingle(); + + if (response == null) return null; + + return UserSettings.fromJson(response); + } catch (e) { + print('Error fetching user settings: $e'); + return null; + } + } + + /// Gets settings from a specific device. + /// [deviceId] is the device ID to fetch settings from. + Future getSettingsFromDevice(String deviceId) async { + final userId = _supabase.auth.currentUser?.id; + if (userId == null) return null; + + try { + final response = await _supabase + .from('user_settings') + .select() + .eq('user_id', userId) + .eq('device_id', deviceId) + .maybeSingle(); + + if (response == null) return null; + + return UserSettings.fromJson(response); + } catch (e) { + print('Error fetching device settings: $e'); + return null; + } + } + + /// Gets all settings for the current user across all devices. + Future> getAllDeviceSettings() async { + final userId = _supabase.auth.currentUser?.id; + if (userId == null) return []; + + try { + final response = await _supabase + .from('user_settings') + .select() + .eq('user_id', userId) + .order('updated_at', ascending: false); + + return (response as List).map((json) => UserSettings.fromJson(json)).toList(); + } catch (e) { + print('Error fetching all device settings: $e'); + return []; + } + } + + /// Gets all registered devices for the current user. + Future> getRegisteredDevices() async { + final userId = _supabase.auth.currentUser?.id; + if (userId == null) return []; + + try { + final response = await _supabase + .from('user_devices') + .select() + .eq('user_id', userId) + .eq('revoked_at', 'null') + .order('last_seen_at', ascending: false); + + return (response as List).map((json) => UserDevice.fromJson(json)).toList(); + } catch (e) { + print('Error fetching registered devices: $e'); + return []; + } + } + + /// Saves the current local settings to Supabase. + /// Automatically increments version and handles conflict resolution. + Future saveSettings() async { + final userId = _supabase.auth.currentUser?.id; + if (userId == null) return null; + + try { + // Get remote device ID (from user_devices table) + final deviceId = await _deviceIdentity.getRemoteId(_supabase); + if (deviceId == null || deviceId.isEmpty) { + print('Cannot save settings: device is not registered'); + return null; + } + + // Get current settings from server + final currentSettings = await getSettings(); + final newVersion = (currentSettings?.version ?? 0) + 1; + + // Build keymaps JSON from current settings + final keymapsData = await _buildKeymapsData(); + + // Get ignored devices + final ignoredDevices = core.settings.getIgnoredDevices(); + final ignoredIds = ignoredDevices.map((d) => d.id).toList(); + final ignoredNames = ignoredDevices.map((d) => d.name).toList(); + + final settings = UserSettings( + userId: userId, + deviceId: deviceId, + keymaps: keymapsData, + ignoredDeviceIds: ignoredIds, + ignoredDeviceNames: ignoredNames, + version: newVersion, + ); + + final response = await _supabase + .from('user_settings') + .upsert( + settings.toJson(), + onConflict: 'user_id,device_id', + ) + .select() + .single(); + + return UserSettings.fromJson(response); + } catch (e) { + print('Error saving user settings: $e'); + return null; + } + } + + /// Loads settings from Supabase and applies them locally. + /// Returns true if settings were applied, false otherwise. + /// If [deviceId] is provided, loads settings from that specific device. + Future loadAndApplySettings({String? deviceId}) async { + UserSettings? settings; + + if (deviceId != null) { + settings = await getSettingsFromDevice(deviceId); + } else { + settings = await getSettings(); + } + + if (settings == null) return false; + + try { + // Apply keymaps + if (settings.keymaps != null) { + await _applyKeymaps(settings.keymaps!); + } + + // Apply ignored devices + if (settings.ignoredDeviceIds != null && settings.ignoredDeviceNames != null) { + await _applyIgnoredDevices(settings.ignoredDeviceIds!, settings.ignoredDeviceNames!); + } + + return true; + } catch (e) { + print('Error applying settings: $e'); + return false; + } + } + + /// Checks if server settings are newer than local. + /// Returns true if server has newer data. + /// If [deviceId] is provided, checks settings from that specific device. + Future hasNewerSettingsOnServer({String? deviceId}) async { + UserSettings? serverSettings; + + if (deviceId != null) { + serverSettings = await getSettingsFromDevice(deviceId); + } else { + serverSettings = await getSettings(); + } + + if (serverSettings == null) return false; + + // Get local version from settings + final localVersion = core.settings.prefs.getInt('settings_version') ?? 0; + final localUpdatedAt = core.settings.prefs.getString('settings_updated_at'); + final localDateTime = localUpdatedAt != null ? DateTime.tryParse(localUpdatedAt) : null; + + final localSettings = UserSettings( + version: localVersion, + updatedAt: localDateTime, + ); + + return serverSettings.isNewerThan(localSettings); + } + + /// Gets the last sync information. + Future<({DateTime? lastSynced, int? version})> getLastSyncInfo() async { + final settings = await getSettings(); + return ( + lastSynced: settings?.updatedAt, + version: settings?.version, + ); + } + + /// Builds keymaps data from current settings. + Future> _buildKeymapsData() async { + final data = {}; + + // Get all custom app profiles + final profiles = core.settings.getCustomAppProfiles(); + + for (final profileName in profiles) { + final keymap = core.settings.getCustomAppKeymap(profileName); + if (keymap != null) { + data[profileName] = keymap.map(jsonDecode).toList(); + } + } + + /* // Include current app + final currentApp = core.settings.getKeyMap(); + if (currentApp != null) { + data['_current_app'] = currentApp.name; + }*/ + + return data; + } + + /// Applies keymaps from server data. + Future _applyKeymaps(Map keymapsData) async { + for (final entry in keymapsData.entries) { + if (entry.key == '_current_app') { + // Set current app if it exists + final appName = entry.value as String?; + if (appName != null) { + // Find the app and set it + // This will be handled by the settings system + await core.settings.prefs.setString('app', appName); + } + continue; + } + + // Save keymap data for custom app + if (entry.value is List) { + final keymapList = (entry.value as List).map(jsonEncode).toList(); + await core.settings.prefs.setStringList('customapp_${entry.key}', keymapList); + } + } + } + + /// Applies ignored devices from server data. + Future _applyIgnoredDevices(List ids, List names) async { + await core.settings.prefs.setStringList('ignored_device_ids', ids); + await core.settings.prefs.setStringList('ignored_device_names', names); + } + + /// Saves the local version information after successful sync. + Future saveLocalVersionInfo(int version, DateTime updatedAt) async { + await core.settings.prefs.setInt('settings_version', version); + await core.settings.prefs.setString('settings_updated_at', updatedAt.toIso8601String()); + } +} diff --git a/lib/services/device_identity_service.dart b/lib/services/device_identity_service.dart new file mode 100644 index 000000000..ddec3c42d --- /dev/null +++ b/lib/services/device_identity_service.dart @@ -0,0 +1,82 @@ +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DeviceIdentityService { + static const String _deviceIdStorageKey = 'bikecontrol_device_id_v1'; + + final FlutterSecureStorage _storage; + final DeviceInfoPlugin _deviceInfo; + + DeviceIdentityService({ + FlutterSecureStorage storage = const FlutterSecureStorage(), + DeviceInfoPlugin? deviceInfo, + }) : _storage = storage, + _deviceInfo = deviceInfo ?? DeviceInfoPlugin(); + + /// Gets the remote device ID (UUID from user_devices table) for the current device. + /// Returns null if the device is not registered or user is not logged in. + Future getRemoteId(SupabaseClient supabase) async { + final userId = supabase.auth.currentUser?.id; + if (userId == null) return null; + + final localDeviceId = await getOrCreateDeviceId(); + + try { + final response = await supabase + .from('user_devices') + .select('id') + .eq('user_id', userId) + .eq('device_id', localDeviceId) + .maybeSingle(); + + if (response == null) return null; + + return response['id'] as String?; + } catch (e) { + return null; + } + } + + Future currentPlatform() async { + if (kIsWeb) return null; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + return 'ios'; + case TargetPlatform.android: + return 'android'; + case TargetPlatform.macOS: + return 'macos'; + case TargetPlatform.windows: + return 'windows'; + case TargetPlatform.linux: + case TargetPlatform.fuchsia: + return null; + } + } + + Future getOrCreateDeviceId() async { + final existing = await _storage.read(key: _deviceIdStorageKey); + if (existing != null && existing.isNotEmpty) { + return existing; + } + + final platform = await currentPlatform(); + if (platform == null || platform.isEmpty) { + throw StateError('Unsupported platform for device identity'); + } + + final fingerprintSource = await _buildFingerprintSource(platform); + final generated = '${platform}_$fingerprintSource'; + + final trimmedTo255Characters = generated.length > 255 ? generated.substring(0, 255) : generated; + + await _storage.write(key: _deviceIdStorageKey, value: trimmedTo255Characters); + return trimmedTo255Characters; + } + + Future _buildFingerprintSource(String platform) async { + return '${DateTime.now().millisecondsSinceEpoch}_${_deviceInfo.hashCode}'; + } +} diff --git a/lib/services/device_management_service.dart b/lib/services/device_management_service.dart new file mode 100644 index 000000000..aee130449 --- /dev/null +++ b/lib/services/device_management_service.dart @@ -0,0 +1,164 @@ +import 'package:bike_control/models/device_limit_reached_error.dart'; +import 'package:bike_control/models/register_device_result.dart'; +import 'package:bike_control/models/user_device.dart'; +import 'package:bike_control/services/device_identity_service.dart'; +import 'package:prop/prop.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DeviceManagementService { + static const String registerDeviceFunction = 'devices/register'; + static const String meDevicesFunction = 'devices/me'; + static const String revokeDeviceFunction = 'devices/revoke'; + + final SupabaseClient _supabase; + final DeviceIdentityService _deviceIdentityService; + + DeviceManagementService({ + required SupabaseClient supabase, + required DeviceIdentityService deviceIdentityService, + }) : _supabase = supabase, + _deviceIdentityService = deviceIdentityService; + + Future registerCurrentDevice({ + String? deviceName, + String? appVersion, + }) async { + final session = _requireSession(); + final platform = await _requirePlatform(); + final deviceId = await _deviceIdentityService.getOrCreateDeviceId(); + + try { + final response = await _supabase.functions.invoke( + registerDeviceFunction, + method: HttpMethod.post, + headers: _authHeaders(session), + body: { + 'platform': platform, + 'device_id': deviceId, + if (deviceName != null && deviceName.isNotEmpty) 'device_name': deviceName, + if (appVersion != null && appVersion.isNotEmpty) 'app_version': appVersion, + }, + ); + Logger.debug('Device registration response: ${response.data}'); + final payload = Map.from(response.data as Map); + return RegisterDeviceResult.fromJson(payload); + } on FunctionException catch (error) { + final limitError = _parseDeviceLimitError(error); + if (limitError != null) { + throw limitError; + } + rethrow; + } + } + + Future> getMyDevices() async { + final session = _requireSession(); + final response = await _supabase.functions.invoke( + meDevicesFunction, + method: HttpMethod.get, + headers: _authHeaders(session), + ); + return _parseDevicesPayload(response.data); + } + + Future> revokeDevice({ + required String platform, + required String deviceId, + }) async { + final session = _requireSession(); + final response = await _supabase.functions.invoke( + revokeDeviceFunction, + method: HttpMethod.post, + headers: _authHeaders(session), + body: { + 'platform': platform, + 'device_id': deviceId, + }, + ); + + return _parseDevicesPayload(response.data); + } + + Future currentDeviceId() { + return _deviceIdentityService.getOrCreateDeviceId(); + } + + Future currentPlatform() { + return _deviceIdentityService.currentPlatform(); + } + + Session _requireSession() { + final session = _supabase.auth.currentSession; + if (session == null) { + throw StateError('No active Supabase session'); + } + return session; + } + + Future _requirePlatform() async { + final platform = await _deviceIdentityService.currentPlatform(); + if (platform == null || platform.isEmpty) { + throw StateError('Unsupported platform for device management'); + } + return platform; + } + + Map _authHeaders(Session session) { + return {'Authorization': 'Bearer ${session.accessToken}'}; + } + + List _parseDevicesPayload(dynamic payload) { + Logger.debug('Devices response: $payload'); + if (payload is List) { + return payload + .whereType() + .map((item) => Map.from(item)) + .map(UserDevice.fromJson) + .toList(growable: false); + } + + if (payload is Map) { + if (payload['devices'] is List) { + final devices = payload['devices'] as List; + return devices + .whereType() + .map((item) => Map.from(item)) + .map(UserDevice.fromJson) + .toList(growable: false); + } + + final grouped = []; + for (final entry in payload.entries) { + final value = entry.value; + if (value is! List) continue; + grouped.addAll( + value.whereType().map((item) => Map.from(item)).map((json) { + final withPlatform = { + 'platform': json['platform'] ?? entry.key, + ...json, + }; + return UserDevice.fromJson(withPlatform); + }), + ); + } + return grouped; + } + + return const []; + } + + DeviceLimitReachedError? _parseDeviceLimitError(FunctionException error) { + if (error.status != 409) { + return null; + } + final details = error.details; + if (details is! Map) { + return null; + } + final json = Map.from(details); + if (json['error'] != 'device_limit_reached') { + return null; + } + return DeviceLimitReachedError.fromJson(json); + } +} diff --git a/lib/services/entitlements_service.dart b/lib/services/entitlements_service.dart new file mode 100644 index 000000000..85a024238 --- /dev/null +++ b/lib/services/entitlements_service.dart @@ -0,0 +1,240 @@ +import 'dart:convert'; + +import 'package:bike_control/models/device_limit_reached_error.dart'; +import 'package:bike_control/models/entitlement.dart'; +import 'package:bike_control/services/device_identity_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:prop/prop.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class EntitlementsService extends ChangeNotifier { + static const Duration refreshTtl = Duration(minutes: 10); + + static const String _entitlementsFunction = 'get-entitlements'; + static const String _cacheEntitlementsKey = 'entitlements_cache_items'; + static const String _cacheLastFetchedAtKey = 'entitlements_cache_last_fetched_at'; + static const String _cacheRegisteredDeviceKey = 'entitlements_cache_registered_device'; + + final SupabaseClient _supabase; + final DeviceIdentityService _deviceIdentityService; + + SharedPreferences? _prefs; + bool _isInitialized = false; + Future? _inFlightRefresh; + + DateTime? _lastFetchedAt; + List _entitlements = const []; + bool _isRegisteredDevice = false; + DeviceLimitReachedError? _lastDeviceLimitError; + + EntitlementsService( + this._supabase, { + required DeviceIdentityService deviceIdentityService, + }) : _deviceIdentityService = deviceIdentityService; + + List get current => List.unmodifiable(_entitlements); + + DateTime? get lastFetchedAt => _lastFetchedAt; + bool get isRegisteredDevice => _isRegisteredDevice; + DeviceLimitReachedError? get lastDeviceLimitError => _lastDeviceLimitError; + + bool get isCacheStale { + final fetchedAt = _lastFetchedAt; + if (fetchedAt == null) { + return true; + } + return DateTime.now().difference(fetchedAt) >= refreshTtl; + } + + Future initialize() async { + if (_isInitialized) { + return; + } + + _prefs = await SharedPreferences.getInstance(); + _restoreCacheFromPrefs(); + _isInitialized = true; + } + + Future refresh({bool force = false}) async { + await initialize(); + + if (!force && !isCacheStale) { + return; + } + + final existing = _inFlightRefresh; + if (existing != null) { + return existing; + } + + final task = _refreshInternal(); + _inFlightRefresh = task; + try { + await task; + } finally { + _inFlightRefresh = null; + } + } + + bool hasActive(String productKey) { + return _entitlements.any((entitlement) { + return entitlement.productKey == productKey && entitlement.isActive; + }); + } + + DateTime? activeUntil(String productKey) { + DateTime? latest; + for (final entitlement in _entitlements) { + if (entitlement.productKey != productKey) { + continue; + } + final value = entitlement.activeUntil; + if (value == null) { + continue; + } + if (latest == null || value.isAfter(latest)) { + latest = value; + } + } + return latest; + } + + Future clearCache() async { + await initialize(); + _entitlements = const []; + _lastFetchedAt = null; + _isRegisteredDevice = false; + await _prefs?.remove(_cacheEntitlementsKey); + await _prefs?.remove(_cacheLastFetchedAtKey); + await _prefs?.remove(_cacheRegisteredDeviceKey); + notifyListeners(); + } + + Future _refreshInternal() async { + try { + final session = _supabase.auth.currentSession; + if (session == null) { + return; + } + final platform = await _deviceIdentityService.currentPlatform(); + if (platform == null || platform.isEmpty) { + return; + } + final deviceId = await _deviceIdentityService.getOrCreateDeviceId(); + + final response = await _supabase.functions.invoke( + _entitlementsFunction, + method: HttpMethod.get, + headers: { + 'Authorization': 'Bearer ${session.accessToken}', + 'X-Device-Platform': platform, + 'X-Device-Id': deviceId, + }, + ); + + final payload = response.data; + Logger.debug('Entitlements response: $payload'); + final parsed = _extractPayload(payload); + final list = parsed.entitlements; + final entitlements = list + .whereType() + .map((item) => Map.from(item)) + .map(Entitlement.fromJson) + .toList(growable: false); + + _entitlements = entitlements; + _isRegisteredDevice = parsed.isRegisteredDevice; + _lastFetchedAt = DateTime.now(); + _lastDeviceLimitError = null; + await _persistCache(); + notifyListeners(); + } on FunctionException catch (error, stackTrace) { + final details = error.details; + if (error.status == 409 && details is Map) { + final json = Map.from(details); + if (json['error'] == 'device_limit_reached') { + _lastDeviceLimitError = DeviceLimitReachedError.fromJson(json); + notifyListeners(); + return; + } + } + debugPrint('Failed to refresh entitlements: $error'); + debugPrintStack(stackTrace: stackTrace); + } catch (error, stackTrace) { + debugPrint('Failed to refresh entitlements: $error'); + debugPrintStack(stackTrace: stackTrace); + } + } + + _EntitlementsPayload _extractPayload(dynamic payload) { + if (payload is List) { + return _EntitlementsPayload( + entitlements: payload, + isRegisteredDevice: false, + ); + } + if (payload is Map && payload['entitlements'] is List) { + return _EntitlementsPayload( + entitlements: payload['entitlements'] as List, + isRegisteredDevice: payload['is_registered_device'] == true, + ); + } + throw StateError('Unexpected entitlements response: $payload'); + } + + Future _persistCache() async { + await _prefs?.setString( + _cacheEntitlementsKey, + jsonEncode(_entitlements.map((e) => e.toJson()).toList(growable: false)), + ); + await _prefs?.setString( + _cacheLastFetchedAtKey, + _lastFetchedAt?.toIso8601String() ?? '', + ); + await _prefs?.setBool( + _cacheRegisteredDeviceKey, + _isRegisteredDevice, + ); + } + + void _restoreCacheFromPrefs() { + final rawLastFetchedAt = _prefs?.getString(_cacheLastFetchedAtKey); + _lastFetchedAt = DateTime.tryParse(rawLastFetchedAt ?? ''); + _isRegisteredDevice = _prefs?.getBool(_cacheRegisteredDeviceKey) ?? false; + + final rawEntitlements = _prefs?.getString(_cacheEntitlementsKey); + if (rawEntitlements == null || rawEntitlements.isEmpty) { + _entitlements = const []; + return; + } + + try { + final decoded = jsonDecode(rawEntitlements); + if (decoded is! List) { + _entitlements = const []; + return; + } + _entitlements = decoded + .whereType() + .map((item) => Map.from(item)) + .map(Entitlement.fromJson) + .toList(growable: false); + } catch (error, stackTrace) { + debugPrint('Failed to restore entitlement cache: $error'); + debugPrintStack(stackTrace: stackTrace); + _entitlements = const []; + } + } +} + +class _EntitlementsPayload { + final List entitlements; + final bool isRegisteredDevice; + + const _EntitlementsPayload({ + required this.entitlements, + required this.isRegisteredDevice, + }); +} diff --git a/lib/services/settings_sync_service.dart b/lib/services/settings_sync_service.dart new file mode 100644 index 000000000..b9677cd6f --- /dev/null +++ b/lib/services/settings_sync_service.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:bike_control/models/user_settings.dart'; +import 'package:bike_control/repositories/user_settings_repository.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:flutter/foundation.dart'; + +/// Service that manages automatic syncing of settings for Pro users. +class SettingsSyncService { + final UserSettingsRepository _repository; + bool _isSyncing = false; + + final ValueNotifier lastSyncedAt = ValueNotifier(null); + final ValueNotifier isSyncing = ValueNotifier(false); + final ValueNotifier lastError = ValueNotifier(null); + + SettingsSyncService({UserSettingsRepository? repository}) + : _repository = repository ?? UserSettingsRepository(core.supabase); + + /// Initializes the sync service and sets up listeners. + Future initialize() async { + // Check if user is pro and logged in + if (!_canSync()) return; + + // Load last sync info + await _loadLastSyncInfo(); + + // Listen for settings changes + _setupSettingsListeners(); + } + + /// Disposes resources. + void dispose() { + lastSyncedAt.dispose(); + isSyncing.dispose(); + lastError.dispose(); + } + + /// Checks if the user can sync (is pro and logged in). + bool _canSync() { + return IAPManager.instance.hasActiveSubscription && IAPManager.instance.isLoggedIn; + } + + /// Loads the last sync information. + Future _loadLastSyncInfo() async { + if (!_canSync()) return; + + try { + final info = await _repository.getLastSyncInfo(); + lastSyncedAt.value = info.lastSynced; + } catch (e) { + print('Error loading last sync info: $e'); + } + } + + /// Syncs local settings to the server. + /// Returns true if sync was successful. + Future syncToServer() async { + if (_isSyncing) return false; + if (!_canSync()) { + lastError.value = 'Pro subscription required'; + return false; + } + + _isSyncing = true; + isSyncing.value = true; + lastError.value = null; + + try { + final settings = await _repository.saveSettings(); + + if (settings != null) { + lastSyncedAt.value = settings.updatedAt; + await _repository.saveLocalVersionInfo(settings.version, settings.updatedAt ?? DateTime.now()); + return true; + } else { + lastError.value = 'Failed to save settings'; + return false; + } + } catch (e) { + lastError.value = e.toString(); + return false; + } finally { + _isSyncing = false; + isSyncing.value = false; + } + } + + /// Syncs from server to local (download settings). + /// Returns true if sync was successful. + /// If [deviceId] is provided, syncs settings from that specific device. + Future syncFromServer({String? deviceId}) async { + if (_isSyncing) return false; + if (!_canSync()) { + lastError.value = 'Pro subscription required'; + return false; + } + + _isSyncing = true; + isSyncing.value = true; + lastError.value = null; + + try { + final hasNewer = await _repository.hasNewerSettingsOnServer(deviceId: deviceId); + + if (!hasNewer && !kDebugMode) { + // No newer settings on server + return true; + } + + final success = await _repository.loadAndApplySettings(deviceId: deviceId); + + if (success) { + // Update last sync info + UserSettings? settings; + if (deviceId != null) { + settings = await _repository.getSettingsFromDevice(deviceId); + } else { + settings = await _repository.getSettings(); + } + + if (settings != null) { + lastSyncedAt.value = settings.updatedAt; + await _repository.saveLocalVersionInfo(settings.version, settings.updatedAt ?? DateTime.now()); + } + } + + return success; + } catch (e) { + lastError.value = e.toString(); + return false; + } finally { + _isSyncing = false; + isSyncing.value = false; + } + } + + /// Checks if server has newer settings. + /// If [deviceId] is provided, checks settings from that specific device. + Future checkForUpdates({String? deviceId}) async { + if (!_canSync()) return false; + + try { + return await _repository.hasNewerSettingsOnServer(deviceId: deviceId); + } catch (e) { + return false; + } + } + + /// Sets up listeners for settings changes to trigger auto-sync. + void _setupSettingsListeners() { + // Listen to settings changes - this is a simplified approach + // In a real implementation, you'd want to debounce these calls + } +} diff --git a/lib/theme.dart b/lib/theme.dart deleted file mode 100644 index 931cbf4bc..000000000 --- a/lib/theme.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flex_color_scheme/flex_color_scheme.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; - -abstract final class AppTheme { - // The FlexColorScheme defined light mode ThemeData. - static ThemeData light = FlexThemeData.light( - // Using FlexColorScheme built-in FlexScheme enum based colors - scheme: FlexScheme.redM3, - // Component theme configurations for light mode. - subThemesData: const FlexSubThemesData( - interactionEffects: true, - tintedDisabledControls: true, - useM2StyleDividerInM3: true, - inputDecoratorIsFilled: true, - inputDecoratorBorderType: FlexInputBorderType.outline, - alignedDropdown: true, - navigationRailUseIndicator: true, - ), - // Direct ThemeData properties. - visualDensity: FlexColorScheme.comfortablePlatformDensity, - cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true), - ); - - // The FlexColorScheme defined dark mode ThemeData. - static ThemeData dark = FlexThemeData.dark( - // Using FlexColorScheme built-in FlexScheme enum based colors. - scheme: FlexScheme.redM3, - // Component theme configurations for dark mode. - subThemesData: const FlexSubThemesData( - interactionEffects: true, - tintedDisabledControls: true, - blendOnColors: true, - useM2StyleDividerInM3: true, - inputDecoratorIsFilled: true, - inputDecoratorBorderType: FlexInputBorderType.outline, - alignedDropdown: true, - navigationRailUseIndicator: true, - ), - // Direct ThemeData properties. - visualDensity: FlexColorScheme.comfortablePlatformDensity, - cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true), - ).copyWith( - scaffoldBackgroundColor: Color(0xff0b1623), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white), - ), - ); -} diff --git a/lib/utils/actions/android.dart b/lib/utils/actions/android.dart index b34df2bc5..1a23a981b 100644 --- a/lib/utils/actions/android.dart +++ b/lib/utils/actions/android.dart @@ -1,50 +1,188 @@ -import 'dart:ui'; +import 'dart:async'; import 'package:accessibility/accessibility.dart'; -import 'package:swift_control/main.dart'; -import 'package:swift_control/utils/actions/base_actions.dart'; -import 'package:swift_control/utils/keymap/keymap.dart'; +import 'package:android_intent_plus/android_intent.dart'; +import 'package:bike_control/bluetooth/devices/hid/hid_device.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/widgets/keymap_explanation.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/services.dart'; -class AndroidActions extends BaseActions { - static const MYWHOOSH_APP_PACKAGE = "com.mywhoosh.whooshgame"; - static const TRAININGPEAKS_APP_PACKAGE = "com.indieVelo.client"; +import '../keymap/apps/supported_app.dart'; +import '../single_line_exception.dart'; +class AndroidActions extends BaseActions { WindowEvent? windowInfo; + final accessibilityHandler = Accessibility(); + StreamSubscription? _keymapUpdateSubscription; + StreamSubscription? _accessibilitySubscription; + StreamSubscription? _hidKeyPressedSubscription; + + AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]}); + @override - void init(Keymap? keymap) { - streamEvents().listen((windowEvent) { - windowInfo = windowEvent; + void init(SupportedApp? supportedApp) { + super.init(supportedApp); + _accessibilitySubscription = streamEvents().listen((windowEvent) { + if (supportedApp != null) { + windowInfo = windowEvent; + } + }); + + // Update handled keys list when keymap changes + updateHandledKeys(); + + // Listen to keymap changes and update handled keys + _keymapUpdateSubscription?.cancel(); + _keymapUpdateSubscription = supportedApp?.keymap.updateStream.listen((_) { + updateHandledKeys(); + }); + + _hidKeyPressedSubscription = hidKeyPressed().listen((keyPressed) async { + final hidDevice = HidDevice(keyPressed.source); + final button = hidDevice.getOrAddButton(keyPressed.hidKey, () => ControllerButton(keyPressed.hidKey)); + + var availableDevice = core.connection.controllerDevices.firstOrNullWhere( + (e) => e.toString() == hidDevice.toString(), + ); + if (availableDevice == null) { + core.connection.addDevices([hidDevice]); + availableDevice = hidDevice; + } else { + availableDevice.supportsLongPress = false; + } + if (keyPressed.keyDown) { + availableDevice.handleButtonsClicked([button]); + } else if (keyPressed.keyUp) { + availableDevice.handleButtonsClicked([]); + } }); } @override - void decreaseGear() { - if (windowInfo == null) { - throw Exception("Decrease gear: No window info"); - } else { - final point = switch (windowInfo!.packageName) { - MYWHOOSH_APP_PACKAGE => Offset(windowInfo!.windowWidth * 0.80, windowInfo!.windowHeight * 0.94), - TRAININGPEAKS_APP_PACKAGE => Offset(windowInfo!.windowWidth / 2 * 1.15, windowInfo!.windowHeight * 0.74), - _ => throw UnimplementedError("Decreasing gear not supported for ${windowInfo!.packageName}"), - }; - - accessibilityHandler.performTouch(point.dx, point.dy); + Future performAction( + ControllerButton button, { + required bool isKeyDown, + required bool isKeyUp, + ButtonTrigger trigger = ButtonTrigger.singleClick, + }) async { + final superResult = await super.performAction(button, isKeyDown: isKeyDown, isKeyUp: isKeyUp, trigger: trigger); + if (superResult is! NotHandled) { + // Increment command count after successful execution + return superResult; + } + final keyPair = supportedApp!.keymap.getKeyPair(button, trigger: trigger)!; + + if (keyPair.isSpecialKey) { + if (!IAPManager.instance.hasActiveSubscription) { + return Error('Pro subscription required for media control'); + } + await accessibilityHandler.controlMedia(switch (keyPair.physicalKey) { + PhysicalKeyboardKey.mediaTrackNext => MediaAction.next, + PhysicalKeyboardKey.mediaPlayPause => MediaAction.playPause, + PhysicalKeyboardKey.audioVolumeUp => MediaAction.volumeUp, + PhysicalKeyboardKey.audioVolumeDown => MediaAction.volumeDown, + _ => throw SingleLineException("No action for key: ${keyPair.physicalKey}"), + }); + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); + return Success("Key pressed: ${keyPair.toString()}"); + } + + if (keyPair.androidAction == AndroidSystemAction.assistant) { + try { + await _launchAssistant(); + } on PlatformException { + return Error('No assistant app available on this device'); + } + } + + if (keyPair.androidAction != null && keyPair.androidAction != AndroidSystemAction.assistant) { + if (!IAPManager.instance.hasActiveSubscription) { + return Error('Pro subscription required for Android system actions'); + } + if (!core.settings.getLocalEnabled() || !core.logic.showLocalControl || !isKeyDown) { + return Ignored('Global action ignored'); + } + await accessibilityHandler.performGlobalAction(keyPair.androidAction!.globalAction!); + await IAPManager.instance.incrementCommandCount(); + return Success("Global action: ${keyPair.androidAction!.title}"); } + + final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: windowInfo); + if (point != Offset.zero) { + try { + await accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp); + } on PlatformException catch (e) { + return Error("Accessibility Service not working. Follow instructions at https://dontkillmyapp.com/"); + } + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); + return Success( + "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp + ? "click" + : isKeyDown + ? "down" + : "up"}", + ); + } + return NotHandled('No action assigned for ${button.name.splitByUpperCase()}'); } - @override - void increaseGear() { - if (windowInfo == null) { - throw Exception("Increasing gear: No window info"); - } else { - final point = switch (windowInfo!.packageName) { - MYWHOOSH_APP_PACKAGE => Offset(windowInfo!.windowWidth * 0.98, windowInfo!.windowHeight * 0.94), - TRAININGPEAKS_APP_PACKAGE => Offset(windowInfo!.windowWidth / 2 * 1.32, windowInfo!.windowHeight * 0.74), - _ => throw UnimplementedError("Increasing gear not supported for ${windowInfo!.packageName}"), - }; - - accessibilityHandler.performTouch(point.dx, point.dy); + void ignoreHidDevices() { + accessibilityHandler.ignoreHidDevices(); + } + + Future _launchAssistant() async { + final intents = [ + AndroidIntent(action: 'android.intent.action.VOICE_COMMAND'), + AndroidIntent(action: 'android.intent.action.VOICE_ASSIST'), + AndroidIntent(action: 'android.intent.action.ASSIST'), + ]; + PlatformException? lastException; + + for (final intent in intents) { + try { + await intent.launch(); + return; + } on PlatformException catch (e) { + lastException = e; + } } + + throw PlatformException( + code: 'assistant_unavailable', + message: lastException?.message ?? 'Could not launch assistant', + ); + } + + void updateHandledKeys() { + if (supportedApp == null) { + accessibilityHandler.setHandledKeys([]); + return; + } + + // Get all keys from the keymap that have a mapping defined + final handledKeys = supportedApp!.keymap.keyPairs + .filter((keyPair) => !keyPair.hasNoAction) + .expand((keyPair) => keyPair.buttons) + .filter((e) => e.action == null && e.icon == null) + .map((button) => button.name) + .toSet() + .toList(); + + accessibilityHandler.setHandledKeys(handledKeys); + } + + @override + void cleanup() { + _accessibilitySubscription?.cancel(); + _keymapUpdateSubscription?.cancel(); + _hidKeyPressedSubscription?.cancel(); } } diff --git a/lib/utils/actions/base_actions.dart b/lib/utils/actions/base_actions.dart index 0c452fd67..714d27edd 100644 --- a/lib/utils/actions/base_actions.dart +++ b/lib/utils/actions/base_actions.dart @@ -1,55 +1,264 @@ import 'dart:io'; +import 'dart:math'; +import 'package:accessibility/accessibility.dart'; +import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/actions/desktop.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/widgets/keymap_explanation.dart'; +import 'package:dartx/dartx.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:screen_retriever/screen_retriever.dart'; -import '../keymap/keymap.dart'; -import 'android.dart'; -import 'desktop.dart'; +import '../keymap/apps/supported_app.dart'; -abstract class BaseActions { - Keymap? get keymap => null; +enum SupportedMode { keyboard, touch, media } - void init(Keymap? keymap) {} - void increaseGear(); - void decreaseGear(); +sealed class ActionResult { + final String message; + const ActionResult(this.message); } -class StubActions extends BaseActions { - @override - void decreaseGear() { - print('Decrease gear'); +class Success extends ActionResult { + const Success(super.message); +} + +class NotHandled extends ActionResult { + const NotHandled(super.message); +} + +class Ignored extends ActionResult { + const Ignored(super.message); +} + +class Error extends ActionResult { + const Error(super.message); +} + +abstract class BaseActions { + final List supportedModes; + + SupportedApp? supportedApp; + + BaseActions({required this.supportedModes}); + + void cleanup(); + + void init(SupportedApp? supportedApp) { + this.supportedApp = supportedApp; + debugPrint('Supported app: ${supportedApp?.name ?? "None"}'); + + if (supportedApp != null) { + final allButtons = core.connection.devices.map((e) => e.availableButtons).flatten().distinct().toList(); + supportedApp.keymap.addNewButtons(allButtons); + } } - @override - void increaseGear() { - print('Increase gear'); + Future resolveTouchPosition({required KeyPair keyPair, required WindowEvent? windowInfo}) async { + if (keyPair.touchPosition != Offset.zero) { + // convert relative position to absolute position based on window info + + // TODO support multiple screens + final Size displaySize; + final double devicePixelRatio; + if (Platform.isWindows) { + // TODO remove once https://github.com/flutter/flutter/pull/164460 is available in stable + final display = await screenRetriever.getPrimaryDisplay(); + displaySize = display.size; + devicePixelRatio = 1.0; + } else { + final display = WidgetsBinding.instance.platformDispatcher.views.first.display; + displaySize = display.size; + devicePixelRatio = display.devicePixelRatio; + } + + late final Size physicalSize; + if (this is AndroidActions) { + if (windowInfo != null && windowInfo.packageName != 'de.jonasbark.swiftcontrol') { + // a trainer app is in foreground, so use the always assume landscape + physicalSize = Size(max(displaySize.width, displaySize.height), min(displaySize.width, displaySize.height)); + } else { + // display size is already in physical pixels + physicalSize = displaySize; + } + } else if (this is DesktopActions) { + // display size is in logical pixels, convert to physical pixels + // TODO on macOS the notch is included here, but it's not part of the usable screen area, so we should exclude it + physicalSize = displaySize / devicePixelRatio; + } else { + physicalSize = displaySize; + } + + final x = (keyPair.touchPosition.dx / 100.0) * physicalSize.width; + final y = (keyPair.touchPosition.dy / 100.0) * physicalSize.height; + + if (kDebugMode) { + print("Screen size: $physicalSize vs $displaySize => Touch at: $x, $y"); + } + return Offset(x, y); + } + return Offset.zero; } -} -class ActionHandler { - late BaseActions actions; + Future performAction( + ControllerButton button, { + required bool isKeyDown, + required bool isKeyUp, + ButtonTrigger trigger = ButtonTrigger.singleClick, + }) async { + if (supportedApp == null) { + return Error( + AppLocalizations.current.couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet(button.name.splitByUpperCase()), + ); + } + + final keyPair = supportedApp!.keymap.getKeyPair(button, trigger: trigger); + if (keyPair == null || keyPair.hasNoAction) { + return Error(AppLocalizations.current.noActionAssignedForButton(button.name.splitByUpperCase())); + } + + final guard = proGuard(button: button, trigger: trigger, keyPair: keyPair); + if (guard is! NotHandled) { + return guard; + } + + // Handle Headwind actions + if (keyPair.inGameAction == InGameAction.headwindSpeed || + keyPair.inGameAction == InGameAction.headwindHeartRateMode) { + final headwind = core.connection.accessories.where((h) => h.isConnected).firstOrNull; + if (headwind == null) { + return Error('No Headwind connected'); + } + + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); + return await headwind.handleKeypair(keyPair, isKeyDown: isKeyDown); + } - ActionHandler() { - if (kIsWeb) { - actions = StubActions(); - } else if (Platform.isAndroid) { - actions = AndroidActions(); - } else { - actions = DesktopActions(); + if (core.logic.hasNoConnectionMethod) { + if (GyroscopeSteeringButtons.values.contains(button)) { + return Ignored('Too many messages from gyroscope steering'); + } else { + return Error(AppLocalizations.current.pleaseSelectAConnectionMethodFirst); + } + } else if (!(await core.logic.isTrainerConnected())) { + return Error(AppLocalizations.current.noConnectionMethodIsConnectedOrActive); } + + final directConnectHandled = await _handleDirectConnect(keyPair, button, isKeyUp: isKeyUp, isKeyDown: isKeyDown); + if (directConnectHandled is NotHandled && directConnectHandled.message.isNotEmpty) { + core.connection.signalNotification(LogNotification(directConnectHandled.message)); + } else if (directConnectHandled is! NotHandled) { + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); + } + return directConnectHandled; } - Keymap? get keymap => actions.keymap; + Future _handleDirectConnect( + KeyPair keyPair, + ControllerButton button, { + required bool isKeyDown, + required bool isKeyUp, + }) async { + if (keyPair.inGameAction != null) { + final actions = []; + for (final connectedTrainer in core.logic.connectedTrainerConnections) { + final result = await connectedTrainer.sendAction( + keyPair, + isKeyDown: isKeyDown, + isKeyUp: isKeyUp, + ); + actions.add(result); + } + if (actions.isNotEmpty) { + return actions.first; + } + } + return NotHandled(''); + } - void init(Keymap? keymap) { - actions.init(keymap); + ActionResult proGuard({ + required ControllerButton button, + required ButtonTrigger trigger, + required KeyPair keyPair, + }) { + if (keyPair.isProAction && !IAPManager.instance.hasActiveSubscription) { + return Error('Pro subscription required for action: $keyPair'); + } + + if (!IAPManager.instance.hasActiveSubscription && supportedApp != null) { + final activeTriggers = ButtonTrigger.values.where((candidate) { + final candidatePair = supportedApp!.keymap.getKeyPair(button, trigger: candidate); + return candidatePair != null && !candidatePair.hasNoAction; + }).toList(); + + if (activeTriggers.length > 1 && trigger != activeTriggers.first) { + return Error('Pro subscription required for additional trigger types'); + } + } + + return NotHandled(''); } +} - void increaseGear() { - actions.increaseGear(); +class StubActions extends BaseActions { + StubActions({super.supportedModes = const []}); + + final List performedActions = []; + + @override + Future performAction( + ControllerButton button, { + bool isKeyDown = true, + bool isKeyUp = false, + ButtonTrigger trigger = ButtonTrigger.singleClick, + }) async { + performedActions.add(PerformedAction(button, isDown: isKeyDown, isUp: isKeyUp, trigger: trigger)); + return Future.value(Ignored('${button.name.splitByUpperCase()} clicked')); } - void decreaseGear() { - actions.decreaseGear(); + @override + void cleanup() { + performedActions.clear(); + } +} + +class PerformedAction { + final ControllerButton button; + final bool isDown; + final bool isUp; + final ButtonTrigger trigger; + + PerformedAction( + this.button, { + required this.isDown, + required this.isUp, + this.trigger = ButtonTrigger.singleClick, + }); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PerformedAction && + runtimeType == other.runtimeType && + button.copyWith(sourceDeviceId: null) == other.button.copyWith(sourceDeviceId: null) && + isDown == other.isDown && + isUp == other.isUp && + trigger == other.trigger; + + @override + int get hashCode => Object.hash(button, isDown, isUp, trigger); + + @override + String toString() { + return '{button: $button, isDown: $isDown, isUp: $isUp, trigger: $trigger}'; } } diff --git a/lib/utils/actions/desktop.dart b/lib/utils/actions/desktop.dart index cdafca0cb..b9afa8ba0 100644 --- a/lib/utils/actions/desktop.dart +++ b/lib/utils/actions/desktop.dart @@ -1,32 +1,193 @@ -import 'package:keypress_simulator/keypress_simulator.dart'; -import 'package:swift_control/utils/actions/base_actions.dart'; +import 'dart:convert'; +import 'dart:io'; -import '../keymap/keymap.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/apps/my_whoosh.dart'; +import 'package:bike_control/utils/keymap/apps/rouvy.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_screen_capture/flutter_screen_capture.dart'; +import 'package:image/image.dart' as image_lib; +import 'package:keypress_simulator/keypress_simulator.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; class DesktopActions extends BaseActions { - Keymap? _keymap; + DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]}); - @override - Keymap? get keymap => _keymap; + // Track keys that are currently held down in long press mode @override - void init(Keymap? keymap) { - _keymap = keymap; - } + Future performAction( + ControllerButton button, { + required bool isKeyDown, + required bool isKeyUp, + ButtonTrigger trigger = ButtonTrigger.singleClick, + }) async { + final superResult = await super.performAction(button, isKeyDown: isKeyDown, isKeyUp: isKeyUp, trigger: trigger); + if (superResult is! NotHandled) { + return superResult; + } + final keyPair = supportedApp!.keymap.getKeyPair(button, trigger: trigger)!; - @override - void decreaseGear() { - if (keymap == null) { - throw Exception('Keymap is not set'); + if (keyPair.screenshotPath?.trim().isNotEmpty == true) { + if (!isKeyDown) { + return Ignored('Screenshot capture only runs on key down'); + } + final screenshotDirectory = keyPair.screenshotPath!.trim(); + try { + final capturedArea = await ScreenCapture().captureEntireScreen(); + if (capturedArea == null) { + return Error('Failed to capture screenshot'); + } + + final directory = Directory(screenshotDirectory); + + final timestamp = DateTime.now().toIso8601String().split('.').first.replaceAll(':', '-'); + final fileName = 'BikeControl $timestamp.jpg'; + final separator = directory.path.endsWith(Platform.pathSeparator) ? '' : Platform.pathSeparator; + final screenshotFile = File('${directory.path}$separator$fileName'); + screenshotFile.writeAsBytes(image_lib.encodeJpg(capturedArea.toImage()), flush: true); + await IAPManager.instance.incrementCommandCount(); + return Success('Screenshot saved: ${screenshotFile.path}'); + } catch (e) { + return Error('Failed to save screenshot: $e'); + } + } + + if (keyPair.command?.trim().isNotEmpty == true) { + if (!isKeyDown) { + return Ignored('Shortcut launch only runs on key down'); + } + final commandPath = keyPair.command!.trim(); + if (defaultTargetPlatform == TargetPlatform.macOS) { + final launched = await launchUrlString('shortcuts://run-shortcut?name=$commandPath'); + if (!launched) { + return Error('Failed to launch shortcut: ${keyPair.command}'); + } + await IAPManager.instance.incrementCommandCount(); + return Success('Shortcut launched: ${keyPair.command}'); + } else if (defaultTargetPlatform == TargetPlatform.windows) { + try { + final process = await Process.start(commandPath, const [], runInShell: true); + process.stderr.transform(const Utf8Decoder()).transform(const LineSplitter()).listen((line) { + core.connection.signalNotification(LogNotification('Command error: $line')); + }); + } catch (e) { + return Error('Failed to run command: $e'); + } + await IAPManager.instance.incrementCommandCount(); + return Success('Command launched: $commandPath'); + } } - keyPressSimulator.simulateKeyDown(_keymap!.decrease); + + if (core.settings.getLocalEnabled()) { + // Handle media keys + if (keyPair.isSpecialKey) { + if (!IAPManager.instance.hasActiveSubscription) { + return Error('Pro subscription required for media control'); + } + try { + await keyPressSimulator.simulateMediaKey(keyPair.physicalKey!); + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); + return Success('Media key pressed: $keyPair'); + } catch (e) { + return Error('Failed to simulate media key: $e'); + } + } + + if (keyPair.physicalKey != null) { + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); + if (keyPair.logicalKey != null && navigatorKey.currentContext?.mounted == true) { + final label = keyPair.logicalKey!.keyLabel; + final keyName = label.isNotEmpty ? label : keyPair.logicalKey!.debugName ?? 'Key'; + buildToast( + location: ToastLocation.bottomLeft, + title: + '${isKeyDown + ? "↓" + : isKeyUp + ? "↑" + : "•"} $keyName', + ); + } + + final trainerApp = core.settings.getTrainerApp(); + // only those two seem to support targeting specific PIDs, for the rest we just send the key events globally + final packageName = (trainerApp is Rouvy || trainerApp is MyWhoosh) ? trainerApp!.packageName : null; + + if (isKeyDown && isKeyUp) { + await keyPressSimulator.simulateKeyDown( + keyPair.physicalKey, + keyPair.modifiers, + packageName, + ); + await keyPressSimulator.simulateKeyUp( + keyPair.physicalKey, + keyPair.modifiers, + packageName, + ); + + return Success('Key clicked: $keyPair'); + } else if (isKeyDown) { + await keyPressSimulator.simulateKeyDown( + keyPair.physicalKey, + keyPair.modifiers, + core.settings.getTrainerApp()?.name, + ); + return Success('Key pressed: $keyPair'); + } else { + await keyPressSimulator.simulateKeyUp( + keyPair.physicalKey, + keyPair.modifiers, + core.settings.getTrainerApp()?.name, + ); + return Success('Key released: $keyPair'); + } + } else { + final point = await resolveTouchPosition(keyPair: keyPair, windowInfo: null); + if (point != Offset.zero) { + // Increment command count after successful execution + await IAPManager.instance.incrementCommandCount(); + if (isKeyDown && isKeyUp) { + await keyPressSimulator.simulateMouseClickDown(point); + // slight move to register clicks on some apps, see issue #116 + await keyPressSimulator.simulateMouseClickUp(point); + return Success('Mouse clicked at: ${point.dx.toInt()} ${point.dy.toInt()}'); + } else if (isKeyDown) { + await keyPressSimulator.simulateMouseClickDown(point); + return Success('Mouse down at: ${point.dx.toInt()} ${point.dy.toInt()}'); + } else { + await keyPressSimulator.simulateMouseClickUp(point); + return Success('Mouse up at: ${point.dx.toInt()} ${point.dy.toInt()}'); + } + } + } + } + return NotHandled('Action not handled for button: $button'); } - @override - void increaseGear() { - if (keymap == null) { - throw Exception('Keymap is not set'); + // Release all held keys (useful for cleanup) + Future releaseAllHeldKeys(List list) async { + for (final action in list) { + final keyPair = supportedApp?.keymap.getKeyPair(action); + final longPressKeyPair = supportedApp?.keymap.getKeyPair(action, trigger: ButtonTrigger.longPress); + if (longPressKeyPair?.physicalKey != null) { + await keyPressSimulator.simulateKeyUp(longPressKeyPair!.physicalKey); + } else if (keyPair?.physicalKey != null) { + await keyPressSimulator.simulateKeyUp(keyPair!.physicalKey); + } } - keyPressSimulator.simulateKeyDown(_keymap!.increase); } + + @override + void cleanup() {} } diff --git a/lib/utils/actions/remote.dart b/lib/utils/actions/remote.dart new file mode 100644 index 000000000..d5ebb5290 --- /dev/null +++ b/lib/utils/actions/remote.dart @@ -0,0 +1,81 @@ +import 'dart:ui'; + +import 'package:accessibility/accessibility.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:flutter/foundation.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../../gen/l10n.dart'; +import '../../widgets/keymap_explanation.dart'; +import '../iap/iap_manager.dart'; + +class RemoteActions extends BaseActions { + RemoteActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.keyboard]}); + + @override + Future performAction( + ControllerButton button, { + required bool isKeyDown, + required bool isKeyUp, + ButtonTrigger trigger = ButtonTrigger.singleClick, + }) async { + final keyPair = supportedApp!.keymap.getKeyPair(button, trigger: trigger); + + if (keyPair == null || keyPair.hasNoAction) { + return Error(AppLocalizations.current.noActionAssignedForButton(button.name.splitByUpperCase())); + } + + final guard = proGuard(button: button, trigger: trigger, keyPair: keyPair); + if (guard is! NotHandled) { + return guard; + } + + if (defaultTargetPlatform == TargetPlatform.iOS && keyPair.command?.trim().isNotEmpty == true) { + if (!isKeyDown) { + return Ignored('Shortcut launch only runs on key down'); + } + final shortcutName = Uri.encodeQueryComponent(keyPair.command!.trim()); + final launched = await launchUrlString('shortcuts://run-shortcut?name=$shortcutName'); + if (!launched) { + return Error('Failed to launch shortcut: ${keyPair.command}'); + } + await IAPManager.instance.incrementCommandCount(); + return Success('Shortcut launched: ${keyPair.command}'); + } + + final superResult = await super.performAction(button, isKeyDown: isKeyDown, isKeyUp: isKeyUp, trigger: trigger); + if (superResult is! NotHandled) { + return superResult; + } + + if (!core.remotePairing.isConnected.value && !core.remoteKeyboardPairing.isConnected.value) { + return Error('Not connected to a ${core.settings.getLastTarget()?.name ?? 'remote'} device'); + } + + if (core.remotePairing.isConnected.value) { + if (keyPair.touchPosition == Offset.zero) { + return Error('Key $keyPair does not have a valid touch position'); + } + return core.remotePairing.sendAction(keyPair, isKeyDown: isKeyDown, isKeyUp: isKeyUp); + } else if (core.remoteKeyboardPairing.isConnected.value) { + if (keyPair.physicalKey == null) { + return Error('Key $keyPair does not have a valid physical key for keyboard actions'); + } + return core.remoteKeyboardPairing.sendAction(keyPair, isKeyDown: isKeyDown, isKeyUp: isKeyUp); + } else { + return Error('Not connected to a ${core.settings.getLastTarget()?.name ?? 'remote'} device'); + } + } + + @override + Future resolveTouchPosition({required KeyPair keyPair, required WindowEvent? windowInfo}) async { + // for remote actions we use the relative position only + return keyPair.touchPosition; + } + + @override + void cleanup() {} +} diff --git a/lib/utils/ble.dart b/lib/utils/ble.dart deleted file mode 100644 index 5344a56b9..000000000 --- a/lib/utils/ble.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; - -class BleUuid { - static final ZWIFT_CUSTOM_SERVICE_UUID = Guid("00000001-19CA-4651-86E5-FA29DCDD09D1"); - static final ZWIFT_RIDE_CUSTOM_SERVICE_UUID = Guid("0000fc82-0000-1000-8000-00805f9b34fb"); - static final ZWIFT_ASYNC_CHARACTERISTIC_UUID = Guid("00000002-19CA-4651-86E5-FA29DCDD09D1"); - static final ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = Guid("00000003-19CA-4651-86E5-FA29DCDD09D1"); - static final ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = Guid("00000004-19CA-4651-86E5-FA29DCDD09D1"); -} - -class Constants { - static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc - - // Zwift Play = RC1 - static const RC1_LEFT_SIDE = 0x03; - static const RC1_RIGHT_SIDE = 0x02; - - // Zwift Click = BC1 - static const BC1 = 0x09; - - static final RIDE_ON = Uint8List.fromList([0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e]); - - // these don't actually seem to matter, its just the header has to be 7 bytes RIDEON + 2 - static final REQUEST_START = Uint8List.fromList([0, 9]); //byteArrayOf(1, 2) - static final RESPONSE_START_CLICK = Uint8List.fromList([1, 3]); // from device - static final RESPONSE_START_PLAY = Uint8List.fromList([1, 4]); // from device - - // Message types received from device - static const CONTROLLER_NOTIFICATION_MESSAGE_TYPE = 07; - static const EMPTY_MESSAGE_TYPE = 21; - static const BATTERY_LEVEL_TYPE = 25; - - // not figured out the protobuf type this really is, the content is just two varints. - static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55; - static const int PLAY_NOTIFICATION_MESSAGE_TYPE = 7; - static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35; - - // see this if connected to Core then Zwift connects to it. just one byte - static const DISCONNECT_MESSAGE_TYPE = 0xFE; -} - -enum DeviceType { - click, - ride, - playLeft, - playRight; - - @override - String toString() { - return super.toString().split('.').last; - } - - // add constructor - static DeviceType? fromManufacturerData(int data) { - switch (data) { - case Constants.BC1: - return DeviceType.click; - case Constants.RC1_LEFT_SIDE: - return DeviceType.playLeft; - case Constants.RC1_RIGHT_SIDE: - return DeviceType.playRight; - } - return null; - } -} diff --git a/lib/utils/connection.dart b/lib/utils/connection.dart deleted file mode 100644 index 0836cbbf1..000000000 --- a/lib/utils/connection.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:dartx/dartx.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:swift_control/main.dart'; -import 'package:swift_control/utils/devices/ble_device.dart'; -import 'package:swift_control/utils/requirements/android.dart'; - -import 'messages/notification.dart'; - -class Connection { - final devices = []; - var androidNotificationsSetup = false; - - final Map> _streamSubscriptions = {}; - final StreamController _actionStreams = StreamController.broadcast(); - Stream get actionStream => _actionStreams.stream; - - final Map> _connectionSubscriptions = {}; - final StreamController _connectionStreams = StreamController.broadcast(); - Stream get connectionStream => _connectionStreams.stream; - - final ValueNotifier hasDevices = ValueNotifier(false); - late StreamSubscription> _scanResultsSubscription; - - void startScanning() { - _scanResultsSubscription = FlutterBluePlus.scanResults.listen( - (results) { - final scanResults = results.mapNotNull(BleDevice.fromScanResult).toList(); - _addDevices(scanResults); - }, - onError: (e) { - _actionStreams.add(LogNotification(e.toString())); - }, - ); - } - - void _addDevices(List dev) { - final newDevices = dev.where((device) => !devices.contains(device)).toList(); - devices.addAll(newDevices); - - for (final device in newDevices) { - _connect(device).then((_) {}); - } - hasDevices.value = devices.isNotEmpty; - if (devices.isNotEmpty && !androidNotificationsSetup && !kIsWeb && Platform.isAndroid) { - androidNotificationsSetup = true; - actionHandler.init(null); - NotificationRequirement.setup().catchError((e) { - _actionStreams.add(LogNotification(e.toString())); - }); - } - } - - Future _connect(BleDevice bleDevice) async { - try { - await bleDevice.connect(); - - final actionSubscription = bleDevice.actionStream.listen((data) { - _actionStreams.add(data); - }); - _streamSubscriptions[bleDevice] = actionSubscription; - - final connectionStateSubscription = bleDevice.device.connectionState.listen((state) async { - _connectionStreams.add(bleDevice); - }); - _connectionSubscriptions[bleDevice] = connectionStateSubscription; - } catch (e, backtrace) { - if (e is FlutterBluePlusException && e.code == FbpErrorCode.connectionCanceled.index) { - // ignore connections canceled by the user - } else { - _actionStreams.add(LogNotification(e.toString())); - if (kDebugMode) { - print(e); - print("backtrace: $backtrace"); - } - } - } - } - - void reset() { - FlutterBluePlus.stopScan(); - for (var device in devices) { - device.device.disconnect(); - } - devices.clear(); - } -} diff --git a/lib/utils/core.dart b/lib/utils/core.dart new file mode 100644 index 000000000..ce25e6d37 --- /dev/null +++ b/lib/utils/core.dart @@ -0,0 +1,359 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_ble_emulator.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/obc_mdns_emulator.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; +import 'package:bike_control/bluetooth/devices/trainer_connection.dart'; +import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_emulator.dart'; +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/bluetooth/remote_keyboard_pairing.dart'; +import 'package:bike_control/bluetooth/remote_pairing.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/actions/remote.dart'; +import 'package:bike_control/utils/keymap/apps/my_whoosh.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/requirements/android.dart'; +import 'package:bike_control/utils/settings/settings.dart'; +import 'package:dartx/dartx.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:prop/prop.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../bluetooth/connection.dart'; +import '../bluetooth/devices/mywhoosh/link.dart'; +import 'keymap/apps/rouvy.dart'; +import 'media_key_handler.dart'; +import 'requirements/multi.dart'; +import 'requirements/platform.dart'; + +final core = Core(); + +class Core { + late BaseActions actionHandler; + final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + final settings = Settings(); + final connection = Connection(); + + late final supabase = Supabase.instance.client; + late final whooshLink = WhooshLink(); + late final zwiftEmulator = ZwiftEmulator(); + late final zwiftMdnsEmulator = FtmsMdnsEmulator(); + late final obpMdnsEmulator = OpenBikeControlMdnsEmulator(); + late final obpBluetoothEmulator = OpenBikeControlBluetoothEmulator(); + late final remotePairing = RemotePairing(); + late final remoteKeyboardPairing = RemoteKeyboardPairing(); + + late final mediaKeyHandler = MediaKeyHandler(); + late final logic = CoreLogic(); + late final permissions = Permissions(); +} + +class Permissions { + Future> getScanRequirements() async { + final List list; + if (screenshotMode) { + list = []; + } else if (kIsWeb) { + final availability = await UniversalBle.getBluetoothAvailabilityState(); + if (availability == AvailabilityState.unsupported) { + list = [UnsupportedPlatform()]; + } else { + list = [BluetoothTurnedOn()]; + } + } else if (Platform.isMacOS) { + list = [ + BluetoothTurnedOn(), + if (core.settings.getShowOnboarding()) NotificationRequirement(), + ]; + } else if (Platform.isIOS) { + list = [ + BluetoothTurnedOn(), + NotificationRequirement(), + ]; + } else if (Platform.isWindows) { + list = [ + BluetoothTurnedOn(), + NotificationRequirement(), + ]; + } else if (Platform.isAndroid) { + final deviceInfoPlugin = DeviceInfoPlugin(); + final deviceInfo = await deviceInfoPlugin.androidInfo; + list = [ + if (deviceInfo.version.sdkInt <= 30) + LocationRequirement() + else ...[ + BluetoothScanRequirement(), + BluetoothConnectRequirement(), + ], + BluetoothTurnedOn(), + NotificationRequirement(), + ]; + } else { + list = [UnsupportedPlatform()]; + } + + await Future.wait(list.map((e) => e.getStatus())); + return list.where((e) => !e.status).toList(); + } + + List getLocalControlRequirements() { + return [Platform.isAndroid ? AccessibilityRequirement() : KeyboardRequirement()]; + } + + List getRemoteControlRequirements() { + return [ + BluetoothTurnedOn(), + if (Platform.isAndroid) ...[ + BluetoothScanRequirement(), + BluetoothConnectRequirement(), + BluetoothAdvertiseRequirement(), + ], + ]; + } +} + +extension Granted on List { + Future get allGranted async { + await Future.wait(map((e) => e.getStatus())); + return where((element) => !element.status).isEmpty; + } +} + +class CoreLogic { + bool get showLocalControl { + return core.settings.getLastTarget()?.connectionType == ConnectionType.local && + (Platform.isMacOS || Platform.isWindows || Platform.isAndroid); + } + + bool get canRunAndroidService { + return Platform.isAndroid && core.actionHandler is AndroidActions; + } + + Future isAndroidServiceRunning() async { + if (canRunAndroidService) { + return (core.actionHandler as AndroidActions).accessibilityHandler.isRunning(); + } + return false; + } + + bool get isZwiftBleEnabled { + return core.settings.getZwiftBleEmulatorEnabled() && showZwiftBleEmulator; + } + + bool get isZwiftMdnsEnabled { + return core.settings.getZwiftMdnsEmulatorEnabled() && showZwiftMsdnEmulator; + } + + bool get isObpBleEnabled { + return core.settings.getObpBleEnabled() && showObpBluetoothEmulator; + } + + bool get isObpMdnsEnabled { + return core.settings.getObpMdnsEnabled() && showObpMdnsEmulator; + } + + bool get isMyWhooshLinkEnabled { + return core.settings.getMyWhooshLinkEnabled() && showMyWhooshLink; + } + + bool get showZwiftBleEmulator { + return core.settings.getTrainerApp()?.supportsZwiftEmulation == true && + core.settings.getLastTarget() != Target.thisDevice; + } + + bool get showZwiftMsdnEmulator { + return core.settings.getTrainerApp()?.supportsZwiftEmulation == true && core.settings.getTrainerApp() is! Rouvy; + } + + bool get showObpMdnsEmulator { + return core.settings.getTrainerApp()?.supportsOpenBikeProtocol.containsAny([ + OpenBikeProtocolSupport.network, + OpenBikeProtocolSupport.dircon, + ]) == + true; + } + + bool get showObpBluetoothEmulator { + return (core.settings.getTrainerApp()?.supportsOpenBikeProtocol.contains(OpenBikeProtocolSupport.ble) == true) && + core.settings.getLastTarget() != Target.thisDevice; + } + + bool get isRemoteControlEnabled { + return core.settings.getRemoteControlEnabled() && showRemote; + } + + bool get isRemoteKeyboardControlEnabled { + return core.settings.getRemoteKeyboardControlEnabled() && showRemote; + } + + bool get showMyWhooshLink => + core.settings.getTrainerApp() is MyWhoosh && + core.settings.getLastTarget() != null && + core.whooshLink.isCompatible(core.settings.getLastTarget()!); + + bool get showRemote => core.settings.getLastTarget() != Target.thisDevice && core.actionHandler is RemoteActions; + + bool get showForegroundMessage => + core.actionHandler is RemoteActions && !kIsWeb && Platform.isIOS && core.remotePairing.isConnected.value; + + AppInfo? get obpConnectedApp => + core.obpMdnsEmulator.connectedApp.value ?? core.obpBluetoothEmulator.connectedApp.value; + + bool get emulatorEnabled => + screenshotMode || + (core.settings.getMyWhooshLinkEnabled() && showMyWhooshLink) || + (core.settings.getZwiftBleEmulatorEnabled() && showZwiftBleEmulator) || + (core.settings.getZwiftMdnsEmulatorEnabled() && showZwiftMsdnEmulator) || + (core.settings.getObpBleEnabled() && showObpBluetoothEmulator) || + (core.settings.getObpMdnsEnabled() && showObpMdnsEmulator); + + bool get showObpActions => + (core.settings.getObpBleEnabled() && showObpBluetoothEmulator) || + (core.settings.getObpMdnsEnabled() && showObpMdnsEmulator); + + bool get ignoreWarnings => + core.settings.getTrainerApp()?.supportsZwiftEmulation == true || + core.settings.getTrainerApp()?.supportsOpenBikeProtocol.isNotEmpty == true; + + bool get showLocalRemoteOptions => + core.actionHandler.supportedModes.isNotEmpty && + (showLocalControl || isRemoteControlEnabled || isRemoteKeyboardControlEnabled); + + bool get hasNoConnectionMethod => + !screenshotMode && + !isZwiftBleEnabled && + !isZwiftMdnsEnabled && + !showObpActions && + !(core.settings.getMyWhooshLinkEnabled() && showMyWhooshLink) && + !showLocalRemoteOptions; + + bool get hasRecommendedConnectionMethods => + showObpBluetoothEmulator || + showObpMdnsEmulator || + showLocalControl || + showZwiftBleEmulator || + showZwiftMsdnEmulator || + showMyWhooshLink; + + List get connectedTrainerConnections => [ + if (isMyWhooshLinkEnabled) core.whooshLink, + if (isObpMdnsEnabled) core.obpMdnsEmulator, + if (isObpBleEnabled) core.obpBluetoothEmulator, + if (isZwiftBleEnabled) core.zwiftEmulator, + if (isZwiftMdnsEnabled) core.zwiftMdnsEmulator, + if (isRemoteControlEnabled) core.remotePairing, + if (isRemoteKeyboardControlEnabled) core.remoteKeyboardPairing, + ].filter((e) => e.isConnected.value).toList(); + + List get enabledTrainerConnections => [ + if (isMyWhooshLinkEnabled) core.whooshLink, + if (isObpMdnsEnabled) core.obpMdnsEmulator, + if (isObpBleEnabled) core.obpBluetoothEmulator, + if (isZwiftBleEnabled) core.zwiftEmulator, + if (isZwiftMdnsEnabled) core.zwiftMdnsEmulator, + if (isRemoteControlEnabled) core.remotePairing, + if (isRemoteKeyboardControlEnabled) core.remoteKeyboardPairing, + ]; + + List get trainerConnections => [ + if (showMyWhooshLink) core.whooshLink, + if (showObpMdnsEmulator) core.obpMdnsEmulator, + if (showObpBluetoothEmulator) core.obpBluetoothEmulator, + if (showZwiftBleEmulator) core.zwiftEmulator, + if (showZwiftMsdnEmulator) core.zwiftMdnsEmulator, + if (showRemote) core.remotePairing, + if (showRemote) core.remoteKeyboardPairing, + ]; + + Future isTrainerConnected() async { + if (screenshotMode) { + return true; + } else if (showLocalControl && core.settings.getLocalEnabled()) { + if (canRunAndroidService) { + return isAndroidServiceRunning(); + } else { + return true; + } + } else if (connectedTrainerConnections.isNotEmpty) { + return true; + } else { + return false; + } + } + + void startEnabledConnectionMethod() async { + if (screenshotMode) { + return; + } + if (isZwiftBleEnabled && + await core.permissions.getRemoteControlRequirements().allGranted && + !core.zwiftEmulator.isStarted.value) { + core.zwiftEmulator.startAdvertising(() {}).catchError((e, s) { + recordError(e, s, context: 'Zwift BLE Emulator'); + core.settings.setZwiftBleEmulatorEnabled(false); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Zwift mDNS Emulator: $e'), + ); + }); + } + if (isZwiftMdnsEnabled && !core.zwiftMdnsEmulator.isStarted.value) { + core.zwiftMdnsEmulator.startServer().catchError((e, s) { + recordError(e, s, context: 'Zwift mDNS Emulator'); + core.settings.setZwiftMdnsEmulatorEnabled(false); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Zwift mDNS Emulator: $e'), + ); + }); + } + if (isObpMdnsEnabled && !core.obpMdnsEmulator.isStarted.value) { + core.obpMdnsEmulator.startServer().catchError((e, s) { + recordError(e, s, context: 'OBP mDNS Emulator'); + core.settings.setObpMdnsEnabled(false); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start OpenBikeControl mDNS Emulator: $e'), + ); + }); + } + if (isObpBleEnabled && + await core.permissions.getRemoteControlRequirements().allGranted && + !core.obpBluetoothEmulator.isStarted.value) { + core.obpBluetoothEmulator.startServer().catchError((e, s) { + recordError(e, s, context: 'OBP BLE Emulator'); + core.settings.setObpBleEnabled(false); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start OpenBikeControl BLE Emulator: $e'), + ); + }); + } + + if (isMyWhooshLinkEnabled && !core.whooshLink.isStarted.value) { + core.connection.startMyWhooshServer(); + } + + if (isRemoteControlEnabled && !core.remotePairing.isStarted.value) { + core.remotePairing.startAdvertising().catchError((e, s) { + recordError(e, s, context: 'Remote Pairing'); + core.settings.setRemoteControlEnabled(false); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Remote Control pairing: $e'), + ); + }); + } + + if (isRemoteKeyboardControlEnabled && !core.remoteKeyboardPairing.isStarted.value) { + core.remoteKeyboardPairing.startAdvertising().catchError((e, s) { + recordError(e, s, context: 'Remote Keyboard Pairing'); + core.settings.setRemoteKeyboardControlEnabled(false); + core.connection.signalNotification( + AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Failed to start Remote Keyboard Control pairing: $e'), + ); + }); + } + } +} diff --git a/lib/utils/crypto/encryption_utils.dart b/lib/utils/crypto/encryption_utils.dart deleted file mode 100644 index afde94bc1..000000000 --- a/lib/utils/crypto/encryption_utils.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:typed_data'; - -import 'package:pointycastle/digests/sha256.dart'; -import 'package:pointycastle/ecc/api.dart'; -import 'package:pointycastle/key_derivators/api.dart'; -import 'package:pointycastle/key_derivators/hkdf.dart'; - -class EncryptionUtils { - static const int KEY_LENGTH = 32; - static const int HKDF_LENGTH = 36; - static const int MAC_LENGTH = 4; - - static Uint8List publicKeyToByteArray(ECPublicKey ecPublicKey) { - final affineX = ecPublicKey.Q!.x!.toBigInteger(); - final affineY = ecPublicKey.Q!.y!.toBigInteger(); - final affineXUnsigned = _bigIntToUnsignedBytes(affineX!); - final affineYUnsigned = _bigIntToUnsignedBytes(affineY!); - return Uint8List.fromList([...affineXUnsigned, ...affineYUnsigned]); - } - - static ECPublicKey generatePublicKey(Uint8List publicKeyBytes, ECDomainParameters params) { - final bitLength = params.n.bitLength ~/ 8; - final xBytes = publicKeyBytes.sublist(0, bitLength); - final yBytes = publicKeyBytes.sublist(bitLength, 2 * bitLength); - final x = BigInt.parse(xBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(), radix: 16); - final y = BigInt.parse(yBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(), radix: 16); - final ecPoint = params.curve.createPoint(x, y); - return ECPublicKey(ecPoint, params); - } - - static Uint8List generateSharedSecretBytes(ECPrivateKey privateKey, ECPublicKey serverPublicKey) { - final ecdh = ECDHBasicAgreement(); - ecdh.init(privateKey); - final sharedSecret = ecdh.calculateAgreement(serverPublicKey); - return _bigIntToUnsignedBytes(sharedSecret); - } - - static Uint8List generateHKDFBytes(Uint8List secretKey, Uint8List salt) { - final hkdf = HKDFKeyDerivator(SHA256Digest()); - final params = HkdfParameters(secretKey, HKDF_LENGTH, salt); - hkdf.init(params); - final result = Uint8List(HKDF_LENGTH); - hkdf.deriveKey(null, 0, result, 0); - return result; - } - - static Uint8List _bigIntToUnsignedBytes(BigInt number) { - final bytes = number.toRadixString(16).padLeft((number.bitLength + 7) >> 3 << 1, '0'); - return Uint8List.fromList( - List.generate(bytes.length ~/ 2, (i) => int.parse(bytes.substring(i * 2, i * 2 + 2), radix: 16)), - ); - } -} diff --git a/lib/utils/crypto/local_key_provider.dart b/lib/utils/crypto/local_key_provider.dart deleted file mode 100644 index e60f7560b..000000000 --- a/lib/utils/crypto/local_key_provider.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:pointycastle/export.dart'; -import 'package:swift_control/utils/crypto/encryption_utils.dart'; - -class LocalKeyProvider { - late AsymmetricKeyPair pair; - - LocalKeyProvider() { - generateKeyPair(); - } - - Uint8List getPublicKeyBytes() { - return EncryptionUtils.publicKeyToByteArray(pair.publicKey as ECPublicKey); - } - - ECPublicKey getPublicKey() { - return pair.publicKey as ECPublicKey; - } - - ECPrivateKey getPrivateKey() { - return pair.privateKey as ECPrivateKey; - } - - void generateKeyPair() { - final keyParams = ECKeyGeneratorParameters(ECCurve_secp256r1()); - final secureRandom = FortunaRandom(); - final random = Random.secure(); - final seeds = List.generate(32, (_) => random.nextInt(256)); - secureRandom.seed(KeyParameter(Uint8List.fromList(seeds))); - - final keyGenerator = ECKeyGenerator(); - keyGenerator.init(ParametersWithRandom(keyParams, secureRandom)); - pair = keyGenerator.generateKeyPair(); - } -} diff --git a/lib/utils/crypto/zap_crypto.dart b/lib/utils/crypto/zap_crypto.dart deleted file mode 100644 index 45135e227..000000000 --- a/lib/utils/crypto/zap_crypto.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'dart:typed_data'; - -import 'package:pointycastle/export.dart'; -import 'package:swift_control/utils/crypto/encryption_utils.dart'; - -import 'local_key_provider.dart'; - -class ZapCrypto { - final LocalKeyProvider localKeyProvider; - final AESEngine aesEngine = AESEngine(); - - Uint8List? encryptionKeyBytes; - Uint8List? ivBytes; - int counter = 0; - - ZapCrypto(this.localKeyProvider); - - void initialise(Uint8List devicePublicKeyBytes) { - final hkdfBytes = generateHmacKeyDerivationFunctionBytes(devicePublicKeyBytes); - encryptionKeyBytes = hkdfBytes.sublist(0, EncryptionUtils.KEY_LENGTH); - ivBytes = hkdfBytes.sublist(32, EncryptionUtils.HKDF_LENGTH); - } - - Uint8List encrypt(Uint8List data) { - assert(encryptionKeyBytes != null && ivBytes != null, "Not initialised"); - - final counterValue = counter; - counter++; - - final nonceBytes = createNonce(ivBytes!, counterValue); - final encrypted = encryptDecrypt(true, nonceBytes, data); - return Uint8List.fromList(createCounterBytes(counterValue) + encrypted); - } - - Uint8List decrypt(Uint8List counterArray, Uint8List payload) { - assert(encryptionKeyBytes != null && ivBytes != null, "Not initialised"); - - final nonceBytes = Uint8List.fromList(ivBytes! + counterArray); - return encryptDecrypt(false, nonceBytes, payload); - } - - Uint8List encryptDecrypt(bool encrypt, Uint8List nonceBytes, Uint8List data) { - final aeadParameters = AEADParameters( - KeyParameter(encryptionKeyBytes!), - EncryptionUtils.MAC_LENGTH * 8, - nonceBytes, - Uint8List(0), - ); - final ccmBlockCipher = CCMBlockCipher(aesEngine); - ccmBlockCipher.init(encrypt, aeadParameters); - final processed = Uint8List(ccmBlockCipher.getOutputSize(data.length)); - ccmBlockCipher.processBytes(data, 0, data.length, processed, 0); - ccmBlockCipher.doFinal(processed, 0); - return processed; - } - - Uint8List generateHmacKeyDerivationFunctionBytes(Uint8List devicePublicKeyBytes) { - final serverPublicKey = EncryptionUtils.generatePublicKey( - devicePublicKeyBytes, - localKeyProvider.getPublicKey().parameters!, - ); - final sharedSecretBytes = EncryptionUtils.generateSharedSecretBytes( - localKeyProvider.getPrivateKey(), - serverPublicKey, - ); - final salt = Uint8List.fromList( - EncryptionUtils.publicKeyToByteArray(serverPublicKey) + localKeyProvider.getPublicKeyBytes(), - ); - return EncryptionUtils.generateHKDFBytes(sharedSecretBytes, salt); - } - - Uint8List createNonce(Uint8List iv, int messageCounter) { - return Uint8List.fromList(iv + createCounterBytes(messageCounter)); - } - - Uint8List createCounterBytes(int messageCounter) { - final buffer = ByteData(4); - buffer.setInt32(0, messageCounter, Endian.big); - return buffer.buffer.asUint8List(); - } -} diff --git a/lib/utils/devices/ble_device.dart b/lib/utils/devices/ble_device.dart deleted file mode 100644 index 9fa8bf41a..000000000 --- a/lib/utils/devices/ble_device.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:swift_control/utils/ble.dart'; -import 'package:swift_control/utils/crypto/local_key_provider.dart'; -import 'package:swift_control/utils/devices/zwift_click.dart'; -import 'package:swift_control/utils/devices/zwift_play.dart'; -import 'package:swift_control/utils/devices/zwift_ride.dart'; - -import '../crypto/zap_crypto.dart'; -import '../messages/notification.dart'; - -abstract class BleDevice { - final ScanResult scanResult; - final zapEncryption = ZapCrypto(LocalKeyProvider()); - - bool supportsEncryption = true; - - BleDevice(this.scanResult); - - static BleDevice? fromScanResult(ScanResult scanResult) { - if (scanResult.device.platformName == 'Zwift Ride') { - return ZwiftRide(scanResult); - } - final manufacturerData = scanResult.advertisementData.manufacturerData; - final data = manufacturerData[Constants.ZWIFT_MANUFACTURER_ID]; - if (data == null || data.isEmpty) { - return null; - } - final type = DeviceType.fromManufacturerData(data.first); - return switch (type) { - DeviceType.click => ZwiftClick(scanResult), - DeviceType.playRight => ZwiftPlay(scanResult), - DeviceType.playLeft => ZwiftPlay(scanResult), - _ => null, - }; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is BleDevice && runtimeType == other.runtimeType && scanResult == other.scanResult; - - @override - int get hashCode => scanResult.hashCode; - - @override - String toString() { - return runtimeType.toString(); - } - - BluetoothDevice get device => scanResult.device; - final StreamController actionStreamInternal = StreamController.broadcast(); - Stream get actionStream => actionStreamInternal.stream; - - Future connect() async { - await device.connect(autoConnect: false).timeout(const Duration(seconds: 3)); - - var filteredStateStream = device.connectionState.where((s) => s == BluetoothConnectionState.connected); - - // Start listening now, before invokeMethod, to ensure we don't miss the response - Future futureState = filteredStateStream.first; - - // wait for connection - await futureState.timeout( - const Duration(seconds: 10), - onTimeout: () { - throw TimeoutException('Failed to connect in time.'); - }, - ); - - if (!kIsWeb && Platform.isAndroid) { - await device.requestMtu(256); - } - final services = await device.discoverServices(); - - if (device.isConnected) { - await handleServices(services); - } - } - - Future handleServices(List services); -} diff --git a/lib/utils/devices/zwift_click.dart b/lib/utils/devices/zwift_click.dart deleted file mode 100644 index cb4958af2..000000000 --- a/lib/utils/devices/zwift_click.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'dart:io'; - -import 'package:dartx/dartx.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:swift_control/main.dart'; -import 'package:swift_control/utils/devices/ble_device.dart'; -import 'package:swift_control/utils/messages/notification.dart'; - -import '../ble.dart'; -import '../crypto/encryption_utils.dart'; -import '../messages/click_notification.dart'; - -class ZwiftClick extends BleDevice { - ZwiftClick(super.scanResult); - - List get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK; - Guid get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID; - - @override - Future handleServices(List services) async { - final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId); - - if (customService == null) { - throw Exception('Custom service not found'); - } - - final asyncCharacteristic = customService.characteristics.firstOrNullWhere( - (characteristic) => characteristic.uuid == BleUuid.ZWIFT_ASYNC_CHARACTERISTIC_UUID, - ); - final syncTxCharacteristic = customService.characteristics.firstOrNullWhere( - (characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID, - ); - final syncRxCharacteristic = customService.characteristics.firstOrNullWhere( - (characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID, - ); - - if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) { - throw Exception('Characteristics not found'); - } - - if (!asyncCharacteristic.isNotifying) { - await asyncCharacteristic.setNotifyValue(true); - } - final asyncSubscription = asyncCharacteristic.lastValueStream.listen((onData) { - _processCharacteristic('async', Uint8List.fromList(onData)); - }); - device.cancelWhenDisconnected(asyncSubscription); - - if (!syncTxCharacteristic.isNotifying) { - await syncTxCharacteristic.setNotifyValue(true, forceIndications: !kIsWeb && Platform.isAndroid); - } - final syncSubscription = syncTxCharacteristic.lastValueStream.listen((onData) { - _processCharacteristic('sync', Uint8List.fromList(onData)); - }); - device.cancelWhenDisconnected(syncSubscription); - - await _setupHandshake(syncRxCharacteristic); - } - - Future _setupHandshake(BluetoothCharacteristic syncRxCharacteristic) async { - if (supportsEncryption) { - await syncRxCharacteristic.write([ - ...Constants.RIDE_ON, - ...Constants.REQUEST_START, - ...zapEncryption.localKeyProvider.getPublicKeyBytes(), - ], withoutResponse: true); - } else { - await syncRxCharacteristic.write(Constants.RIDE_ON, withoutResponse: true); - } - } - - void _processCharacteristic(String tag, Uint8List bytes) { - if (kDebugMode && false) { - print('Received $tag: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}'); - print('Received $tag: ${String.fromCharCodes(bytes)}'); - } - - if (bytes.isEmpty) { - return; - } - - try { - if (bytes.startsWith(startCommand)) { - _processDevicePublicKeyResponse(bytes); - } else if (bytes.startsWith(Constants.RIDE_ON)) { - //print("Empty RideOn response - unencrypted mode"); - } else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) { - _processData(bytes); - } else if (bytes[0] == Constants.DISCONNECT_MESSAGE_TYPE) { - //print("Disconnect message"); - } else { - //print("Unprocessed - Data Type: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}"); - } - } catch (e, stackTrace) { - print("Error processing data: $e"); - print("Stack Trace: $stackTrace"); - actionStreamInternal.add(LogNotification(e.toString())); - } - } - - ClickNotification? _lastClickNotification; - - void _processData(Uint8List bytes) { - int type; - Uint8List message; - - if (supportsEncryption) { - final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4 - final payload = bytes.sublist(4); - - final data = zapEncryption.decrypt(counter, payload); - type = data[0]; - message = data.sublist(1); - } else { - type = bytes[0]; - message = bytes.sublist(1); - } - - switch (type) { - case Constants.EMPTY_MESSAGE_TYPE: - //print("Empty Message"); // expected when nothing happening - break; - case Constants.BATTERY_LEVEL_TYPE: - //print("Battery level update: $message"); - break; - case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE: - case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE: - case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE: // untested - processClickNotification(message); - break; - } - } - - void _processDevicePublicKeyResponse(Uint8List bytes) { - final devicePublicKeyBytes = bytes.sublist(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length); - zapEncryption.initialise(devicePublicKeyBytes); - if (kDebugMode) { - print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}"); - } - } - - void processClickNotification(Uint8List message) { - final ClickNotification clickNotification = ClickNotification(message); - if (_lastClickNotification == null || _lastClickNotification != clickNotification) { - _lastClickNotification = clickNotification; - actionStreamInternal.add(clickNotification); - - if (clickNotification.buttonUp) { - actionHandler.increaseGear(); - } else if (clickNotification.buttonDown) { - actionHandler.decreaseGear(); - } - } - } -} diff --git a/lib/utils/devices/zwift_play.dart b/lib/utils/devices/zwift_play.dart deleted file mode 100644 index 42eddc6b9..000000000 --- a/lib/utils/devices/zwift_play.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:swift_control/utils/devices/zwift_click.dart'; -import 'package:swift_control/utils/messages/controller_notification.dart'; - -import '../../main.dart'; -import '../ble.dart'; - -class ZwiftPlay extends ZwiftClick { - ZwiftPlay(super.scanResult); - - ControllerNotification? _lastControllerNotification; - - @override - List get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY; - - @override - void processClickNotification(Uint8List message) { - final ControllerNotification clickNotification = ControllerNotification(message); - if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) { - _lastControllerNotification = clickNotification; - actionStreamInternal.add(clickNotification); - - if (clickNotification.rightPad && clickNotification.analogLR.abs() == 100) { - actionHandler.increaseGear(); - } else if (!clickNotification.rightPad && clickNotification.analogLR.abs() == 100) { - actionHandler.decreaseGear(); - } - } - } -} diff --git a/lib/utils/devices/zwift_ride.dart b/lib/utils/devices/zwift_ride.dart deleted file mode 100644 index 99d0410e7..000000000 --- a/lib/utils/devices/zwift_ride.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:swift_control/utils/devices/zwift_play.dart'; - -import '../ble.dart'; - -class ZwiftRide extends ZwiftPlay { - ZwiftRide(super.scanResult); - - @override - Guid get customServiceId => BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID; - - @override - bool get supportsEncryption => false; -} diff --git a/lib/utils/i18n_extension.dart b/lib/utils/i18n_extension.dart new file mode 100644 index 000000000..d08e5b431 --- /dev/null +++ b/lib/utils/i18n_extension.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:bike_control/gen/l10n.dart'; + +extension Intl on BuildContext { + AppLocalizations get i18n => AppLocalizations.of(this); +} diff --git a/lib/utils/iap/iap_manager.dart b/lib/utils/iap/iap_manager.dart new file mode 100644 index 000000000..ae8b0b379 --- /dev/null +++ b/lib/utils/iap/iap_manager.dart @@ -0,0 +1,409 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/pages/paywall.dart'; +import 'package:bike_control/services/device_identity_service.dart'; +import 'package:bike_control/services/device_management_service.dart'; +import 'package:bike_control/services/entitlements_service.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/revenuecat_service.dart'; +import 'package:bike_control/utils/iap/windows_iap_service.dart'; +import 'package:bike_control/utils/windows_store_environment.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +enum SubscriptionPlan { + monthly, + yearly, +} + +/// Unified IAP manager that handles platform-specific IAP services. +class IAPManager { + static IAPManager? _instance; + static IAPManager get instance { + _instance ??= IAPManager._(); + return _instance!; + } + + static const String premiumMonthlyProductKey = 'premium_monthly'; + static const String premiumYearlyProductKey = 'premium_yearly'; + static int dailyCommandLimit = 15; + + RevenueCatService? _revenueCatService; + WindowsIAPService? _windowsIapService; + StreamSubscription? _authSubscription; + bool _isInitialized = false; + + final DeviceIdentityService deviceIdentity = DeviceIdentityService(); + late final DeviceManagementService deviceManagement = DeviceManagementService( + supabase: core.supabase, + deviceIdentityService: deviceIdentity, + ); + late final EntitlementsService entitlements = EntitlementsService( + core.supabase, + deviceIdentityService: deviceIdentity, + ); + + ValueNotifier isPurchased = ValueNotifier(false); + ValueNotifier isLocalPro = ValueNotifier(false); + + IAPManager._(); + + bool get isLoggedIn => core.supabase.auth.currentSession != null; + + bool get hasActiveSubscription => + (isLoggedIn && (entitlements.hasActive(premiumMonthlyProductKey)) || + entitlements.hasActive(premiumYearlyProductKey)) || + (!isLoggedIn && isLocalPro.value); + + bool get isProEnabled => hasActiveSubscription && (isLoggedIn || (!isLoggedIn && isLocalPro.value)); + + bool get isProEnabledForCurrentDevice => + hasActiveSubscription && ((isLoggedIn && entitlements.isRegisteredDevice) || (!isLoggedIn && isLocalPro.value)); + + DateTime? get premiumActiveUntil => + entitlements.activeUntil(premiumMonthlyProductKey) ?? entitlements.activeUntil(premiumYearlyProductKey); + + /// Initialize the IAP manager. + Future initialize() async { + if (_isInitialized) return; + + final prefs = FlutterSecureStorage(aOptions: AndroidOptions()); + await entitlements.initialize(); + entitlements.addListener(_onEntitlementsChanged); + _bindAuthLifecycle(); + + if (kIsWeb) { + _isInitialized = true; + return; + } + + try { + if (Platform.isWindows) { + _windowsIapService = WindowsIAPService( + prefs, + entitlementsService: entitlements, + ); + await _windowsIapService!.initialize(); + } else if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) { + _revenueCatService = RevenueCatService( + prefs, + isPurchasedNotifier: isPurchased, + isProNotifier: isLocalPro, + getDailyCommandLimit: () => dailyCommandLimit, + setDailyCommandLimit: (limit) => dailyCommandLimit = limit, + entitlementsService: entitlements, + premiumProductKeyMonthly: premiumMonthlyProductKey, + premiumProductKeyYearly: premiumYearlyProductKey, + ); + await _revenueCatService!.initialize(); + } else { + throw UnsupportedError('Unsupported platform for IAP: ${Platform.operatingSystem}'); + } + + await entitlements.refresh(); + _syncPurchaseFlagFromEntitlements(); + _isInitialized = true; + } catch (e) { + debugPrint('Error initializing IAP manager: $e'); + _isInitialized = true; + } + } + + /// Called on app start when a session may already exist. + Future refreshEntitlementsOnAppStart() async { + await entitlements.refresh(force: true); + _syncPurchaseFlagFromEntitlements(); + } + + /// Called on app resume to refresh stale entitlement cache. + Future refreshEntitlementsOnResume() async { + await entitlements.refresh(); + _syncPurchaseFlagFromEntitlements(); + } + + /// Check if the trial period has started. + bool get hasTrialStarted { + if (isOutsideStoreWindowsBuild) { + return true; + } + if (_revenueCatService != null) { + return _revenueCatService!.hasTrialStarted; + } else if (_windowsIapService != null) { + return _windowsIapService!.hasTrialStarted; + } + return false; + } + + /// Start the trial period. + Future startTrial() async { + if (_revenueCatService != null) { + await _revenueCatService!.startTrial(); + } + } + + /// Get the number of days remaining in the trial. + int get trialDaysRemaining { + if (isOutsideStoreWindowsBuild) { + return 0; + } + if (_revenueCatService != null) { + return _revenueCatService!.trialDaysRemaining; + } else if (_windowsIapService != null) { + return _windowsIapService!.trialDaysRemaining; + } + return 0; + } + + /// Check if the trial has expired. + bool get isTrialExpired { + if (isProEnabled) { + return false; + } + if (isOutsideStoreWindowsBuild && !isPurchased.value) { + return true; + } + if (_revenueCatService != null) { + return _revenueCatService!.isTrialExpired; + } else if (_windowsIapService != null) { + return _windowsIapService!.isTrialExpired; + } + return false; + } + + /// Check if the user can execute a command. + bool get canExecuteCommand { + if (isProEnabled) return true; + if (_revenueCatService == null && _windowsIapService == null) return true; + + if (_revenueCatService != null) { + return _revenueCatService!.canExecuteCommand; + } else if (_windowsIapService != null) { + return _windowsIapService!.canExecuteCommand; + } + return true; + } + + /// Get the number of commands remaining today (for free tier after trial). + int get commandsRemainingToday { + if (isProEnabled) { + return -1; + } + if (_revenueCatService != null) { + return _revenueCatService!.commandsRemainingToday; + } else if (_windowsIapService != null) { + return _windowsIapService!.commandsRemainingToday; + } + return -1; + } + + /// Get the daily command count. + int get dailyCommandCount { + if (_revenueCatService != null) { + return _revenueCatService!.dailyCommandCount; + } else if (_windowsIapService != null) { + return _windowsIapService!.dailyCommandCount; + } + return 0; + } + + /// Increment the daily command count. + Future incrementCommandCount() async { + if (isProEnabled) { + return; + } + if (_revenueCatService != null) { + await _revenueCatService!.incrementCommandCount(); + } else if (_windowsIapService != null) { + await _windowsIapService!.incrementCommandCount(); + } + } + + /// Get a status message for the user. + String getStatusMessage() { + final activeUntil = premiumActiveUntil; + final expiryInfo = activeUntil != null ? '\nexpires at ${_formatDate(activeUntil)}' : ''; + + if (kIsWeb) { + return "Web"; + } else if (isProEnabledForCurrentDevice) { + return 'Pro version$expiryInfo'; + } else if (isProEnabled) { + return 'Pro version (unregistered device)$expiryInfo'; + } else if (isPurchased.value) { + return AppLocalizations.current.fullVersion; + } else if (isOutsideStoreWindowsBuild) { + return AppLocalizations.current.trialExpired(dailyCommandLimit); + } else if (!hasTrialStarted) { + return '${_revenueCatService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available'; + } else if (!isTrialExpired) { + return AppLocalizations.current.trialDaysRemaining(trialDaysRemaining); + } else { + return AppLocalizations.current.commandsRemainingToday(commandsRemainingToday, dailyCommandLimit); + } + } + + String _formatDate(DateTime date) { + final local = date.toLocal(); + // when today return full time, otherwise just date + final now = DateTime.now(); + if (local.year == now.year && local.month == now.month && local.day == now.day) { + return '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}'; + } else { + return '${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year}'; + } + } + + /// Purchase the full version. + Future purchaseFullVersion(BuildContext context, {bool fromPaywall = false}) async { + if (isOutsideStoreWindowsBuild) { + return purchaseSubscription(context, fromPaywall: fromPaywall); + } + if ((Platform.isWindows || Platform.isMacOS) && !fromPaywall) { + return _showPaywall(context, false); + } else if (_revenueCatService != null) { + return _revenueCatService!.purchaseFullVersion( + context, + directPurchase: fromPaywall, + ); + } else if (_windowsIapService != null) { + return _windowsIapService!.purchaseFullVersion(); + } + } + + /// Purchase a subscription. + Future purchaseSubscription( + BuildContext context, { + SubscriptionPlan plan = SubscriptionPlan.monthly, + bool fromPaywall = false, + }) async { + if ((Platform.isWindows || Platform.isMacOS) && !fromPaywall) { + return _showPaywall(context, true); + } else if (_revenueCatService != null) { + return _revenueCatService!.purchaseSubscription( + context, + directPurchase: fromPaywall, + yearly: plan == SubscriptionPlan.yearly, + ); + } else if (_windowsIapService != null) { + return _windowsIapService!.purchaseSubscription( + context, + yearly: plan == SubscriptionPlan.yearly, + ); + } + } + + Future _showPaywall(BuildContext context, bool subscription) async { + openDrawer( + context: context, + builder: (c) => Paywall(defaultToFullVersion: !subscription), + position: OverlayPosition.bottom, + ); + } + + /// Restore previous purchases. + Future restorePurchases() async { + if (_revenueCatService != null) { + await _revenueCatService!.restorePurchases(); + } else if (_windowsIapService != null) {} + _syncPurchaseFlagFromEntitlements(); + } + + /// Check if RevenueCat is being used. + bool get isUsingRevenueCat => _revenueCatService != null; + + /// Check if running on Windows + bool get isWindows => _windowsIapService != null; + + bool get isOutsideStoreWindowsBuild => + !kIsWeb && defaultTargetPlatform == TargetPlatform.windows && WindowsStoreEnvironment.isOutsideStoreCached; + + /// Check if user is logged in (Windows Stripe requires this) + bool get isWindowsLoggedIn => _windowsIapService?.isLoggedIn ?? false; + + /// Open Stripe Billing Portal (Windows only) + /// Returns false if user has no Stripe customer (should hide button) + Future openBillingPortal(BuildContext context) async { + if (_windowsIapService == null) return false; + return _windowsIapService!.openBillingPortal(context); + } + + /// Check if user has a Stripe customer record (Windows only) + Future hasStripeCustomer() async { + if (_windowsIapService == null) return false; + return _windowsIapService!.hasStripeCustomer(); + } + + /// Dispose the manager. + void dispose() { + _authSubscription?.cancel(); + entitlements.removeListener(_onEntitlementsChanged); + _revenueCatService?.dispose(); + _windowsIapService?.dispose(); + } + + Future reset(bool fullReset) async { + isPurchased.value = false; + await entitlements.clearCache(); + _windowsIapService?.reset(); + await _revenueCatService?.reset(fullReset); + } + + Future redeem(String purchaseId) async { + if (_revenueCatService != null) { + await _revenueCatService!.redeem(purchaseId); + await entitlements.refresh(force: true); + _syncPurchaseFlagFromEntitlements(); + } + } + + void setAttributes() { + _revenueCatService?.setAttributes(); + } + + void _bindAuthLifecycle() { + _authSubscription ??= core.supabase.auth.onAuthStateChange.listen((data) { + unawaited(_handleAuthStateChange(data.event, data.session)); + }); + } + + Future _handleAuthStateChange(AuthChangeEvent event, Session? session) async { + switch (event) { + case AuthChangeEvent.initialSession: + case AuthChangeEvent.signedIn: + case AuthChangeEvent.tokenRefreshed: + case AuthChangeEvent.userUpdated: + case AuthChangeEvent.mfaChallengeVerified: + final userId = session?.user.id; + if (userId != null) { + await _revenueCatService?.logInWithSupabaseUserId(userId); + } + await _revenueCatService?.setAttributes(); + await entitlements.refresh(force: true); + _syncPurchaseFlagFromEntitlements(); + return; + case AuthChangeEvent.signedOut: + // ignore: deprecated_member_use + case AuthChangeEvent.userDeleted: + await _revenueCatService?.logOut(); + await entitlements.clearCache(); + isPurchased.value = false; + return; + case AuthChangeEvent.passwordRecovery: + return; + } + } + + void _onEntitlementsChanged() { + _syncPurchaseFlagFromEntitlements(); + } + + void _syncPurchaseFlagFromEntitlements() { + if (isProEnabled) { + isPurchased.value = true; + } + } +} diff --git a/lib/utils/iap/revenuecat_service.dart b/lib/utils/iap/revenuecat_service.dart new file mode 100644 index 000000000..275032a8a --- /dev/null +++ b/lib/utils/iap/revenuecat_service.dart @@ -0,0 +1,528 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/services/entitlements_service.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:prop/prop.dart' as zp; +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:purchases_ui_flutter/purchases_ui_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:version/version.dart'; + +/// RevenueCat-based IAP service for iOS, macOS, and Android +class RevenueCatService { + static const int trialDays = 5; + + static const String _trialStartDateKey = 'iap_trial_start_date'; + static const String _purchaseStatusKey = 'iap_purchase_status'; + static const String _dailyCommandCountKey = 'iap_daily_command_count'; + static const String _lastCommandDateKey = 'iap_last_command_date'; + static const String _syncedPurchasesKey = 'iap_synced_purchases'; + + // RevenueCat entitlement identifier + static const String fullVersionEntitlement = 'Full Version'; + static const String proVersionEntitlement = 'pro'; + + final FlutterSecureStorage _prefs; + final ValueNotifier isPurchasedNotifier; + final ValueNotifier isProNotifier; + final int Function() getDailyCommandLimit; + final void Function(int limit) setDailyCommandLimit; + final EntitlementsService entitlementsService; + final String premiumProductKeyMonthly; + final String premiumProductKeyYearly; + + bool _isInitialized = false; + bool _isConfigured = false; + String? _trialStartDate; + String? _lastCommandDate; + int? _dailyCommandCount; + + RevenueCatService( + this._prefs, { + required this.isPurchasedNotifier, + required this.isProNotifier, + required this.getDailyCommandLimit, + required this.setDailyCommandLimit, + required this.entitlementsService, + required this.premiumProductKeyMonthly, + required this.premiumProductKeyYearly, + }); + + /// Initialize the RevenueCat service + Future initialize() async { + if (_isInitialized) return; + + try { + // Skip RevenueCat initialization on web or unsupported platforms + if (kIsWeb) { + debugPrint('RevenueCat not supported on web'); + _isInitialized = true; + return; + } + + // Get API key from environment variable + final String apiKey; + + if (Platform.isAndroid) { + apiKey = + Platform.environment['REVENUECAT_API_KEY_ANDROID'] ?? + const String.fromEnvironment('REVENUECAT_API_KEY_ANDROID', defaultValue: ''); + } else if (Platform.isIOS || Platform.isMacOS) { + apiKey = + Platform.environment['REVENUECAT_API_KEY_IOS'] ?? + const String.fromEnvironment('REVENUECAT_API_KEY_IOS', defaultValue: ''); + } else { + apiKey = ''; + } + + if (apiKey.isEmpty) { + debugPrint('RevenueCat API key not found in environment'); + core.connection.signalNotification( + LogNotification('RevenueCat API key not configured'), + ); + isPurchasedNotifier.value = false; + _isInitialized = true; + return; + } + + // Configure RevenueCat + final configuration = PurchasesConfiguration(apiKey); + final session = core.supabase.auth.currentSession; + if (session != null) { + configuration.appUserID = session.user.id; + } + + // Enable debug logs in debug mode + if (kDebugMode) { + await Purchases.setLogLevel(LogLevel.debug); + } + + await Purchases.configure(configuration); + _isConfigured = true; + + debugPrint('RevenueCat initialized successfully'); + core.connection.signalNotification( + LogNotification('RevenueCat initialized'), + ); + + // Listen for customer info updates + Purchases.addCustomerInfoUpdateListener((customerInfo) { + _handleCustomerInfoUpdate(customerInfo); + }); + + _trialStartDate = await _prefs.read(key: _trialStartDateKey); + core.connection.signalNotification( + LogNotification('Trial start date: $_trialStartDate => $trialDaysRemaining'), + ); + + _lastCommandDate = await _prefs.read(key: _lastCommandDateKey); + final commandCount = await _prefs.read(key: _dailyCommandCountKey) ?? '0'; + _dailyCommandCount = int.tryParse(commandCount); + + // Check existing purchase status + await _checkExistingPurchase(); + + _isInitialized = true; + + if (!isTrialExpired && Platform.isAndroid) { + setDailyCommandLimit(80); + } + await _syncRevenueCatUser(session); + await setAttributes(); + } catch (e, s) { + recordError(e, s, context: 'Initializing RevenueCat Service'); + core.connection.signalNotification( + AlertNotification( + zp.LogLevel.LOGLEVEL_ERROR, + 'There was an error initializing RevenueCat. Please check your configuration.', + ), + ); + debugPrint('Failed to initialize RevenueCat: $e'); + isPurchasedNotifier.value = false; + _isInitialized = true; + } + } + + /// Check if the user has an active entitlement + Future _checkExistingPurchase() async { + try { + final storedStatus = await _prefs.read(key: _syncedPurchasesKey); + if (storedStatus != "true") { + await _prefs.write(key: _syncedPurchasesKey, value: "true"); + await Purchases.syncPurchases(); + } + // Check current entitlement status from RevenueCat + final customerInfo = await Purchases.getCustomerInfo(); + await _handleCustomerInfoUpdate(customerInfo); + } catch (e, s) { + debugPrint('Error checking existing purchase: $e'); + recordError(e, s, context: 'Checking existing purchase'); + } + } + + /// Handle customer info updates from RevenueCat + Future _handleCustomerInfoUpdate(CustomerInfo customerInfo) async { + final hasFullVersionEntitlements = customerInfo.entitlements.active.containsKey(fullVersionEntitlement); + + final userId = await Purchases.appUserID; + core.connection.signalNotification(LogNotification('User ID: $userId at ${customerInfo.requestDate}')); + core.connection.signalNotification(LogNotification('Full Version entitlement: $hasFullVersionEntitlements')); + + isProNotifier.value = customerInfo.entitlements.active.containsKey(proVersionEntitlement); + + if (!hasFullVersionEntitlements) { + // purchased before IAP migration + if (Platform.isAndroid) { + final storedStatus = await _prefs.read(key: _purchaseStatusKey); + if (storedStatus == "true") { + core.connection.signalNotification(LogNotification('Setting full version based on stored status')); + await Purchases.setAttributes({_purchaseStatusKey: "true"}); + isPurchasedNotifier.value = true; + } + } else { + final purchasedVersion = customerInfo.originalApplicationVersion; + core.connection.signalNotification(LogNotification('Apple receipt validated for version: $purchasedVersion')); + if (purchasedVersion != null && purchasedVersion.contains(".")) { + final parsedVersion = Version.parse(purchasedVersion); + isPurchasedNotifier.value = parsedVersion < Version(4, 2, 0) || parsedVersion >= Version(4, 4, 0); + } else { + final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 1337; + isPurchasedNotifier.value = + purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58) || purchasedVersionAsInt >= 77; + } + } + } else { + isPurchasedNotifier.value = hasFullVersionEntitlements; + } + return isPurchasedNotifier.value; + } + + /// Present the RevenueCat paywall + Future presentPaywall(Offering offering) async { + try { + if (!_isInitialized) { + await initialize(); + } + + final paywallResult = await RevenueCatUI.presentPaywall(displayCloseButton: true, offering: offering); + + debugPrint('Paywall result: $paywallResult'); + if (paywallResult == PaywallResult.purchased || paywallResult == PaywallResult.restored) { + await refreshEntitlementsWithRetry(); + } + } catch (e, s) { + debugPrint('Error presenting paywall: $e'); + recordError(e, s, context: 'Presenting paywall'); + core.connection.signalNotification( + AlertNotification( + zp.LogLevel.LOGLEVEL_ERROR, + 'There was an error displaying the paywall. Please try again.', + ), + ); + } + } + + /// Restore previous purchases + Future restorePurchases() async { + try { + final customerInfo = await Purchases.restorePurchases(); + final result = await _handleCustomerInfoUpdate(customerInfo); + await refreshEntitlementsWithRetry(); + + if (result) { + core.connection.signalNotification( + AlertNotification(zp.LogLevel.LOGLEVEL_INFO, 'Purchase restored'), + ); + } + } catch (e, s) { + core.connection.signalNotification( + AlertNotification( + zp.LogLevel.LOGLEVEL_ERROR, + 'There was an error restoring purchases. Please try again.', + ), + ); + recordError(e, s, context: 'Restore Purchases'); + debugPrint('Error restoring purchases: $e'); + } + } + + /// Purchase the full version (use paywall instead) + Future purchaseFullVersion(BuildContext context, {bool directPurchase = false}) async { + // Direct the user to the paywall for a better experience + final offerings = await Purchases.getOfferings(); + final defaultOffering = offerings.all['pro']; + if (defaultOffering == null) { + buildToast(title: 'Full version offering not available right now.'); + return; + } + + if (Platform.isMacOS || directPurchase) { + try { + final lifetimePackage = defaultOffering.lifetime; + if (lifetimePackage == null) { + await presentPaywall(defaultOffering); + return; + } + + final purchaseParams = PurchaseParams.package(lifetimePackage); + PurchaseResult result = await Purchases.purchase(purchaseParams); + core.connection.signalNotification( + LogNotification('Purchase result: $result'), + ); + await refreshEntitlementsWithRetry(); + } on PlatformException catch (e) { + var errorCode = PurchasesErrorHelper.getErrorCode(e); + if (errorCode != PurchasesErrorCode.purchaseCancelledError) { + buildToast(title: e.message); + } + } + } else { + await presentPaywall(defaultOffering); + } + } + + /// Purchase the subscription (use paywall instead) + Future purchaseSubscription( + BuildContext context, { + bool yearly = false, + bool directPurchase = false, + }) async { + // Direct the user to the paywall for a better experience + final offerings = await Purchases.getOfferings(); + Offering? proOffering; + if (isPurchasedNotifier.value) { + proOffering = offerings.all['proonly-freemonth']; + } else { + proOffering = offerings.all['proonly']; + } + if (proOffering == null) { + buildToast(title: 'Subscription offering not available right now.'); + return; + } + + if (Platform.isMacOS || directPurchase) { + try { + final packageToPurchase = yearly + ? (proOffering.annual ?? proOffering.monthly) + : (proOffering.monthly ?? proOffering.annual); + if (packageToPurchase == null) { + await presentPaywall(proOffering); + return; + } + + final purchaseParams = PurchaseParams.package(packageToPurchase); + PurchaseResult result = await Purchases.purchase(purchaseParams); + core.connection.signalNotification( + LogNotification('Purchase result: $result'), + ); + await refreshEntitlementsWithRetry(); + } on PlatformException catch (e) { + var errorCode = PurchasesErrorHelper.getErrorCode(e); + if (errorCode != PurchasesErrorCode.purchaseCancelledError) { + buildToast(title: e.message); + } + } + } else { + await presentPaywall(proOffering); + } + } + + Future logInWithSupabaseUserId(String supabaseUserId) async { + if (!_isConfigured) { + return; + } + await Purchases.logIn(supabaseUserId); + } + + Future logOut() async { + if (!_isConfigured) { + return; + } + try { + await Purchases.logOut(); + } on PlatformException catch (error) { + // RevenueCat throws when logging out anonymous users. + if (error.code != '22') { + // LogOut was called but the current user is anonymous. + rethrow; + } + } + } + + Future refreshEntitlementsWithRetry() async { + const retryDelays = [ + Duration(seconds: 1), + Duration(seconds: 2), + Duration(seconds: 4), + ]; + + await entitlementsService.refresh(force: true); + if (entitlementsService.hasActive(premiumProductKeyMonthly) || + entitlementsService.hasActive(premiumProductKeyYearly)) { + isPurchasedNotifier.value = true; + return; + } + + for (final delay in retryDelays) { + await Future.delayed(delay); + await entitlementsService.refresh(force: true); + if (entitlementsService.hasActive(premiumProductKeyMonthly) || + entitlementsService.hasActive(premiumProductKeyYearly)) { + isPurchasedNotifier.value = true; + return; + } + } + } + + /// Check if the trial period has started + bool get hasTrialStarted { + return _trialStartDate != null; + } + + /// Start the trial period + Future startTrial() async { + if (!hasTrialStarted) { + await _prefs.write(key: _trialStartDateKey, value: DateTime.now().toIso8601String()); + } + } + + /// Get the number of days remaining in the trial + int get trialDaysRemaining { + if (isPurchasedNotifier.value) return 0; + + final trialStart = _trialStartDate; + if (trialStart == null) return trialDays; + + final startDate = DateTime.parse(trialStart); + final now = DateTime.now(); + final daysPassed = now.difference(startDate).inDays; + final remaining = trialDays - daysPassed; + + return remaining > 0 ? remaining : 0; + } + + /// Check if the trial has expired + bool get isTrialExpired { + return (!isPurchasedNotifier.value && hasTrialStarted && trialDaysRemaining <= 0); + } + + /// Get the number of commands executed today + int get dailyCommandCount { + final lastDate = _lastCommandDate; + final today = DateTime.now().toIso8601String().split('T')[0]; + + if (lastDate != today) { + // Reset counter for new day + _lastCommandDate = today; + _dailyCommandCount = 0; + } + + return _dailyCommandCount ?? 0; + } + + /// Increment the daily command count + Future incrementCommandCount() async { + if (isPurchasedNotifier.value) { + return; // No need to track for purchased users + } + try { + final today = DateTime.now().toIso8601String().split('T')[0]; + final lastDate = await _prefs.read(key: _lastCommandDateKey); + + if (lastDate != today) { + // Reset counter for new day + _lastCommandDate = today; + _dailyCommandCount = 1; + await _prefs.write(key: _lastCommandDateKey, value: today); + await _prefs.write(key: _dailyCommandCountKey, value: '1'); + } else { + final count = _dailyCommandCount ?? 0; + _dailyCommandCount = count + 1; + await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString()); + } + } catch (e, s) { + final count = _dailyCommandCount ?? 0; + _dailyCommandCount = count + 1; + // e.g. https://github.com/OpenBikeControl/bikecontrol/issues/279 + debugPrint('Error incrementing command count: $e'); + recordError(e, s, context: 'Incrementing command count'); + } + } + + /// Check if the user can execute a command + bool get canExecuteCommand { + if (isPurchasedNotifier.value) return true; + if (!isTrialExpired && !Platform.isAndroid) return true; + return dailyCommandCount < getDailyCommandLimit(); + } + + /// Get the number of commands remaining today (for free tier after trial) + int get commandsRemainingToday { + if (isPurchasedNotifier.value || (!isTrialExpired && !Platform.isAndroid)) return -1; // Unlimited + final remaining = getDailyCommandLimit() - dailyCommandCount; + return remaining > 0 ? remaining : 0; // Never return negative + } + + /// Dispose the service + void dispose() {} + + Future reset(bool fullReset) async { + if (fullReset) { + await _prefs.deleteAll(); + } else { + await _prefs.delete(key: _purchaseStatusKey); + _isInitialized = false; + Purchases.invalidateCustomerInfoCache(); + await initialize(); + _checkExistingPurchase(); + } + } + + Future redeem(String purchaseId) async { + await Purchases.setAttributes({"purchase_id": purchaseId}); + core.connection.signalNotification(LogNotification('Redeemed purchase ID: $purchaseId')); + Purchases.invalidateCustomerInfoCache(); + _checkExistingPurchase(); + isPurchasedNotifier.value = true; + } + + Future setAttributes() async { + if (!_isConfigured) { + return; + } + final Session? session = core.supabase.auth.currentSession; + + // attributes are fully anonymous + await Purchases.setAttributes({ + if (session?.user.id != null) "bikecontrol_user": session!.user.id, + "bikecontrol_trainer": core.settings.getTrainerApp()?.name ?? '-', + "bikecontrol_target": core.settings.getLastTarget()?.name ?? '-', + if (core.connection.controllerDevices.isNotEmpty) + 'bikecontrol_controllers': core.connection.controllerDevices.joinToString( + transform: (d) => d.toString(), + separator: ',', + ), + 'bikecontrol_keymap': core.settings.getKeyMap()?.name ?? '-', + }); + } + + Future _syncRevenueCatUser(Session? session) async { + if (!_isConfigured) { + return; + } + if (session == null) { + await logOut(); + return; + } + await logInWithSupabaseUserId(session.user.id); + } +} diff --git a/lib/utils/iap/windows_iap_service.dart b/lib/utils/iap/windows_iap_service.dart new file mode 100644 index 000000000..b0e3a3539 --- /dev/null +++ b/lib/utils/iap/windows_iap_service.dart @@ -0,0 +1,319 @@ +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/subscriptions/login.dart'; +import 'package:bike_control/services/entitlements_service.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/iap/windows_stripe_service.dart'; +import 'package:bike_control/utils/windows_store_environment.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:flutter/material.dart' show BackButton; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:windows_iap/windows_iap.dart'; + +/// Windows-specific IAP service for Microsoft Store purchases and server-side sync. +class WindowsIAPService { + static const String productId = '9NP42GS03Z26'; + static const int trialDays = 7; + static const int dailyCommandLimit = 15; + + static const String _purchaseStatusKey = 'iap_purchase_status_2'; + static const String _dailyCommandCountKey = 'iap_daily_command_count'; + static const String _lastCommandDateKey = 'iap_last_command_date'; + + final FlutterSecureStorage _prefs; + final EntitlementsService _entitlementsService; + final WindowsStripeService _stripeService; + + bool _isInitialized = false; + + String? _lastCommandDate; + int? _dailyCommandCount; + + final _windowsIapPlugin = WindowsIap(); + + WindowsIAPService( + this._prefs, { + required EntitlementsService entitlementsService, + }) : _entitlementsService = entitlementsService, + _stripeService = WindowsStripeService(core.supabase); + + /// Initialize the Windows IAP service + Future initialize() async { + if (_isInitialized) return; + + try { + await _checkExistingPurchase(); + + _lastCommandDate = await _prefs.read(key: _lastCommandDateKey); + _dailyCommandCount = int.tryParse(await _prefs.read(key: _dailyCommandCountKey) ?? '0'); + _isInitialized = true; + } catch (e, s) { + recordError(e, s, context: 'Initializing'); + debugPrint('Failed to initialize Windows IAP: $e'); + _isInitialized = true; + } + } + + /// Check if the user has already purchased the app + Future _checkExistingPurchase() async { + if (_entitlementsService.hasActive(IAPManager.premiumMonthlyProductKey)) { + IAPManager.instance.isPurchased.value = true; + return; + } + + final storedStatus = await _prefs.read(key: _purchaseStatusKey); + core.connection.signalNotification(LogNotification('Is purchased status: $storedStatus')); + if (storedStatus == "true") { + IAPManager.instance.isPurchased.value = true; + return; + } + + final boughtProducts = await _windowsIapPlugin.getAddonLicenses(); + if (boughtProducts.containsKey(IAPManager.premiumMonthlyProductKey) && + boughtProducts[IAPManager.premiumMonthlyProductKey]!.isActive == true) { + IAPManager.instance.isLocalPro.value = true; + return; + } + + final trial = await _windowsIapPlugin.getTrialStatusAndRemainingDays(); + core.connection.signalNotification(LogNotification('Trial status: $trial')); + final trialEndDate = trial.remainingDays; + if (trial.isTrial && trialEndDate.isNotEmpty && !trialEndDate.contains("?")) { + try { + trialDaysRemaining = DateTime.parse(trialEndDate).difference(DateTime.now()).inDays; + } catch (e) { + core.connection.signalNotification(LogNotification('Error parsing trial end date: $e')); + trialDaysRemaining = 0; + } + } else { + final isStorePackaged = await WindowsStoreEnvironment.isPackaged(); + trial.isActive = isStorePackaged; + trialDaysRemaining = 0; + } + + if (trial.isActive && !trial.isTrial && trialDaysRemaining <= 0) { + IAPManager.instance.isPurchased.value = true; + await _prefs.write(key: _purchaseStatusKey, value: "true"); + } else { + IAPManager.instance.isPurchased.value = false; + } + } + + /// Purchase and then sync subscription state to Supabase. + Future purchaseFullVersion() async { + try { + final status = await _windowsIapPlugin.makePurchase(productId); + if (status == StorePurchaseStatus.succeeded || status == StorePurchaseStatus.alreadyPurchased) { + IAPManager.instance.isPurchased.value = true; + buildToast( + title: 'Purchase Successful', + subtitle: 'Purchase complete. Sync may take a moment.', + ); + } + } catch (e, s) { + recordError(e, s, context: 'Purchasing on Windows'); + debugPrint('Error purchasing on Windows: $e'); + } + } + + /// Check if the trial period has started + bool get hasTrialStarted => trialDaysRemaining >= 0; + + /// Get the number of days remaining in the trial + int trialDaysRemaining = 0; + + /// Check if the trial has expired + bool get isTrialExpired { + if (IAPManager.instance.isProEnabled) { + return false; + } + return !IAPManager.instance.isPurchased.value && hasTrialStarted && trialDaysRemaining <= 0; + } + + /// Get the number of commands executed today + int get dailyCommandCount { + final lastDate = _lastCommandDate; + final today = DateTime.now().toIso8601String().split('T')[0]; + + if (lastDate != today) { + return 0; + } + + return _dailyCommandCount ?? 0; + } + + /// Increment the daily command count + Future incrementCommandCount() async { + final today = DateTime.now().toIso8601String().split('T')[0]; + final lastDate = _lastCommandDate; + + if (lastDate != today) { + _lastCommandDate = today; + _dailyCommandCount = 1; + await _prefs.write(key: _lastCommandDateKey, value: today); + await _prefs.write(key: _dailyCommandCountKey, value: "1"); + } else { + final count = _dailyCommandCount ?? 0; + _dailyCommandCount = count + 1; + await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString()); + } + } + + /// Check if the user can execute a command + bool get canExecuteCommand { + if (IAPManager.instance.isProEnabled || IAPManager.instance.isPurchased.value) return true; + if (!isTrialExpired) return true; + return dailyCommandCount < dailyCommandLimit; + } + + /// Get the number of commands remaining today (for free tier after trial) + int get commandsRemainingToday { + if (IAPManager.instance.isProEnabled || IAPManager.instance.isPurchased.value || !isTrialExpired) { + return -1; + } + final remaining = dailyCommandLimit - dailyCommandCount; + return remaining > 0 ? remaining : 0; + } + + /// Dispose the service + void dispose() {} + + void reset() { + _prefs.deleteAll(); + } + + /// Check if user is logged in (required for Stripe on Windows) + bool get isLoggedIn => _stripeService.isLoggedIn; + + /// Start Stripe Checkout to purchase a subscription + /// Shows a dialog if user is not logged in + Future purchaseSubscription(BuildContext context, {bool yearly = false}) async { + if (!isLoggedIn) { + await _showLoginRequiredDialog(context); + return; + } + + try { + final storeId = await _windowsIapPlugin.getStoreId(); + await _stripeService.startCheckout( + priceId: yearly ? 'yearly' : 'monthly', + storeId: storeId, + successUrl: 'bikecontrol://stripe-success', + cancelUrl: 'bikecontrol://stripe-cancel', + ); + } on StripeException catch (e) { + if (context.mounted) { + buildToast( + title: 'Checkout Error', + subtitle: e.message, + ); + } + } catch (e, s) { + recordError(e, s, context: 'Starting Stripe checkout'); + if (context.mounted) { + buildToast( + title: 'Checkout Error', + subtitle: 'Failed to start checkout. Please try again.', + ); + } + } + } + + /// Open Stripe Billing Portal to manage subscription + /// Returns false if user has no Stripe customer (should hide button in this case) + Future openBillingPortal(BuildContext context) async { + if (!isLoggedIn) { + await _showLoginRequiredDialog(context); + return true; // Return true to keep the button visible (user might log in) + } + + try { + await _stripeService.openPortal(returnUrl: 'bikecontrol://stripe-portal-return'); + return true; + } on StripeException catch (e) { + if (e.statusCode == 404) { + // No Stripe customer found - should hide the portal button + return false; + } + if (context.mounted) { + buildToast( + title: 'Portal Error', + subtitle: e.message, + ); + } + return true; + } catch (e, s) { + recordError(e, s, context: 'Opening Stripe portal'); + if (context.mounted) { + buildToast( + title: 'Portal Error', + subtitle: 'Failed to open billing portal. Please try again.', + ); + } + return true; + } + } + + /// Check if user has a Stripe customer record + Future hasStripeCustomer() async { + return _stripeService.hasStripeCustomer(); + } + + /// Show dialog informing user that login is required for Windows subscriptions + Future _showLoginRequiredDialog(BuildContext context) async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.lock, color: Colors.orange), + const SizedBox(width: 8), + Text('Login Required'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'A subscription on Windows requires you to be logged in. This allows us to manage your subscription across devices and provide you with secure payment processing through Stripe.', + ), + const SizedBox(height: 16), + Text( + 'Please log in or create an account to continue.', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + actions: [ + SecondaryButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('Cancel'), + ), + PrimaryButton( + onPressed: () async { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (c) => Scaffold( + headers: [ + AppBar( + leading: [BackButton()], + ), + ], + child: LoginPage(pushed: true), + ), + ), + ); + // Navigate to login page - this would need to be handled by the caller + }, + child: Text('Go to Login'), + ), + ], + ), + ); + } +} diff --git a/lib/utils/iap/windows_stripe_service.dart b/lib/utils/iap/windows_stripe_service.dart new file mode 100644 index 000000000..fbb0dc323 --- /dev/null +++ b/lib/utils/iap/windows_stripe_service.dart @@ -0,0 +1,211 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Exception thrown when Stripe operations fail +class StripeException implements Exception { + final String message; + final int? statusCode; + + StripeException(this.message, {this.statusCode}); + + @override + String toString() => 'StripeException: $message (status: $statusCode)'; +} + +/// Service for handling Stripe Checkout and Portal on Windows +/// Requires user to be logged in with a valid Supabase session +class WindowsStripeService { + static const String _checkoutFunction = 'stripe-checkout'; + static const String _portalFunction = 'stripe-portal'; + + final SupabaseClient _supabase; + + WindowsStripeService(this._supabase); + + /// Check if the user is logged in + bool get isLoggedIn => _supabase.auth.currentSession != null; + + /// Get the current session + Session? get _session => _supabase.auth.currentSession; + + /// Start a Stripe Checkout session for subscription purchase + /// + /// [priceId] must be either 'monthly' or 'yearly' + /// [successUrl] optional, defaults to app origin + '/success' + /// [cancelUrl] optional, defaults to app origin + '/cancel' + /// + /// Throws [StripeException] if the user is not logged in or the request fails + Future startCheckout({ + required String priceId, + required String? storeId, + String? successUrl, + String? cancelUrl, + }) async { + if (!isLoggedIn) { + throw StripeException('Authentication required. Please log in to purchase a subscription.'); + } + + if (priceId != 'monthly' && priceId != 'yearly') { + throw StripeException('Invalid price_id. Must be "monthly" or "yearly"'); + } + + try { + final body = { + 'price_id': priceId, + }; + + if (storeId != null) { + body['store_id'] = storeId; + } + if (successUrl != null) { + body['success_url'] = successUrl; + } + if (cancelUrl != null) { + body['cancel_url'] = cancelUrl; + } + + final response = await _supabase.functions.invoke( + _checkoutFunction, + method: HttpMethod.post, + headers: { + 'Authorization': 'Bearer ${_session!.accessToken}', + }, + body: body, + ); + + final data = response.data; + if (data is! Map) { + throw StripeException('Invalid response format from server'); + } + + final url = data['url'] as String?; + if (url == null || url.isEmpty) { + throw StripeException('No checkout URL returned from server'); + } + + // Launch the Stripe Checkout URL + final uri = Uri.parse(url); + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + throw StripeException('Could not launch checkout URL'); + } + } on FunctionException catch (e) { + final status = e.status; + final details = e.details; + + String errorMessage = 'Failed to start checkout'; + + if (details is Map) { + errorMessage = details['error'] as String? ?? errorMessage; + } + + if (status == 400) { + throw StripeException(errorMessage, statusCode: status); + } else if (status == 401) { + throw StripeException('Your session has expired. Please log in again.', statusCode: status); + } else if (status == 500) { + throw StripeException('Server error: $errorMessage', statusCode: status); + } + + throw StripeException(errorMessage, statusCode: status); + } catch (e) { + if (e is StripeException) rethrow; + throw StripeException('Failed to start checkout: $e'); + } + } + + /// Open the Stripe Billing Portal for managing subscriptions + /// + /// [returnUrl] optional, URL to return to after leaving the portal + /// + /// Throws [StripeException] if the user is not logged in, has no Stripe customer, + /// or the request fails + Future openPortal({String? returnUrl}) async { + if (!isLoggedIn) { + throw StripeException('Authentication required. Please log in to manage your subscription.'); + } + + try { + final body = {}; + if (returnUrl != null) { + body['return_url'] = returnUrl; + } + + final response = await _supabase.functions.invoke( + _portalFunction, + method: HttpMethod.post, + headers: { + 'Authorization': 'Bearer ${_session!.accessToken}', + }, + body: body.isNotEmpty ? body : null, + ); + + final data = response.data; + if (data is! Map) { + throw StripeException('Invalid response format from server'); + } + + final url = data['url'] as String?; + if (url == null || url.isEmpty) { + throw StripeException('No portal URL returned from server'); + } + + // Launch the Stripe Portal URL + final uri = Uri.parse(url); + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + throw StripeException('Could not launch portal URL'); + } + } on FunctionException catch (e) { + final status = e.status; + final details = e.details; + + String errorMessage = 'Failed to open billing portal'; + + if (details is Map) { + errorMessage = details['error'] as String? ?? errorMessage; + } + + if (status == 401) { + throw StripeException('Your session has expired. Please log in again.', statusCode: status); + } else if (status == 404) { + // Special case: user has never completed checkout + throw StripeException( + 'No subscription found. Please purchase a subscription first.', + statusCode: status, + ); + } else if (status == 500) { + throw StripeException('Server error: $errorMessage', statusCode: status); + } + + throw StripeException(errorMessage, statusCode: status); + } catch (e) { + if (e is StripeException) rethrow; + throw StripeException('Failed to open billing portal: $e'); + } + } + + /// Check if the user has a Stripe customer record (has completed checkout) + /// This is useful for determining whether to show the "Manage Subscription" button + Future hasStripeCustomer() async { + if (!isLoggedIn) return false; + + try { + await _supabase.functions.invoke( + _portalFunction, + method: HttpMethod.post, + headers: { + 'Authorization': 'Bearer ${_session!.accessToken}', + }, + ); + return true; + } on FunctionException catch (e) { + if (e.status == 404) { + return false; + } + // For other errors, assume they might have a customer + // This is a best-effort check + return true; + } catch (e) { + return true; + } + } +} diff --git a/lib/utils/interpreter.dart b/lib/utils/interpreter.dart new file mode 100644 index 000000000..cd6f44817 --- /dev/null +++ b/lib/utils/interpreter.dart @@ -0,0 +1,311 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:d4rt/d4rt.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +const String kDefaultDeviceScript = ''' +import 'dart:io'; +import 'dart:async'; + +Future> main(String characteristicUuid, List data) async { + var client = HttpClient(); + + try { + HttpClientRequest request = await client.getUrl(Uri.parse('https://api.ipify.org?format=json')); + // Optionally set up headers... + // Optionally write to the request object... + HttpClientResponse response = await request.close(); + // Process the response + + final list = await readBody(response); + return [characteristicUuid, list]; + } catch (e) { + print('Error: \$e'); + } finally { + client.close(); + } +} + + +Future> readBody(HttpClientResponse response) async { + final completer = Completer>(); + final bytes = []; + + response.listen( + (chunk) => bytes.addAll(chunk), + onDone: () => completer.complete(bytes), + onError: (e, st) => completer.completeError(e, st), + cancelOnError: true, + ); + + final allBytes = await completer.future; + return allBytes; +} + + +'''; + +class ScriptValidationResult { + final bool isValid; + final String? errorMessage; + + const ScriptValidationResult.valid() : isValid = true, errorMessage = null; + + const ScriptValidationResult.invalid(this.errorMessage) : isValid = false; +} + +class ScriptExecutionResult { + final String characteristicUuid; + final Uint8List data; + + const ScriptExecutionResult({ + required this.characteristicUuid, + required this.data, + }); +} + +class DeviceScriptService { + DeviceScriptService._(); + + static final DeviceScriptService instance = DeviceScriptService._(); + + final Map _customScriptCache = {}; + final Set _missingScriptCache = {}; + + Future loadScriptForEditing(String deviceType) async { + final script = await _loadCustomScript(deviceType); + return script ?? kDefaultDeviceScript; + } + + Future hasCustomScript(String deviceType) async { + if (kIsWeb) { + return false; + } + + if (_customScriptCache.containsKey(deviceType)) { + return true; + } + + if (_missingScriptCache.contains(deviceType)) { + return false; + } + + final file = await _scriptFileForDeviceType(deviceType); + final exists = await file.exists(); + if (!exists) { + _missingScriptCache.add(deviceType); + } + return exists; + } + + Future saveScript({ + required String deviceType, + required String source, + }) async { + if (kIsWeb) { + return ScriptValidationResult.invalid('Script files are not supported on web.'); + } + + final cleaned = source.replaceAll('BikeControl', '').replaceAll('Bike Control', '').replaceAll('bikecontrol', ''); + final validation = validateScript(cleaned); + if (!validation.isValid) { + return validation; + } + + final file = await _scriptFileForDeviceType(deviceType); + await file.writeAsString(cleaned, flush: true); + + _customScriptCache[deviceType] = cleaned; + _missingScriptCache.remove(deviceType); + + return const ScriptValidationResult.valid(); + } + + Future deleteScript(String deviceType) async { + if (kIsWeb) { + return; + } + + final file = await _scriptFileForDeviceType(deviceType); + if (await file.exists()) { + await file.delete(); + } + + _customScriptCache.remove(deviceType); + _missingScriptCache.add(deviceType); + } + + ScriptValidationResult validateScript(String source) { + try { + final interpreter = D4rt(); + interpreter.grant(NetworkPermission.any); + interpreter.grant(FilesystemPermission.read); + final introspection = interpreter.analyze(source: source); + + FunctionInfo? mainFunction; + for (final function in introspection.functions) { + if (function.name == 'main') { + mainFunction = function; + break; + } + } + + if (mainFunction == null) { + return ScriptValidationResult.invalid('The script must declare a top-level main function.'); + } + + if (mainFunction.arity != 2 || + mainFunction.parameterNames.length != 2 || + mainFunction.namedParameterNames.isNotEmpty) { + return ScriptValidationResult.invalid( + 'main must have exactly two positional parameters: (String characteristicUuid, List data).', + ); + } + + final returnType = (mainFunction.returnType ?? '').replaceAll(' ', ''); + final returnsList = + returnType == 'List' || + returnType.startsWith('List<') || + returnType == 'Future' || + returnType.startsWith('Future or Future>.', + ); + } + + return const ScriptValidationResult.valid(); + } catch (e) { + return ScriptValidationResult.invalid(e.toString()); + } + } + + Future runCustomScript({ + required String deviceType, + required String characteristicUuid, + required Uint8List data, + }) async { + final source = await _loadCustomScript(deviceType); + if (source == null) { + return null; + } + + return runScriptSource( + source: source, + characteristicUuid: characteristicUuid, + data: data, + deviceTypeForLog: deviceType, + ); + } + + Future runScriptSource({ + required String source, + required String characteristicUuid, + required Uint8List data, + String? deviceTypeForLog, + }) async { + core.connection.signalNotification( + LogNotification( + 'Running custom script${deviceTypeForLog != null ? ' for device type "$deviceTypeForLog"' : ''} with characteristic $characteristicUuid input data: ${data.join()}', + ), + ); + + final interpreter = D4rt(); + interpreter.grant(NetworkPermission.any); + interpreter.grant(FilesystemPermission.read); + final result = await interpreter.execute( + source: source, + positionalArgs: [characteristicUuid, data.toList()], + ); + + if (result is! List || result.length < 2) { + throw const FormatException('Script output must be [characteristicUuid, data].'); + } + + final outputCharacteristic = result[0]; + final outputData = _parseData(result[1]); + + if (outputCharacteristic is! String || outputCharacteristic.trim().isEmpty) { + throw const FormatException('The first output item must be a non-empty String UUID.'); + } + + if (outputData == null) { + throw const FormatException('The second output item must be a List (0..255).'); + } + + return ScriptExecutionResult( + characteristicUuid: outputCharacteristic.trim().toLowerCase(), + data: outputData, + ); + } + + Future _loadCustomScript(String deviceType) async { + if (kIsWeb) { + return null; + } + + final cached = _customScriptCache[deviceType]; + if (cached != null) { + return cached; + } + + if (_missingScriptCache.contains(deviceType)) { + return null; + } + + final file = await _scriptFileForDeviceType(deviceType); + if (!await file.exists()) { + _missingScriptCache.add(deviceType); + return null; + } + + final script = await file.readAsString(); + _customScriptCache[deviceType] = script; + return script; + } + + Future _scriptFileForDeviceType(String deviceType) async { + final baseDirectory = await getApplicationSupportDirectory(); + final scriptDirectory = Directory('${baseDirectory.path}${Platform.pathSeparator}device_scripts'); + if (!await scriptDirectory.exists()) { + await scriptDirectory.create(recursive: true); + } + + final sanitizedType = _sanitizeDeviceType(deviceType); + return File('${scriptDirectory.path}${Platform.pathSeparator}$sanitizedType.dart'); + } + + Uint8List? _parseData(dynamic raw) { + if (raw is Uint8List) { + return raw; + } + + if (raw is! List) { + return null; + } + + final bytes = []; + for (final value in raw) { + if (value is! num) { + return null; + } + + final byteValue = value.toInt(); + if (byteValue < 0 || byteValue > 255) { + return null; + } + + bytes.add(byteValue); + } + + return Uint8List.fromList(bytes); + } + + String _sanitizeDeviceType(String deviceType) { + return deviceType.replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '_'); + } +} diff --git a/lib/utils/keymap/apps/biketerra.dart b/lib/utils/keymap/apps/biketerra.dart new file mode 100644 index 000000000..0a4770906 --- /dev/null +++ b/lib/utils/keymap/apps/biketerra.dart @@ -0,0 +1,67 @@ +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/services.dart'; + +import '../buttons.dart'; +import '../keymap.dart'; + +class Biketerra extends SupportedApp { + Biketerra() + : super( + name: 'Biketerra', + packageName: "Biketerra", + compatibleTargets: Target.values, + supportsZwiftEmulation: true, + keymap: Keymap( + keyPairs: [ + ...ControllerButton.values + .filter((e) => e.action == InGameAction.shiftDown) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyS, + logicalKey: LogicalKeyboardKey.keyS, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.shiftUp) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyW, + logicalKey: LogicalKeyboardKey.keyW, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.navigateRight) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.arrowRight, + logicalKey: LogicalKeyboardKey.arrowRight, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.navigateLeft) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.arrowLeft, + logicalKey: LogicalKeyboardKey.arrowLeft, + ), + ), + + ...ControllerButton.values + .filter((e) => e.action == InGameAction.toggleUi) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyU, + logicalKey: LogicalKeyboardKey.keyU, + ), + ), + ], + ), + ); +} diff --git a/lib/utils/keymap/apps/custom_app.dart b/lib/utils/keymap/apps/custom_app.dart new file mode 100644 index 000000000..0a16e8953 --- /dev/null +++ b/lib/utils/keymap/apps/custom_app.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; + +import '../buttons.dart'; +import '../keymap.dart'; + +class CustomApp extends SupportedApp { + final String profileName; + + CustomApp({this.profileName = 'Other'}) + : super( + name: profileName, + compatibleTargets: kIsWeb + ? [Target.thisDevice] + : [ + if (!Platform.isIOS) Target.thisDevice, + Target.otherDevice, + ], + packageName: "custom_$profileName", + supportsZwiftEmulation: !kIsWeb, + keymap: Keymap(keyPairs: []), + ); + + List encodeKeymap() { + // encode to save in preferences + return keymap.keyPairs.map((e) => e.encode()).toList(); + } + + void decodeKeymap(List data) { + // decode from preferences + + if (data.isEmpty) { + return; + } + + final keyPairs = data.map((e) => KeyPair.decode(e)).whereType().toList(); + if (keyPairs.isEmpty) { + return; + } + keymap.keyPairs = keyPairs; + } + + void setKey( + ControllerButton zwiftButton, { + required PhysicalKeyboardKey? physicalKey, + required LogicalKeyboardKey? logicalKey, + List modifiers = const [], + ButtonTrigger trigger = ButtonTrigger.singleClick, + bool isLongPress = false, + Offset? touchPosition, + InGameAction? inGameAction, + int? inGameActionValue, + }) { + // set the key for the zwift button + final resolvedTrigger = isLongPress ? ButtonTrigger.longPress : trigger; + final keyPair = keymap.getKeyPair(zwiftButton, trigger: resolvedTrigger); + if (keyPair != null) { + keyPair.physicalKey = physicalKey; + keyPair.logicalKey = logicalKey; + keyPair.modifiers = modifiers; + keyPair.trigger = resolvedTrigger; + keyPair.touchPosition = touchPosition ?? Offset.zero; + keyPair.inGameAction = inGameAction; + keyPair.inGameActionValue = inGameActionValue; + } else { + keymap.addKeyPair( + KeyPair( + buttons: [zwiftButton], + physicalKey: physicalKey, + logicalKey: logicalKey, + modifiers: modifiers, + trigger: resolvedTrigger, + touchPosition: touchPosition ?? Offset.zero, + inGameAction: inGameAction, + inGameActionValue: inGameActionValue, + ), + ); + } + } +} + diff --git a/lib/utils/keymap/apps/my_whoosh.dart b/lib/utils/keymap/apps/my_whoosh.dart new file mode 100644 index 000000000..27963c6ce --- /dev/null +++ b/lib/utils/keymap/apps/my_whoosh.dart @@ -0,0 +1,102 @@ +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/services.dart'; + +import '../buttons.dart'; +import '../keymap.dart'; + +class MyWhoosh extends SupportedApp { + MyWhoosh() + : super( + name: 'MyWhoosh', + packageName: "MyWhoosh", + compatibleTargets: Target.values, + supportsZwiftEmulation: false, + star: true, + keymap: Keymap( + keyPairs: [ + ...ControllerButton.values + .filter((e) => e.action == InGameAction.shiftDown) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyI, + logicalKey: LogicalKeyboardKey.keyI, + touchPosition: Offset(80, 94), + inGameAction: InGameAction.shiftDown, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.shiftUp) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyK, + logicalKey: LogicalKeyboardKey.keyK, + touchPosition: Offset(97, 94), + inGameAction: InGameAction.shiftUp, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.steerRight) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyD, + logicalKey: LogicalKeyboardKey.keyD, + touchPosition: Offset(60, 80), + isLongPress: true, + inGameAction: InGameAction.steerRight, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.steerLeft) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + touchPosition: Offset(32, 80), + isLongPress: true, + inGameAction: InGameAction.steerLeft, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.navigateLeft) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.arrowLeft, + logicalKey: LogicalKeyboardKey.arrowLeft, + touchPosition: Offset(32, 80), + isLongPress: true, + inGameAction: InGameAction.steerLeft, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.navigateRight) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.arrowRight, + logicalKey: LogicalKeyboardKey.arrowRight, + touchPosition: Offset(32, 80), + isLongPress: true, + inGameAction: InGameAction.steerLeft, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.toggleUi) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyH, + logicalKey: LogicalKeyboardKey.keyH, + inGameAction: InGameAction.toggleUi, + ), + ), + ], + ), + ); +} diff --git a/lib/utils/keymap/apps/openbikecontrol.dart b/lib/utils/keymap/apps/openbikecontrol.dart new file mode 100644 index 000000000..1578c7acc --- /dev/null +++ b/lib/utils/keymap/apps/openbikecontrol.dart @@ -0,0 +1,18 @@ +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; + +import '../keymap.dart'; + +class OpenBikeControl extends SupportedApp { + OpenBikeControl() + : super( + name: 'OpenBikeControl Compatible', + packageName: "org.openbikecontrol", + compatibleTargets: Target.values, + supportsZwiftEmulation: false, + supportsOpenBikeProtocol: [OpenBikeProtocolSupport.network, OpenBikeProtocolSupport.ble], + keymap: Keymap( + keyPairs: [], + ), + ); +} diff --git a/lib/utils/keymap/apps/rouvy.dart b/lib/utils/keymap/apps/rouvy.dart new file mode 100644 index 000000000..02ff39f11 --- /dev/null +++ b/lib/utils/keymap/apps/rouvy.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../keymap.dart'; + +class Rouvy extends SupportedApp { + Rouvy() + : super( + name: 'Rouvy', + packageName: "Rouvy", + compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values, + supportsZwiftEmulation: !kIsWeb && Platform.isAndroid, + star: true, + keymap: Keymap( + keyPairs: [ + // https://support.rouvy.com/hc/de/articles/32452137189393-Virtuelles-Schalten#h_01K5GMVG4KVYZ0Y6W7RBRZC9MA + ...ControllerButton.values + .filter((e) => e.action == InGameAction.shiftDown) + .map( + (b) => KeyPair( + buttons: [b], + inGameAction: InGameAction.shiftDown, + physicalKey: PhysicalKeyboardKey.comma, + logicalKey: LogicalKeyboardKey.comma, + touchPosition: Offset(94, 80), + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.shiftUp) + .map( + (b) => KeyPair( + buttons: [b], + inGameAction: InGameAction.shiftUp, + physicalKey: PhysicalKeyboardKey.period, + logicalKey: LogicalKeyboardKey.period, + touchPosition: Offset(94, 72), + ), + ), + // like escape + KeyPair( + buttons: [ZwiftButtons.b], + physicalKey: PhysicalKeyboardKey.keyB, + logicalKey: LogicalKeyboardKey.keyB, + inGameAction: InGameAction.back, + ), + KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyY, + logicalKey: LogicalKeyboardKey.keyY, + inGameAction: InGameAction.kudos, + ), + KeyPair( + buttons: [ZwiftButtons.y], + physicalKey: PhysicalKeyboardKey.keyZ, + logicalKey: LogicalKeyboardKey.keyZ, + inGameAction: InGameAction.pause, + ), + ], + ), + ); +} diff --git a/lib/utils/keymap/apps/supported_app.dart b/lib/utils/keymap/apps/supported_app.dart new file mode 100644 index 000000000..c074fa573 --- /dev/null +++ b/lib/utils/keymap/apps/supported_app.dart @@ -0,0 +1,51 @@ +import 'package:bike_control/utils/keymap/apps/biketerra.dart'; +import 'package:bike_control/utils/keymap/apps/openbikecontrol.dart'; +import 'package:bike_control/utils/keymap/apps/rouvy.dart'; +import 'package:bike_control/utils/keymap/apps/training_peaks.dart'; +import 'package:bike_control/utils/keymap/apps/zwift.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; + +import '../keymap.dart'; +import 'custom_app.dart'; +import 'my_whoosh.dart'; + +enum OpenBikeProtocolSupport { + ble, + network, + dircon, +} + +abstract class SupportedApp { + final List compatibleTargets; + final String packageName; + final String name; + final Keymap keymap; + final bool supportsZwiftEmulation; + final List supportsOpenBikeProtocol; + final bool star; + + const SupportedApp({ + required this.name, + required this.packageName, + required this.keymap, + required this.compatibleTargets, + required this.supportsZwiftEmulation, + this.supportsOpenBikeProtocol = const [], + this.star = false, + }); + + static final List supportedApps = [ + MyWhoosh(), + Zwift(), + TrainingPeaks(), + Biketerra(), + Rouvy(), + OpenBikeControl(), + CustomApp(), + ]; + + @override + String toString() { + return runtimeType.toString(); + } +} diff --git a/lib/utils/keymap/apps/training_peaks.dart b/lib/utils/keymap/apps/training_peaks.dart new file mode 100644 index 000000000..e009f632b --- /dev/null +++ b/lib/utils/keymap/apps/training_peaks.dart @@ -0,0 +1,137 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/elite/elite_square.dart'; +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../keymap.dart'; + +class TrainingPeaks extends SupportedApp { + TrainingPeaks() + : super( + name: 'TrainingPeaks Virtual', + packageName: "TPVirtual", + compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values, + supportsZwiftEmulation: false, + supportsOpenBikeProtocol: [OpenBikeProtocolSupport.ble], //, OpenBikeProtocolSupport.dircon], + star: true, + keymap: Keymap( + keyPairs: [ + // Explicit controller-button mappings with updated touch coordinates + KeyPair( + buttons: [ZwiftButtons.shiftUpRight], + physicalKey: PhysicalKeyboardKey.numpadAdd, + logicalKey: LogicalKeyboardKey.numpadAdd, + touchPosition: Offset(22.65384615384622, 7.0769230769229665), + ), + KeyPair( + buttons: [ZwiftButtons.shiftDownRight], + physicalKey: PhysicalKeyboardKey.numpadAdd, + logicalKey: LogicalKeyboardKey.numpadAdd, + touchPosition: Offset(22.61769250748708, 8.13909075507417), + ), + KeyPair( + buttons: [ZwiftButtons.shiftUpLeft], + physicalKey: PhysicalKeyboardKey.numpadSubtract, + logicalKey: LogicalKeyboardKey.numpadSubtract, + touchPosition: Offset(18.14448747554958, 6.772862761010401), + ), + KeyPair( + buttons: [ZwiftButtons.shiftDownLeft], + physicalKey: PhysicalKeyboardKey.numpadSubtract, + logicalKey: LogicalKeyboardKey.numpadSubtract, + touchPosition: Offset(18.128205128205135, 6.75213675213675), + ), + + // Navigation buttons (keep arrow key mappings and add touch positions) + ...ControllerButton.values + .filter((e) => e.action == InGameAction.steerRight) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.arrowRight, + logicalKey: LogicalKeyboardKey.arrowRight, + touchPosition: Offset(56.75858807279006, 92.42753954973301), + ), + ), + + ...ControllerButton.values + .filter((e) => e.action == InGameAction.steerLeft) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.arrowLeft, + logicalKey: LogicalKeyboardKey.arrowLeft, + touchPosition: Offset(41.11538461538456, 92.64957264957286), + ), + ), + KeyPair( + buttons: [ZwiftButtons.navigationUp], + physicalKey: PhysicalKeyboardKey.arrowUp, + logicalKey: LogicalKeyboardKey.arrowUp, + touchPosition: Offset(42.28406293368177, 92.61854987939971), + ), + + // Face buttons with touch positions and keyboard fallbacks where sensible + KeyPair( + buttons: [ZwiftButtons.z, EliteSquareButtons.z], + physicalKey: null, + logicalKey: null, + touchPosition: Offset(33.993890038715456, 92.43667306401531), + ), + KeyPair( + buttons: [ZwiftButtons.a, EliteSquareButtons.a], + physicalKey: null, + logicalKey: null, + touchPosition: Offset(47.37191097597044, 92.86963594239016), + ), + KeyPair( + buttons: [ZwiftButtons.b, EliteSquareButtons.b], + physicalKey: null, + logicalKey: null, + touchPosition: Offset(41.12364102683652, 83.72743323236598), + ), + KeyPair( + buttons: [ZwiftButtons.y, EliteSquareButtons.y], + physicalKey: null, + logicalKey: null, + touchPosition: Offset(58.52936866684111, 84.31131200977018), + ), + + // Keep other existing mappings (toggle UI, increase/decrease resistance) + ...ControllerButton.values + .filter((e) => e.action == InGameAction.toggleUi) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.keyH, + logicalKey: LogicalKeyboardKey.keyH, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.increaseResistance) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.pageUp, + logicalKey: LogicalKeyboardKey.pageUp, + ), + ), + ...ControllerButton.values + .filter((e) => e.action == InGameAction.decreaseResistance) + .map( + (b) => KeyPair( + buttons: [b], + physicalKey: PhysicalKeyboardKey.pageDown, + logicalKey: LogicalKeyboardKey.pageDown, + ), + ), + ], + ), + ); +} diff --git a/lib/utils/keymap/apps/zwift.dart b/lib/utils/keymap/apps/zwift.dart new file mode 100644 index 000000000..d24b0502f --- /dev/null +++ b/lib/utils/keymap/apps/zwift.dart @@ -0,0 +1,110 @@ +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:flutter/services.dart'; + +import '../keymap.dart'; + +class Zwift extends SupportedApp { + Zwift() + : super( + name: 'Zwift', + packageName: "Zwift", + supportsZwiftEmulation: true, + compatibleTargets: [ + Target.thisDevice, + Target.otherDevice, + ], + keymap: Keymap( + keyPairs: [ + KeyPair( + buttons: [ZwiftButtons.navigationUp], + physicalKey: PhysicalKeyboardKey.arrowUp, + logicalKey: LogicalKeyboardKey.arrowUp, + inGameAction: InGameAction.openActionBar, + ), + KeyPair( + buttons: [ZwiftButtons.navigationDown], + physicalKey: PhysicalKeyboardKey.arrowDown, + logicalKey: LogicalKeyboardKey.arrowDown, + inGameAction: InGameAction.uturn, + ), + KeyPair( + buttons: [ZwiftButtons.navigationLeft], + physicalKey: PhysicalKeyboardKey.arrowLeft, + logicalKey: LogicalKeyboardKey.arrowLeft, + inGameAction: InGameAction.steerLeft, + ), + KeyPair( + buttons: [ZwiftButtons.navigationRight], + physicalKey: PhysicalKeyboardKey.arrowRight, + logicalKey: LogicalKeyboardKey.arrowRight, + inGameAction: InGameAction.steerRight, + ), + KeyPair( + buttons: [ZwiftButtons.shiftUpLeft], + physicalKey: null, + logicalKey: null, + inGameAction: InGameAction.shiftDown, + ), + KeyPair( + buttons: [ZwiftButtons.shiftUpRight], + physicalKey: null, + logicalKey: null, + inGameAction: InGameAction.shiftUp, + ), + KeyPair( + buttons: [ZwiftButtons.shiftDownLeft], + physicalKey: null, + logicalKey: null, + inGameAction: InGameAction.shiftDown, + ), + KeyPair( + buttons: [ZwiftButtons.shiftDownRight], + physicalKey: null, + logicalKey: null, + inGameAction: InGameAction.shiftUp, + ), + KeyPair( + buttons: [ZwiftButtons.paddleLeft], + physicalKey: null, + logicalKey: null, + inGameAction: InGameAction.shiftDown, + ), + KeyPair( + buttons: [ZwiftButtons.paddleRight], + physicalKey: null, + logicalKey: null, + inGameAction: InGameAction.shiftUp, + ), + KeyPair( + buttons: [ZwiftButtons.y], + physicalKey: PhysicalKeyboardKey.space, + logicalKey: LogicalKeyboardKey.space, + inGameAction: InGameAction.usePowerUp, + isLongPress: true, + ), + KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.enter, + logicalKey: LogicalKeyboardKey.enter, + inGameAction: InGameAction.select, + ), + KeyPair( + buttons: [ZwiftButtons.b], + physicalKey: PhysicalKeyboardKey.escape, + logicalKey: LogicalKeyboardKey.escape, + inGameAction: InGameAction.back, + ), + KeyPair( + buttons: [ZwiftButtons.z], + physicalKey: null, + logicalKey: null, + inGameAction: InGameAction.rideOnBomb, + isLongPress: true, + ), + ], + ), + ); +} diff --git a/lib/utils/keymap/buttons.dart b/lib/utils/keymap/buttons.dart new file mode 100644 index 000000000..18af0e4fe --- /dev/null +++ b/lib/utils/keymap/buttons.dart @@ -0,0 +1,143 @@ +import 'package:bike_control/bluetooth/devices/cycplus/cycplus_bc2.dart'; +import 'package:bike_control/bluetooth/devices/elite/elite_square.dart'; +import 'package:bike_control/bluetooth/devices/elite/elite_sterzo.dart'; +import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart'; +import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart'; +import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart'; +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/widgets/keymap_explanation.dart'; +import 'package:dartx/dartx.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +enum InGameAction { + shiftUp('Shift Up', icon: BootstrapIcons.patchPlus), + shiftDown('Shift Down', icon: BootstrapIcons.patchMinus), + uturn('U-Turn', alternativeTitle: 'Down', icon: BootstrapIcons.arrowDownUp), + tuck('Tuck', icon: BootstrapIcons.speedometer), + steerLeft('Steer Left', alternativeTitle: 'Left', icon: RadixIcons.doubleArrowLeft, isLongPress: true), + steerRight('Steer Right', alternativeTitle: 'Right', icon: RadixIcons.doubleArrowRight, isLongPress: true), + + // mywhoosh + cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], icon: BootstrapIcons.cameraReels), + emote('Emote', possibleValues: [1, 2, 3, 4, 5, 6], icon: BootstrapIcons.emojiSmile), + toggleUi('Toggle UI', icon: RadixIcons.iconSwitch), + navigateLeft('Navigate Left', icon: BootstrapIcons.signTurnLeft), + navigateRight('Navigate Right', icon: BootstrapIcons.signTurnRight), + increaseResistance('Increase Resistance', icon: LucideIcons.chartNoAxesColumnIncreasing), + decreaseResistance('Decrease Resistance', icon: LucideIcons.chartNoAxesColumnDecreasing), + + // zwift + openActionBar('Open Action Bar', alternativeTitle: 'Up', icon: BootstrapIcons.menuApp, isLongPress: true), + usePowerUp('Use Power-Up', icon: Icons.flash_on_outlined, isLongPress: true), + select('Select', icon: LucideIcons.mousePointerClick), + back('Back', icon: BootstrapIcons.arrowLeft), + rideOnBomb('Ride On Bomb', icon: LucideIcons.bomb, isLongPress: true), + + // rouvy + kudos('Kudos', icon: BootstrapIcons.handThumbsUp), + pause('Pause/Resume', icon: BootstrapIcons.pause, isLongPress: true), + + // headwind + headwindSpeed('Headwind Speed', possibleValues: [0, 25, 50, 75, 100], icon: Icons.air), + headwindHeartRateMode('Headwind HR Mode', icon: Icons.favorite), + + // openbikecontrol + up('Up', icon: RadixIcons.arrowUp), + down('Down', icon: RadixIcons.arrowDown), + home('Home', icon: RadixIcons.home), + menu('Menu', icon: RadixIcons.dropdownMenu); + + final String title; + final bool isLongPress; + final IconData? icon; + final String? alternativeTitle; + final List? possibleValues; + + const InGameAction(this.title, {this.possibleValues, this.alternativeTitle, this.icon, this.isLongPress = false}); + + @override + String toString() { + return title; + } +} + +class ControllerButton { + static const int _deviceIdSuffixLength = 4; + static const _unset = Object(); + final String name; + final int? identifier; + final InGameAction? action; + final Color? color; + final IconData? icon; + final String? sourceDeviceId; + + const ControllerButton( + this.name, { + this.color, + this.icon, + this.identifier, + this.action, + this.sourceDeviceId, + }); + + ControllerButton copyWith({ + String? name, + int? identifier, + InGameAction? action, + Color? color, + IconData? icon, + Object? sourceDeviceId = _unset, + }) { + final newSourceDeviceId = sourceDeviceId == _unset ? this.sourceDeviceId : sourceDeviceId as String?; + + return ControllerButton( + name ?? this.name, + color: color ?? this.color, + icon: icon ?? this.icon, + identifier: identifier ?? this.identifier, + action: action ?? this.action, + sourceDeviceId: newSourceDeviceId, + ); + } + + String get displayName { + if (sourceDeviceId == null) { + return name.splitByUpperCase(); + } + + final shortenedId = sourceDeviceId!.length <= _deviceIdSuffixLength + ? sourceDeviceId! + : sourceDeviceId!.substring(sourceDeviceId!.length - _deviceIdSuffixLength); + return '${name.splitByUpperCase()} (${shortenedId.toUpperCase()})'; + } + + @override + String toString() { + return name; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ControllerButton && + runtimeType == other.runtimeType && + name == other.name && + identifier == other.identifier && + action == other.action && + color == other.color && + icon == other.icon && + sourceDeviceId == other.sourceDeviceId; + + @override + int get hashCode => Object.hash(name, action, identifier, color, icon, sourceDeviceId); + + static List get values => [ + ...SterzoButtons.values, + ...GyroscopeSteeringButtons.values, + ...ZwiftButtons.values, + ...EliteSquareButtons.values, + ...WahooKickrShiftButtons.values, + ...CycplusBc2Buttons.values, + ...OpenBikeProtocolParser.BUTTON_NAMES.values, + ].distinct().toList(); +} diff --git a/lib/utils/keymap/keymap.dart b/lib/utils/keymap/keymap.dart index e3933b810..0ddfceec0 100644 --- a/lib/utils/keymap/keymap.dart +++ b/lib/utils/keymap/keymap.dart @@ -1,12 +1,549 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:accessibility/accessibility.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../actions/base_actions.dart'; +import 'apps/custom_app.dart'; + +enum AndroidSystemAction { + back('Back', Icons.arrow_back_ios, GlobalAction.back), + dpadCenter('Select', Icons.radio_button_checked_outlined, GlobalAction.dpadCenter), + down('Arrow Down', Icons.arrow_downward, GlobalAction.down), + right('Arrow Right', Icons.arrow_forward, GlobalAction.right), + up('Arrow Up', Icons.arrow_upward, GlobalAction.up), + left('Arrow Left', Icons.arrow_back, GlobalAction.left), + home('Home', Icons.home_outlined, GlobalAction.home), + recents('Recents', Icons.apps, GlobalAction.recents), + assistant('Open Assistant', Icons.assistant_outlined, null); + + final String title; + final IconData icon; + final GlobalAction? globalAction; + + const AndroidSystemAction(this.title, this.icon, this.globalAction); +} + +class Keymap { + static Keymap custom = Keymap(keyPairs: []); + + List keyPairs; + + Keymap({required this.keyPairs}); + + final StreamController _updateStream = StreamController.broadcast(); + Stream get updateStream => _updateStream.stream; + + @override + String toString() { + return keyPairs.joinToString( + separator: ('\n---------\n'), + transform: (k) => + '''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nTrigger: ${k.trigger.title}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}''', + ); + } + + PhysicalKeyboardKey? getPhysicalKey(ControllerButton action) { + // get the key pair by in game action + return getKeyPair(action, trigger: ButtonTrigger.singleClick)?.physicalKey; + } + + List getKeyPairs(ControllerButton action) { + return keyPairs.where((element) => element.buttons.contains(action)).toList(); + } + + KeyPair? getKeyPair(ControllerButton action, {ButtonTrigger? trigger}) { + final pairs = getKeyPairs(action); + if (trigger != null) { + return pairs.firstOrNullWhere((element) => element.trigger == trigger); + } + return pairs.firstOrNullWhere((element) => element.trigger == ButtonTrigger.singleClick) ?? pairs.firstOrNull; + } + + KeyPair getOrCreateKeyPair(ControllerButton button, {required ButtonTrigger trigger}) { + final existing = getKeyPair(button, trigger: trigger); + if (existing != null) { + if (existing.buttons.length > 1) { + existing.buttons.remove(button); + final keyPair = KeyPair( + touchPosition: existing.touchPosition, + buttons: [button], + physicalKey: existing.physicalKey, + logicalKey: existing.logicalKey, + modifiers: List.of(existing.modifiers), + trigger: existing.trigger, + inGameAction: existing.inGameAction, + inGameActionValue: existing.inGameActionValue, + androidAction: existing.androidAction, + command: existing.command, + screenshotPath: existing.screenshotPath, + ); + addKeyPair(keyPair); + return keyPair; + } + return existing; + } + + final keyPair = KeyPair( + touchPosition: Offset.zero, + buttons: [button], + physicalKey: null, + logicalKey: null, + trigger: trigger, + ); + addKeyPair(keyPair); + return keyPair; + } + + bool hasAnyMappedAction(ControllerButton button) { + return getKeyPairs(button).any((keyPair) => !keyPair.hasNoAction); + } + + void reset() { + for (final keyPair in keyPairs) { + keyPair.physicalKey = null; + keyPair.logicalKey = null; + keyPair.touchPosition = Offset.zero; + keyPair.trigger = ButtonTrigger.singleClick; + keyPair.inGameAction = null; + keyPair.inGameActionValue = null; + keyPair.androidAction = null; + keyPair.command = null; + keyPair.screenshotPath = null; + } + _updateStream.add(null); + } + + void addKeyPair(KeyPair keyPair) { + keyPairs.add(keyPair); + _updateStream.add(null); + + if (core.actionHandler.supportedApp is CustomApp) { + core.settings.setKeyMap(core.actionHandler.supportedApp!); + } + } + + ControllerButton getOrAddButton(String name, ControllerButton button) { + final allButtons = keyPairs.expand((kp) => kp.buttons).toSet().toList(); + if (allButtons.none((b) => b.name == name)) { + addKeyPair( + KeyPair( + touchPosition: Offset.zero, + buttons: [button], + physicalKey: null, + logicalKey: null, + inGameAction: button.action, + trigger: button.action?.isLongPress == true ? ButtonTrigger.longPress : ButtonTrigger.singleClick, + ), + ); + return button; + } else { + return allButtons.firstWhere((b) => b.name == name); + } + } + + void addNewButtons(List availableButtons) { + final newButtons = availableButtons.filter( + (button) => getKeyPair(button, trigger: ButtonTrigger.singleClick) == null, + ); + for (final button in newButtons) { + final buttonFromBase = core.settings.getTrainerApp()?.keymap.getKeyPair( + button, + trigger: ButtonTrigger.singleClick, + ); + addKeyPair( + KeyPair( + touchPosition: buttonFromBase?.touchPosition ?? Offset.zero, + buttons: [button], + inGameAction: button.action, + physicalKey: buttonFromBase?.physicalKey, + logicalKey: buttonFromBase?.logicalKey, + trigger: + buttonFromBase?.trigger ?? + (button.action?.isLongPress == true ? ButtonTrigger.longPress : ButtonTrigger.singleClick), + inGameActionValue: buttonFromBase?.inGameActionValue, + androidAction: buttonFromBase?.androidAction, + command: buttonFromBase?.command, + screenshotPath: buttonFromBase?.screenshotPath, + ), + ); + } + } + + void signalUpdate() { + _updateStream.add(null); + } +} + +class KeyPair { + final List buttons; + PhysicalKeyboardKey? physicalKey; + LogicalKeyboardKey? logicalKey; + List modifiers; + Offset touchPosition; + ButtonTrigger trigger; + InGameAction? inGameAction; + int? inGameActionValue; + AndroidSystemAction? androidAction; + String? command; + String? screenshotPath; + + KeyPair({ + required this.buttons, + required this.physicalKey, + required this.logicalKey, + this.modifiers = const [], + this.touchPosition = Offset.zero, + this.trigger = ButtonTrigger.singleClick, + bool isLongPress = false, + this.inGameAction, + this.inGameActionValue, + this.androidAction, + this.command, + this.screenshotPath, + }) { + if (isLongPress) { + this.trigger = ButtonTrigger.longPress; + } + } + + bool get isLongPress => trigger == ButtonTrigger.longPress; + + set isLongPress(bool value) { + if (value) { + trigger = ButtonTrigger.longPress; + } else if (trigger == ButtonTrigger.longPress) { + trigger = ButtonTrigger.singleClick; + } + } -enum Keymap { - myWhoosh(increase: PhysicalKeyboardKey.keyK, decrease: PhysicalKeyboardKey.keyI), - indieVelo(increase: PhysicalKeyboardKey(0x70030), decrease: PhysicalKeyboardKey(0x70038)), - plusMinus(increase: PhysicalKeyboardKey(0x70030), decrease: PhysicalKeyboardKey(0x70038)); + bool get isSpecialKey => + physicalKey == PhysicalKeyboardKey.mediaPlayPause || + physicalKey == PhysicalKeyboardKey.mediaTrackNext || + physicalKey == PhysicalKeyboardKey.mediaTrackPrevious || + physicalKey == PhysicalKeyboardKey.mediaStop || + physicalKey == PhysicalKeyboardKey.audioVolumeUp || + physicalKey == PhysicalKeyboardKey.audioVolumeDown; + + IconData? get icon { + return switch (physicalKey) { + _ when isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media) => switch (physicalKey) { + PhysicalKeyboardKey.mediaPlayPause => Icons.play_arrow_outlined, + PhysicalKeyboardKey.mediaStop => Icons.stop, + PhysicalKeyboardKey.mediaTrackPrevious => Icons.skip_previous, + PhysicalKeyboardKey.mediaTrackNext => Icons.skip_next, + PhysicalKeyboardKey.audioVolumeUp => Icons.volume_up, + PhysicalKeyboardKey.audioVolumeDown => Icons.volume_down, + _ => Icons.keyboard, + }, + //_ when inGameAction != null && core.logic.emulatorEnabled => Icons.link, + _ + when inGameAction != null && + inGameAction!.icon != null && + (core.logic.emulatorEnabled || + [InGameAction.headwindHeartRateMode, InGameAction.headwindSpeed].contains(inGameAction!)) => + inGameAction!.icon, + + _ when screenshotPath != null && screenshotPath!.trim().isNotEmpty => Icons.image_outlined, + _ when command != null && command!.trim().isNotEmpty => + Platform.isMacOS || Platform.isIOS ? Icons.rocket_launch_outlined : Icons.terminal, + _ + when androidAction != null && + core.logic.showLocalControl && + core.settings.getLocalEnabled() && + core.actionHandler is AndroidActions => + androidAction!.icon, + _ when physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard) => + RadixIcons.keyboard, + _ + when touchPosition != Offset.zero && + core.logic.showLocalRemoteOptions && + core.actionHandler is AndroidActions => + Icons.touch_app, + _ when touchPosition != Offset.zero && core.logic.showLocalRemoteOptions => BootstrapIcons.mouse, + _ => null, + }; + } + + bool get hasNoAction => + logicalKey == null && + physicalKey == null && + touchPosition == Offset.zero && + inGameAction == null && + androidAction == null && + (screenshotPath == null || screenshotPath!.trim().isEmpty) && + (command == null || command!.trim().isEmpty); + + bool get hasActiveAction => + screenshotMode || + (physicalKey != null && (core.logic.showLocalControl && core.settings.getLocalEnabled()) || + (core.logic.showRemote && core.settings.getRemoteKeyboardControlEnabled()) && + core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) || + (isSpecialKey && + core.logic.showLocalControl && + core.settings.getLocalEnabled() && + core.actionHandler is AndroidActions) || + (androidAction != null && + core.logic.showLocalControl && + core.settings.getLocalEnabled() && + core.actionHandler is AndroidActions) || + (touchPosition != Offset.zero && + core.logic.showLocalRemoteOptions && + core.actionHandler.supportedModes.contains(SupportedMode.touch)) || + (inGameAction != null && + core.logic.obpConnectedApp != null && + core.logic.obpConnectedApp!.supportedActions.contains(inGameAction)) || + (inGameAction != null && + core.logic.showMyWhooshLink && + core.settings.getMyWhooshLinkEnabled() && + core.whooshLink.supportedActions.contains(inGameAction)) || + (inGameAction != null && + core.logic.showZwiftBleEmulator && + core.settings.getZwiftBleEmulatorEnabled() && + core.zwiftEmulator.supportedActions.contains(inGameAction)) || + (inGameAction != null && + core.logic.showZwiftMsdnEmulator && + core.settings.getZwiftMdnsEmulatorEnabled() && + core.zwiftMdnsEmulator.supportedActions.contains(inGameAction)) || + (inGameAction != null && + [InGameAction.headwindHeartRateMode, InGameAction.headwindSpeed].contains(inGameAction) && + (core.connection.accessories.isNotEmpty || kDebugMode)) || + (screenshotPath != null && screenshotPath!.trim().isNotEmpty) || + (command != null && command!.trim().isNotEmpty); + + @override + String toString() { + final text = + (inGameAction != null && + (core.logic.emulatorEnabled || + [InGameAction.headwindHeartRateMode, InGameAction.headwindSpeed].contains(inGameAction!))) + ? [ + inGameAction!.title, + if (inGameActionValue != null) '$inGameActionValue', + ].joinToString(separator: ': ') + : (androidAction != null && core.logic.showLocalControl && core.actionHandler is AndroidActions) + ? androidAction!.title + : (screenshotPath != null && screenshotPath!.trim().isNotEmpty) + ? screenshotPath! + : (command != null && command!.trim().isNotEmpty) + ? command! + : (isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media)) + ? switch (physicalKey) { + PhysicalKeyboardKey.mediaPlayPause => AppLocalizations.current.playPause, + PhysicalKeyboardKey.mediaStop => AppLocalizations.current.stop, + PhysicalKeyboardKey.mediaTrackPrevious => AppLocalizations.current.previous, + PhysicalKeyboardKey.mediaTrackNext => AppLocalizations.current.next, + PhysicalKeyboardKey.audioVolumeUp => AppLocalizations.current.volumeUp, + PhysicalKeyboardKey.audioVolumeDown => AppLocalizations.current.volumeDown, + _ => 'Unknown', + } + : (physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) + ? null + : (touchPosition != Offset.zero && core.logic.showLocalRemoteOptions) + ? 'X:${touchPosition.dx.toInt()}, Y:${touchPosition.dy.toInt()}${inGameAction != null ? ' (${inGameAction!.title})' : ''}' + : ''; + if (text != null && text.isNotEmpty) { + return text; + } + final baseKey = logicalKey?.keyLabel ?? text ?? 'Not assigned'; + + if (physicalKey == null || !core.actionHandler.supportedModes.contains(SupportedMode.keyboard)) { + return 'Not assigned'; + } + if (modifiers.isEmpty || baseKey == 'Not assigned') { + if (baseKey.trim().isEmpty) { + return 'Space'; + } + return baseKey + (inGameAction != null ? ' (${inGameAction!.title})' : ''); + } + + // Format modifiers + key (e.g., "Ctrl+Alt+R") + final modifierStrings = modifiers.map((m) { + return switch (m) { + ModifierKey.shiftModifier => 'Shift', + ModifierKey.controlModifier => 'Ctrl', + ModifierKey.altModifier => 'Alt', + ModifierKey.metaModifier => 'Meta', + ModifierKey.functionModifier => 'Fn', + _ => m.name, + }; + }).toList(); + + return '${modifierStrings.join('+')}+$baseKey'; + } + + String encode() { + // encode to save in preferences + + return jsonEncode({ + 'actions': buttons + .map( + (e) => e.sourceDeviceId == null + ? e.name + : { + 'name': e.name, + 'deviceId': e.sourceDeviceId, + }, + ) + .toList(), + if (logicalKey != null) 'logicalKey': logicalKey?.keyId.toString(), + if (physicalKey != null) 'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0', + if (modifiers.isNotEmpty) 'modifiers': modifiers.map((e) => e.name).toList(), + if (touchPosition != Offset.zero) 'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy}, + 'trigger': trigger.name, + // Keep for backward compatibility with older app versions. + 'isLongPress': isLongPress, + 'inGameAction': inGameAction?.name, + 'inGameActionValue': inGameActionValue, + 'androidAction': androidAction?.name, + 'command': command, + 'screenshotPath': screenshotPath, + }); + } + + static KeyPair? decode(String data) { + // decode from preferences + final decoded = jsonDecode(data); + + // Support both percentage-based (new) and pixel-based (old) formats for backward compatibility + final Offset touchPosition = decoded.containsKey('touchPosition') + ? Offset( + (decoded['touchPosition']['x'] as num).toDouble(), + (decoded['touchPosition']['y'] as num).toDouble(), + ) + : Offset.zero; + + ControllerButton? decodeButton(dynamic raw) { + String? name; + String? deviceId; + + if (raw is String) { + name = raw; + } else if (raw is Map) { + name = raw['name']?.toString(); + deviceId = raw['deviceId']?.toString(); + } + + if (name == null) { + return null; + } + + final baseButton = ControllerButton.values.firstOrNullWhere((element) => element.name == name); + + if (baseButton != null) { + return baseButton.copyWith(sourceDeviceId: deviceId); + } + + return ControllerButton(name, sourceDeviceId: deviceId); + } + + final buttons = (decoded['actions'] as List) + .map(decodeButton) + .whereType() + .toList(); + if (buttons.isEmpty) { + return null; + } + + // Decode modifiers if present + final List modifiers = decoded.containsKey('modifiers') + ? (decoded['modifiers'] as List) + .map((e) => ModifierKey.values.firstOrNullWhere((element) => element.name == e)) + .whereType() + .toList() + : []; + + final rawCommand = decoded['command']?.toString().trim(); + final rawScreenshotPath = decoded['screenshotPath']?.toString().trim(); + final rawLegacyShortcutName = decoded['shortcutName']?.toString().trim(); + + final decodedTrigger = decoded.containsKey('trigger') + ? ButtonTrigger.values.firstOrNullWhere((element) => element.name == decoded['trigger']) + : null; + + return KeyPair( + buttons: buttons, + logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0 + ? LogicalKeyboardKey(int.parse(decoded['logicalKey'])) + : null, + physicalKey: decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0 + ? PhysicalKeyboardKey(int.parse(decoded['physicalKey'])) + : null, + modifiers: modifiers, + touchPosition: touchPosition, + trigger: + decodedTrigger ?? ((decoded['isLongPress'] ?? false) ? ButtonTrigger.longPress : ButtonTrigger.singleClick), + inGameAction: decoded.containsKey('inGameAction') + ? InGameAction.values.firstOrNullWhere((element) => element.name == decoded['inGameAction']) + : null, + inGameActionValue: decoded['inGameActionValue'], + androidAction: decoded.containsKey('androidAction') + ? AndroidSystemAction.values.firstOrNullWhere((element) => element.name == decoded['androidAction']) + : null, + command: rawCommand != null && rawCommand.isNotEmpty + ? rawCommand + : (rawLegacyShortcutName != null && rawLegacyShortcutName.isNotEmpty ? rawLegacyShortcutName : null), + screenshotPath: rawScreenshotPath != null && rawScreenshotPath.isNotEmpty ? rawScreenshotPath : null, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is KeyPair && + runtimeType == other.runtimeType && + physicalKey == other.physicalKey && + logicalKey == other.logicalKey && + modifiers == other.modifiers && + touchPosition == other.touchPosition && + trigger == other.trigger && + inGameAction == other.inGameAction && + inGameActionValue == other.inGameActionValue && + androidAction == other.androidAction && + command == other.command && + screenshotPath == other.screenshotPath; + + @override + int get hashCode => Object.hash( + physicalKey, + logicalKey, + modifiers, + touchPosition, + trigger, + inGameAction, + inGameActionValue, + androidAction, + command, + screenshotPath, + ); + + bool get isProAction => + command != null && command!.trim().isNotEmpty || + screenshotPath != null && screenshotPath!.trim().isNotEmpty || + isSpecialKey || + (androidAction != null && core.logic.showLocalControl && core.actionHandler is AndroidActions); +} - final PhysicalKeyboardKey increase; - final PhysicalKeyboardKey decrease; +enum ButtonTrigger { + singleClick, + doubleClick, + longPress; - const Keymap({required this.increase, required this.decrease}); + String get title { + return switch (this) { + ButtonTrigger.singleClick => 'Single Click', + ButtonTrigger.doubleClick => 'Double Click', + ButtonTrigger.longPress => 'Long Press', + }; + } } diff --git a/lib/utils/keymap/manager.dart b/lib/utils/keymap/manager.dart new file mode 100644 index 000000000..39731e41d --- /dev/null +++ b/lib/utils/keymap/manager.dart @@ -0,0 +1,316 @@ +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import 'apps/custom_app.dart'; + +class KeymapManager { + // Singleton instance + static final KeymapManager _instance = KeymapManager._internal(); + + // Private constructor + KeymapManager._internal(); + + // Factory constructor to return the singleton instance + factory KeymapManager() { + return _instance; + } + + Future showNewProfileDialog(BuildContext context) async { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.i18n.newCustomProfile), + content: TextField( + controller: controller, + hintText: context.i18n.profileName, + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(context.i18n.cancel)), + TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text(context.i18n.create)), + ], + ), + ); + } + + Widget getManageProfileDialog( + BuildContext context, + String? currentProfile, { + required VoidCallback onDone, + }) { + return Builder( + builder: (context) { + return Button.outline( + child: Icon(Icons.settings), + onPressed: () => showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + if (currentProfile != null && core.actionHandler.supportedApp is CustomApp) + MenuButton( + child: Text(context.i18n.rename), + onPressed: (c) async { + final newName = await _showRenameProfileDialog( + context, + currentProfile, + ); + if (newName != null && newName.isNotEmpty && newName != currentProfile) { + await core.settings.duplicateCustomAppProfile(currentProfile, newName); + await core.settings.deleteCustomAppProfile(currentProfile); + final customApp = CustomApp(profileName: newName); + final savedKeymap = core.settings.getCustomAppKeymap(newName); + if (savedKeymap != null) { + customApp.decodeKeymap(savedKeymap); + } + core.actionHandler.supportedApp = customApp; + await core.settings.setKeyMap(customApp); + } + onDone(); + }, + ), + if (currentProfile != null) + MenuButton( + child: Text(context.i18n.duplicate), + onPressed: (c) async { + final newName = await duplicate( + context, + currentProfile, + ); + onDone(); + }, + ), + MenuButton( + child: Text(context.i18n.importAction), + onPressed: (c) async { + final jsonData = await _showImportDialog(context); + if (jsonData != null && jsonData.isNotEmpty) { + final success = await core.settings.importCustomAppProfile(jsonData); + if (success) { + buildToast(title: context.i18n.profileImportedSuccessfully); + } else { + buildToast(title: context.i18n.failedToImportProfile); + } + } + }, + ), + if (currentProfile != null) + MenuButton( + child: Text(context.i18n.exportAction), + onPressed: (c) { + final currentProfile = (core.actionHandler.supportedApp as CustomApp).profileName; + final jsonData = core.settings.exportCustomAppProfile(currentProfile); + if (jsonData != null) { + Clipboard.setData(ClipboardData(text: jsonData)); + + buildToast(title: context.i18n.profileExportedToClipboard(currentProfile)); + } + }, + ), + if (currentProfile != null) + MenuButton( + onPressed: (c) async { + final confirmed = await _showDeleteConfirmDialog( + context, + currentProfile, + ); + if (confirmed == true) { + await core.settings.deleteCustomAppProfile(currentProfile); + } + onDone(); + }, + child: Text( + context.i18n.delete, + style: TextStyle(color: Theme.of(context).colorScheme.destructive), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Future _showRenameProfileDialog(BuildContext context, String currentName) async { + final controller = TextEditingController(text: currentName); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.i18n.renameProfile), + content: TextField( + controller: controller, + hintText: context.i18n.profileName, + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(context.i18n.cancel)), + TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text(context.i18n.rename)), + ], + ), + ); + } + + Future _showDuplicateProfileDialog(BuildContext context, String currentName) async { + final controller = TextEditingController(text: '$currentName (Copy)'); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.i18n.createNewProfileByDuplicating(currentName)), + content: TextField( + controller: controller, + placeholder: Text(context.i18n.newProfileName), + hintText: context.i18n.newProfileName, + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(context.i18n.cancel)), + TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text(context.i18n.duplicate)), + ], + ), + ); + } + + Future _showDeleteConfirmDialog(BuildContext context, String profileName) async { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.i18n.deleteProfile), + content: Text(context.i18n.deleteProfileConfirmation(profileName)), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.i18n.cancel)), + DestructiveButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.i18n.delete), + ), + ], + ), + ); + } + + Future _showImportDialog(BuildContext context) async { + final controller = TextEditingController(); + + // Try to get data from clipboard + try { + final clipboardData = await Clipboard.getData('text/plain'); + if (clipboardData?.text != null) { + controller.text = clipboardData!.text!; + } + } catch (e) { + // Ignore clipboard errors + } + + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.i18n.importProfile), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.i18n.pasteExportedJsonData), + SizedBox(height: 16), + TextField( + controller: controller, + hintText: context.i18n.jsonData, + border: Border(), + maxLines: 5, + autofocus: true, + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text(context.i18n.cancel)), + TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text(context.i18n.importAction)), + ], + ), + ); + } + + Future duplicate(BuildContext? context, String currentProfile, {String? skipName}) async { + final newName = skipName ?? await _showDuplicateProfileDialog(context!, currentProfile); + if (newName != null && newName.isNotEmpty) { + if (core.actionHandler.supportedApp is CustomApp) { + await core.settings.duplicateCustomAppProfile(currentProfile, newName); + final customApp = CustomApp(profileName: newName); + final savedKeymap = core.settings.getCustomAppKeymap(newName); + if (savedKeymap != null) { + customApp.decodeKeymap(savedKeymap); + } + core.actionHandler.supportedApp = customApp; + await core.settings.setKeyMap(customApp); + return newName; + } else { + final customApp = CustomApp(profileName: newName); + + final connectedDeviceButtons = core.connection.controllerDevices.expand((e) => e.availableButtons).toSet(); + core.actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) { + pair.buttons.filter((button) => connectedDeviceButtons.contains(button) == true).forEachIndexed(( + button, + indexB, + ) { + customApp.setKey( + button, + physicalKey: pair.physicalKey, + logicalKey: pair.logicalKey, + trigger: pair.trigger, + touchPosition: pair.touchPosition, + inGameAction: pair.inGameAction, + inGameActionValue: pair.inGameActionValue, + modifiers: pair.modifiers, + ); + }); + }); + + core.actionHandler.supportedApp = customApp; + await core.settings.setKeyMap(customApp); + return newName; + } + } + return null; + } + + String duplicateSync(String currentProfile, String newName) { + if (core.actionHandler.supportedApp is CustomApp) { + core.settings.duplicateCustomAppProfile(currentProfile, newName); + final customApp = CustomApp(profileName: newName); + final savedKeymap = core.settings.getCustomAppKeymap(newName); + if (savedKeymap != null) { + customApp.decodeKeymap(savedKeymap); + } + core.actionHandler.supportedApp = customApp; + core.settings.setKeyMap(customApp); + return newName; + } else { + final customApp = CustomApp(profileName: newName); + + final connectedDeviceButtons = core.connection.controllerDevices.expand((e) => e.availableButtons).toSet(); + core.actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) { + pair.buttons.filter((button) => connectedDeviceButtons.contains(button) == true).forEachIndexed(( + button, + indexB, + ) { + customApp.setKey( + button, + physicalKey: pair.physicalKey, + logicalKey: pair.logicalKey, + trigger: pair.trigger, + touchPosition: pair.touchPosition, + inGameAction: pair.inGameAction, + inGameActionValue: pair.inGameActionValue, + modifiers: pair.modifiers, + ); + }); + }); + + core.actionHandler.supportedApp = customApp; + core.settings.setKeyMap(customApp); + return newName; + } + } +} + diff --git a/lib/utils/media_key_handler.dart b/lib/utils/media_key_handler.dart new file mode 100644 index 000000000..8e7257054 --- /dev/null +++ b/lib/utils/media_key_handler.dart @@ -0,0 +1,127 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/hid/hid_device.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_volume_controller/flutter_volume_controller.dart'; +import 'package:media_key_detector/media_key_detector.dart'; + +import 'smtc_stub.dart' if (dart.library.io) 'package:smtc_windows/smtc_windows.dart'; + +class MediaKeyHandler { + final ValueNotifier isMediaKeyDetectionEnabled = ValueNotifier(false); + + bool _smtcInitialized = false; + double? _lastVolume; + SMTCWindows? _smtc; + + void initialize() { + isMediaKeyDetectionEnabled.addListener(() async { + if (!isMediaKeyDetectionEnabled.value) { + FlutterVolumeController.removeListener(); + if (Platform.isWindows) { + _smtc?.disableSmtc(); + } else { + mediaKeyDetector.setIsPlaying(isPlaying: false); + mediaKeyDetector.removeListener(_onMediaKeyDetectedListener); + } + } else { + FlutterVolumeController.addListener( + (volume) { + _lastVolume ??= volume; + if (volume != _lastVolume) { + final bool hasAction; + if (volume > _lastVolume!) { + hasAction = _onMediaKeyDetectedListener(MediaKey.volumeUp); + } else { + hasAction = _onMediaKeyDetectedListener(MediaKey.volumeDown); + } + if (hasAction) { + // revert volume + FlutterVolumeController.setVolume(_lastVolume!); + } else { + _lastVolume = volume; + } + } + }, + ); + if (Platform.isWindows) { + if (!_smtcInitialized) { + _smtcInitialized = true; + await SMTCWindows.initialize(); + } + + _smtc = SMTCWindows( + metadata: const MusicMetadata( + title: 'BikeControl Media Key Handler', + album: 'BikeControl', + albumArtist: 'BikeControl', + artist: 'BikeControl', + ), + // Timeline info for the OS media player + timeline: const PlaybackTimeline( + startTimeMs: 0, + endTimeMs: 1000, + positionMs: 0, + minSeekTimeMs: 0, + maxSeekTimeMs: 1000, + ), + config: const SMTCConfig( + fastForwardEnabled: true, + nextEnabled: true, + pauseEnabled: true, + playEnabled: true, + rewindEnabled: true, + prevEnabled: true, + stopEnabled: true, + ), + ); + _smtc!.buttonPressStream.listen(_onMediaKeyPressedListener); + } else { + mediaKeyDetector.addListener(_onMediaKeyDetectedListener); + mediaKeyDetector.setIsPlaying(isPlaying: true); + } + } + }); + } + + bool _onMediaKeyDetectedListener(MediaKey mediaKey) { + final hidDevice = HidDevice('HID Device'); + + var availableDevice = core.connection.controllerDevices.firstOrNullWhere( + (e) => e.toString() == hidDevice.toString(), + ); + if (availableDevice == null) { + core.connection.addDevices([hidDevice]); + availableDevice = hidDevice; + } + + final keyPressed = mediaKey.name; + + final button = availableDevice.getOrAddButton( + keyPressed, + () => ControllerButton(keyPressed), + ); + + availableDevice.handleButtonsClicked([button]); + + return core.actionHandler.supportedApp?.keymap.hasAnyMappedAction(button) == true; + } + + bool _onMediaKeyPressedListener(PressedButton mediaKey) { + return _onMediaKeyDetectedListener(switch (mediaKey) { + PressedButton.play => MediaKey.playPause, + PressedButton.pause => MediaKey.playPause, + PressedButton.next => MediaKey.fastForward, + PressedButton.previous => MediaKey.rewind, + PressedButton.stop => MediaKey.playPause, + PressedButton.fastForward => MediaKey.fastForward, + PressedButton.rewind => MediaKey.rewind, + PressedButton.record => throw UnimplementedError(), + PressedButton.channelUp => MediaKey.volumeUp, + PressedButton.channelDown => MediaKey.volumeDown, + }); + } +} diff --git a/lib/utils/messages/click_notification.dart b/lib/utils/messages/click_notification.dart deleted file mode 100644 index 05e18b5bc..000000000 --- a/lib/utils/messages/click_notification.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:typed_data'; - -import 'package:swift_control/utils/messages/notification.dart'; - -import '../../protocol/zwift.pb.dart'; - -class ClickNotification extends BaseNotification { - static const int BTN_PRESSED = 0; - - bool buttonUp = false; - bool buttonDown = false; - - ClickNotification(Uint8List message) { - final status = ClickKeyPadStatus.fromBuffer(message); - buttonUp = status.buttonPlus.value == BTN_PRESSED; - buttonDown = status.buttonMinus.value == BTN_PRESSED; - } - - @override - String toString() { - return 'Click: {buttonUp: $buttonUp, buttonDown: $buttonDown}'; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ClickNotification && - runtimeType == other.runtimeType && - buttonUp == other.buttonUp && - buttonDown == other.buttonDown; - - @override - int get hashCode => buttonUp.hashCode ^ buttonDown.hashCode; -} diff --git a/lib/utils/messages/controller_notification.dart b/lib/utils/messages/controller_notification.dart deleted file mode 100644 index 7758c3043..000000000 --- a/lib/utils/messages/controller_notification.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'dart:typed_data'; - -import 'package:swift_control/utils/messages/notification.dart'; - -import '../../protocol/zwift.pb.dart'; - -class ControllerNotification extends BaseNotification { - static const int BTN_PRESSED = 0; - - late bool rightPad, buttonY, buttonZ, buttonA, buttonB, buttonOn, buttonShift; - late int analogLR, analogUD; - - ControllerNotification(Uint8List message) { - final status = PlayKeyPadStatus.fromBuffer(message); - - rightPad = status.rightPad.value == BTN_PRESSED; - buttonY = status.buttonYUp.value == BTN_PRESSED; - buttonZ = status.buttonZLeft.value == BTN_PRESSED; - buttonA = status.buttonARight.value == BTN_PRESSED; - buttonB = status.buttonBDown.value == BTN_PRESSED; - buttonOn = status.buttonOn.value == BTN_PRESSED; - buttonShift = status.buttonShift.value == BTN_PRESSED; - analogLR = status.analogLR; - analogUD = status.analogUD; - } - - @override - String toString() { - final allTrueParameters = [ - //if (rightPad) 'rightPad', - if (buttonY) 'buttonY', - if (buttonZ) 'buttonZ', - if (buttonA) 'buttonA', - if (buttonB) 'buttonB', - if (buttonOn) 'buttonOn', - if (buttonShift) 'buttonShift', - ]; - return '${rightPad ? 'Right' : 'Left'}: {$allTrueParameters, analogLR: $analogLR, analogUD: $analogUD}'; - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ControllerNotification && - runtimeType == other.runtimeType && - rightPad == other.rightPad && - buttonY == other.buttonY && - buttonZ == other.buttonZ && - buttonA == other.buttonA && - buttonB == other.buttonB && - buttonOn == other.buttonOn && - buttonShift == other.buttonShift && - analogLR == other.analogLR && - analogUD == other.analogUD; - - @override - int get hashCode => - rightPad.hashCode ^ - buttonY.hashCode ^ - buttonZ.hashCode ^ - buttonA.hashCode ^ - buttonB.hashCode ^ - buttonOn.hashCode ^ - buttonShift.hashCode ^ - analogLR.hashCode ^ - analogUD.hashCode; -} diff --git a/lib/utils/messages/notification.dart b/lib/utils/messages/notification.dart deleted file mode 100644 index 28b0649c9..000000000 --- a/lib/utils/messages/notification.dart +++ /dev/null @@ -1,12 +0,0 @@ -class BaseNotification {} - -class LogNotification extends BaseNotification { - final String message; - - LogNotification(this.message); - - @override - String toString() { - return message; - } -} diff --git a/lib/utils/requirements/android.dart b/lib/utils/requirements/android.dart index bd1f30ec1..0519473da 100644 --- a/lib/utils/requirements/android.dart +++ b/lib/utils/requirements/android.dart @@ -1,70 +1,239 @@ +import 'dart:async'; import 'dart:io'; +import 'dart:isolate'; +import 'dart:ui'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/requirements/platform.dart'; +import 'package:bike_control/widgets/accessibility_disclosure_dialog.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:swift_control/main.dart'; -import 'package:swift_control/utils/requirements/platform.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; +import 'package:url_launcher/url_launcher_string.dart'; class AccessibilityRequirement extends PlatformRequirement { - AccessibilityRequirement() : super('Allow Accessibility Service'); + AccessibilityRequirement() + : super( + AppLocalizations.current.allowAccessibilityService, + description: AppLocalizations.current.accessibilityDescription, + icon: Icons.accessibility_new, + ); @override - Future call() async { - return accessibilityHandler.openPermissions(); + Future call(BuildContext context, VoidCallback onUpdate) async { + await _showDisclosureDialog(context, onUpdate); + await getStatus(); } @override - Future getStatus() async { - status = await accessibilityHandler.hasPermission(); + Future getStatus() async { + status = await (core.actionHandler as AndroidActions).accessibilityHandler.hasPermission(); + return status; + } + + Future _showDisclosureDialog(BuildContext context, VoidCallback onUpdate) async { + return showDialog( + context: context, + barrierDismissible: false, // Prevent dismissing by tapping outside + builder: (BuildContext context) { + return AccessibilityDisclosureDialog( + onAccept: () { + Navigator.of(context).pop(); + // Open accessibility settings after user consents + (core.actionHandler as AndroidActions).accessibilityHandler.openPermissions().then((_) async { + await getStatus(); + onUpdate(); + }); + }, + onDeny: () async { + await getStatus(); + Navigator.of(context).pop(); + // User denied, no action taken + }, + ); + }, + ); } } -class NotificationRequirement extends PlatformRequirement { - NotificationRequirement() : super('Allow adding persistent Notification'); +class BluetoothScanRequirement extends PlatformRequirement { + BluetoothScanRequirement() : super(AppLocalizations.current.allowBluetoothScan, icon: Icons.bluetooth_searching); + + @override + Future call(BuildContext context, VoidCallback onUpdate) async { + await Permission.bluetoothScan.request(); + await getStatus(); + } + + @override + Future getStatus() async { + final state = await Permission.bluetoothScan.status; + status = state.isGranted || state.isLimited; + return status; + } +} + +class LocationRequirement extends PlatformRequirement { + LocationRequirement() : super(AppLocalizations.current.allowLocationForBluetooth, icon: Icons.location_on); + + @override + Future call(BuildContext context, VoidCallback onUpdate) async { + await Permission.locationWhenInUse.request(); + await getStatus(); + } + + @override + Future getStatus() async { + final state = await Permission.locationWhenInUse.status; + status = state.isGranted || state.isLimited; + return status; + } +} + +class BluetoothConnectRequirement extends PlatformRequirement { + BluetoothConnectRequirement() + : super(AppLocalizations.current.allowBluetoothConnections, icon: Icons.bluetooth_connected); + + @override + Future call(BuildContext context, VoidCallback onUpdate) async { + await Permission.bluetoothConnect.request(); + await getStatus(); + } @override - Future call() async { - await flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation() - ?.requestNotificationsPermission(); + Future getStatus() async { + final state = await Permission.bluetoothConnect.status; + status = state.isGranted || state.isLimited; + return status; + } +} + +ReceivePort? _receivePort; +StreamSubscription? _sub; + +class NotificationRequirement extends PlatformRequirement { + NotificationRequirement() + : super( + AppLocalizations.current.allowPersistentNotification, + description: AppLocalizations.current.notificationDescription, + icon: Icons.notifications_active, + ); + @override + Future call(BuildContext context, VoidCallback onUpdate) async { + if (Platform.isAndroid) { + final result = await core.flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.requestNotificationsPermission(); + if (result == false) { + buildToast( + title: 'Enable notifications for BikeControl in Android Settings', + ); + } + } else if (Platform.isIOS) { + final result = await core.flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.requestPermissions( + alert: true, + badge: false, + sound: false, + ); + core.settings.setHasAskedPermissions(true); + if (result == false) { + buildToast( + title: 'Enable notifications for BikeControl in System Preferences → Notifications → Bike Control', + ); + launchUrlString('x-apple.systempreferences:com.apple.preference.notifications'); + } + } else if (Platform.isMacOS) { + final result = await core.flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.requestPermissions( + alert: true, + badge: false, + sound: false, + ); + core.settings.setHasAskedPermissions(true); + if (result == false) { + buildToast( + title: 'Enable notifications for BikeControl in System Preferences → Notifications → Bike Control', + ); + launchUrlString('x-apple.systempreferences:com.apple.preference.notifications'); + } + } + await getStatus(); return; } @override - Future getStatus() async { - final bool granted = - await flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation() - ?.areNotificationsEnabled() ?? - false; - status = granted; + Future getStatus() async { + if (Platform.isAndroid) { + final bool granted = + await core.flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.areNotificationsEnabled() ?? + false; + status = granted; + } else if (Platform.isIOS) { + final permissions = await core.flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.checkPermissions(); + status = permissions?.isEnabled == true || core.settings.hasAskedPermissions(); + } else if (Platform.isMacOS) { + final permissions = await core.flutterLocalNotificationsPlugin + .resolvePlatformSpecificImplementation() + ?.checkPermissions(); + status = permissions?.isEnabled == true || core.settings.hasAskedPermissions(); + } else { + status = true; + } + return status; } static Future setup() async { - const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings( - '@mipmap/ic_launcher', - ); - - await flutterLocalNotificationsPlugin.initialize( - InitializationSettings(android: initializationSettingsAndroid), + print('NOTIFICATION SETUP'); + await core.flutterLocalNotificationsPlugin.initialize( + InitializationSettings( + android: AndroidInitializationSettings( + '@drawable/ic_notification', + ), + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ), + macOS: DarwinInitializationSettings( + requestAlertPermission: false, + ), + windows: WindowsInitializationSettings( + appName: 'BikeControl', + appUserModelId: 'OpenBikeControl.BikeControl', + guid: UUID.short(0x12).toString(), + ), + ), onDidReceiveBackgroundNotificationResponse: notificationTapBackground, onDidReceiveNotificationResponse: (n) { - if (n.actionId != null) { - connection.reset(); - exit(0); - } + notificationTapBackground(n); }, ); + } - const String channelGroupId = 'SwiftControl'; + static Future addPersistentNotification() async { + const String channelGroupId = 'BikeControl'; // create the group first - await flutterLocalNotificationsPlugin + await core.flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation()! .createNotificationChannelGroup( AndroidNotificationChannelGroup(channelGroupId, channelGroupId, description: 'Keep Alive'), ); // create channels associated with the group - await flutterLocalNotificationsPlugin + await core.flutterLocalNotificationsPlugin .resolvePlatformSpecificImplementation()! .createNotificationChannel( const AndroidNotificationChannel( @@ -78,21 +247,62 @@ class NotificationRequirement extends PlatformRequirement { await AndroidFlutterLocalNotificationsPlugin().startForegroundService( 1, channelGroupId, - 'Bluetooth keep alive', + AppLocalizations.current.allowsRunningInBackground, foregroundServiceTypes: {AndroidServiceForegroundType.foregroundServiceTypeConnectedDevice}, + startType: AndroidServiceStartType.startRedeliverIntent, notificationDetails: AndroidNotificationDetails( channelGroupId, 'Keep Alive', - actions: [AndroidNotificationAction('Exit', 'Exit', cancelNotification: true, showsUserInterface: false)], + actions: [ + AndroidNotificationAction( + 'disconnect', + AppLocalizations.current.disconnectDevices, + cancelNotification: true, + showsUserInterface: false, + ), + AndroidNotificationAction( + 'close', + AppLocalizations.current.close, + cancelNotification: true, + showsUserInterface: false, + ), + ], ), ); + + _receivePort = ReceivePort(); + // If already registered, remove and re-register + IsolateNameServer.removePortNameMapping('_backgroundChannelKey'); + final ok = IsolateNameServer.registerPortWithName(_receivePort!.sendPort, '_backgroundChannelKey'); + if (!ok) { + // If this happens, something else re-registered immediately or you’re in a weird state. + throw StateError('Failed to register port name'); + } + final backgroundMessagePort = _receivePort!.asBroadcastStream(); + _sub = backgroundMessagePort.listen((message) { + print('Background isolate received message: $message'); + if (message == 'disconnect' || message == 'close') { + UniversalBle.onAvailabilityChange = null; + core.connection.disconnectAll(); + } + if (message == 'close') { + core.actionHandler.cleanup(); + core.connection.stop(); + SystemNavigator.pop(); + AndroidFlutterLocalNotificationsPlugin().stopForegroundService(); + AndroidFlutterLocalNotificationsPlugin().cancelAll(); + } + + //exit(0); + }); } } @pragma('vm:entry-point') void notificationTapBackground(NotificationResponse notificationResponse) { if (notificationResponse.actionId != null) { - connection.reset(); - exit(0); + final sendPort = IsolateNameServer.lookupPortByName('_backgroundChannelKey'); + sendPort?.send(notificationResponse.actionId); + //exit(0); } } diff --git a/lib/utils/requirements/multi.dart b/lib/utils/requirements/multi.dart index 405de4106..72da7ee4a 100644 --- a/lib/utils/requirements/multi.dart +++ b/lib/utils/requirements/multi.dart @@ -1,97 +1,197 @@ import 'dart:io'; -import 'package:dartx/dartx.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/keymap/apps/my_whoosh.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/keymap/apps/zwift.dart'; +import 'package:bike_control/utils/requirements/platform.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:bluetooth_low_energy/bluetooth_low_energy.dart'; +import 'package:flutter/foundation.dart'; import 'package:keypress_simulator/keypress_simulator.dart'; -import 'package:swift_control/pages/scan.dart'; -import 'package:swift_control/utils/requirements/platform.dart'; - -import '../../main.dart'; -import '../keymap/keymap.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; class KeyboardRequirement extends PlatformRequirement { - KeyboardRequirement() : super('Keyboard access'); + KeyboardRequirement() : super(AppLocalizations.current.keyboardAccess, icon: Icons.keyboard); @override - Future call() async { - return keyPressSimulator.requestAccess(onlyOpenPrefPane: Platform.isMacOS); + Future call(BuildContext context, VoidCallback onUpdate) async { + buildToast( + title: AppLocalizations.current.enableKeyboardAccessMessage, + ); + await keyPressSimulator.requestAccess(onlyOpenPrefPane: Platform.isMacOS); } @override - Future getStatus() async { + Future getStatus() async { status = await keyPressSimulator.isAccessAllowed(); + return status; } } -class KeymapRequirement extends PlatformRequirement { - KeymapRequirement() : super('Select your Keymap / App'); +class BluetoothAdvertiseRequirement extends PlatformRequirement { + BluetoothAdvertiseRequirement() + : super(AppLocalizations.current.bluetoothAdvertiseAccess, icon: Icons.bluetooth_audio); @override - Future call() async {} - - @override - Future getStatus() async { - status = actionHandler.keymap != null; + Future call(BuildContext context, VoidCallback onUpdate) async { + await Permission.bluetoothAdvertise.request(); } @override - Widget? build(BuildContext context, VoidCallback onUpdate) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: DropdownMenu( - dropdownMenuEntries: - Keymap.values.map((key) => DropdownMenuEntry(value: key, label: key.name.capitalize())).toList(), - onSelected: (keymap) { - actionHandler.init(keymap); - onUpdate(); - }, - initialSelection: null, - hintText: 'Keymap', - ), - ); + Future getStatus() async { + status = await Permission.bluetoothAdvertise.status == PermissionStatus.granted; + return status; } } class BluetoothTurnedOn extends PlatformRequirement { - BluetoothTurnedOn() : super('Bluetooth turned on'); + BluetoothTurnedOn() : super(AppLocalizations.current.bluetoothTurnedOn, icon: Icons.bluetooth); @override - Future call() async { - return FlutterBluePlus.turnOn(); + Future call(BuildContext context, VoidCallback onUpdate) async { + final currentState = await UniversalBle.getBluetoothAvailabilityState(); + if (!kIsWeb && Platform.isIOS) { + // on iOS we cannot programmatically enable Bluetooth, just open settings + await PeripheralManager().showAppSettings(); + } else if (currentState == AvailabilityState.poweredOff) { + if (Platform.isMacOS) { + buildToast(title: name); + } else { + await UniversalBle.enableBluetooth(); + } + } else { + // I guess bluetooth is on now + // TODO move UniversalBle.onAvailabilityChange + getStatus(); + onUpdate(); + } } @override - Future getStatus() async { - status = FlutterBluePlus.adapterStateNow != BluetoothAdapterState.off; + Widget? build(BuildContext context, VoidCallback onUpdate) { + return OutlineButton( + onPressed: () { + call(context, onUpdate); + }, + child: Text(context.i18n.enableBluetooth), + ); + } + + @override + Future getStatus() async { + final currentState = screenshotMode + ? AvailabilityState.poweredOn + : await UniversalBle.getBluetoothAvailabilityState(); + status = currentState == AvailabilityState.poweredOn || screenshotMode; + return status; } } class UnsupportedPlatform extends PlatformRequirement { - UnsupportedPlatform() : super('Unsupported platform :(') { + UnsupportedPlatform() + : super( + kIsWeb + ? AppLocalizations.current.browserNotSupported + : AppLocalizations.current.platformNotSupported('platform'), + icon: Icons.error_outline, + ) { status = false; } @override - Future call() async {} + Future call(BuildContext context, VoidCallback onUpdate) async {} @override - Future getStatus() async {} + Future getStatus() async { + return status; + } } -class BluetoothScanning extends PlatformRequirement { - BluetoothScanning() : super('Bluetooth Scanning') { +class ErrorRequirement extends PlatformRequirement { + ErrorRequirement(super.name, {required super.icon}) { status = false; } @override - Future call() async {} + Future call(BuildContext context, VoidCallback onUpdate) async { + onUpdate(); + } @override - Future getStatus() async {} + Future getStatus() async { + return false; + } +} - @override - Widget? build(BuildContext context, VoidCallback onUpdate) { - return ScanWidget(); +typedef BoolFunction = bool Function(); + +enum Target { + thisDevice( + icon: Icons.devices, + ), + otherDevice( + icon: Icons.settings_remote_outlined, + ); + + final IconData icon; + + const Target({required this.icon}); + + String getTitle(BuildContext context) { + return switch (this) { + Target.thisDevice => context.i18n.targetThisDevice, + Target.otherDevice => context.i18n.targetOtherDevice, + }; + } + + bool get isCompatible { + return core.settings.getTrainerApp()?.compatibleTargets.contains(this) == true; + } + + bool get isBeta { + final supportedApp = core.settings.getTrainerApp(); + + if (supportedApp is Zwift) { + // everything is supported, this device is not compatible anyway + return false; + } + + return switch (this) { + Target.thisDevice => false, + _ => supportedApp == null || supportedApp.supportsOpenBikeProtocol.isEmpty, + }; + } + + String getDescription(SupportedApp? app) { + final appName = app?.name ?? 'the Trainer app'; + final preferredConnectionMethod = app?.supportsOpenBikeProtocol.isNotEmpty == true + ? AppLocalizations.current.openBikeControlConnection + : app is MyWhoosh + ? AppLocalizations.current.myWhooshDirectConnection + : ''; + + return switch (this) { + Target.thisDevice when !isCompatible => AppLocalizations.current.platformRestrictionOtherDevicesOnly(appName), + Target.otherDevice when !isCompatible => AppLocalizations.current.platformRestrictionNotSupported, + Target.thisDevice => AppLocalizations.current.runAppOnThisDevice(appName), + Target.otherDevice => AppLocalizations.current.runAppOnPlatformRemotely( + appName, + AppLocalizations.current.targetOtherDevice, + preferredConnectionMethod, + ), + }; + } + + ConnectionType get connectionType { + return switch (this) { + Target.thisDevice when !kIsWeb && !Platform.isIOS => ConnectionType.local, + _ => ConnectionType.remote, + }; } } diff --git a/lib/utils/requirements/platform.dart b/lib/utils/requirements/platform.dart index d8530d27d..424e7be7b 100644 --- a/lib/utils/requirements/platform.dart +++ b/lib/utils/requirements/platform.dart @@ -1,38 +1,22 @@ -import 'dart:io'; - -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'; abstract class PlatformRequirement { String name; + String? description; + final IconData icon; late bool status; - PlatformRequirement(this.name); + PlatformRequirement(this.name, {this.description, required this.icon}); - Future getStatus(); + Future getStatus(); - Future call(); + Future call(BuildContext context, VoidCallback onUpdate); Widget? build(BuildContext context, VoidCallback onUpdate) { return null; } -} -Future> getRequirements() async { - List list; - if (kIsWeb) { - list = [BluetoothTurnedOn(), BluetoothScanning()]; - } else if (Platform.isMacOS) { - list = [BluetoothTurnedOn(), KeyboardRequirement(), KeymapRequirement(), BluetoothScanning()]; - } else if (Platform.isWindows) { - list = [BluetoothTurnedOn(), KeyboardRequirement(), KeymapRequirement(), BluetoothScanning()]; - } else if (Platform.isAndroid) { - list = [BluetoothTurnedOn(), AccessibilityRequirement(), NotificationRequirement(), BluetoothScanning()]; - } else { - list = [UnsupportedPlatform()]; + Widget? buildDescription() { + return null; } - await Future.wait(list.map((e) => e.getStatus())); - return list; } diff --git a/lib/utils/requirements/windows.dart b/lib/utils/requirements/windows.dart new file mode 100644 index 000000000..7d95c82f3 --- /dev/null +++ b/lib/utils/requirements/windows.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; +import 'package:win32/win32.dart'; +import 'package:bike_control/utils/windows_store_environment.dart'; + +const _hive = HKEY_CURRENT_USER; + +class WindowsProtocolHandler { + bool get shouldRegisterForOutsideStoreBuild => WindowsStoreEnvironment.isOutsideStoreCached; + + List getArguments(List? arguments) { + if (arguments == null) return ['%s']; + + if (arguments.isEmpty && !arguments.any((e) => e.contains('%s'))) { + throw ArgumentError('arguments must contain at least 1 instance of "%s"'); + } + + return arguments; + } + + void register(String scheme, {String? executable, List? arguments}) { + if (defaultTargetPlatform != TargetPlatform.windows) return; + + final prefix = _regPrefix(scheme); + final capitalized = scheme[0].toUpperCase() + scheme.substring(1); + final args = getArguments(arguments).map((a) => _sanitize(a)); + final cmd = '${executable ?? Platform.resolvedExecutable} ${args.join(' ')}'; + + _regCreateStringKey(_hive, prefix, '', 'URL:$capitalized'); + _regCreateStringKey(_hive, prefix, 'URL Protocol', ''); + _regCreateStringKey(_hive, '$prefix\\shell\\open\\command', '', cmd); + } + + void registerForOutsideStoreBuild(String scheme, {String? executable, List? arguments}) { + if (!shouldRegisterForOutsideStoreBuild) return; + register(scheme, executable: executable, arguments: arguments); + } + + void unregister(String scheme) { + if (defaultTargetPlatform != TargetPlatform.windows) return; + + final txtKey = TEXT(_regPrefix(scheme)); + try { + RegDeleteTree(HKEY_CURRENT_USER, txtKey); + } finally { + free(txtKey); + } + } + + String _regPrefix(String scheme) => 'SOFTWARE\\Classes\\$scheme'; + + int _regCreateStringKey(int hKey, String key, String valueName, String data) { + final txtKey = TEXT(key); + final txtValue = TEXT(valueName); + final txtData = TEXT(data); + try { + return RegSetKeyValue( + hKey, + txtKey, + txtValue, + REG_SZ, + txtData, + txtData.length * 2 + 2, + ); + } finally { + free(txtKey); + free(txtValue); + free(txtData); + } + } + + String _sanitize(String value) { + value = value.replaceAll(r'%s', '%1').replaceAll(r'"', '\\"'); + return '"$value"'; + } +} diff --git a/lib/utils/settings/settings.dart b/lib/utils/settings/settings.dart new file mode 100644 index 000000000..22700249b --- /dev/null +++ b/lib/utils/settings/settings.dart @@ -0,0 +1,492 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/gyroscope/gyroscope_steering.dart'; +import 'package:bike_control/services/settings_sync_service.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/apps/supported_app.dart'; +import 'package:bike_control/utils/requirements/android.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:bike_control/utils/windows_store_environment.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider_windows/path_provider_windows.dart'; +import 'package:prop/emulators/prefs.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_windows/shared_preferences_windows.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../../main.dart'; +import '../actions/desktop.dart'; +import '../keymap/apps/custom_app.dart'; +import '../keymap/buttons.dart'; + +class Settings { + late SharedPreferences prefs; + SettingsSyncService? _syncService; + Timer? _syncDebounceTimer; + + Future init({bool retried = false}) async { + try { + prefs = await SharedPreferences.getInstance(); + propPrefs.initialize(prefs); + if (!screenshotMode) { + try { + await NotificationRequirement.setup(); + } catch (error, stack) { + recordError(error, stack, context: 'Notification setup'); + } + } + initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown); + + if (getShowOnboarding() && getTrainerApp() != null) { + // If onboarding is to be shown, but a trainer app is already set, + // skip onboarding and set to not show again. + await setShowOnboarding(false); + } + + if (core.actionHandler is DesktopActions) { + // Must add this line. + await windowManager.ensureInitialized(); + } + + final app = getKeyMap(); + core.actionHandler.init(app); + + try { + await Supabase.initialize( + url: 'https://pikrcyynovdvogrldfnw.supabase.co', + anonKey: const String.fromEnvironment('SUPABASE_ANON_KEY'), + ); + } catch (e, s) { + recordError(e, s, context: 'Supabase initialization'); + } + + if (!kIsWeb && Platform.isWindows) { + await WindowsStoreEnvironment.initialize(); + } + + // Initialize IAP manager + await IAPManager.instance.initialize(); + + // Start trial if this is the first launch + if (!IAPManager.instance.hasTrialStarted && !IAPManager.instance.isPurchased.value) { + await IAPManager.instance.startTrial(); + } + + // Initialize settings sync service for Pro users + try { + _syncService = SettingsSyncService(); + await _syncService!.initialize(); + } catch (e) { + // Sync service is not critical, continue without it + print('Failed to initialize settings sync: $e'); + } + + return null; + } catch (e, s) { + recordError(e, s, context: 'Init'); + if (!retried) { + if (Platform.isWindows) { + // delete settings file + final fs = SharedPreferencesWindows.instance.fs; + + final pathProvider = PathProviderWindows(); + final String? directory = await pathProvider.getApplicationSupportPath(); + if (directory == null) { + return null; + } + final String fileLocation = path.join(directory, 'shared_preferences.json'); + final file = fs.file(fileLocation); + if (await file.exists()) { + await file.delete(); + } + } + return init(retried: true); + } else { + return '$e\n$s'; + } + } + } + + Future reset() async { + await prefs.clear(); + IAPManager.instance.reset(true); + init(); + } + + void setTrainerApp(SupportedApp app) { + prefs.setString('trainer_app', app.name); + } + + SupportedApp? getTrainerApp() { + final appName = prefs.getString('trainer_app'); + if (appName == null) { + return null; + } + return SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName); + } + + Future setKeyMap(SupportedApp app) async { + if (app is CustomApp) { + await prefs.setStringList('customapp_${app.profileName}', app.encodeKeymap()); + } + await prefs.setString('app', app.name); + _triggerAutoSync(); + } + + SupportedApp? getKeyMap() { + final appName = prefs.getString('app'); + if (appName == null) { + return null; + } + + // Check if it's a custom app with a profile name + if (appName.startsWith('Custom') || prefs.containsKey('customapp_$appName')) { + final customApp = CustomApp(profileName: appName); + final appSetting = prefs.getStringList('customapp_$appName'); + if (appSetting != null) { + try { + customApp.decodeKeymap(appSetting); + } catch (e, s) { + recordError(e, s, context: 'Decoding custom app keymap for $appName'); + // reset it + prefs.remove('customapp_$appName'); + } + } + return customApp; + } else { + return SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName); + } + } + + List getCustomAppProfiles() { + // Get all keys starting with 'customapp_' + final keys = prefs.getKeys().where((key) => key.startsWith('customapp_')).toList(); + return keys.map((key) => key.replaceFirst('customapp_', '')).toList(); + } + + List? getCustomAppKeymap(String profileName) { + return prefs.getStringList('customapp_$profileName'); + } + + Future deleteCustomAppProfile(String profileName) async { + await prefs.remove('customapp_$profileName'); + // If the current app is the one being deleted, reset + if (prefs.getString('app') == profileName) { + core.actionHandler.init(null); + await prefs.remove('app'); + } + _triggerAutoSync(); + } + + Future duplicateCustomAppProfile(String sourceProfileName, String newProfileName) async { + final sourceData = prefs.getStringList('customapp_$sourceProfileName'); + if (sourceData != null) { + await prefs.setStringList('customapp_$newProfileName', sourceData); + } + _triggerAutoSync(); + } + + String? exportCustomAppProfile(String profileName) { + final data = prefs.getStringList('customapp_$profileName'); + if (data == null) return null; + var encoder = JsonEncoder.withIndent(" "); + return encoder.convert({ + 'version': 1, + 'profileName': profileName, + 'keymap': data.map((e) => jsonDecode(e)).toList(), + }); + } + + Future importCustomAppProfile(String jsonData, {String? newProfileName}) async { + try { + final decoded = jsonDecode(jsonData); + + // Validate the structure + if (decoded['version'] == null || decoded['keymap'] == null) { + return false; + } + + final profileName = newProfileName ?? decoded['profileName'] ?? 'Imported'; + final keymap = (decoded['keymap'] as List).map((e) => jsonEncode(e)).toList().cast(); + + await prefs.setStringList('customapp_$profileName', keymap); + _triggerAutoSync(); + return true; + } catch (e) { + print(e); + return false; + } + } + + String? getLastSeenVersion() { + return prefs.getString('last_seen_version'); + } + + Target? getLastTarget() { + final targetString = prefs.getString('last_target'); + if (targetString == null) return null; + return Target.values.firstOrNullWhere((e) => e.name == targetString); + } + + Future setLastTarget(Target target) async { + await prefs.setString('last_target', target.name); + initializeActions(target.connectionType); + IAPManager.instance.setAttributes(); + } + + Future setLastSeenVersion(String version) async { + await prefs.setString('last_seen_version', version); + } + + bool getVibrationEnabled() { + return prefs.getBool('vibration_enabled') ?? true; + } + + Future setVibrationEnabled(bool enabled) async { + await prefs.setBool('vibration_enabled', enabled); + } + + bool getMyWhooshLinkEnabled() { + return prefs.getBool('mywhoosh_link_enabled') ?? false; + } + + Future setMyWhooshLinkEnabled(bool enabled) async { + await prefs.setBool('mywhoosh_link_enabled', enabled); + } + + bool getObpMdnsEnabled() { + return prefs.getBool('openbikeprotocol_mdns_enabled') ?? false; + } + + Future setObpMdnsEnabled(bool enabled) async { + await prefs.setBool('openbikeprotocol_mdns_enabled', enabled); + } + + bool getObpBleEnabled() { + return prefs.getBool('openbikeprotocol_ble_enabled') ?? false; + } + + Future setObpBleEnabled(bool enabled) async { + await prefs.setBool('openbikeprotocol_ble_enabled', enabled); + } + + bool getZwiftBleEmulatorEnabled() { + return prefs.getBool('zwift_emulator_enabled') ?? false; + } + + Future setZwiftBleEmulatorEnabled(bool enabled) async { + await prefs.setBool('zwift_emulator_enabled', enabled); + } + + bool getZwiftMdnsEmulatorEnabled() { + return prefs.getBool('zwift_mdns_emulator_enabled') ?? false; + } + + Future setZwiftMdnsEmulatorEnabled(bool enabled) async { + await prefs.setBool('zwift_mdns_emulator_enabled', enabled); + } + + bool getMiuiWarningDismissed() { + return prefs.getBool('miui_warning_dismissed') ?? false; + } + + Future setMiuiWarningDismissed(bool dismissed) async { + await prefs.setBool('miui_warning_dismissed', dismissed); + } + + List _getIgnoredDeviceIds() { + return prefs.getStringList('ignored_device_ids') ?? []; + } + + List _getIgnoredDeviceNames() { + return prefs.getStringList('ignored_device_names') ?? []; + } + + Future addIgnoredDevice(String deviceId, String deviceName) async { + final ids = _getIgnoredDeviceIds(); + final names = _getIgnoredDeviceNames(); + + if (!ids.contains(deviceId)) { + ids.add(deviceId); + names.add(deviceName); + await prefs.setStringList('ignored_device_ids', ids); + await prefs.setStringList('ignored_device_names', names); + _triggerAutoSync(); + } + } + + Future removeIgnoredDevice(String deviceId) async { + final ids = _getIgnoredDeviceIds(); + final names = _getIgnoredDeviceNames(); + + final index = ids.indexOf(deviceId); + if (index != -1) { + ids.removeAt(index); + names.removeAt(index); + await prefs.setStringList('ignored_device_ids', ids); + await prefs.setStringList('ignored_device_names', names); + _triggerAutoSync(); + } + } + + List<({String id, String name})> getIgnoredDevices() { + final ids = _getIgnoredDeviceIds(); + final names = _getIgnoredDeviceNames(); + + final result = <({String id, String name})>[]; + for (int i = 0; i < ids.length && i < names.length; i++) { + result.add((id: ids[i], name: names[i])); + } + return result; + } + + bool getShowZwiftClickV2ReconnectWarning() { + return prefs.getBool('zwift_click_v2_reconnect_warning') ?? true; + } + + Future setShowZwiftClickV2ReconnectWarning(bool show) async { + await prefs.setBool('zwift_click_v2_reconnect_warning', show); + } + + void setRemoteControlEnabled(bool value) { + prefs.setBool('remote_control_enabled', value); + } + + bool getRemoteControlEnabled() { + return prefs.getBool('remote_control_enabled') ?? false; + } + + void setRemoteKeyboardControlEnabled(bool value) { + prefs.setBool('remote_keyboard_control_enabled', value); + } + + bool getRemoteKeyboardControlEnabled() { + return prefs.getBool('remote_keyboard_control_enabled') ?? false; + } + + bool getLocalEnabled() { + return prefs.getBool('local_control_enabled') ?? false; + } + + void setLocalEnabled(bool value) { + prefs.setBool('local_control_enabled', value); + } + + // Button Simulator Hotkey Settings + Map getButtonSimulatorHotkeys() { + final json = prefs.getString('button_simulator_hotkeys'); + if (json == null) return {}; + try { + final decoded = jsonDecode(json) as Map; + return decoded.map( + (key, value) => MapEntry(InGameAction.values.firstWhere((e) => e.name == key), value.toString()), + ); + } catch (e) { + return {}; + } + } + + Future setButtonSimulatorHotkeys(Map hotkeys) async { + await prefs.setString( + 'button_simulator_hotkeys', + jsonEncode(hotkeys.map((key, value) => MapEntry(key.name, value))), + ); + } + + Future setButtonSimulatorHotkey(InGameAction action, String hotkey) async { + final hotkeys = getButtonSimulatorHotkeys(); + hotkeys[action] = hotkey; + await setButtonSimulatorHotkeys(hotkeys); + } + + Future removeButtonSimulatorHotkey(InGameAction action) async { + final hotkeys = getButtonSimulatorHotkeys(); + hotkeys.remove(action); + await setButtonSimulatorHotkeys(hotkeys); + } + + void setPhoneSteeringEnabled(bool value) { + prefs.setBool('phone_steering_enabled', value); + } + + bool getPhoneSteeringEnabled() { + return prefs.getBool('phone_steering_enabled') ?? false; + } + + void setPhoneSteeringThreshold(int value) { + prefs.setInt('phone_steering_threshold', value); + } + + double getPhoneSteeringThreshold() { + return prefs.getInt('phone_steering_threshold')?.toDouble() ?? GyroscopeSteering.STEERING_THRESHOLD; + } + + // SRAM AXS Settings + static const int _sramAxsDoubleClickWindowDefaultMs = 350; + static const int _sramAxsDoubleClickWindowMinMs = 150; + static const int _sramAxsDoubleClickWindowMaxMs = 800; + + int getSramAxsDoubleClickWindowMs() { + final v = prefs.getInt('sram_axs_double_click_window_ms') ?? _sramAxsDoubleClickWindowDefaultMs; + return v.clamp(_sramAxsDoubleClickWindowMinMs, _sramAxsDoubleClickWindowMaxMs); + } + + Future setSramAxsDoubleClickWindowMs(int ms) async { + final v = ms.clamp(_sramAxsDoubleClickWindowMinMs, _sramAxsDoubleClickWindowMaxMs); + await prefs.setInt('sram_axs_double_click_window_ms', v); + } + + bool getShowOnboarding() { + return !kIsWeb && (prefs.getBool('show_onboarding') ?? true); + } + + Future setShowOnboarding(bool show) async { + await prefs.setBool('show_onboarding', show); + } + + bool hasAskedPermissions() { + return prefs.getBool('asked_permissions') ?? false; + } + + Future setHasAskedPermissions(bool asked) async { + await prefs.setBool('asked_permissions', asked); + } + + bool getMediaKeyDetectionEnabled() { + return prefs.getBool('media_key_detection_enabled') ?? false; + } + + Future setMediaKeyDetectionEnabled(bool enabled) async { + await prefs.setBool('media_key_detection_enabled', enabled); + _triggerAutoSync(); + } + + /// Triggers automatic sync to server for Pro users. + /// Uses debouncing to avoid excessive sync calls. + void _triggerAutoSync() { + if (_syncService == null) return; + if (!IAPManager.instance.hasActiveSubscription) return; + if (!IAPManager.instance.isLoggedIn) return; + + // Cancel existing timer + _syncDebounceTimer?.cancel(); + + // Set new timer to sync after 2 seconds of inactivity + _syncDebounceTimer = Timer(const Duration(seconds: 10), () { + _syncService?.syncToServer(); + }); + } + + /// Disposes the sync service and cleans up resources. + void dispose() { + _syncDebounceTimer?.cancel(); + _syncService?.dispose(); + _syncService = null; + } +} diff --git a/lib/utils/single_line_exception.dart b/lib/utils/single_line_exception.dart new file mode 100644 index 000000000..b27d7dce5 --- /dev/null +++ b/lib/utils/single_line_exception.dart @@ -0,0 +1,10 @@ +class SingleLineException implements Exception { + final String message; + + SingleLineException(this.message); + + @override + String toString() { + return message; + } +} diff --git a/lib/utils/smtc_stub.dart b/lib/utils/smtc_stub.dart new file mode 100644 index 000000000..4814869fb --- /dev/null +++ b/lib/utils/smtc_stub.dart @@ -0,0 +1,63 @@ +class SMTCWindows { + SMTCWindows({required MusicMetadata metadata, required PlaybackTimeline timeline, required SMTCConfig config}) {} + + get buttonPressStream => null; + + void disableSmtc() {} + + static Future initialize() async {} +} + +enum PressedButton { play, pause, next, previous, fastForward, rewind, stop, record, channelUp, channelDown } + +class MusicMetadata { + final String? title; + final String? artist; + final String? album; + final String? albumArtist; + final String? thumbnail; + + const MusicMetadata({ + this.title, + this.artist, + this.album, + this.albumArtist, + this.thumbnail, + }); +} + +class PlaybackTimeline { + final int startTimeMs; + final int endTimeMs; + final int positionMs; + final int? minSeekTimeMs; + final int? maxSeekTimeMs; + + const PlaybackTimeline({ + required this.startTimeMs, + required this.endTimeMs, + required this.positionMs, + this.minSeekTimeMs, + this.maxSeekTimeMs, + }); +} + +class SMTCConfig { + final bool playEnabled; + final bool pauseEnabled; + final bool stopEnabled; + final bool nextEnabled; + final bool prevEnabled; + final bool fastForwardEnabled; + final bool rewindEnabled; + + const SMTCConfig({ + required this.playEnabled, + required this.pauseEnabled, + required this.stopEnabled, + required this.nextEnabled, + required this.prevEnabled, + required this.fastForwardEnabled, + required this.rewindEnabled, + }); +} diff --git a/lib/utils/windows_store_environment.dart b/lib/utils/windows_store_environment.dart new file mode 100644 index 000000000..d27d6da80 --- /dev/null +++ b/lib/utils/windows_store_environment.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Utilities for determining whether the app is running in a Windows Store/MSIX +/// packaged context. +/// +/// Security note: +/// - Build-time secrets (e.g. --dart-define) are extractable from the binary. +/// - This check is meant to answer a platform truth: "Is this app packaged?" +/// - For real entitlement enforcement, rely on Store license APIs (and +/// optionally a backend), not on compile-time flags. +class WindowsStoreEnvironment { + static const MethodChannel _channel = MethodChannel('bike_control/store_env'); + static bool? _isPackagedCache; + + /// Debug-only escape hatch to simulate Store packaged mode. + /// + /// This is intentionally *not* a security feature. + static const bool _forcePackagedForDebug = bool.fromEnvironment('FORCE_STORE_PACKAGED', defaultValue: false); + + static Future initialize() async { + _isPackagedCache = await isPackaged(); + } + + static bool get isPackagedCached => _isPackagedCache ?? false; + + static bool get isOutsideStoreCached => !isPackagedCached; + + /// Returns true when running as an MSIX/Store packaged app. + /// + /// In debug/profile you may set `--dart-define=FORCE_STORE_PACKAGED=true` + /// for local testing. + static Future isPackaged() async { + if (!kReleaseMode && _forcePackagedForDebug) return true; + if (_isPackagedCache != null) return _isPackagedCache!; + + try { + final result = await _channel.invokeMethod('isPackaged'); + _isPackagedCache = result ?? false; + return _isPackagedCache!; + } catch (_) { + // If the platform implementation isn't present (e.g., non-Windows), + // treat as unpackaged. + _isPackagedCache = false; + return false; + } + } +} diff --git a/lib/widgets/accessibility_disclosure_dialog.dart b/lib/widgets/accessibility_disclosure_dialog.dart new file mode 100644 index 000000000..08eb896a1 --- /dev/null +++ b/lib/widgets/accessibility_disclosure_dialog.dart @@ -0,0 +1,81 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; + +class AccessibilityDisclosureDialog extends StatelessWidget { + final VoidCallback onAccept; + final VoidCallback onDeny; + + const AccessibilityDisclosureDialog({ + super.key, + required this.onAccept, + required this.onDeny, + }); + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, // Prevent back navigation from dismissing dialog + child: AlertDialog( + title: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text(context.i18n.accessibilityServicePermissionRequired), + ), + padding: EdgeInsets.symmetric(vertical: 16), + content: SizedBox( + height: 560, + child: Scrollbar( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.i18n.accessibilityServiceExplanation, + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 16), + Text(context.i18n.whyPermissionNeeded), + SizedBox(height: 8), + Text(context.i18n.accessibilityReasonTouch.replaceAll('• ', '')).li, + Text(context.i18n.accessibilityReasonWindow.replaceAll('• ', '')).li, + Text(context.i18n.accessibilityReasonControl.replaceAll('• ', '')).li, + SizedBox(height: 16), + Text( + context.i18n.howBikeControlUsesPermission, + style: TextStyle(fontWeight: FontWeight.w600), + ), + SizedBox(height: 8), + Text(context.i18n.accessibilityUsageGestures.replaceAll('• ', '')).li, + Text(context.i18n.accessibilityUsageMonitor.replaceAll('• ', '')).li, + Text(context.i18n.accessibilityUsageNoData.replaceAll('• ', '')).li, + SizedBox(height: 16), + Text( + context.i18n.accessibilityDisclaimer, + style: TextStyle(fontStyle: FontStyle.italic), + ), + SizedBox(height: 16), + Text( + context.i18n.mustChooseAllowOrDeny, + style: TextStyle(fontWeight: FontWeight.w600, color: Colors.orange), + ), + ], + ), + ), + ), + ), + actions: [ + DestructiveButton( + onPressed: onDeny, + child: Text(context.i18n.deny), + ), + PrimaryButton( + onPressed: onAccept, + child: Text(context.i18n.allow), + ), + SizedBox(width: 8), + ], + ), + ); + } +} diff --git a/lib/widgets/apps/local_tile.dart b/lib/widgets/apps/local_tile.dart new file mode 100644 index 000000000..5879303b5 --- /dev/null +++ b/lib/widgets/apps/local_tile.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/actions/android.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:bike_control/widgets/ui/warning.dart'; +import 'package:dartx/dartx.dart'; +import 'package:device_auto_rotate_checker/device_auto_rotate_checker.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class LocalTile extends StatefulWidget { + const LocalTile({super.key}); + + @override + State createState() => _LocalTileState(); +} + +class _LocalTileState extends State { + bool? _isRunningAndroidService; + bool _showAutoRotationWarning = false; + bool _showMiuiWarning = false; + StreamSubscription? _autoRotateStream; + + @override + void initState() { + super.initState(); + if (core.logic.canRunAndroidService) { + core.logic.isAndroidServiceRunning().then((isRunning) { + core.connection.signalNotification(LogNotification('Local Control: $isRunning')); + setState(() { + _isRunningAndroidService = isRunning; + }); + }); + } + + if (Platform.isAndroid) { + DeviceAutoRotateChecker.checkAutoRotate().then((isEnabled) { + if (!isEnabled) { + setState(() { + _showAutoRotationWarning = true; + }); + } + }); + _autoRotateStream = DeviceAutoRotateChecker.autoRotateStream.listen((isEnabled) { + setState(() { + _showAutoRotationWarning = !isEnabled; + }); + }); + + // Check if device is MIUI and using local accessibility service + if (core.actionHandler is AndroidActions) { + _checkMiuiDevice(); + } + } + } + + @override + void dispose() { + _autoRotateStream?.cancel(); + super.dispose(); + } + + Future _checkMiuiDevice() async { + try { + // Don't show if user has dismissed the warning + if (core.settings.getMiuiWarningDismissed()) { + return; + } + + final deviceInfo = await DeviceInfoPlugin().androidInfo; + final isMiui = + deviceInfo.manufacturer.toLowerCase() == 'xiaomi' || + deviceInfo.brand.toLowerCase() == 'xiaomi' || + deviceInfo.brand.toLowerCase() == 'redmi' || + deviceInfo.brand.toLowerCase() == 'poco'; + if (isMiui && mounted) { + setState(() { + _showMiuiWarning = true; + }); + } + } catch (e) { + // Silently fail if device info is not available + } + } + + @override + Widget build(BuildContext context) { + final children = [ + // show warning only for android when using local accessibility service + if (_showAutoRotationWarning) + Warning( + important: false, + children: [ + Text(context.i18n.enableAutoRotation), + ], + ), + if (_showMiuiWarning) + Warning( + children: [ + Row( + children: [ + Icon(Icons.warning_amber), + SizedBox(width: 8), + Expanded( + child: Text(context.i18n.miuiDeviceDetected).bold, + ), + IconButton.destructive( + icon: Icon(Icons.close), + onPressed: () async { + await core.settings.setMiuiWarningDismissed(true); + setState(() { + _showMiuiWarning = false; + }); + }, + ), + ], + ), + SizedBox(height: 8), + Text( + context.i18n.miuiWarningDescription, + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 8), + Text( + context.i18n.miuiEnsureProperWorking, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + Text( + context.i18n.miuiDisableBatteryOptimization, + style: TextStyle(fontSize: 14), + ), + Text( + context.i18n.miuiEnableAutostart, + style: TextStyle(fontSize: 14), + ), + Text( + context.i18n.miuiLockInRecentApps, + style: TextStyle(fontSize: 14), + ), + SizedBox(height: 12), + OutlineButton( + onPressed: () async { + final url = Uri.parse('https://dontkillmyapp.com/xiaomi'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + }, + leading: Icon(Icons.open_in_new), + child: Text(context.i18n.viewDetailedInstructions), + ), + ], + ), + if (_isRunningAndroidService == false) + Warning( + children: [ + Text(context.i18n.accessibilityServiceNotRunning).xSmall, + SizedBox(height: 8), + Row( + spacing: 8, + children: [ + Expanded( + child: OutlineButton( + child: Text('dontkillmyapp.com'), + onPressed: () { + launchUrlString('https://dontkillmyapp.com/'); + }, + ), + ), + IconButton.secondary( + onPressed: () { + core.logic.isAndroidServiceRunning().then((isRunning) { + core.connection.signalNotification(LogNotification('Local Control: $isRunning')); + setState(() { + _isRunningAndroidService = isRunning; + }); + }); + }, + icon: Icon(Icons.refresh), + ), + ], + ), + ], + ), + ]; + return ConnectionMethod( + supportedActions: null, + isEnabled: core.settings.getLocalEnabled(), + type: ConnectionMethodType.local, + isRecommended: true, + showTroubleshooting: true, + instructionLink: 'INSTRUCTIONS_LOCAL.md', + title: context.i18n.controlAppUsingModes( + core.settings.getTrainerApp()?.name ?? '', + core.actionHandler.supportedModes.joinToString(transform: (e) => e.name.capitalize()), + ), + description: context.i18n.enableKeyboardMouseControl(core.settings.getTrainerApp()?.name ?? ''), + requirements: core.permissions.getLocalControlRequirements(), + isStarted: core.logic.canRunAndroidService ? _isRunningAndroidService == true : core.settings.getLocalEnabled(), + onChange: (value) { + core.settings.setLocalEnabled(value); + setState(() {}); + if (core.logic.canRunAndroidService) { + core.logic.isAndroidServiceRunning().then((isRunning) { + core.connection.signalNotification(LogNotification('Local Control: $isRunning')); + setState(() { + _isRunningAndroidService = isRunning; + }); + }); + } else { + core.connection.signalNotification(LogNotification('Local Control: $value')); + } + }, + additionalChild: children.isNotEmpty + ? Column( + children: children, + ) + : null, + ); + } +} diff --git a/lib/widgets/apps/mywhoosh_link_tile.dart b/lib/widgets/apps/mywhoosh_link_tile.dart new file mode 100644 index 000000000..e880ca617 --- /dev/null +++ b/lib/widgets/apps/mywhoosh_link_tile.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/markdown.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class MyWhooshLinkTile extends StatefulWidget { + const MyWhooshLinkTile({super.key}); + + @override + State createState() => _MywhooshLinkTileState(); +} + +class _MywhooshLinkTileState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: core.whooshLink.isStarted, + builder: (context, isStarted, _) { + return ValueListenableBuilder( + valueListenable: core.whooshLink.isConnected, + builder: (context, isConnected, _) { + return ConnectionMethod( + isRecommended: Platform.isIOS, + supportedActions: core.whooshLink.supportedActions, + isEnabled: core.settings.getMyWhooshLinkEnabled(), + type: ConnectionMethodType.network, + title: context.i18n.connectUsingMyWhooshLink, + instructionLink: 'INSTRUCTIONS_MYWHOOSH_LINK.md', + description: isConnected + ? context.i18n.myWhooshLinkConnected + : isStarted + ? context.i18n.checkMyWhooshConnectionScreen + : context.i18n.myWhooshLinkDescriptionLocal, + requirements: [], + showTroubleshooting: true, + onChange: (value) { + core.settings.setMyWhooshLinkEnabled(value); + if (!value) { + core.whooshLink.stopServer(); + } else if (value) { + buildToast( + title: AppLocalizations.of(context).myWhooshLinkInfo, + level: LogLevel.LOGLEVEL_INFO, + duration: Duration(seconds: 6), + closeTitle: 'Open', + onClose: () { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (c) => MarkdownPage(assetPath: 'INSTRUCTIONS_MYWHOOSH_LINK.md'), + ); + }, + ); + core.connection.startMyWhooshServer().catchError((e, s) { + recordError(e, s, context: 'MyWhoosh Link Server'); + core.settings.setMyWhooshLinkEnabled(false); + buildToast( + title: context.i18n.errorStartingMyWhooshLink, + ); + }); + } + }, + isStarted: isStarted, + isConnected: isConnected, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/apps/openbikecontrol_ble_tile.dart b/lib/widgets/apps/openbikecontrol_ble_tile.dart new file mode 100644 index 000000000..df5e4261c --- /dev/null +++ b/lib/widgets/apps/openbikecontrol_ble_tile.dart @@ -0,0 +1,63 @@ +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class OpenBikeControlBluetoothTile extends StatefulWidget { + const OpenBikeControlBluetoothTile({super.key}); + + @override + State createState() => _OpenBikeProtocolTileState(); +} + +class _OpenBikeProtocolTileState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: core.obpBluetoothEmulator.isStarted, + builder: (context, isStarted, _) { + return ValueListenableBuilder( + valueListenable: core.obpBluetoothEmulator.connectedApp, + builder: (context, isConnected, _) { + return ConnectionMethod( + isRecommended: true, + supportedActions: isConnected?.supportedActions, + isEnabled: core.settings.getObpBleEnabled(), + type: ConnectionMethodType.openBikeControl, + title: context.i18n.connectUsingBluetooth, + description: isConnected != null + ? context.i18n.connectedTo( + "${isConnected.appId}:\n${isConnected.supportedActions.joinToString(transform: (s) => s.title)}", + ) + : isStarted + ? context.i18n.chooseBikeControlInConnectionScreen + : context.i18n.letsAppConnectOverBluetooth(core.settings.getTrainerApp()?.name ?? ''), + requirements: core.permissions.getRemoteControlRequirements(), + onChange: (value) { + core.settings.setObpBleEnabled(value); + if (!value) { + core.obpBluetoothEmulator.stopServer(); + } else if (value) { + core.obpBluetoothEmulator.startServer().catchError((e, s) { + recordError(e, s, context: 'OBP BLE Emulator'); + core.settings.setObpBleEnabled(false); + buildToast( + level: LogLevel.LOGLEVEL_WARNING, + title: context.i18n.errorStartingOpenBikeControlBluetoothServer, + ); + }); + } + }, + isStarted: isStarted, + isConnected: isConnected != null, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/apps/openbikecontrol_mdns_tile.dart b/lib/widgets/apps/openbikecontrol_mdns_tile.dart new file mode 100644 index 000000000..479fb3f1f --- /dev/null +++ b/lib/widgets/apps/openbikecontrol_mdns_tile.dart @@ -0,0 +1,67 @@ +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:dartx/dartx.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class OpenBikeControlMdnsTile extends StatefulWidget { + const OpenBikeControlMdnsTile({super.key}); + + @override + State createState() => _OpenBikeProtocolTileState(); +} + +class _OpenBikeProtocolTileState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: core.obpMdnsEmulator.isStarted, + builder: (context, isStarted, _) { + return ValueListenableBuilder( + valueListenable: core.obpMdnsEmulator.connectedApp, + builder: (context, isConnected, _) { + return ConnectionMethod( + isRecommended: true, + supportedActions: isConnected?.supportedActions, + isEnabled: core.settings.getObpMdnsEnabled(), + type: ConnectionMethodType.openBikeControl, + title: context.i18n.connectDirectlyOverNetwork, + + description: isConnected != null + ? context.i18n.connectedTo( + "${isConnected.appId}:\n${isConnected.supportedActions.joinToString(transform: (s) => s.title)}", + ) + : isStarted + ? context.i18n.chooseBikeControlInConnectionScreen + : context.i18n.letsAppConnectOverNetwork(core.settings.getTrainerApp()?.name ?? ''), + requirements: [], + onChange: (value) { + core.settings.setObpMdnsEnabled(value); + if (!value) { + core.obpMdnsEmulator.stopServer(); + } else if (value) { + core.obpMdnsEmulator.startServer().catchError((e, s) { + recordError(e, s, context: 'OBP mDNS Emulator'); + core.settings.setObpMdnsEnabled(false); + core.connection.signalNotification( + AlertNotification( + LogLevel.LOGLEVEL_ERROR, + context.i18n.errorStartingOpenBikeControlServer, + ), + ); + }); + } + setState(() {}); + }, + isStarted: isStarted, + isConnected: isConnected != null, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/apps/zwift_mdns_tile.dart b/lib/widgets/apps/zwift_mdns_tile.dart new file mode 100644 index 000000000..9613ce920 --- /dev/null +++ b/lib/widgets/apps/zwift_mdns_tile.dart @@ -0,0 +1,67 @@ +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:flutter/material.dart'; +import 'package:prop/prop.dart'; + +class ZwiftMdnsTile extends StatefulWidget { + final VoidCallback onUpdate; + + const ZwiftMdnsTile({super.key, required this.onUpdate}); + + @override + State createState() => _ZwiftTileState(); +} + +class _ZwiftTileState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: core.zwiftMdnsEmulator.isConnected, + builder: (context, isConnected, _) { + return ValueListenableBuilder( + valueListenable: core.zwiftMdnsEmulator.isStarted, + builder: (context, isStarted, _) { + return StatefulBuilder( + builder: (context, setState) { + return ConnectionMethod( + isRecommended: true, + supportedActions: core.zwiftMdnsEmulator.supportedActions, + type: ConnectionMethodType.network, + isEnabled: core.settings.getZwiftMdnsEmulatorEnabled(), + title: context.i18n.enableZwiftControllerNetwork, + description: !isStarted + ? context.i18n.zwiftControllerDescription + : isConnected + ? context.i18n.connected + : context.i18n.waitingForConnectionKickrBike(core.settings.getTrainerApp()?.name ?? ''), + instructionLink: 'INSTRUCTIONS_ZWIFT.md', + isStarted: isStarted, + isConnected: isConnected, + onChange: (start) { + core.settings.setZwiftMdnsEmulatorEnabled(start); + if (start) { + core.zwiftMdnsEmulator.startServer().catchError((e, s) { + recordError(e, s, context: 'Zwift mDNS Emulator'); + core.settings.setZwiftMdnsEmulatorEnabled(false); + core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString())); + setState(() {}); + widget.onUpdate(); + }); + } else { + core.zwiftMdnsEmulator.stop(); + } + setState(() {}); + }, + requirements: [], + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/apps/zwift_tile.dart b/lib/widgets/apps/zwift_tile.dart new file mode 100644 index 000000000..17c1ac39c --- /dev/null +++ b/lib/widgets/apps/zwift_tile.dart @@ -0,0 +1,67 @@ +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:flutter/material.dart'; +import 'package:prop/prop.dart'; + +class ZwiftTile extends StatefulWidget { + final VoidCallback onUpdate; + + const ZwiftTile({super.key, required this.onUpdate}); + + @override + State createState() => _ZwiftTileState(); +} + +class _ZwiftTileState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: core.zwiftEmulator.isConnected, + builder: (context, isConnected, _) { + return ValueListenableBuilder( + valueListenable: core.zwiftEmulator.isStarted, + builder: (context, isStarted, _) { + return StatefulBuilder( + builder: (context, setState) { + return ConnectionMethod( + supportedActions: core.zwiftEmulator.supportedActions, + isRecommended: true, + isEnabled: core.settings.getZwiftBleEmulatorEnabled(), + type: ConnectionMethodType.bluetooth, + instructionLink: 'INSTRUCTIONS_ZWIFT.md', + isStarted: isStarted, + isConnected: isConnected, + onChange: (value) { + core.settings.setZwiftBleEmulatorEnabled(value); + if (!value) { + core.zwiftEmulator.stopAdvertising(); + } else if (value) { + core.zwiftEmulator.startAdvertising(widget.onUpdate).catchError((e, s) { + recordError(e, s, context: 'Zwift BLE Emulator'); + core.zwiftEmulator.cleanup(); + core.zwiftEmulator.isStarted.value = false; + core.settings.setZwiftBleEmulatorEnabled(false); + core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString())); + }); + } + setState(() {}); + }, + title: context.i18n.enableZwiftControllerBluetooth, + description: !isStarted + ? context.i18n.zwiftControllerDescription + : isConnected + ? context.i18n.connected + : context.i18n.waitingForConnectionKickrBike(core.settings.getTrainerApp()?.name ?? ''), + requirements: core.permissions.getRemoteControlRequirements(), + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/changelog_dialog.dart b/lib/widgets/changelog_dialog.dart new file mode 100644 index 000000000..ce7ce0bc9 --- /dev/null +++ b/lib/widgets/changelog_dialog.dart @@ -0,0 +1,69 @@ +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_md/flutter_md.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class ChangelogDialog extends StatelessWidget { + final Markdown entry; + + const ChangelogDialog({super.key, required this.entry}); + + @override + Widget build(BuildContext context) { + final latestVersion = Markdown( + blocks: entry.blocks.skip(1).takeWhile((b) => b.type != 'heading').toList(), + markdown: entry.markdown, + ); + return AlertDialog( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.i18n.whatsNew), + SizedBox(height: 4), + Text(context.i18n.version(entry.blocks.first.text)).small, + ], + ), + content: Container( + constraints: BoxConstraints(minWidth: 460, maxHeight: 500), + child: Scrollbar( + child: SingleChildScrollView( + child: MarkdownWidget( + markdown: latestVersion, + theme: MarkdownThemeData( + textStyle: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.primary), + ), + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.i18n.gotIt), + ), + ], + ); + } + + static Future showIfNeeded(BuildContext context, String currentVersion, String? lastSeenVersion) async { + // Show dialog if this is a new version + if (lastSeenVersion != currentVersion && lastSeenVersion != null && !screenshotMode) { + try { + final entry = await rootBundle.loadString('CHANGELOG.md'); + if (context.mounted) { + final markdown = Markdown.fromString(entry); + showDialog( + context: context, + useRootNavigator: true, + routeSettings: RouteSettings(name: '/changelog'), + 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 new file mode 100644 index 000000000..7eb7132ad --- /dev/null +++ b/lib/widgets/custom_keymap_selector.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../utils/keymap/apps/custom_app.dart'; + +class HotKeyListenerDialog extends StatefulWidget { + final CustomApp customApp; + final KeyPair? keyPair; + final ButtonTrigger trigger; + const HotKeyListenerDialog({ + super.key, + required this.customApp, + required this.keyPair, + this.trigger = ButtonTrigger.singleClick, + }); + + @override + State createState() => _HotKeyListenerState(); +} + +class _HotKeyListenerState extends State { + late StreamSubscription _actionSubscription; + + final FocusNode _focusNode = FocusNode(); + KeyDownEvent? _pressedKey; + ControllerButton? _pressedButton; + final Set _activeModifiers = {}; + + @override + void initState() { + super.initState(); + _pressedButton = widget.keyPair?.buttons.firstOrNull; + _actionSubscription = core.connection.actionStream.listen((data) { + if (!mounted || widget.keyPair != null) { + return; + } + if (data is ButtonNotification) { + setState(() { + _pressedButton = data.buttonsClicked.singleOrNull; + }); + } + }); + _focusNode.requestFocus(); + } + + @override + void dispose() { + _actionSubscription.cancel(); + _focusNode.dispose(); + super.dispose(); + } + + void _onKey(KeyEvent event) { + setState(() { + // Track modifier keys + if (event is KeyDownEvent) { + final wasModifier = _updateModifierState(event.logicalKey, add: true); + // Regular key pressed - record it along with active modifiers + if (!wasModifier) { + if (_pressedKey?.logicalKey != event.logicalKey) {} + _pressedKey = event; + widget.customApp.setKey( + _pressedButton!, + physicalKey: _pressedKey!.physicalKey, + logicalKey: _pressedKey!.logicalKey, + modifiers: _activeModifiers.toList(), + touchPosition: widget.keyPair?.touchPosition, + trigger: widget.trigger, + ); + } + } else if (event is KeyUpEvent) { + // Clear modifier when released + _updateModifierState(event.logicalKey, add: false); + } + }); + } + + bool _updateModifierState(LogicalKeyboardKey key, {required bool add}) { + ModifierKey? modifier; + + if (key == LogicalKeyboardKey.shift || + key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + modifier = ModifierKey.shiftModifier; + } else if (key == LogicalKeyboardKey.control || + key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + modifier = ModifierKey.controlModifier; + } else if (key == LogicalKeyboardKey.alt || + key == LogicalKeyboardKey.altLeft || + key == LogicalKeyboardKey.altRight) { + modifier = ModifierKey.altModifier; + } else if (key == LogicalKeyboardKey.meta || + key == LogicalKeyboardKey.metaLeft || + key == LogicalKeyboardKey.metaRight) { + modifier = ModifierKey.metaModifier; + } else if (key == LogicalKeyboardKey.fn) { + modifier = ModifierKey.functionModifier; + } + + if (modifier != null) { + if (add) { + _activeModifiers.add(modifier); + } else { + _activeModifiers.remove(modifier); + } + return true; + } + return false; + } + + String _formatModifierName(ModifierKey m) { + return switch (m) { + ModifierKey.shiftModifier => 'Shift', + ModifierKey.controlModifier => 'Ctrl', + ModifierKey.altModifier => 'Alt', + ModifierKey.metaModifier => 'Meta', + ModifierKey.functionModifier => 'Fn', + _ => m.name, + }; + } + + String _formatKey(KeyDownEvent? key) { + if (key == null) { + return _activeModifiers.isEmpty + ? AppLocalizations.current.waiting + : '${_activeModifiers.map(_formatModifierName).join('+')}+...'; + } + + if (_activeModifiers.isEmpty) { + return key.logicalKey.keyLabel; + } + + final modifierStrings = _activeModifiers.map(_formatModifierName); + + return '${modifierStrings.join('+')}+${key.logicalKey.keyLabel}'; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + content: _pressedButton == null + ? Text(AppLocalizations.current.pressButtonOnClickDevice) + : KeyboardListener( + focusNode: _focusNode, + autofocus: true, + onKeyEvent: _onKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 20, + children: [ + Text( + AppLocalizations.current.pressKeyToAssign(_pressedButton?.displayName ?? _pressedButton.toString()), + ), + Text(_formatKey(_pressedKey)), + if (kDebugMode && (Platform.isAndroid || Platform.isIOS)) + SizedBox( + height: 300, + width: 300, + child: ListView( + shrinkWrap: true, + children: LogicalKeyboardKey.knownLogicalKeys + .map( + (key) => ListTile( + contentPadding: EdgeInsets.zero, + minVerticalPadding: 0, + title: Row( + children: [ + Chip(label: Text(key.keyLabel)), + ], + ), + onTap: () { + setState(() { + _pressedKey = KeyDownEvent( + physicalKey: PhysicalKeyboardKey(0x80), + logicalKey: key, + character: null, + timeStamp: Duration.zero, + ); + widget.customApp.setKey( + _pressedButton!, + physicalKey: _pressedKey!.physicalKey, + logicalKey: key, + modifiers: _activeModifiers.toList(), + touchPosition: widget.keyPair?.touchPosition, + trigger: widget.trigger, + ); + }); + }, + ), + ) + .toList(), + ), + ), + ], + ), + ), + + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(_pressedKey), child: Text(AppLocalizations.current.ok)), + ], + ); + } +} diff --git a/lib/widgets/device_script_drawer.dart b/lib/widgets/device_script_drawer.dart new file mode 100644 index 000000000..b58db558a --- /dev/null +++ b/lib/widgets/device_script_drawer.dart @@ -0,0 +1,388 @@ +import 'package:bike_control/utils/interpreter.dart'; +import 'package:bike_control/widgets/ui/loading_widget.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class DeviceScriptDrawer extends StatefulWidget { + final String deviceType; + + const DeviceScriptDrawer({super.key, required this.deviceType}); + + @override + State createState() => _DeviceScriptDrawerState(); +} + +class _DeviceScriptDrawerState extends State { + final TextEditingController _controller = TextEditingController(); + final TextEditingController _tryCharacteristicController = TextEditingController(); + final TextEditingController _tryHexController = TextEditingController(text: '01 02 03'); + + bool _isLoading = true; + bool _isSaving = false; + bool _isDeleting = false; + bool _isTrying = false; + bool _hasSavedScript = false; + String? _validationError; + String? _tryOutputCharacteristic; + String? _tryOutputHex; + String? _tryError; + + @override + void initState() { + super.initState(); + _tryCharacteristicController.text = '00000000-0000-0000-0000-000000000000'; + _loadScript(); + } + + @override + void dispose() { + _controller.dispose(); + _tryCharacteristicController.dispose(); + _tryHexController.dispose(); + super.dispose(); + } + + Future _loadScript() async { + final hasSavedScript = await DeviceScriptService.instance.hasCustomScript(widget.deviceType); + final source = await DeviceScriptService.instance.loadScriptForEditing(widget.deviceType); + if (!mounted) { + return; + } + + _controller.text = source; + setState(() { + _isLoading = false; + _hasSavedScript = hasSavedScript; + }); + } + + Future _saveScript() async { + setState(() { + _validationError = null; + }); + + final result = await DeviceScriptService.instance.saveScript( + deviceType: widget.deviceType, + source: _controller.text, + ); + + if (!mounted) { + return; + } + + setState(() { + _validationError = result.errorMessage; + }); + + if (!result.isValid) { + return; + } + + buildToast(title: 'Script saved for ${widget.deviceType}.'); + closeDrawer(context); + } + + Future _deleteScript() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete script?'), + content: Text('This will remove the saved script for ${widget.deviceType}.'), + actions: [ + OutlineButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + DestructiveButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + setState(() { + _validationError = null; + }); + + await DeviceScriptService.instance.deleteScript(widget.deviceType); + + if (!mounted) { + return; + } + + _controller.text = kDefaultDeviceScript; + setState(() { + _hasSavedScript = false; + }); + + buildToast(title: 'Script deleted for ${widget.deviceType}.'); + } + + Future _tryScript() async { + final characteristicUuid = _tryCharacteristicController.text.trim().toLowerCase(); + final hexInput = _tryHexController.text.trim(); + + setState(() { + _tryError = null; + _tryOutputCharacteristic = null; + _tryOutputHex = null; + }); + + if (characteristicUuid.isEmpty) { + setState(() { + _tryError = 'Characteristic UUID is required.'; + }); + return; + } + + try { + final data = _parseHexInput(hexInput); + final result = await DeviceScriptService.instance.runScriptSource( + source: _controller.text, + characteristicUuid: characteristicUuid, + data: data, + ); + + if (!mounted) { + return; + } + + setState(() { + _tryOutputCharacteristic = result.characteristicUuid; + _tryOutputHex = _toHex(result.data); + }); + } catch (e) { + if (!mounted) { + return; + } + setState(() { + _tryError = e.toString(); + }); + } + } + + Uint8List _parseHexInput(String input) { + final normalized = input + .replaceAll(RegExp(r'0x', caseSensitive: false), '') + .replaceAll(RegExp(r'[^0-9a-fA-F]'), ''); + + if (normalized.isEmpty) { + return Uint8List(0); + } + + if (normalized.length.isOdd) { + throw const FormatException('Hex input must have an even number of characters.'); + } + + final bytes = []; + for (var i = 0; i < normalized.length; i += 2) { + bytes.add(int.parse(normalized.substring(i, i + 2), radix: 16)); + } + return Uint8List.fromList(bytes); + } + + String _toHex(Uint8List bytes) { + if (bytes.isEmpty) { + return '(empty)'; + } + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ').toUpperCase(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: 780, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Text('Run Script', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + Text( + 'Device type: ${widget.deviceType}', + style: TextStyle(color: Theme.of(context).colorScheme.mutedForeground, fontSize: 12), + ), + Text( + 'This script will run whenever a value is received via bluetooth.\nRequired signature: Future> main(String characteristicUuid, List data)', + style: TextStyle(color: Theme.of(context).colorScheme.mutedForeground, fontSize: 12), + ), + Expanded( + child: _isLoading + ? Center(child: SmallProgressIndicator()) + : TextArea( + controller: _controller, + expands: true, + minLines: null, + maxLines: null, + textAlignVertical: TextAlignVertical.top, + placeholder: Text('Write your script here...'), + ).inlineCode, + ), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.card.withAlpha(180), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Text('Try Script', style: TextStyle(fontWeight: FontWeight.w600)), + Row( + spacing: 8, + children: [ + Expanded( + child: Column( + children: [ + TextField( + controller: _tryCharacteristicController, + placeholder: const Text('Characteristic UUID'), + hintText: 'Characteristic UUID', + ), + TextField( + controller: _tryHexController, + placeholder: const Text('Hex input (e.g. 01 FF 2A)'), + hintText: 'Hex input (e.g. 01 FF 2A)', + ), + ], + ), + ), + LoadingWidget( + onLoadCallback: (isLoading) { + if (!mounted) { + return; + } + setState(() { + _isTrying = isLoading; + }); + }, + futureCallback: _tryScript, + renderChild: (isLoading, tap) => OutlineButton( + onPressed: (_isLoading || _isSaving || _isDeleting) ? null : tap, + child: isLoading + ? const Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + SmallProgressIndicator(), + Text('Trying...'), + ], + ) + : const Text('Try'), + ), + ), + ], + ), + if (_tryError != null) + Text( + _tryError!, + style: TextStyle(color: Theme.of(context).colorScheme.destructive, fontSize: 12), + ), + if (_tryOutputCharacteristic != null && _tryOutputHex != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + 'Output characteristic: $_tryOutputCharacteristic', + ).inlineCode, + Text( + 'Output data (hex): $_tryOutputHex', + ).inlineCode, + ], + ), + ], + ), + ), + if (_validationError != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.destructive.withAlpha(24), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _validationError!, + style: TextStyle(color: Theme.of(context).colorScheme.destructive, fontSize: 12), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + if (_hasSavedScript) + LoadingWidget( + onLoadCallback: (isLoading) { + if (!mounted) { + return; + } + setState(() { + _isDeleting = isLoading; + }); + }, + futureCallback: _deleteScript, + renderChild: (isLoading, tap) => DestructiveButton( + onPressed: (_isLoading || _isSaving || _isTrying) ? null : tap, + child: isLoading + ? const Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + SmallProgressIndicator(), + Text('Deleting...'), + ], + ) + : const Text('Delete'), + ), + ), + if (!_hasSavedScript) const SizedBox.shrink(), + const Spacer(), + OutlineButton( + onPressed: (_isSaving || _isDeleting || _isTrying) ? null : () => closeDrawer(context), + child: const Text('Cancel'), + ), + LoadingWidget( + onLoadCallback: (isLoading) { + if (!mounted) { + return; + } + setState(() { + _isSaving = isLoading; + }); + }, + futureCallback: _saveScript, + renderChild: (isLoading, tap) => PrimaryButton( + onPressed: (_isLoading || _isDeleting || _isTrying) ? null : tap, + child: isLoading + ? const Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + SmallProgressIndicator(color: Colors.black), + Text('Saving...'), + ], + ) + : const Text('Save'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/go_pro_dialog.dart b/lib/widgets/go_pro_dialog.dart new file mode 100644 index 000000000..ca956dcde --- /dev/null +++ b/lib/widgets/go_pro_dialog.dart @@ -0,0 +1,52 @@ +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/ui/loading_widget.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Shows a dialog prompting the user to upgrade to Pro. +/// Returns true if the user initiated a purchase, false otherwise. +Future showGoProDialog(BuildContext context) async { + final iapManager = IAPManager.instance; + + final result = await showDialog( + context: context, + builder: (c) => Container( + constraints: BoxConstraints(maxWidth: 400), + child: AlertDialog( + title: Row( + children: [ + Icon(Icons.workspace_premium, color: Colors.orange), + const SizedBox(width: 8), + Text('Pro Feature'), + ], + ), + content: Text('This feature is only available with Pro. Upgrade to Pro to unlock all features.'), + actions: [ + Button.secondary( + onPressed: () => Navigator.of(c).pop(false), + child: Text('Cancel'), + ), + LoadingWidget( + futureCallback: () async { + await iapManager.purchaseSubscription(context); + Navigator.of(c).pop(true); + }, + renderChild: (isLoading, tap) => PrimaryButton( + onPressed: tap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + isLoading ? SmallProgressIndicator() : Icon(Icons.workspace_premium, size: 16), + const SizedBox(width: 8), + Text('Go Pro'), + ], + ), + ), + ), + ], + ), + ), + ); + + return result ?? false; +} diff --git a/lib/widgets/iap_status_widget.dart b/lib/widgets/iap_status_widget.dart new file mode 100644 index 000000000..216c1d806 --- /dev/null +++ b/lib/widgets/iap_status_widget.dart @@ -0,0 +1,549 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/ui/loading_widget.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:intl/intl.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +/// Widget to display IAP status and allow purchases +class IAPStatusWidget extends StatefulWidget { + final bool small; + const IAPStatusWidget({super.key, required this.small}); + + @override + State createState() => _IAPStatusWidgetState(); +} + +final _normalDate = DateTime(2026, 3, 15, 0, 0, 0, 0, 0); +final _iapDate = DateTime(2025, 12, 21, 0, 0, 0, 0, 0); + +enum AlreadyBoughtOption { fullPurchase, iap, no } + +class _IAPStatusWidgetState extends State { + bool _isPurchasing = false; + bool _isSmall = false; + AlreadyBoughtOption? _alreadyBoughtQuestion; + + final _purchaseIdField = const TextFieldKey(#purchaseId); + + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _isSmall = widget.small; + } + + @override + void didUpdateWidget(covariant IAPStatusWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.small != widget.small) { + setState(() { + _isSmall = widget.small; + }); + } + } + + @override + Widget build(BuildContext context) { + final iapManager = IAPManager.instance; + final isOutsideStoreWindowsBuild = iapManager.isOutsideStoreWindowsBuild; + final isTrialExpired = iapManager.isTrialExpired; + if (isTrialExpired) { + _isSmall = false; + } + final trialDaysRemaining = iapManager.trialDaysRemaining; + final commandsRemaining = iapManager.commandsRemainingToday; + final dailyCommandCount = iapManager.dailyCommandCount; + + return kIsWeb + ? SizedBox() + : Button( + onPressed: _isSmall + ? () { + setState(() { + _isSmall = false; + }); + } + : () { + if (Platform.isAndroid) { + if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) { + _handlePurchase(context); + } + } else { + _handlePurchase(context); + } + }, + style: ButtonStyle.card().withBackgroundColor( + color: Theme.of(context).colorScheme.muted, + hoverColor: Theme.of(context).colorScheme.primaryForeground, + ), + child: AnimatedContainer( + duration: Duration(milliseconds: 700), + width: double.infinity, + child: ValueListenableBuilder( + valueListenable: IAPManager.instance.isPurchased, + builder: (context, isPurchased, child) { + final hasPremiumAccess = iapManager.isProEnabled || isPurchased; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasPremiumAccess) ...[ + Row( + children: [ + Icon(Icons.check_circle, color: Colors.green), + const SizedBox(width: 8), + Text( + AppLocalizations.of(context).fullVersion, + style: TextStyle( + color: Colors.green, + ), + ), + ], + ), + ] else if (!isTrialExpired) ...[ + if (!Platform.isAndroid) + Basic( + leadingAlignment: Alignment.centerLeft, + leading: Icon(Icons.access_time, color: Colors.blue), + title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)), + subtitle: _isSmall + ? null + : Text( + AppLocalizations.of(context).trialPeriodDescription(IAPManager.dailyCommandLimit), + ), + trailing: _isSmall ? Icon(Icons.expand_more) : null, + ) + else + Basic( + leadingAlignment: Alignment.centerLeft, + leading: Icon(Icons.lock), + title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, + children: [ + SizedBox(), + Text( + commandsRemaining >= 0 + ? context.i18n + .commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit) + .replaceAll( + '${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}', + IAPManager.dailyCommandLimit.toString(), + ) + : AppLocalizations.of( + context, + ).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit), + ).small, + if (commandsRemaining >= 0 && dailyCommandCount > 0) + SizedBox( + width: 300, + child: LinearProgressIndicator( + value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(), + backgroundColor: Colors.gray[300], + color: commandsRemaining > 0 ? Colors.orange : Colors.red, + ), + ), + ], + ), + trailing: _isSmall ? Icon(Icons.expand_more) : null, + trailingAlignment: Alignment.centerRight, + ), + ] else ...[ + Basic( + leadingAlignment: Alignment.centerLeft, + leading: Icon(Icons.lock), + title: Text(AppLocalizations.of(context).trialExpired(IAPManager.dailyCommandLimit)), + trailing: _isSmall ? Icon(Icons.expand_more) : null, + trailingAlignment: Alignment.centerRight, + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, + children: [ + SizedBox(), + Text( + commandsRemaining >= 0 + ? context.i18n.commandsRemainingToday( + commandsRemaining, + IAPManager.dailyCommandLimit, + ) + : AppLocalizations.of( + context, + ).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit), + ).small, + if (commandsRemaining >= 0) + SizedBox( + width: 300, + child: LinearProgressIndicator( + value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(), + backgroundColor: Colors.gray[300], + color: commandsRemaining > 0 ? Colors.orange : Colors.red, + ), + ), + ], + ), + ), + ], + if (!hasPremiumAccess && !_isSmall) ...[ + if (Platform.isAndroid) + Padding( + padding: const EdgeInsets.only(left: 42.0, top: 16.0), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Divider(), + const SizedBox(), + if (_alreadyBoughtQuestion == null && DateTime.now().isBefore(_normalDate)) ...[ + Text(AppLocalizations.of(context).alreadyBoughtTheAppPreviously).small, + Row( + children: [ + Builder( + builder: (context) { + return OutlineButton( + child: Text(AppLocalizations.of(context).yes), + onPressed: () { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + MenuButton( + child: Text( + AppLocalizations.of( + context, + ).beforeDate(DateFormat.yMMMd().format(_iapDate)), + ), + onPressed: (c) { + setState(() { + _alreadyBoughtQuestion = AlreadyBoughtOption.fullPurchase; + }); + }, + ), + MenuButton( + child: Text( + AppLocalizations.of( + context, + ).afterDate(DateFormat.yMMMd().format(_iapDate)), + ), + onPressed: (c) { + setState(() { + _alreadyBoughtQuestion = AlreadyBoughtOption.iap; + }); + }, + ), + ], + ), + ); + }, + ); + }, + ), + const SizedBox(width: 8), + OutlineButton( + child: Text(AppLocalizations.of(context).no), + onPressed: () { + setState(() { + _alreadyBoughtQuestion = AlreadyBoughtOption.no; + }); + }, + ), + ], + ), + ] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.fullPurchase) ...[ + Text( + AppLocalizations.of(context).alreadyBoughtTheApp, + ).small, + Form( + onSubmit: (context, values) async { + String purchaseId = _purchaseIdField[values]!.trim(); + setState(() { + _isLoading = true; + }); + final redeemed = await _redeemPurchase( + purchaseId: purchaseId, + supabaseAnonKey: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpa3JjeXlub3Zkdm9ncmxkZm53Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYwNjMyMzksImV4cCI6MjA4MTYzOTIzOX0.oxJovYahRiZ6XvCVR-qww6OQ5jY6cjOyUiFHJsW9MVk', + supabaseUrl: 'https://pikrcyynovdvogrldfnw.supabase.co', + ); + if (redeemed) { + await IAPManager.instance.redeem(purchaseId); + buildToast( + title: 'Success', + subtitle: 'Purchase redeemed successfully!', + ); + setState(() { + _isLoading = false; + }); + } else { + setState(() { + _isLoading = false; + }); + if (mounted) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text('Error'), + content: Text( + 'Failed to redeem purchase. Please check your Purchase ID and try again or contact me directly. Sorry about that!', + ), + actions: [ + OutlineButton( + child: Text(context.i18n.getSupport), + onPressed: () async { + final appUserId = await Purchases.appUserID; + launchUrlString( + 'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help%20for%20$appUserId', + ); + }, + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('OK'), + ), + ], + ); + }, + ); + } + } + }, + child: Row( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: FormField( + showErrors: { + FormValidationMode.submitted, + FormValidationMode.changed, + }, + key: _purchaseIdField, + label: Text('Purchase ID'), + validator: RegexValidator( + RegExp(r'GPA.[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{5}'), + message: 'Please enter a valid Purchase ID.', + ), + child: TextField( + placeholder: Text('GPA.****-****-****-*****'), + ), + ), + ), + FormErrorBuilder( + builder: (context, errors, child) { + return PrimaryButton( + onPressed: errors.isEmpty ? () => context.submitForm() : null, + child: _isLoading + ? SmallProgressIndicator(color: Colors.black) + : const Text('Submit'), + ); + }, + ), + ], + ), + ), + ] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.no || + DateTime.now().isAfter(_normalDate)) ...[ + PrimaryButton( + onPressed: _isPurchasing ? null : () => _handlePurchase(context), + leading: Icon(Icons.star), + child: _isPurchasing + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SmallProgressIndicator(), + const SizedBox(width: 8), + Text('Processing...'), + ], + ) + : Text( + isOutsideStoreWindowsBuild + ? AppLocalizations.of(context).goPro + : AppLocalizations.of(context).unlockFullVersion, + ), + ), + ] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) ...[ + PrimaryButton( + onPressed: _isPurchasing ? null : () => _handlePurchase(context), + leading: Icon(Icons.star), + child: _isPurchasing + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SmallProgressIndicator(), + const SizedBox(width: 8), + Text('Processing...'), + ], + ) + : Text( + isOutsideStoreWindowsBuild + ? AppLocalizations.of(context).goPro + : AppLocalizations.of(context).unlockFullVersion, + ), + ), + Text( + AppLocalizations.of(context).restorePurchaseInfo, + ).xSmall, + OutlineButton( + child: Text(context.i18n.getSupport), + onPressed: () async { + final appUserId = await Purchases.appUserID; + launchUrlString( + 'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help%20for%20$appUserId', + ); + }, + ), + ], + if (IAPManager.instance.isUsingRevenueCat) + _buildRestoreAction( + label: 'Restore Purchases', + leftPadding: 0, + ), + ], + ), + ) + else ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(left: 42.0), + child: Builder( + builder: (context) { + return PrimaryButton( + onPressed: _isPurchasing ? null : () => _handlePurchase(context), + leading: Icon(Icons.star), + child: _isPurchasing + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SmallProgressIndicator(), + const SizedBox(width: 8), + Text('Processing...'), + ], + ) + : Text( + isOutsideStoreWindowsBuild + ? AppLocalizations.of(context).goPro + : AppLocalizations.of(context).unlockFullVersion, + ), + ); + }, + ), + ), + if (IAPManager.instance.isUsingRevenueCat) + _buildRestoreAction( + label: 'Restore Purchases', + leftPadding: 42.0, + ), + if (Platform.isWindows) + _buildRestoreAction( + label: 'Restore / Sync subscription', + leftPadding: 42.0, + ), + ], + ], + ], + ); + }, + ), + ), + ); + } + + Future _handlePurchase(BuildContext context) async { + setState(() { + _isPurchasing = true; + }); + + try { + if (IAPManager.instance.isOutsideStoreWindowsBuild) { + await IAPManager.instance.purchaseSubscription(context); + } else { + // Use RevenueCat paywall if available, otherwise fall back to legacy + await IAPManager.instance.purchaseFullVersion(context); + } + } catch (e) { + if (mounted) { + buildToast( + title: 'Error', + subtitle: 'An error occurred: $e', + ); + } + } finally { + if (mounted) { + setState(() { + _isPurchasing = false; + }); + } + } + } + + Widget _buildRestoreAction({ + required String label, + required double leftPadding, + }) { + return Padding( + padding: EdgeInsets.only(left: leftPadding, top: 8.0, bottom: 8), + child: LoadingWidget( + futureCallback: () async { + await IAPManager.instance.restorePurchases(); + await IAPManager.instance.refreshEntitlementsOnResume(); + }, + renderChild: (isLoading, tap) => LinkButton( + onPressed: tap, + child: isLoading ? SmallProgressIndicator() : Text(label).small, + ), + ), + ); + } + + Future _redeemPurchase({ + required String supabaseUrl, + required String supabaseAnonKey, + required String purchaseId, + }) async { + final uri = Uri.parse('$supabaseUrl/functions/v1/redeem-purchase'); + + final appUserId = await Purchases.appUserID; + + final response = await http.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $supabaseAnonKey', + }, + body: jsonEncode({ + 'purchaseId': purchaseId, + 'userId': appUserId, + }), + ); + + if (response.statusCode != 200) { + return false; + } + + final body = response.body; + final decoded = jsonDecode(body) as Map; + + core.connection.signalNotification(LogNotification(body)); + + return decoded['success'] == true; + } +} diff --git a/lib/widgets/ignored_devices_dialog.dart b/lib/widgets/ignored_devices_dialog.dart new file mode 100644 index 000000000..a6545c2b4 --- /dev/null +++ b/lib/widgets/ignored_devices_dialog.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; + +class IgnoredDevicesDialog extends StatefulWidget { + const IgnoredDevicesDialog({super.key}); + + @override + State createState() => _IgnoredDevicesDialogState(); +} + +class _IgnoredDevicesDialogState extends State { + List<({String id, String name})> _ignoredDevices = []; + + @override + void initState() { + super.initState(); + _loadIgnoredDevices(); + } + + void _loadIgnoredDevices() { + setState(() { + _ignoredDevices = core.settings.getIgnoredDevices(); + }); + } + + Future _removeDevice(String deviceId) async { + await core.settings.removeIgnoredDevice(deviceId); + _loadIgnoredDevices(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(context.i18n.ignoredDevices), + content: SizedBox( + width: double.maxFinite, + child: _ignoredDevices.isEmpty + ? Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + context.i18n.noIgnoredDevices, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ) + : ListView.builder( + shrinkWrap: true, + itemCount: _ignoredDevices.length, + itemBuilder: (context, index) { + final device = _ignoredDevices[index]; + return ListTile( + title: Text(device.name), + subtitle: Text( + device.id, + style: TextStyle(fontSize: 12), + ), + trailing: IconButton( + icon: Icon(Icons.delete_outline), + tooltip: context.i18n.removeFromIgnoredList, + onPressed: () => _removeDevice(device.id), + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(context.i18n.close), + ), + ], + ); + } +} diff --git a/lib/widgets/keyboard_pair_widget.dart b/lib/widgets/keyboard_pair_widget.dart new file mode 100644 index 000000000..d8f5f846d --- /dev/null +++ b/lib/widgets/keyboard_pair_widget.dart @@ -0,0 +1,54 @@ +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:prop/prop.dart' show LogLevel; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class RemoteKeyboardPairingWidget extends StatefulWidget { + const RemoteKeyboardPairingWidget({super.key}); + + @override + State createState() => _PairWidgetState(); +} + +class _PairWidgetState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: core.remoteKeyboardPairing.isStarted, + builder: (context, isStarted, child) { + return ValueListenableBuilder( + valueListenable: core.remoteKeyboardPairing.isConnected, + builder: (context, isConnected, child) { + return ConnectionMethod( + supportedActions: null, + isRecommended: false, + isEnabled: core.logic.isRemoteKeyboardControlEnabled, + isStarted: isStarted, + showTroubleshooting: true, + type: ConnectionMethodType.bluetooth, + instructionLink: 'https://youtube.com/shorts/qalBSiAz7wg', + title: AppLocalizations.of(context).actAsBluetoothKeyboard, + description: AppLocalizations.of(context).bluetoothKeyboardExplanation, + isConnected: isConnected, + requirements: core.permissions.getRemoteControlRequirements(), + onChange: (value) async { + core.settings.setRemoteKeyboardControlEnabled(value); + if (!value) { + core.remoteKeyboardPairing.stopAdvertising(); + } else { + core.remoteKeyboardPairing.startAdvertising().catchError((e) { + core.settings.setRemoteControlEnabled(false); + core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString())); + }); + } + setState(() {}); + }, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/keymap_explanation.dart b/lib/widgets/keymap_explanation.dart new file mode 100644 index 000000000..b2c9b48c8 --- /dev/null +++ b/lib/widgets/keymap_explanation.dart @@ -0,0 +1,517 @@ +import 'dart:async'; + +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/button_edit.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/keymap/apps/custom_app.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/utils/keymap/manager.dart'; +import 'package:bike_control/widgets/ui/button_widget.dart'; +import 'package:bike_control/widgets/ui/colored_title.dart'; +import 'package:bike_control/widgets/ui/colors.dart'; +import 'package:bike_control/widgets/ui/loading_widget.dart'; +import 'package:bike_control/widgets/ui/pro_badge.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../bluetooth/messages/notification.dart'; +import '../utils/iap/iap_manager.dart'; + +enum _TriggerConflictResolution { + goPro, + replaceOtherTriggers, +} + +class KeymapExplanation extends StatefulWidget { + final Keymap keymap; + final VoidCallback onUpdate; + const KeymapExplanation({super.key, required this.keymap, required this.onUpdate}); + + @override + State createState() => _KeymapExplanationState(); +} + +class _KeymapExplanationState extends State { + late StreamSubscription _updateStreamListener; + + late StreamSubscription _actionSubscription; + + bool _isDrawerOpen = false; + bool _isMobile = false; + + @override + void initState() { + super.initState(); + _updateStreamListener = widget.keymap.updateStream.listen((_) { + setState(() {}); + }); + _actionSubscription = core.connection.actionStream.listen((data) async { + if (!mounted) { + return; + } + if (data is ButtonNotification && data.buttonsClicked.length == 1) { + final clickedButton = data.buttonsClicked.first; + if (!_isDrawerOpen) { + final hasFallbackLongPress = + data.device.supportsLongPress == false && + widget.keymap.getKeyPair(clickedButton, trigger: ButtonTrigger.longPress)?.hasNoAction == false; + _openButtonEditor( + data.device, + clickedButton, + hasFallbackLongPress ? ButtonTrigger.longPress : ButtonTrigger.singleClick, + ); + } + setState(() {}); + } + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _isMobile = MediaQuery.sizeOf(context).width < 860; + } + + @override + void dispose() { + super.dispose(); + _updateStreamListener.cancel(); + _actionSubscription.cancel(); + } + + @override + void didUpdateWidget(KeymapExplanation oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.keymap != widget.keymap) { + _updateStreamListener.cancel(); + _updateStreamListener = widget.keymap.updateStream.listen((_) { + setState(() {}); + }); + } + } + + @override + Widget build(BuildContext context) { + final keyButtonMap = core.connection.controllerDevices.associateWith((device) { + return device.availableButtons.distinct().sortedBy( + (button) => button.color != null ? '0${(button.icon?.codePoint ?? 0)}' : '1${(button.icon?.codePoint ?? 0)}', + ); + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + if (core.connection.controllerDevices.isNotEmpty && !screenshotMode) + Text( + AppLocalizations.of(context).clickAButtonOnYourController, + style: TextStyle(fontSize: 12), + ).muted, + + for (final devicePair in keyButtonMap.entries) ...[ + SizedBox(height: 12), + ColoredTitle(text: devicePair.key.toString()), + if (devicePair.value.isEmpty) + Text( + devicePair.key.buttonExplanation, + style: TextStyle(height: 1), + ).muted, + for (final button in devicePair.value) ...[ + Card( + fillColor: Theme.of(context).colorScheme.background, + filled: true, + borderColor: ComponentTheme.maybeOf(context)?.color ?? Theme.of(context).colorScheme.border, + padding: _isMobile ? EdgeInsets.zero : null, + clipBehavior: Clip.antiAlias, + child: _isMobile + ? Column( + children: [ + Container( + color: Theme.of(context).colorScheme.card.withAlpha(70), + height: 52, + child: Row( + children: [ + Transform.scale( + scale: 0.7, + child: SizedBox( + width: 80, + child: ButtonWidget( + button: button, + big: true, + ), + ), + ), + Expanded( + child: Text(button.name.splitByUpperCase()).medium.small, + ), + ], + ), + ), + _buildTriggerButton( + context, + device: devicePair.key, + deviceButton: button, + trigger: ButtonTrigger.singleClick, + supportsLongPress: devicePair.key.supportsLongPress, + ), + _buildTriggerButton( + context, + device: devicePair.key, + deviceButton: button, + trigger: ButtonTrigger.doubleClick, + supportsLongPress: devicePair.key.supportsLongPress, + ), + _buildTriggerButton( + context, + device: devicePair.key, + deviceButton: button, + trigger: ButtonTrigger.longPress, + supportsLongPress: devicePair.key.supportsLongPress, + ), + ], + ) + : Row( + children: [ + Expanded( + child: Basic( + leading: SizedBox( + width: 58, + child: Center( + child: IntrinsicHeight( + child: ButtonWidget( + button: button, + big: true, + ), + ), + ), + ), + content: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildTriggerButton( + context, + device: devicePair.key, + deviceButton: button, + trigger: ButtonTrigger.singleClick, + supportsLongPress: devicePair.key.supportsLongPress, + ), + _buildTriggerButton( + context, + device: devicePair.key, + deviceButton: button, + trigger: ButtonTrigger.doubleClick, + supportsLongPress: devicePair.key.supportsLongPress, + ), + _buildTriggerButton( + context, + device: devicePair.key, + deviceButton: button, + trigger: ButtonTrigger.longPress, + supportsLongPress: devicePair.key.supportsLongPress, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ], + ], + ); + } + + Widget _buildTriggerButton( + BuildContext context, { + required ControllerButton deviceButton, + required BaseDevice device, + required ButtonTrigger trigger, + required bool supportsLongPress, + }) { + final keyPair = widget.keymap.getKeyPair(deviceButton, trigger: trigger); + final longPressKeyPair = widget.keymap.getKeyPair(deviceButton, trigger: ButtonTrigger.longPress); + final showProBanner = _shouldShowProBanner(button: deviceButton, trigger: trigger); + final hasAction = keyPair != null && !keyPair.hasNoAction; + final hasLongPressAction = longPressKeyPair != null && !longPressKeyPair.hasNoAction; + final usesLongPressToggleMode = !supportsLongPress && hasLongPressAction; + final isDisabled = usesLongPressToggleMode && trigger != ButtonTrigger.longPress; + final actionText = hasAction ? keyPair.toString() : context.i18n.noActionAssigned; + final hintText = switch (trigger) { + ButtonTrigger.singleClick || + ButtonTrigger.doubleClick when isDisabled => context.i18n.removeOnePressAction(device.name), + _ => null, + }; + + final column = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + spacing: _isMobile ? 4 : 0, + children: [ + Align( + alignment: _isMobile ? Alignment.centerLeft : Alignment.centerRight, + child: Padding( + padding: EdgeInsets.only(right: showProBanner ? 26 : 0), + child: Text(trigger.title).xSmall.muted, + ), + ), + if (!isDisabled) + Row( + spacing: 6, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasAction) Icon(keyPair.icon ?? Icons.check_circle_outline, size: 14), + if (hasAction || _isMobile) + Flexible( + child: Text( + actionText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: _isMobile && !hasAction + ? TextStyle( + color: Theme.of(context).colorScheme.secondaryForeground.withAlpha(60), + fontStyle: FontStyle.italic, + fontWeight: FontWeight.normal, + ) + : null, + ).small, + ), + ], + ), + + if (trigger == ButtonTrigger.longPress && !supportsLongPress && hasAction) + Text(context.i18n.longTapExplanation).xSmall.muted, + ], + ); + + return LoadingWidget( + futureCallback: () async { + await _onTriggerPressed( + device: device, + button: deviceButton, + trigger: trigger, + hasAction: hasAction, + forceConflictDialog: showProBanner, + hintText: hintText, + ); + }, + renderChild: (isLoading, tap) => Stack( + children: [ + if (_isMobile) ...[ + Divider(), + Button.ghost( + onPressed: tap, + child: Container( + width: _isMobile ? null : 120, + constraints: BoxConstraints(minHeight: 52), + child: Row( + children: [ + Expanded(child: column), + if (isLoading) SmallProgressIndicator() else if (hasAction) Icon(Icons.chevron_right), + ], + ), + ), + ), + ] else + Button.outline( + style: ButtonStyle.outline().withBorder( + border: hasAction + ? Border.all(color: BKColor.main, width: 2) + : Border.all(color: Theme.of(context).colorScheme.border, width: 1), + ), + onPressed: tap, + child: Container( + width: _isMobile ? null : 140, + constraints: BoxConstraints(minHeight: 52), + child: isLoading ? SmallProgressIndicator() : column, + ), + ), + if (showProBanner) + Positioned( + top: 0, + right: 0, + child: ProBadge( + borderRadius: BorderRadius.only(topRight: Radius.circular(6), bottomLeft: Radius.circular(6)), + ), + ), + ], + ), + ); + } + + Future _onTriggerPressed({ + required BaseDevice device, + required ControllerButton button, + required ButtonTrigger trigger, + required bool hasAction, + bool forceConflictDialog = false, + required String? hintText, + }) async { + final isPro = IAPManager.instance.hasActiveSubscription; + final hasOtherAssignedTrigger = _hasActiveTriggerOtherThan(button, trigger); + + final shouldShowConflictDialog = hintText != null || forceConflictDialog || (!hasAction && hasOtherAssignedTrigger); + + if ((!isPro || hintText != null) && shouldShowConflictDialog) { + final resolution = await _showTriggerConflictDialog(trigger, hintText: hintText); + if (!mounted || resolution == null) { + return; + } + + if (resolution == _TriggerConflictResolution.goPro) { + await IAPManager.instance.purchaseSubscription(context); + if (!mounted || !IAPManager.instance.hasActiveSubscription) { + return; + } + await _openButtonEditor(device, button, trigger); + return; + } + + await _openButtonEditor(device, button, trigger, clearOtherTriggers: true); + return; + } + + await _openButtonEditor(device, button, trigger); + } + + bool _shouldShowProBanner({required ControllerButton button, required ButtonTrigger trigger}) { + if (IAPManager.instance.hasActiveSubscription) { + return false; + } + final activeTriggers = _activeTriggers(button); + return activeTriggers.length > 1 && activeTriggers.skip(1).contains(trigger); + } + + List _activeTriggers(ControllerButton button) { + return ButtonTrigger.values.where((trigger) { + final keyPair = widget.keymap.getKeyPair(button, trigger: trigger); + return keyPair != null && !keyPair.hasNoAction; + }).toList(); + } + + bool _hasActiveTriggerOtherThan(ControllerButton button, ButtonTrigger trigger) { + return _activeTriggers(button).any((candidate) => candidate != trigger); + } + + Future<_TriggerConflictResolution?> _showTriggerConflictDialog(ButtonTrigger trigger, {required String? hintText}) { + return showDialog<_TriggerConflictResolution>( + context: context, + builder: (c) => Container( + constraints: BoxConstraints(maxWidth: 420), + child: AlertDialog( + title: Row( + children: [ + if (!IAPManager.instance.hasActiveSubscription) ...[ + Icon(Icons.workspace_premium, color: Colors.orange), + const SizedBox(width: 8), + ], + Text(AppLocalizations.of(context).additionalTriggerAssignment), + ], + ), + content: Text( + hintText ?? AppLocalizations.of(context).anotherTriggerIsAlreadyAssignedForThisButton(trigger.title), + ), + actions: [ + Button.secondary(onPressed: () => Navigator.of(c).pop(), child: Text(AppLocalizations.of(context).cancel)), + Button.secondary( + onPressed: () => Navigator.of(c).pop(_TriggerConflictResolution.replaceOtherTriggers), + child: Text(AppLocalizations.of(context).replaceExisting), + ), + if (!IAPManager.instance.hasActiveSubscription) + PrimaryButton( + onPressed: () => Navigator.of(c).pop(_TriggerConflictResolution.goPro), + child: Text(AppLocalizations.of(context).goPro), + ), + ], + ), + ), + ); + } + + void _clearOtherTriggerAssignments(Keymap keymap, ControllerButton button, ButtonTrigger keepTrigger) { + for (final trigger in ButtonTrigger.values) { + if (trigger == keepTrigger) { + continue; + } + final existing = keymap.getKeyPair(button, trigger: trigger); + if (existing == null || existing.hasNoAction) { + continue; + } + + final keyPair = keymap.getOrCreateKeyPair(button, trigger: trigger); + keyPair.physicalKey = null; + keyPair.logicalKey = null; + keyPair.modifiers = []; + keyPair.touchPosition = Offset.zero; + keyPair.inGameAction = null; + keyPair.inGameActionValue = null; + keyPair.androidAction = null; + keyPair.command = null; + keyPair.screenshotPath = null; + } + } + + Future _openButtonEditor( + BaseDevice device, + ControllerButton button, + ButtonTrigger trigger, { + bool clearOtherTriggers = false, + }) async { + Keymap selectedKeymap = widget.keymap; + if (core.actionHandler.supportedApp is! CustomApp) { + final currentProfile = core.actionHandler.supportedApp!.name; + final newName = await KeymapManager().duplicate( + context, + currentProfile, + skipName: '$currentProfile (Copy)', + ); + if (!mounted) { + return; + } + if (newName != null) { + buildToast(title: context.i18n.createdNewCustomProfile(newName)); + selectedKeymap = core.actionHandler.supportedApp!.keymap; + } + } + + if (clearOtherTriggers) { + _clearOtherTriggerAssignments(selectedKeymap, button, trigger); + selectedKeymap.signalUpdate(); + } + + final selectedKeyPair = selectedKeymap.getOrCreateKeyPair(button, trigger: trigger); + + _isDrawerOpen = true; + await openDrawer( + context: context, + builder: (c) => ButtonEditPage( + device: device, + keyPair: selectedKeyPair, + keymap: selectedKeymap, + trigger: trigger, + onUpdate: () { + selectedKeymap.signalUpdate(); + widget.onUpdate(); + }, + ), + position: OverlayPosition.end, + ); + widget.onUpdate(); + _isDrawerOpen = false; + } +} + +extension SplitByUppercase on String { + String splitByUpperCase() { + return replaceAllMapped(RegExp(r'([a-z])([A-Z])'), (match) => '${match.group(1)} ${match.group(2)}').capitalize(); + } +} diff --git a/lib/widgets/logviewer.dart b/lib/widgets/logviewer.dart new file mode 100644 index 000000000..985433106 --- /dev/null +++ b/lib/widgets/logviewer.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show SelectionArea; +import 'package:flutter/services.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../bluetooth/messages/notification.dart'; + +class LogViewer extends StatefulWidget { + const LogViewer({super.key}); + + @override + State createState() => _LogviewerState(); +} + +class _LogviewerState extends State { + late StreamSubscription _actionSubscription; + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + + _actionSubscription = core.connection.actionStream.listen((data) { + if (mounted) { + setState(() {}); + if (_scrollController.hasClients) { + // scroll to the bottom + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 60), + curve: Curves.easeInOut, + ); + } + } + }); + } + + @override + void dispose() { + _actionSubscription.cancel(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(context.i18n.logViewer).bold, + OutlineButton( + child: Text(context.i18n.share), + onPressed: () { + final logText = core.connection.lastLogEntries + .map((entry) => '${entry.date.toString().split(" ").last} ${entry.entry}') + .join('\n'); + Clipboard.setData(ClipboardData(text: logText)); + + buildToast(title: context.i18n.logsHaveBeenCopiedToClipboard); + }, + ), + ], + ), + core.connection.lastLogEntries.isEmpty + ? Container() + : Expanded( + child: Card( + child: SelectionArea( + child: SingleChildScrollView( + controller: _scrollController, + child: SizedBox( + width: double.infinity, + child: Text.rich( + TextSpan( + children: core.connection.lastLogEntries + .map( + (action) => [ + TextSpan( + text: action.date.toString().split(" ").last, + style: TextStyle( + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + fontFamily: "monospace", + fontFamilyFallback: ["Courier"], + ), + ), + TextSpan( + text: " ${action.entry}\n", + style: TextStyle( + fontSize: 12, + fontFeatures: [FontFeature.tabularFigures()], + fontWeight: FontWeight.bold, + ), + ), + ], + ) + .flatten() + .toList(), + ), + ), + ), + ), + ), + ), + ), + + if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Row( + children: [ + Text('Logs file: '), + Expanded(child: SelectableText('${Directory.current.path}/app.log').inlineCode), + ], + ).small, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/menu.dart b/lib/widgets/menu.dart new file mode 100644 index 000000000..135d28824 --- /dev/null +++ b/lib/widgets/menu.dart @@ -0,0 +1,237 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart'; +import 'package:bike_control/pages/markdown.dart'; +import 'package:bike_control/pages/navigation.dart'; +import 'package:bike_control/pages/paywall.dart'; +import 'package:bike_control/pages/subscription.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/title.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show showLicensePage; +import 'package:flutter/services.dart'; +import 'package:in_app_review/in_app_review.dart'; +import 'package:keypress_simulator/keypress_simulator.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import '../utils/iap/iap_manager.dart'; + +List buildMenuButtons(BuildContext context, BCPage currentPage, VoidCallback? openLogs) { + final iap = IAPManager.instance; + return [ + // Pro/Subscription Button + Builder( + builder: (context) { + return Button( + style: ButtonStyle.primary().withBackgroundColor(color: iap.isProEnabled ? Colors.green : null), + onPressed: () { + openDrawer( + context: context, + builder: (c) => SubscriptionPage(), + position: OverlayPosition.end, + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.workspace_premium, size: 14), + const SizedBox(width: 4), + Text('Pro'), + ], + ), + ); + }, + ), + + if (openLogs == null && (IAPManager.instance.isPurchased.value || IAPManager.instance.isProEnabled)) ...[ + Gap(8), + Builder( + builder: (context) { + return OutlineButton( + density: ButtonDensity.icon, + onPressed: () { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + MenuButton( + leading: Icon(Icons.star_rate), + child: Text(context.i18n.leaveAReview), + onPressed: (c) async { + final InAppReview inAppReview = InAppReview.instance; + + if (await inAppReview.isAvailable()) { + inAppReview.requestReview(); + } else { + inAppReview.openStoreListing(appStoreId: 'id6753721284', microsoftStoreId: '9NP42GS03Z26'); + } + }, + ), + ], + ), + ); + }, + child: Icon( + Icons.favorite, + color: Colors.red, + size: 18, + ), + ); + }, + ), + ], + Gap(4), + + BKMenuButton(openLogs: openLogs, currentPage: currentPage), + ]; +} + +Future debugText() async { + final userId = IAPManager.instance.isUsingRevenueCat ? (await Purchases.appUserID) : null; + return ''' + +--- +App Version: ${packageInfoValue?.version}${shorebirdPatch?.number != null ? '+${shorebirdPatch!.number}' : ''} +Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion} +Target: ${core.settings.getLastTarget()?.name ?? '-'} +Trainer App: ${core.settings.getTrainerApp()?.name ?? '-'} +Connected Controllers: ${core.connection.devices.map((e) => e.toString()).join(', ')} +Connected Trainers: ${core.logic.connectedTrainerConnections.map((e) => e.title).join(', ')} +Status: ${IAPManager.instance.getStatusMessage()}${userId != null ? ' (User ID: $userId)' : ''} +Logs: +${core.connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')} +'''; +} + +class BKMenuButton extends StatelessWidget { + final VoidCallback? openLogs; + final BCPage currentPage; + const BKMenuButton({super.key, this.openLogs, required this.currentPage}); + + @override + Widget build(BuildContext context) { + return OutlineButton( + density: ButtonDensity.icon, + child: Icon(Icons.more_vert, size: 18), + onPressed: () => showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + if (kDebugMode) ...[ + MenuButton( + child: Text(context.i18n.continueAction), + onPressed: (c) { + IAPManager.instance.purchaseFullVersion(context); + core.connection.addDevices([ + ZwiftClickV2( + BleDevice( + name: 'Controller', + deviceId: '00:11:22:33:44:55', + ), + ) + ..firmwareVersion = '1.2.0' + ..rssi = -51 + ..batteryLevel = 81, + ]); + }, + ), + MenuButton( + child: Text(context.i18n.reset), + onPressed: (c) async { + await core.settings.reset(); + }, + ), + MenuButton( + child: Text('Send Key'), + onPressed: (c) async { + await Future.delayed(Duration(seconds: 2)); + await keyPressSimulator.simulateKeyDown( + PhysicalKeyboardKey.keyK, + [], + core.settings.getTrainerApp()?.packageName, + ); + await keyPressSimulator.simulateKeyUp( + PhysicalKeyboardKey.keyK, + [], + core.settings.getTrainerApp()?.packageName, + ); + }, + ), + MenuButton( + child: Text('Disconnect'), + onPressed: (c) async { + core.connection.disconnectAll(); + }, + ), + MenuButton( + child: Text('Show Paywall'), + onPressed: (c) async { + openDrawer( + context: context, + builder: (c) => Paywall(), + position: OverlayPosition.bottom, + ); + }, + ), + MenuDivider(), + ], + if (currentPage == BCPage.logs) ...[ + MenuButton( + child: Text('Reset IAP State'), + onPressed: (c) async { + IAPManager.instance.reset(false); + core.settings.init(); + }, + ), + MenuDivider(), + ], + if (openLogs != null) + MenuButton( + leading: Icon(Icons.star_rate), + child: Text(context.i18n.leaveAReview), + onPressed: (c) async { + final InAppReview inAppReview = InAppReview.instance; + + if (await inAppReview.isAvailable()) { + inAppReview.requestReview(); + } else { + inAppReview.openStoreListing(appStoreId: 'id6753721284', microsoftStoreId: '9NP42GS03Z26'); + } + }, + ), + if (openLogs != null) + MenuButton( + leading: Icon(Icons.article_outlined), + child: Text(context.i18n.logs), + onPressed: (c) { + openLogs!(); + }, + ), + MenuButton( + leading: Icon(Icons.update_outlined), + child: Text(context.i18n.changelog), + onPressed: (c) { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md'), + ); + }, + ), + MenuButton( + leading: Icon(Icons.policy_outlined), + child: Text(context.i18n.license), + onPressed: (c) { + showLicensePage(context: context); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/mouse_pair_widget.dart b/lib/widgets/mouse_pair_widget.dart new file mode 100644 index 000000000..50096687c --- /dev/null +++ b/lib/widgets/mouse_pair_widget.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:prop/prop.dart' show LogLevel; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../utils/requirements/multi.dart'; + +class RemoteMousePairingWidget extends StatefulWidget { + const RemoteMousePairingWidget({super.key}); + + @override + State createState() => _PairWidgetState(); +} + +class _PairWidgetState extends State { + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: core.remotePairing.isStarted, + builder: (context, isStarted, child) { + return ValueListenableBuilder( + valueListenable: core.remotePairing.isConnected, + builder: (context, isConnected, child) { + return ConnectionMethod( + supportedActions: null, + isEnabled: core.logic.isRemoteControlEnabled, + isStarted: isStarted, + showTroubleshooting: true, + type: ConnectionMethodType.bluetooth, + isRecommended: false, + instructionLink: 'INSTRUCTIONS_REMOTE_CONTROL.md', + title: context.i18n.enablePairingProcess, + description: context.i18n.pairingDescription, + isConnected: isConnected, + requirements: core.permissions.getRemoteControlRequirements(), + onChange: (value) async { + core.settings.setRemoteControlEnabled(value); + if (!value) { + core.remotePairing.stopAdvertising(); + } else { + core.remotePairing.startAdvertising().catchError((e) { + core.settings.setRemoteControlEnabled(false); + core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, e.toString())); + }); + } + setState(() {}); + }, + additionalChild: isStarted + ? Text( + switch (core.settings.getLastTarget()) { + Target.otherDevice when Platform.isIOS => context.i18n.pairingInstructionsIOS, + _ => context.i18n.pairingInstructions(core.settings.getLastTarget()?.getTitle(context) ?? ''), + }, + ).xSmall + : null, + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/scan.dart b/lib/widgets/scan.dart new file mode 100644 index 000000000..08c5c980c --- /dev/null +++ b/lib/widgets/scan.dart @@ -0,0 +1,111 @@ +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/ui/connection_method.dart'; +import 'package:bike_control/widgets/ui/wifi_animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../utils/requirements/platform.dart'; + +class ScanWidget extends StatefulWidget { + const ScanWidget({super.key}); + + @override + State createState() => _ScanWidgetState(); +} + +class _ScanWidgetState extends State { + List? _needsPermissions; + + @override + void initState() { + super.initState(); + + _checkRequirements(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (_needsPermissions != null && _needsPermissions!.isNotEmpty) + Card( + child: Basic( + title: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.i18n.permissionsRequired).xSmall, + ..._needsPermissions!.map((e) => Text(e.name).li), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 12.0), + child: PrimaryButton( + child: Text(context.i18n.enablePermissions), + onPressed: () async { + await openPermissionSheet(context, _needsPermissions!); + _checkRequirements(); + }, + ), + ), + ), + ) + else + ValueListenableBuilder( + valueListenable: core.connection.isScanning, + builder: (context, isScanning, widget) { + if (isScanning) { + return Column( + spacing: 18, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (core.connection.controllerDevices.isEmpty) + Column( + spacing: 14, + children: [ + SizedBox(), + SmoothWifiAnimation(), + Text( + context.i18n.scanningForDevices, + textAlign: TextAlign.center, + ).small.muted, + ], + ), + SizedBox(), + ], + ); + } else { + return Row( + children: [ + PrimaryButton( + onPressed: () { + core.connection.performScanning(); + }, + child: Text(context.i18n.scan), + ), + ], + ); + } + }, + ), + ], + ); + } + + void _checkRequirements() { + core.permissions.getScanRequirements().then((permissions) { + if (!mounted) return; + setState(() { + _needsPermissions = permissions; + }); + if (permissions.isEmpty && !kIsWeb) { + core.connection.performScanning(); + } + }); + } +} diff --git a/lib/widgets/small_progress_indicator.dart b/lib/widgets/small_progress_indicator.dart deleted file mode 100644 index 9b3711693..000000000 --- a/lib/widgets/small_progress_indicator.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class SmallProgressIndicator extends StatelessWidget { - const SmallProgressIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return Center(child: SizedBox(height: 12, width: 12, child: CircularProgressIndicator(strokeWidth: 2))); - } -} diff --git a/lib/widgets/testbed.dart b/lib/widgets/testbed.dart new file mode 100644 index 000000000..bec0d16f6 --- /dev/null +++ b/lib/widgets/testbed.dart @@ -0,0 +1,519 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:bike_control/utils/actions/base_actions.dart' as actions; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/keymap/apps/custom_app.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/widgets/ui/button_widget.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../bluetooth/messages/notification.dart'; + +/// A developer overlay that visualizes touches and keyboard events. +/// - Touch dots appear where you touch and fade out over [touchRevealDuration]. +/// - Keyboard events are listed temporarily and fade out over [keyboardRevealDuration]. +class Testbed extends StatefulWidget { + const Testbed({ + super.key, + this.enabled = true, + this.showTouches = true, + this.showKeyboard = true, + this.touchRevealDuration = const Duration(seconds: 3), + this.keyboardRevealDuration = const Duration(seconds: 3), + this.maxKeyboardEvents = 6, + this.touchColor = const Color(0xFF00BCD4), // cyan-ish + this.keyboardBadgeColor = const Color(0xCC000000), // translucent black + this.keyboardTextStyle = const TextStyle(color: Colors.white, fontSize: 12), + }); + + final bool enabled; + final bool showTouches; + final bool showKeyboard; + + final Duration touchRevealDuration; + final Duration keyboardRevealDuration; + final int maxKeyboardEvents; + + final Color touchColor; + final Color keyboardBadgeColor; + final TextStyle keyboardTextStyle; + + @override + State createState() => _TestbedState(); +} + +class _TestbedState extends State with SingleTickerProviderStateMixin, WidgetsBindingObserver { + late final Ticker _ticker; + late StreamSubscription _actionSubscription; + + // ----- Touch tracking ----- + final Map _active = {}; + final List<_TouchSample> _history = <_TouchSample>[]; + + // ----- Keyboard tracking ----- + final List<_KeySample> _keys = <_KeySample>[]; + final List<_ActionSample> _actions = <_ActionSample>[]; + + // Focus to receive key events without stealing focus from inputs. + late final FocusNode _focusNode; + bool _isMobile = false; + + Offset? _lastMove; + + bool _isInBackground = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _isMobile = MediaQuery.sizeOf(context).width < 600; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _isInBackground = state == AppLifecycleState.paused || state == AppLifecycleState.hidden; + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + _focusNode = FocusNode(debugLabel: 'TestbedFocus', canRequestFocus: true, skipTraversal: true); + _actionSubscription = core.connection.actionStream.listen((data) async { + if (!mounted || (_isInBackground && data is! AlertNotification)) { + return; + } + if (data is ButtonNotification && data.buttonsClicked.isNotEmpty) { + if (core.settings.getShowOnboarding()) { + final button = data.buttonsClicked.first; + final sample = _KeySample( + button: button, + text: '🔘 ${button.name}', + timestamp: DateTime.now(), + ); + _keys.insert(0, sample); + if (_keys.length > widget.maxKeyboardEvents) { + _keys.removeLast(); + } + } else if (core.actionHandler.supportedApp == null) { + buildToast(level: LogLevel.LOGLEVEL_WARNING, title: context.i18n.selectTrainerAppAndTarget); + } else { + final button = data.buttonsClicked.first; + if (core.actionHandler.supportedApp is! CustomApp && + !core.actionHandler.supportedApp!.keymap.hasAnyMappedAction(button)) { + buildToast( + level: LogLevel.LOGLEVEL_WARNING, + titleWidget: Text.rich( + TextSpan( + children: [ + TextSpan(text: '${context.i18n.useCustomKeymapForButton} '), + WidgetSpan( + child: ButtonWidget(button: button), + ), + TextSpan( + text: context.i18n.button, + ), + ], + ), + ), + ); + } else { + /*final isMobile = MediaQuery.sizeOf(context).width < 600; + buildToast( + context, + location: isMobile ? ToastLocation.topCenter : ToastLocation.bottomRight, + titleWidget: Wrap(children: data.buttonsClicked.map((button) => ButtonWidget(button: button)).toList()), + );*/ + final sample = _KeySample( + button: button, + text: '🔘 ${button.name}', + timestamp: DateTime.now(), + ); + _keys.insert(0, sample); + if (_keys.length > widget.maxKeyboardEvents) { + _keys.removeLast(); + } + } + } + } else if (data is ActionNotification && data.result is! actions.Ignored) { + buildToast( + location: ToastLocation.bottomLeft, + level: data.result is actions.Error ? LogLevel.LOGLEVEL_WARNING : LogLevel.LOGLEVEL_INFO, + title: data.result.message, + duration: Duration(seconds: 1), + ); + } else if (data is AlertNotification) { + buildToast( + location: ToastLocation.bottomRight, + level: data.level, + title: data.alertMessage, + closeTitle: data.buttonTitle ?? 'Close', + onClose: data.onTap, + ); + } + }); + + _ticker = createTicker((_) { + // Cull expired touch and key samples. + final now = DateTime.now(); + _keys.removeWhere((s) => now.difference(s.timestamp) > widget.touchRevealDuration); + _history.removeWhere((s) => now.difference(s.timestamp) > widget.touchRevealDuration); + _actions.removeWhere((k) => now.difference(k.timestamp) > widget.keyboardRevealDuration); + + if (mounted) { + setState(() {}); + } + })..start(); + } + + @override + void dispose() { + _ticker.dispose(); + _focusNode.dispose(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + void _onPointerDown(PointerDownEvent e) { + if (!widget.enabled || + !widget.showTouches || + ((_lastMove ?? Offset.zero) - e.position).distance < 5 || + (e.kind != PointerDeviceKind.unknown && e.kind != PointerDeviceKind.mouse)) { + return; + } + final sample = _TouchSample( + pointer: e.pointer, + position: e.position, + timestamp: DateTime.now(), + phase: _TouchPhase.down, + ); + + _active[e.pointer] = sample; + _history.add(sample); + setState(() {}); + } + + void _onPointerHover(PointerHoverEvent e) { + Future.delayed(Duration(milliseconds: 30)).then((_) { + // delay a bit for better detection of a real click vs fake one + _lastMove = e.position; + }); + } + + void _onPointerUp(PointerUpEvent e) { + if (!widget.enabled || + !widget.showTouches || + ((_lastMove ?? Offset.zero) - e.position).distance < 5 || + (e.kind != PointerDeviceKind.unknown && e.kind != PointerDeviceKind.mouse)) { + return; + } + final sample = _TouchSample( + pointer: e.pointer, + position: e.position, + timestamp: DateTime.now(), + phase: _TouchPhase.up, + ); + _active[e.pointer] = sample; + _history.add(sample); + setState(() {}); + } + + void _onPointerCancel(PointerCancelEvent e) { + if (!widget.enabled || !widget.showTouches || !mounted) return; + _active.remove(e.pointer); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: _onPointerDown, + onPointerHover: _onPointerHover, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerCancel, + behavior: HitTestBehavior.translucent, + child: Focus( + focusNode: _focusNode, + autofocus: true, + canRequestFocus: true, + descendantsAreFocusable: true, + child: Stack( + fit: StackFit.passthrough, + children: [ + if (widget.showTouches) + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _TouchesPainter( + now: DateTime.now(), + samples: _history, + duration: widget.touchRevealDuration, + color: widget.touchColor, + ), + ), + ), + ), + if (widget.showKeyboard) + Positioned( + right: 12, + bottom: _isMobile && !core.settings.getShowOnboarding() ? 92 : 12, + child: IgnorePointer( + child: _KeyboardOverlay( + items: _keys, + duration: widget.keyboardRevealDuration, + badgeColor: widget.keyboardBadgeColor, + textStyle: widget.keyboardTextStyle, + ), + ), + ), + if (widget.showKeyboard) + Positioned( + right: 12, + bottom: 12, + child: IgnorePointer( + child: _ActionOverlay( + items: _actions, + duration: widget.keyboardRevealDuration, + badgeColor: widget.keyboardBadgeColor, + textStyle: widget.keyboardTextStyle, + ), + ), + ), + ], + ), + ), + ); + } +} + +// ===== Touches ===== + +enum _TouchPhase { down, move, up } + +class _TouchSample { + _TouchSample({required this.pointer, required this.position, required this.timestamp, required this.phase}); + + final int pointer; + final Offset position; + final DateTime timestamp; + final _TouchPhase phase; +} + +class _TouchesPainter extends CustomPainter { + _TouchesPainter({required this.now, required this.samples, required this.duration, required this.color}); + + final DateTime now; + final List<_TouchSample> samples; + final Duration duration; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + for (final s in samples) { + final age = now.difference(s.timestamp); + if (age > duration) continue; + + final color = s.phase == _TouchPhase.down ? this.color : Colors.red; + + final t = age.inMilliseconds / duration.inMilliseconds.clamp(1, 1 << 30); + final fade = (1.0 - t).clamp(0.0, 1.0); + + // Two concentric circles: inner filled pulse + outer ring. + final baseRadius = 22.0; + final pulse = 1.0 + 0.5 * math.sin(t * math.pi); // subtle pulsing + final rOuter = baseRadius * (1.0 + 0.35 * t); + final rInner = baseRadius * 0.5 * pulse; + + // Outer ring (stroke, fading) + paint + ..style = PaintingStyle.stroke + ..color = color.withOpacity(0.35 * fade); + canvas.drawCircle(s.position, rOuter, paint); + + // Inner fill (stronger) + final fill = Paint() + ..style = PaintingStyle.fill + ..color = color.withOpacity(0.35 + 0.35 * fade); + canvas.drawCircle(s.position, rInner, fill); + + // Tiny center dot for precision + final center = Paint() + ..style = PaintingStyle.fill + ..color = color.withOpacity(0.9 * fade); + canvas.drawCircle(s.position, 2.5, center); + } + } + + @override + bool shouldRepaint(covariant _TouchesPainter oldDelegate) { + return oldDelegate.now != now || + oldDelegate.samples != samples || + oldDelegate.duration != duration || + oldDelegate.color != color; + } +} + +// ===== Keyboard overlay ===== + +class _KeySample { + _KeySample({required this.text, required this.timestamp, this.button}); + final ControllerButton? button; + final String text; + final DateTime timestamp; +} + +class _KeyboardOverlay extends StatelessWidget { + const _KeyboardOverlay({ + super.key, + required this.items, + required this.duration, + required this.badgeColor, + required this.textStyle, + }); + + final List<_KeySample> items; + final Duration duration; + final Color badgeColor; + final TextStyle textStyle; + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in items) + _KeyboardToast( + item: item, + age: now.difference(item.timestamp), + duration: duration, + badgeColor: badgeColor, + textStyle: textStyle, + ), + ], + ); + } +} + +class _KeyboardToast extends StatelessWidget { + const _KeyboardToast({ + required this.item, + required this.age, + required this.duration, + required this.badgeColor, + required this.textStyle, + }); + + final _KeySample item; + final Duration age; + final Duration duration; + final Color badgeColor; + final TextStyle textStyle; + + @override + Widget build(BuildContext context) { + final t = (age.inMilliseconds / duration.inMilliseconds.clamp(1, 1 << 30)).clamp(0.0, 1.0); + final fade = 1.0 - t; + + return Opacity( + opacity: fade, + child: Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration(color: badgeColor, borderRadius: BorderRadius.circular(12)), + child: item.button != null ? ButtonWidget(button: item.button!) : Text(item.text, style: textStyle), + ), + ); + } +} + +// ===== Action overlay ===== + +class _ActionSample { + _ActionSample({required this.text, required this.timestamp, required this.isError}); + final String text; + final DateTime timestamp; + final bool isError; +} + +class _ActionOverlay extends StatelessWidget { + const _ActionOverlay({ + super.key, + required this.items, + required this.duration, + required this.badgeColor, + required this.textStyle, + }); + + final List<_ActionSample> items; + final Duration duration; + final Color badgeColor; + final TextStyle textStyle; + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (final item in items) + _ActionToast( + item: item, + age: now.difference(item.timestamp), + duration: duration, + badgeColor: badgeColor, + textStyle: textStyle, + ), + ], + ); + } +} + +class _ActionToast extends StatelessWidget { + const _ActionToast({ + required this.item, + required this.age, + required this.duration, + required this.badgeColor, + required this.textStyle, + }); + + final _ActionSample item; + final Duration age; + final Duration duration; + final Color badgeColor; + final TextStyle textStyle; + + @override + Widget build(BuildContext context) { + final t = (age.inMilliseconds / duration.inMilliseconds.clamp(1, 1 << 30)).clamp(0.0, 1.0); + final fade = 1.0 - t; + + return Opacity( + opacity: fade, + child: Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: item.isError ? Colors.red.withOpacity(0.8) : badgeColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text(item.text, style: textStyle), + ), + ); + } +} diff --git a/lib/widgets/title.dart b/lib/widgets/title.dart new file mode 100755 index 000000000..0c891b373 --- /dev/null +++ b/lib/widgets/title.dart @@ -0,0 +1,287 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bike_control/bluetooth/messages/notification.dart'; +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/widgets/ui/gradient_text.dart'; +import 'package:bike_control/widgets/ui/loading_widget.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:in_app_update/in_app_update.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:restart_app/restart_app.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shorebird_code_push/shorebird_code_push.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:version/version.dart'; + +PackageInfo? packageInfoValue; +bool? isFromPlayStore; +Patch? shorebirdPatch; + +class AppTitle extends StatefulWidget { + const AppTitle({super.key}); + + @override + State createState() => _AppTitleState(); +} + +enum UpdateType { + playStore, + shorebird, + appStore, + windowsStore, +} + +class _AppTitleState extends State with WidgetsBindingObserver { + final updater = ShorebirdUpdater(); + + Version? _newVersion; + UpdateType? _updateType; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + IAPManager.instance.entitlements.addListener(_onEntitlementsUpdate); + + if (updater.isAvailable) { + updater + .readCurrentPatch() + .then((patch) { + core.connection.signalNotification(LogNotification('Current Shorebird patch: $patch')); + setState(() { + shorebirdPatch = patch; + }); + }) + .catchError((e, s) { + recordError(e, s, context: 'Shorebird'); + }); + } + + if (packageInfoValue == null) { + PackageInfo.fromPlatform().then((value) { + setState(() { + packageInfoValue = value; + }); + _checkForUpdate(); + }); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _checkForUpdate(); + } + } + + @override + dispose() { + WidgetsBinding.instance.removeObserver(this); + IAPManager.instance.entitlements.removeListener(_onEntitlementsUpdate); + super.dispose(); + } + + void _checkForUpdate() async { + if (screenshotMode) { + return; + } else if (updater.isAvailable) { + final updateStatus = await updater.checkForUpdate(); + if (updateStatus == UpdateStatus.outdated) { + updater + .update() + .then((value) async { + setState(() { + _updateType = UpdateType.shorebird; + }); + final nextPatch = await updater.readNextPatch(); + final currentVersion = Version.parse(packageInfoValue!.version); + setState(() { + _newVersion = Version( + currentVersion.major, + currentVersion.minor, + currentVersion.patch, + build: nextPatch?.number.toString() ?? '', + ); + }); + }) + .catchError((e) { + buildToast(title: AppLocalizations.current.failedToUpdate(e.toString())); + }); + } else if (updateStatus == UpdateStatus.restartRequired) { + _updateType = UpdateType.shorebird; + } + if (_updateType == UpdateType.shorebird) { + final nextPatch = await updater.readNextPatch(); + final currentVersion = Version.parse(packageInfoValue!.version); + setState(() { + _newVersion = Version( + currentVersion.major, + currentVersion.minor, + currentVersion.patch, + build: nextPatch?.number.toString() ?? '', + ); + }); + } + } + + if (kIsWeb) { + // no-op + } else if (Platform.isAndroid) { + try { + final appUpdateInfo = await InAppUpdate.checkForUpdate(); + if (context.mounted && appUpdateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + setState(() { + _updateType = UpdateType.playStore; + }); + } + isFromPlayStore = true; + return null; + } on Exception catch (e) { + isFromPlayStore = false; + print('Failed to check for update: $e'); + } + setState(() {}); + } else if (Platform.isIOS) { + final url = Uri.parse('https://itunes.apple.com/lookup?id=6753721284'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data['resultCount'] > 0) { + final versionString = data['results'][0]['version'] as String; + _compareVersion(versionString); + } + } + } else if (Platform.isMacOS) { + final url = Uri.parse('https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac'); + final res = await http.get(url, headers: {'User-Agent': 'Mozilla/5.0'}); + if (res.statusCode != 200) return null; + + final body = res.body; + final regex = RegExp( + r'>Version ([0-9]{1,2}\.[0-9]{1,2}.[0-9]{1,2})', + dotAll: true, + ); + final match = regex.firstMatch(body); + if (match == null) return null; + final versionString = match.group(1); + + if (versionString != null) { + _compareVersion(versionString); + } + } else if (Platform.isWindows) { + final url = Uri.parse( + 'https://raw.githubusercontent.com/OpenBikeControl/bikecontrol/refs/heads/main/WINDOWS_STORE_VERSION.txt', + ); + final res = await http.get(url, headers: {'User-Agent': 'Mozilla/5.0'}); + if (res.statusCode != 200) return null; + + final body = res.body.trim(); + _compareVersion(body); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GradientText( + 'BikeControl', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22), + ), + if (packageInfoValue != null) + Text( + 'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''} - ${core.settings.getShowOnboarding() ? 'Onboarding' : IAPManager.instance.getStatusMessage()}', + style: TextStyle(fontSize: 12), + ).mono.muted + else + SmallProgressIndicator(), + + if (_newVersion != null && _updateType != null) + Container( + margin: EdgeInsets.only(top: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.destructive, + ), + borderRadius: BorderRadius.circular(8), + ), + child: LoadingWidget( + futureCallback: () async { + if (_updateType == UpdateType.shorebird) { + await _shorebirdRestart(); + } else if (_updateType == UpdateType.playStore) { + await launchUrlString( + 'https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol', + mode: LaunchMode.externalApplication, + ); + } else if (_updateType == UpdateType.appStore) { + await launchUrlString( + 'https://apps.apple.com/app/id6753721284', + mode: LaunchMode.externalApplication, + ); + } else if (_updateType == UpdateType.windowsStore) { + await launchUrlString( + 'ms-windows-store://pdp/?productid=9NP42GS03Z26', + mode: LaunchMode.externalApplication, + ); + } + }, + renderChild: (isLoading, tap) => GhostButton( + onPressed: tap, + trailing: isLoading ? SmallProgressIndicator() : Icon(Icons.update), + child: Text(AppLocalizations.current.newVersionAvailableWithVersion(_newVersion.toString())), + ), + ), + ), + ], + ); + } + + Future _shorebirdRestart() async { + setState(() { + core.connection.disconnectAll(); + core.connection.stop(); + if (Platform.isIOS) { + Restart.restartApp(delayBeforeRestart: 1000); + } else { + exit(0); + } + }); + } + + void _compareVersion(String versionString) { + final parsed = Version.parse(versionString); + final current = Version.parse(packageInfoValue!.version); + if (parsed > current && mounted && !kDebugMode) { + if (Platform.isAndroid) { + setState(() { + _updateType = UpdateType.playStore; + _newVersion = parsed; + }); + } else if (Platform.isIOS || Platform.isMacOS) { + setState(() { + _updateType = UpdateType.appStore; + _newVersion = parsed; + }); + } else if (Platform.isWindows) { + setState(() { + _updateType = UpdateType.windowsStore; + _newVersion = parsed; + }); + } + } + } + + void _onEntitlementsUpdate() { + setState(() {}); + } +} diff --git a/lib/widgets/ui/beta_pill.dart b/lib/widgets/ui/beta_pill.dart new file mode 100644 index 000000000..c407cdd54 --- /dev/null +++ b/lib/widgets/ui/beta_pill.dart @@ -0,0 +1,11 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class BetaPill extends StatelessWidget { + final String text; + const BetaPill({super.key, this.text = 'BETA'}); + + @override + Widget build(BuildContext context) { + return DestructiveBadge(child: Text(text)); + } +} diff --git a/lib/widgets/ui/button_widget.dart b/lib/widgets/ui/button_widget.dart new file mode 100644 index 000000000..10ebe73db --- /dev/null +++ b/lib/widgets/ui/button_widget.dart @@ -0,0 +1,49 @@ +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/widgets/keymap_explanation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class ButtonWidget extends StatelessWidget { + final ControllerButton button; + final bool big; + const ButtonWidget({super.key, required this.button, this.big = false}); + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + constraints: BoxConstraints( + minWidth: big && button.color != null ? 40 : 30, + minHeight: big && button.color != null ? 40 : 0, + ), + decoration: BoxDecoration( + border: Border.all( + color: button.color != null ? Colors.black.getContrastColor(0.3) : Theme.of(context).colorScheme.primary, + ), + shape: button.color != null || button.icon != null ? BoxShape.circle : BoxShape.rectangle, + borderRadius: button.color != null || button.icon != null ? null : BorderRadius.circular(8), + color: button.color ?? Colors.black, + ), + child: Center( + child: button.icon != null + ? Icon( + button.icon, + color: Colors.white, + size: big && button.color != null ? null : 14, + ) + : Text( + button.displayName.splitByUpperCase(), + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: screenshotMode ? null : 'monospace', + fontSize: big && button.color != null ? 20 : 12, + fontWeight: button.color != null ? FontWeight.bold : null, + color: Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/ui/colored_title.dart b/lib/widgets/ui/colored_title.dart new file mode 100644 index 000000000..9ea0f3bb7 --- /dev/null +++ b/lib/widgets/ui/colored_title.dart @@ -0,0 +1,12 @@ +import 'package:bike_control/widgets/ui/gradient_text.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class ColoredTitle extends StatelessWidget { + final String text; + const ColoredTitle({super.key, required this.text}); + + @override + Widget build(BuildContext context) { + return GradientText(text, style: TextStyle(fontSize: 22)).bold; + } +} diff --git a/lib/widgets/ui/colors.dart b/lib/widgets/ui/colors.dart new file mode 100644 index 000000000..2335e0ed6 --- /dev/null +++ b/lib/widgets/ui/colors.dart @@ -0,0 +1,8 @@ +import 'dart:ui'; + +class BKColor { + static const Color main = Color(0xFF0E74B7); + static const Color mainEnd = Color(0xFF0E9297); + static const Color background = Color(0xFFAACCDB); + static const Color backgroundLight = Color(0xFFF2F9FF); +} diff --git a/lib/widgets/ui/connection_method.dart b/lib/widgets/ui/connection_method.dart new file mode 100644 index 000000000..be5f0bdc5 --- /dev/null +++ b/lib/widgets/ui/connection_method.dart @@ -0,0 +1,271 @@ +import 'package:bike_control/gen/l10n.dart'; +import 'package:bike_control/pages/button_edit.dart'; +import 'package:bike_control/pages/markdown.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/requirements/platform.dart'; +import 'package:bike_control/widgets/ui/beta_pill.dart'; +import 'package:bike_control/widgets/ui/colored_title.dart'; +import 'package:bike_control/widgets/ui/permissions_list.dart'; +import 'package:bike_control/widgets/ui/small_progress_indicator.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:dartx/dartx.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +enum ConnectionMethodType { + bluetooth, + network, + openBikeControl, + local, +} + +class ConnectionMethod extends StatefulWidget { + final String title; + final String description; + final String? instructionLink; + final ConnectionMethodType type; + final Widget? additionalChild; + final bool? isConnected; + final bool? isStarted; + final bool isRecommended; + final bool isEnabled; + final bool showTroubleshooting; + final List requirements; + final List? supportedActions; + final Function(bool) onChange; + + const ConnectionMethod({ + super.key, + required this.title, + required this.isRecommended, + required this.type, + required this.isEnabled, + this.additionalChild, + required this.description, + this.instructionLink, + this.showTroubleshooting = false, + required this.onChange, + required this.supportedActions, + required this.requirements, + this.isConnected, + this.isStarted, + }); + + @override + State createState() => _ConnectionMethodState(); +} + +class _ConnectionMethodState extends State with WidgetsBindingObserver { + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (widget.requirements.isNotEmpty && widget.isEnabled) { + if (state == AppLifecycleState.resumed) { + _recheckRequirements(); + } + } + } + + @override + void dispose() { + super.dispose(); + WidgetsBinding.instance.removeObserver(this); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + if (widget.requirements.isNotEmpty && widget.isEnabled && widget.isStarted == false) { + Future.wait(widget.requirements.map((e) => e.getStatus())).then((states) { + final allDone = states.all((e) => e); + if (allDone && widget.isEnabled) { + widget.onChange(true); + } else if (!allDone && widget.isEnabled) { + widget.onChange(false); + } + }); + } + } + + @override + Widget build(BuildContext context) { + return SelectableCard( + onPressed: () { + if (kIsWeb) { + buildToast(title: 'Not Supported on Web :)'); + } else if (widget.requirements.isEmpty) { + widget.onChange(!widget.isEnabled); + } else { + Future.wait(widget.requirements.map((e) => e.getStatus())).then((_) async { + final notDone = widget.requirements.filter((e) => !e.status).toList(); + if (notDone.isEmpty) { + widget.onChange(!widget.isEnabled); + } else { + await openPermissionSheet(context, notDone); + _recheckRequirements(); + setState(() {}); + } + }); + } + }, + isActive: widget.isEnabled, + icon: widget.isEnabled ? Icons.check_box : Icons.check_box_outline_blank, + title: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 8, + children: [ + Row( + spacing: 8, + children: [ + PrimaryBadge( + trailing: widget.isStarted == true && (widget.isConnected == false) + ? SizedBox( + width: 19, + height: 19, + child: SmallProgressIndicator( + color: Theme.of(context).colorScheme.primaryForeground, + ), + ) + : switch (widget.type) { + ConnectionMethodType.bluetooth => Icon(Icons.bluetooth), + ConnectionMethodType.network => Icon(Icons.wifi), + ConnectionMethodType.openBikeControl => Icon(Icons.directions_bike), + ConnectionMethodType.local => Icon(Icons.keyboard), + }, + child: Text(widget.type.name.capitalize()), + ), + + if (widget.isRecommended) SecondaryBadge(leading: Icon(Icons.star), child: Text('Recommended')), + ], + ), + if (widget.title == context.i18n.enablePairingProcess || + widget.title == context.i18n.enableZwiftControllerBluetooth) + Padding( + padding: const EdgeInsets.only(top: 1.0), + child: BetaPill(), + ), + ], + ), + Text(widget.title), + Text( + widget.description, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, + ), + ), + if (widget.isEnabled && widget.additionalChild != null) widget.additionalChild!, + if (widget.instructionLink != null || widget.showTroubleshooting) SizedBox(height: 8), + if (widget.instructionLink != null) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Button( + style: widget.isEnabled && Theme.of(context).brightness == Brightness.light + ? ButtonStyle.outline().withBorder(border: Border.all(color: Colors.gray.shade500)) + : ButtonStyle.outline(), + leading: Icon( + widget.instructionLink!.contains("youtube") ? Icons.ondemand_video : Icons.help_outline, + ), + onPressed: () { + if (widget.instructionLink!.contains("youtube")) { + launchUrlString(widget.instructionLink!); + } else { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (c) => MarkdownPage(assetPath: widget.instructionLink!), + ); + } + }, + child: Text(AppLocalizations.of(context).instructions), + ), + if (widget.supportedActions != null) + Button.outline( + leading: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + padding: EdgeInsets.symmetric(horizontal: 6), + margin: EdgeInsets.only(right: 4), + child: Text( + widget.supportedActions!.length.toString(), + style: TextStyle( + color: Theme.of(context).colorScheme.primaryForeground, + ), + ), + ), + onPressed: () { + openDrawer( + context: context, + position: OverlayPosition.right, + builder: (c) => Container( + padding: EdgeInsets.symmetric(vertical: 32, horizontal: 16), + width: 230, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + ColoredTitle( + text: AppLocalizations.of(context).supportedActions, + ), + Gap(12), + ...widget.supportedActions!.map( + (e) => Basic( + leading: e.icon != null ? Icon(e.icon) : null, + title: Text(e.title), + ), + ), + ], + ), + ), + ); + }, + child: Text(AppLocalizations.of(context).supportedActions), + ), + ], + ), + ], + ), + ); + } + + void _recheckRequirements() { + Future.wait(widget.requirements.map((e) => e.getStatus())).then((result) { + final allDone = result.every((e) => e); + + if (context.mounted && widget.isEnabled != allDone) { + widget.onChange(allDone); + } + }); + } +} + +Future openPermissionSheet(BuildContext context, List notDone) { + return openSheet( + context: context, + draggable: true, + builder: (context) => Padding( + padding: const EdgeInsets.all(16.0), + child: PermissionList( + requirements: notDone, + onDone: () { + closeSheet(context); + }, + ), + ), + position: OverlayPosition.bottom, + ); +} diff --git a/lib/widgets/ui/device_info.dart b/lib/widgets/ui/device_info.dart new file mode 100644 index 000000000..4dfa08612 --- /dev/null +++ b/lib/widgets/ui/device_info.dart @@ -0,0 +1,44 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../main.dart'; + +class DeviceInfo extends StatelessWidget { + final String title; + final String value; + final Widget? additionalInfo; + final IconData icon; + + const DeviceInfo({super.key, required this.title, required this.icon, required this.value, this.additionalInfo}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: screenshotMode ? 160 : null, + height: screenshotMode ? 70 : null, + child: Card( + filled: true, + padding: EdgeInsets.all(12), + fillColor: Theme.of(context).colorScheme.background, + child: Basic( + title: Text(title).xSmall, + subtitle: Row( + children: [ + Text( + value, + style: TextStyle(fontSize: 12), + ), + ?additionalInfo, + ], + ), + trailingAlignment: Alignment.centerRight, + trailing: Icon( + icon, + color: icon == Icons.warning || icon == Icons.battery_alert + ? Theme.of(context).colorScheme.destructive + : null, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/ui/gradient_text.dart b/lib/widgets/ui/gradient_text.dart new file mode 100644 index 000000000..69af83ff0 --- /dev/null +++ b/lib/widgets/ui/gradient_text.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:bike_control/widgets/ui/colors.dart'; + +class GradientText extends StatelessWidget { + const GradientText( + this.text, { + super.key, + this.gradient = const LinearGradient( + colors: [BKColor.main, BKColor.mainEnd], + ), + this.style, + }); + + final String text; + final TextStyle? style; + final Gradient gradient; + + @override + Widget build(BuildContext context) { + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => gradient.createShader( + Rect.fromLTWH(0, 0, bounds.width, bounds.height), + ), + child: Text(text, style: style), + ); + } +} diff --git a/lib/widgets/ui/help_button.dart b/lib/widgets/ui/help_button.dart new file mode 100644 index 000000000..8266a64a6 --- /dev/null +++ b/lib/widgets/ui/help_button.dart @@ -0,0 +1,453 @@ +import 'dart:io'; + +import 'package:bike_control/pages/markdown.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/i18n_extension.dart'; +import 'package:bike_control/widgets/menu.dart'; +import 'package:bike_control/widgets/title.dart'; +import 'package:bike_control/widgets/ui/colored_title.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../../gen/l10n.dart'; + +class HelpButton extends StatelessWidget { + final bool isMobile; + const HelpButton({super.key, required this.isMobile}); + + @override + Widget build(BuildContext context) { + final border = isMobile + ? BorderRadius.only(topRight: Radius.circular(8), topLeft: Radius.circular(8)) + : BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8)); + return Container( + decoration: BoxDecoration( + borderRadius: border, + ), + child: Builder( + builder: (context) { + return Button( + onPressed: () { + showDropdown( + context: context, + builder: (c) => DropdownMenu( + children: [ + MenuLabel(child: Text(context.i18n.instructions)), + MenuButton( + leading: Icon(Icons.ondemand_video), + child: const Text('Instruction Videos'), + onPressed: (c) { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (c) => const _InstructionVideosDrawer(), + ); + }, + ), + MenuButton( + leading: Icon(Icons.help_outline), + child: Text(context.i18n.troubleshootingGuide), + onPressed: (c) { + openDrawer( + context: context, + position: OverlayPosition.bottom, + builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'), + ); + }, + ), + MenuDivider(), + MenuLabel(child: Text(context.i18n.getSupport)), + MenuButton( + leading: Icon(Icons.reddit_outlined), + onPressed: (c) { + launchUrlString('https://www.reddit.com/r/BikeControl/'); + }, + child: Text('Reddit'), + ), + MenuButton( + leading: Icon(Icons.facebook_outlined), + onPressed: (c) { + launchUrlString('https://www.facebook.com/groups/1892836898778912'); + }, + child: Text('Facebook'), + ), + MenuButton( + leading: Icon(RadixIcons.githubLogo), + onPressed: (c) { + launchUrlString('https://github.com/OpenBikeControl/bikecontrol/issues'); + }, + child: Text('GitHub'), + ), + if (!kIsWeb) ...[ + MenuButton( + leading: Icon(Icons.email_outlined), + child: Text('Mail'), + onPressed: (c) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Mail Support'), + content: Container( + constraints: BoxConstraints(maxWidth: 400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Text( + AppLocalizations.of(context).mailSupportExplanation, + ), + ...[ + OutlineButton( + leading: Icon(Icons.reddit_outlined), + onPressed: () { + Navigator.pop(context); + launchUrlString('https://www.reddit.com/r/BikeControl/'); + }, + child: const Text('Reddit'), + ), + OutlineButton( + leading: Icon(Icons.facebook_outlined), + onPressed: () { + Navigator.pop(context); + launchUrlString('https://www.facebook.com/groups/1892836898778912'); + }, + child: const Text('Facebook'), + ), + OutlineButton( + leading: Icon(RadixIcons.githubLogo), + onPressed: () { + Navigator.pop(context); + launchUrlString('https://github.com/OpenBikeControl/bikecontrol/issues'); + }, + child: const Text('GitHub'), + ), + SecondaryButton( + leading: Icon(Icons.mail_outlined), + onPressed: () async { + Navigator.pop(context); + + final isFromStore = (Platform.isAndroid + ? isFromPlayStore == true + : Platform.isIOS); + final suffix = isFromStore ? '' : '-sw'; + + String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app'); + String subject = Uri.encodeComponent( + context.i18n.helpRequested(packageInfoValue?.version ?? ''), + ); + final dbg = await debugText(); + String body = Uri.encodeComponent(""" + + $dbg"""); + Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body"); + + launchUrl(mail); + }, + child: const Text('Mail'), + ), + ], + ], + ), + ), + ); + }, + ); + }, + ), + ], + ], + ), + ); + }, + leading: Icon(Icons.help_outline), + style: ButtonVariance.primary.withBorderRadius( + borderRadius: border, + hoverBorderRadius: border, + ), + child: Padding( + padding: EdgeInsets.only( + bottom: core.settings.getShowOnboarding() && (kIsWeb || Platform.isAndroid || Platform.isIOS) ? 14 : 0, + ), + child: Text(context.i18n.troubleshootingGuide), + ), + ); + }, + ), + ); + } +} + +class _InstructionVideosDrawer extends StatefulWidget { + const _InstructionVideosDrawer(); + + @override + State<_InstructionVideosDrawer> createState() => _InstructionVideosDrawerState(); +} + +class _InstructionVideosDrawerState extends State<_InstructionVideosDrawer> { + static const _channelId = 'UCPuQFntEz__QxznGqNPmpsw'; + late Future> _videosFuture; + + @override + void initState() { + super.initState(); + _videosFuture = _fetchVideos(); + } + + void _retry() { + setState(() { + _videosFuture = _fetchVideos(); + }); + } + + Future> _fetchVideos() async { + final uri = Uri.parse('https://www.youtube.com/feeds/videos.xml?channel_id=$_channelId'); + final response = await http.get(uri); + if (response.statusCode != 200) { + throw Exception('Failed to load videos'); + } + + final entryRegex = RegExp(r'([\s\S]*?)'); + final videos = <_InstructionVideo>[]; + for (final match in entryRegex.allMatches(response.body)) { + final entry = match.group(1) ?? ''; + final url = _extract(entry, RegExp(r']*rel="alternate"[^>]*href="([^"]+)"')); + if (url == null || url.isEmpty) { + continue; + } + + final title = _normalizeTitle( + _decodeXmlEntities(_extract(entry, RegExp(r'([\s\S]*?)')) ?? 'YouTube Video'), + ); + final description = _decodeXmlEntities( + _extract(entry, RegExp(r'([\s\S]*?)')) ?? '', + ).trim(); + final videoId = _extract(entry, RegExp(r'([^<]+)')); + final feedThumbnail = _extract(entry, RegExp(r']*url="([^"]+)"')); + final thumbnailUrl = + feedThumbnail ?? + (videoId == null || videoId.isEmpty + ? 'https://img.youtube.com/vi/default/hqdefault.jpg' + : 'https://img.youtube.com/vi/$videoId/hqdefault.jpg'); + + final uri = Uri.tryParse(url); + final isShort = uri?.pathSegments.contains('shorts') ?? url.contains('/shorts/'); + videos.add( + _InstructionVideo( + url: url, + title: title, + description: description, + thumbnailUrl: thumbnailUrl, + isShort: isShort, + ), + ); + } + + return videos; + } + + String? _extract(String value, RegExp regex) { + final match = regex.firstMatch(value); + if (match == null) { + return null; + } + return match.group(1)?.trim(); + } + + String _decodeXmlEntities(String input) { + return input + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>'); + } + + String _normalizeTitle(String title) { + const prefix = 'BikeControl - '; + if (title.startsWith(prefix)) { + return title.substring(prefix.length).trim(); + } + return title.trim(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + spacing: 8, + children: [ + ColoredTitle(text: 'Instruction Videos'), + Expanded( + child: FutureBuilder>( + future: _videosFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _statusCard(text: 'Loading videos...'); + } + + if (snapshot.hasError) { + return _statusCard( + text: 'Could not load videos from YouTube.', + action: SecondaryButton( + onPressed: _retry, + child: const Text('Retry'), + ), + ); + } + + final videos = snapshot.data ?? <_InstructionVideo>[]; + if (videos.isEmpty) { + return _statusCard( + text: 'No videos found on the channel right now.', + action: SecondaryButton( + onPressed: _retry, + child: const Text('Retry'), + ), + ); + } + + final regularVideos = videos.where((video) => !video.isShort).toList(); + final shortVideos = videos.where((video) => video.isShort).toList(); + + return SingleChildScrollView( + child: Column( + spacing: 12, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final video in regularVideos) + SizedBox( + height: 280, + child: _buildVideoCard(video, fullWidth: true), + ), + if (shortVideos.isNotEmpty) + LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = (constraints.maxWidth / 280).floor().clamp(1, 4); + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.9, + ), + itemCount: shortVideos.length, + itemBuilder: (context, index) { + return _buildVideoCard(shortVideos[index], fullWidth: false); + }, + ); + }, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _statusCard({required String text, Widget? action}) { + return Center( + child: Container( + width: 420, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.gray.withAlpha(100)), + ), + child: Column( + spacing: 12, + mainAxisSize: MainAxisSize.min, + children: [ + Text(text, textAlign: TextAlign.center), + if (action != null) action, + ], + ), + ), + ); + } + + Widget _buildVideoCard(_InstructionVideo video, {required bool fullWidth}) { + return GestureDetector( + onTap: () => launchUrlString(video.url), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.gray.withAlpha(100)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: Stack( + fit: StackFit.expand, + children: [ + Image.network(video.thumbnailUrl, fit: BoxFit.cover), + Center( + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.black.withAlpha(166), + shape: BoxShape.circle, + ), + child: const Icon(Icons.play_arrow, color: Colors.white), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: Column( + spacing: 6, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + video.title, + maxLines: fullWidth ? 3 : 2, + overflow: TextOverflow.ellipsis, + ), + if (video.description.isNotEmpty) + Text( + video.description, + maxLines: fullWidth ? 4 : 2, + overflow: TextOverflow.ellipsis, + ).xSmall.muted, + ], + ), + ), + ], + ), + ), + ); + } +} + +class _InstructionVideo { + final String url; + final String title; + final String description; + final String thumbnailUrl; + final bool isShort; + + const _InstructionVideo({ + required this.url, + required this.title, + required this.description, + required this.thumbnailUrl, + required this.isShort, + }); +} diff --git a/lib/widgets/ui/loading_widget.dart b/lib/widgets/ui/loading_widget.dart new file mode 100644 index 000000000..c523de2c2 --- /dev/null +++ b/lib/widgets/ui/loading_widget.dart @@ -0,0 +1,96 @@ +import 'package:bike_control/main.dart'; +import 'package:bike_control/widgets/ui/toast.dart'; +import 'package:flutter/material.dart'; +import 'package:prop/prop.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, s) { + if (widget.onLoadCallback != null) { + widget.onLoadCallback!(false); + } + recordError(e, s, context: 'Loading'); + if (mounted) { + setState(() { + _error = e; + _loadingState = LoadingState.Error; + if (widget.onErrorCallback != null) { + widget.onErrorCallback!(context, _error); + } else { + buildToast(level: LogLevel.LOGLEVEL_WARNING, title: _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/ui/permissions_list.dart b/lib/widgets/ui/permissions_list.dart new file mode 100644 index 000000000..0f38a6d86 --- /dev/null +++ b/lib/widgets/ui/permissions_list.dart @@ -0,0 +1,126 @@ +import 'dart:io'; + +import 'package:bike_control/utils/requirements/android.dart'; +import 'package:bike_control/utils/requirements/platform.dart'; +import 'package:dartx/dartx.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../../utils/i18n_extension.dart'; + +class PermissionList extends StatefulWidget { + final VoidCallback onDone; + final List requirements; + const PermissionList({super.key, required this.requirements, required this.onDone}); + + @override + State createState() => _PermissionListState(); +} + +class _PermissionListState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (widget.requirements.isNotEmpty) { + if (state == AppLifecycleState.resumed) { + Future.wait(widget.requirements.map((e) => e.getStatus())).then((_) { + final allDone = widget.requirements.every((e) => e.status); + if (allDone && mounted) { + closeSheet(context); + } else if (mounted) { + setState(() {}); + } + }); + } + } + } + + @override + void dispose() { + super.dispose(); + WidgetsBinding.instance.removeObserver(this); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 18, + children: [ + SizedBox(), + Center( + child: Text( + context.i18n.theFollowingPermissionsRequired, + textAlign: TextAlign.center, + ).muted.small, + ), + ...widget.requirements.map( + (e) { + final onPressed = e.status + ? null + : () { + e + .call(context, () { + setState(() {}); + }) + .then((_) { + setState(() {}); + if (widget.requirements.all((e) => e.status)) { + widget.onDone(); + } + }); + }; + final optional = e is NotificationRequirement && (Platform.isMacOS || Platform.isIOS); + return SizedBox( + width: double.infinity, + child: Button( + onPressed: onPressed, + style: ButtonStyle.card().withBackgroundColor( + color: Theme.of(context).brightness == Brightness.dark + ? Theme.of(context).colorScheme.card + : Theme.of(context).colorScheme.card.withLuminance(0.95), + ), + child: Basic( + leading: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.primaryForeground, + ), + padding: EdgeInsets.all(8), + child: Icon(e.icon), + ), + title: Row( + spacing: 8, + children: [ + Expanded(child: Text(e.name)), + Button( + style: e.status + ? ButtonStyle.secondary(size: ButtonSize.small) + : ButtonStyle.primary(size: ButtonSize.small), + onPressed: onPressed, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + e.status ? Text(context.i18n.granted) : Text(context.i18n.grant), + if (optional) Text('Optional', style: TextStyle(fontSize: 10)).muted, + ], + ), + ), + ], + ), + subtitle: e.description != null ? Text(e.description!) : null, + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/ui/pro_badge.dart b/lib/widgets/ui/pro_badge.dart new file mode 100644 index 000000000..2d7a79646 --- /dev/null +++ b/lib/widgets/ui/pro_badge.dart @@ -0,0 +1,33 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class ProBadge extends StatelessWidget { + final EdgeInsetsGeometry? padding; + final BorderRadiusGeometry? borderRadius; + final double fontSize; + + const ProBadge({ + super.key, + this.padding, + this.borderRadius, + this.fontSize = 10, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: padding ?? const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: borderRadius ?? BorderRadius.circular(6), + ), + child: Text( + 'PRO', + style: TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/widgets/ui/small_progress_indicator.dart b/lib/widgets/ui/small_progress_indicator.dart new file mode 100644 index 000000000..37ef9e501 --- /dev/null +++ b/lib/widgets/ui/small_progress_indicator.dart @@ -0,0 +1,18 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class SmallProgressIndicator extends StatelessWidget { + final Color? color; + + const SmallProgressIndicator({super.key, this.color}); + + @override + Widget build(BuildContext context) { + return Center( + child: CircularProgressIndicator( + color: color, + size: 12, + strokeWidth: 2, + ), + ); + } +} diff --git a/lib/widgets/ui/toast.dart b/lib/widgets/ui/toast.dart new file mode 100644 index 000000000..99d420ebf --- /dev/null +++ b/lib/widgets/ui/toast.dart @@ -0,0 +1,63 @@ +import 'package:bike_control/main.dart'; +import 'package:bike_control/widgets/ui/button_widget.dart'; +import 'package:prop/prop.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void buildToast({ + LogLevel level = LogLevel.LOGLEVEL_INFO, + String? title, + Widget? titleWidget, + String closeTitle = 'Close', + ToastLocation location = ToastLocation.bottomRight, + VoidCallback? onClose, + String? subtitle, + Duration? duration, +}) { + if (navigatorKey.currentContext?.mounted ?? false) { + final isMobile = MediaQuery.sizeOf(navigatorKey.currentContext!).width < 600; + showToast( + context: navigatorKey.currentContext!, + location: location, + showDuration: switch (level) { + LogLevel.LOGLEVEL_DEBUG => const Duration(seconds: 2), + LogLevel.LOGLEVEL_INFO => duration ?? const Duration(seconds: 3), + LogLevel.LOGLEVEL_WARNING => duration ?? const Duration(seconds: 5), + LogLevel.LOGLEVEL_ERROR => duration ?? const Duration(seconds: 7), + _ => duration ?? const Duration(seconds: 3), + }, + builder: (context, overlay) => Container( + margin: EdgeInsets.only(bottom: isMobile ? 50 : 0), + child: SurfaceCard( + filled: switch (level) { + LogLevel.LOGLEVEL_WARNING => true, + LogLevel.LOGLEVEL_ERROR => true, + _ => false, + }, + fillColor: switch (level) { + LogLevel.LOGLEVEL_DEBUG => null, + LogLevel.LOGLEVEL_INFO => null, + LogLevel.LOGLEVEL_WARNING => Theme.of(context).colorScheme.chart1, + LogLevel.LOGLEVEL_ERROR => Theme.of(context).colorScheme.destructive, + _ => null, + }, + child: Basic( + title: titleWidget ?? Text(title ?? ''), + subtitle: subtitle != null ? Text(subtitle) : null, + trailing: titleWidget is ButtonWidget + ? null + : PrimaryButton( + size: ButtonSize.small, + onPressed: () { + // Close the toast programmatically when clicking Undo. + overlay.close(); + onClose?.call(); + }, + child: Text(closeTitle), + ), + trailingAlignment: Alignment.center, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/ui/warning.dart b/lib/widgets/ui/warning.dart new file mode 100644 index 000000000..f31a642f8 --- /dev/null +++ b/lib/widgets/ui/warning.dart @@ -0,0 +1,42 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class Warning extends StatelessWidget { + final bool important; + final List children; + const Warning({super.key, required this.children, this.important = true}); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: (important + ? Theme.of(context).colorScheme.destructive.withAlpha(30) + : Theme.of(context).colorScheme.secondary.withAlpha(80)), + border: Border.all( + color: important ? Theme.of(context).colorScheme.destructive : Theme.of(context).colorScheme.secondary, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + if (important) + Icon( + Icons.warning_amber_rounded, + color: Theme.of(context).colorScheme.destructive, + ), + Expanded( + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: children, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/ui/wifi_animation.dart b/lib/widgets/ui/wifi_animation.dart new file mode 100644 index 000000000..21ff232d9 --- /dev/null +++ b/lib/widgets/ui/wifi_animation.dart @@ -0,0 +1,172 @@ +import 'package:bike_control/widgets/ui/colors.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +class SmoothWifiAnimation extends StatefulWidget { + const SmoothWifiAnimation({ + super.key, + this.size = 160, + this.label = 'SCANNING', + }); + + final double size; + final String label; + + @override + State createState() => _ScanningIndicatorState(); +} + +class _ScanningIndicatorState extends State with SingleTickerProviderStateMixin { + late final AnimationController _c; + + @override + void initState() { + super.initState(); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + )..repeat(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final s = widget.size; + + // Colors close to the screenshot + const ringColor = Color(0xFFBFEFF2); // pale cyan + const innerBorder = Color(0xFFE6E6E6); + const iconColor = BKColor.main; // teal + + return SizedBox( + width: s, + height: s + 28, // room for the pill label + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Pulsing outer halo (two staggered ripples looks nicer) + _Ripple( + controller: _c, + color: ringColor, + beginScale: 0.80, + endScale: 1.15, + opacityCurve: Curves.easeOut, + interval: const Interval(0.0, 1.0), + ), + _Ripple( + controller: _c, + color: ringColor, + beginScale: 0.70, + endScale: 1.05, + opacityCurve: Curves.easeOut, + interval: const Interval(0.35, 1.0), + ), + + // Static ring + inner circle + Container( + width: s, + height: s, + alignment: Alignment.center, + child: Container( + width: s * 0.62, + height: s * 0.62, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all(color: innerBorder, width: 1.5), + ), + alignment: Alignment.center, + child: Icon(Icons.wifi_tethering, color: iconColor, size: 40), + ), + ), + + // Bottom pill label + Positioned( + bottom: 26, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: ShapeDecoration( + color: Theme.of(context).colorScheme.primary, + shape: StadiumBorder(), + shadows: [ + BoxShadow( + blurRadius: 10, + offset: Offset(0, 3), + color: Color(0x33000000), + ), + ], + ), + child: Text( + widget.label, + style: TextStyle( + color: Theme.of(context).colorScheme.primaryForeground, + fontSize: 10, + letterSpacing: 2.0, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _Ripple extends StatelessWidget { + const _Ripple({ + required this.controller, + required this.color, + required this.beginScale, + required this.endScale, + required this.opacityCurve, + required this.interval, + }); + + final AnimationController controller; + final Color color; + final double beginScale; + final double endScale; + final Curve opacityCurve; + final Interval interval; + + @override + Widget build(BuildContext context) { + final t = CurvedAnimation(parent: controller, curve: interval); + + // Scale 0..1 -> beginScale..endScale + final scale = Tween(begin: beginScale, end: endScale).animate( + CurvedAnimation(parent: t, curve: Curves.easeOutCubic), + ); + + // Opacity starts visible and fades out + final opacity = Tween(begin: 0.35, end: 0.0).animate( + CurvedAnimation(parent: t, curve: opacityCurve), + ); + + return AnimatedBuilder( + animation: controller, + builder: (_, __) { + return Transform.scale( + scale: scale.value, + child: Opacity( + opacity: opacity.value, + child: Container( + width: 160, + height: 160, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: color, width: 12), + ), + ), + ), + ); + }, + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d23..abbf8dd08 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,54 @@ #include "generated_plugin_registrant.h" +#include +#include +#include +#include +#include +#include +#include +#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); + g_autoptr(FlPluginRegistrar) flutter_screen_capture_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterScreenCapturePlugin"); + flutter_screen_capture_plugin_register_with_registrar(flutter_screen_capture_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_volume_controller_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterVolumeControllerPlugin"); + flutter_volume_controller_plugin_register_with_registrar(flutter_volume_controller_registrar); + g_autoptr(FlPluginRegistrar) gamepads_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GamepadsLinuxPlugin"); + gamepads_linux_plugin_register_with_registrar(gamepads_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) media_key_detector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKeyDetectorPlugin"); + media_key_detector_plugin_register_with_registrar(media_key_detector_linux_registrar); + g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); + screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); + g_autoptr(FlPluginRegistrar) yaru_window_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "YaruWindowLinuxPlugin"); + yaru_window_linux_plugin_register_with_registrar(yaru_window_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87a7..d31cbdfc2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,18 @@ # list(APPEND FLUTTER_PLUGIN_LIST + bluetooth_low_energy_linux + file_selector_linux + flutter_screen_capture + flutter_secure_storage_linux + flutter_volume_controller + gamepads_linux + gtk + media_key_detector_linux + screen_retriever_linux + url_launcher_linux + window_manager + yaru_window_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/logo.png b/logo.png new file mode 100644 index 000000000..88d0f4208 Binary files /dev/null and b/logo.png differ diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 65581d2d7..d6db9e3da 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,62 @@ import FlutterMacOS import Foundation -import flutter_blue_plus_darwin +import app_links +import audio_session +import bluetooth_low_energy_darwin +import device_info_plus +import file_picker +import file_selector_macos import flutter_local_notifications +import flutter_screen_capture +import flutter_secure_storage_darwin +import flutter_volume_controller +import gamepads_darwin +import google_sign_in_ios +import in_app_purchase_storekit +import in_app_review +import ios_receipt +import just_audio import keypress_simulator_macos -import path_provider_foundation +import media_key_detector_macos +import nsd_macos +import package_info_plus +import purchases_flutter +import screen_retriever_macos +import shared_preferences_foundation +import sign_in_with_apple +import universal_ble +import url_launcher_macos +import wakelock_plus +import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + BluetoothLowEnergyDarwinPlugin.register(with: registry.registrar(forPlugin: "BluetoothLowEnergyDarwinPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterScreenCapturePlugin.register(with: registry.registrar(forPlugin: "FlutterScreenCapturePlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + FlutterVolumeControllerPlugin.register(with: registry.registrar(forPlugin: "FlutterVolumeControllerPlugin")) + GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) + InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) + IosReceiptPlugin.register(with: registry.registrar(forPlugin: "IosReceiptPlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + MediaKeyDetectorPlugin.register(with: registry.registrar(forPlugin: "MediaKeyDetectorPlugin")) + NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) + 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 b/macos/Podfile index 29c8eb329..910a70ea9 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '12.00' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -38,5 +38,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.00' + end end end diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ea7c5064d..d20a0fa94 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,42 +1,251 @@ PODS: - - flutter_blue_plus_darwin (0.0.2): + - app_links (6.4.1): + - FlutterMacOS + - AppAuth (2.0.0): + - AppAuth/Core (= 2.0.0) + - AppAuth/ExternalUserAgent (= 2.0.0) + - AppAuth/Core (2.0.0) + - AppAuth/ExternalUserAgent (2.0.0): + - AppAuth/Core + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) + - audio_session (0.0.1): + - FlutterMacOS + - bluetooth_low_energy_darwin (0.0.1): - Flutter - FlutterMacOS + - device_info_plus (0.0.1): + - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS - flutter_local_notifications (0.0.1): - FlutterMacOS + - flutter_screen_capture (2.0.1): + - FlutterMacOS + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - flutter_volume_controller (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) + - gamepads_darwin (0.1.1): + - FlutterMacOS + - google_sign_in_ios (0.0.1): + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 9.0) + - GTMSessionFetcher (>= 3.4.0) + - GoogleSignIn (9.1.0): + - AppAuth (~> 2.0) + - AppCheckCore (~> 11.0) + - GTMAppAuth (~> 5.0) + - GTMSessionFetcher/Core (~> 3.3) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMAppAuth (5.0.0): + - AppAuth/Core (~> 2.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher (3.5.0): + - GTMSessionFetcher/Full (= 3.5.0) + - GTMSessionFetcher/Core (3.5.0) + - GTMSessionFetcher/Full (3.5.0): + - GTMSessionFetcher/Core + - in_app_purchase_storekit (0.0.1): + - Flutter + - FlutterMacOS + - in_app_review (2.0.0): + - FlutterMacOS + - ios_receipt (0.0.1): + - FlutterMacOS + - just_audio (0.0.1): + - Flutter + - FlutterMacOS - keypress_simulator_macos (0.0.1): - FlutterMacOS - - path_provider_foundation (0.0.1): + - media_key_detector_macos (0.0.1): + - FlutterMacOS + - nsd_macos (0.0.1): + - FlutterMacOS + - package_info_plus (0.0.1): + - FlutterMacOS + - PromisesObjC (2.4.0) + - purchases_flutter (9.10.6): + - FlutterMacOS + - PurchasesHybridCommon (= 17.27.1) + - PurchasesHybridCommon (17.27.1): + - RevenueCat (= 5.54.1) + - RevenueCat (5.54.1) + - screen_retriever_macos (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sign_in_with_apple (0.0.1): + - FlutterMacOS + - universal_ble (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS + - window_manager (0.5.0): + - FlutterMacOS DEPENDENCIES: - - flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`) + - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) + - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - 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_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/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`) + - flutter_screen_capture (from `Flutter/ephemeral/.symlinks/plugins/flutter_screen_capture/macos`) + - flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - flutter_volume_controller (from `Flutter/ephemeral/.symlinks/plugins/flutter_volume_controller/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`) + - google_sign_in_ios (from `Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin`) + - in_app_purchase_storekit (from `Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin`) + - in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`) + - ios_receipt (from `Flutter/ephemeral/.symlinks/plugins/ios_receipt/macos`) + - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/darwin`) - keypress_simulator_macos (from `Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - media_key_detector_macos (from `Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos`) + - nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - purchases_flutter (from `Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos`) + - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`) + - 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`) + +SPEC REPOS: + trunk: + - AppAuth + - AppCheckCore + - GoogleSignIn + - GoogleUtilities + - GTMAppAuth + - GTMSessionFetcher + - PromisesObjC + - PurchasesHybridCommon + - RevenueCat EXTERNAL SOURCES: - flutter_blue_plus_darwin: - :path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin + app_links: + :path: Flutter/ephemeral/.symlinks/plugins/app_links/macos + audio_session: + :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + 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_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos flutter_local_notifications: :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + flutter_screen_capture: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_screen_capture/macos + flutter_secure_storage_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin + flutter_volume_controller: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_volume_controller/macos FlutterMacOS: :path: Flutter/ephemeral + gamepads_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos + google_sign_in_ios: + :path: Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin + in_app_purchase_storekit: + :path: Flutter/ephemeral/.symlinks/plugins/in_app_purchase_storekit/darwin + in_app_review: + :path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos + ios_receipt: + :path: Flutter/ephemeral/.symlinks/plugins/ios_receipt/macos + just_audio: + :path: Flutter/ephemeral/.symlinks/plugins/just_audio/darwin keypress_simulator_macos: :path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + media_key_detector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos + nsd_macos: + :path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + purchases_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos + screen_retriever_macos: + :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sign_in_with_apple: + :path: Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos + universal_ble: + :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: - flutter_blue_plus_darwin: 3ea4ec9133b377febcc8a70b28cd2d2dc9242bd9 + app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869 + AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f + audio_session: 728ae3823d914f809c485d390274861a24b0904e + bluetooth_low_energy_darwin: 50bc79258e60586e4c4bed5948bd31d925f37fac + device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215 + file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af + file_selector_macos: 3e56eaea051180007b900eacb006686fd54da150 flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36 - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + flutter_screen_capture: 5db68ea1972d12dfb1b46b5659dad6bc50ea60ef + flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d + flutter_volume_controller: 25d09126b0d695560f11c80b1311d5063fed882f + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + gamepads_darwin: 07af6c60c282902b66574c800e20b2b26e68fda8 + google_sign_in_ios: 7336a3372ea93ea56a21e126a0055ffca3723601 + GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + in_app_purchase_storekit: 2342c0a5da86593124d08dd13d920f39a52b273a + in_app_review: 866c9b17c87a7b46a395bda43f5d3ca02deb585a + ios_receipt: 8741a75f39e6ca0866313b73c69a5b674cf5c98c + just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79 keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + media_key_detector_macos: a93757a483b4b47283ade432b1af9e427c47329f + nsd_macos: 1a38a38a33adbb396b4c6f303bc076073514cadc + package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + purchases_flutter: 55ed35144683d8673f0a4b5077e8f4d8291f291e + PurchasesHybridCommon: 027f03312519c51056457eb2e4f7ee1c91b61b8f + RevenueCat: ecbba580fa453b0d4a0475449b904196d74ef678 + screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161 + shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + sign_in_with_apple: a9e97e744e8edc36aefc2723111f652102a7a727 + universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6 + url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce + wakelock_plus: 9d63063ffb7af1c215209769067c57103bde719d + window_manager: e25faf20d88283a0d46e7b1a759d07261ca27575 -PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82 +PODFILE CHECKSUM: 0fb45929fd801f111c1e68cc98ccc2d06ac5e49e COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index dc266f783..6157ab4ab 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -24,10 +24,10 @@ 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 74C2E383F32990193E6CB903 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B3C733522C0D4DA32BDE037 /* Pods_RunnerTests.framework */; }; + F02438522EA222AE00C673A3 /* assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F02438512EA222AE00C673A3 /* assets.xcassets */; }; FDDED5BC8AE9088C0B2FD749 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38D2ED8D4AF83D5BAAC8B750 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -67,9 +67,8 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* swift_play.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = swift_play.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* BikeControl.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BikeControl.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; @@ -87,6 +86,7 @@ A126A3BE8A8EB6B8BE260F53 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; A7250649471FBC62B0CBB090 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; E286BD279B92C2469232E49F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + F02438512EA222AE00C673A3 /* assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = assets.xcassets; sourceTree = ""; }; FC66CAEEF21DAD867099FB4F /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -120,7 +120,6 @@ 1214C73C0A9D0DF8E46E2062 /* Pods-RunnerTests.release.xcconfig */, 6F6E35429F102BF1B750D24C /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -158,7 +157,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* swift_play.app */, + 33CC10ED2044A3C60003C045 /* BikeControl.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -167,7 +166,7 @@ 33CC11242044D66E0003C045 /* Resources */ = { isa = PBXGroup; children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, + F02438512EA222AE00C673A3 /* assets.xcassets */, 33CC10F42044A3C60003C045 /* MainMenu.xib */, 33CC10F72044A3C60003C045 /* Info.plist */, ); @@ -241,6 +240,7 @@ 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, 416B733CA2212067A6157BC2 /* [CP] Embed Pods Frameworks */, + 64E1C1C2337BD399A29F09AF /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -249,7 +249,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* swift_play.app */; + productReference = 33CC10ED2044A3C60003C045 /* BikeControl.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -270,7 +270,6 @@ 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.Sandbox = { enabled = 1; @@ -315,8 +314,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + F02438522EA222AE00C673A3 /* assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -400,6 +399,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 64E1C1C2337BD399A29F09AF /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; DDE99C1FE0195AFA9D281938 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -557,7 +573,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -570,16 +586,28 @@ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = UZRHKPVWN9; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BikeControl; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 12.4; + PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin; + PRODUCT_NAME = BikeControl; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "mac app store"; SWIFT_VERSION = 5.0; }; name = Profile; @@ -639,7 +667,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +717,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -702,15 +730,24 @@ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = UZRHKPVWN9; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BikeControl; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 12.4; + PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin; + PRODUCT_NAME = BikeControl; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -722,16 +759,28 @@ baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = UZRHKPVWN9; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = BikeControl; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 12.4; + PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin; + PRODUCT_NAME = BikeControl; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "mac app store"; SWIFT_VERSION = 5.0; }; name = Release; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 7af84c5c2..f3d1e50ed 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,7 +31,7 @@ @@ -66,7 +66,7 @@ @@ -83,7 +83,7 @@ diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index b3c176141..430a0a6f9 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,8 +1,23 @@ import Cocoa import FlutterMacOS +import app_links @main class AppDelegate: FlutterAppDelegate { + + public override func application(_ application: NSApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([any NSUserActivityRestoring]) -> Void) -> Bool { + + guard let url = AppLinks.shared.getUniversalLink(userActivity) else { + return false + } + + AppLinks.shared.handleLink(link: url.absoluteString) + + return false // Returning true will stop the propagation to other packages + } + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 96d3fee1a..000000000 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "info": { - "version": 1, - "author": "xcode" - }, - "images": [ - { - "size": "16x16", - "idiom": "mac", - "filename": "app_icon_16.png", - "scale": "1x" - }, - { - "size": "16x16", - "idiom": "mac", - "filename": "app_icon_32.png", - "scale": "2x" - }, - { - "size": "32x32", - "idiom": "mac", - "filename": "app_icon_32.png", - "scale": "1x" - }, - { - "size": "32x32", - "idiom": "mac", - "filename": "app_icon_64.png", - "scale": "2x" - }, - { - "size": "128x128", - "idiom": "mac", - "filename": "app_icon_128.png", - "scale": "1x" - }, - { - "size": "128x128", - "idiom": "mac", - "filename": "app_icon_256.png", - "scale": "2x" - }, - { - "size": "256x256", - "idiom": "mac", - "filename": "app_icon_256.png", - "scale": "1x" - }, - { - "size": "256x256", - "idiom": "mac", - "filename": "app_icon_512.png", - "scale": "2x" - }, - { - "size": "512x512", - "idiom": "mac", - "filename": "app_icon_512.png", - "scale": "1x" - }, - { - "size": "512x512", - "idiom": "mac", - "filename": "app_icon_1024.png", - "scale": "2x" - } - ] -} \ No newline at end of file diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 2280a66b8..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index 2491130a9..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 39918661e..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index 840cd46c9..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index 47189f2c6..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 0e1598407..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 463e51fb8..000000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index b44a43fd4..ee9098216 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = swift_play +PRODUCT_NAME = SwiftControl // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftPlay diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 14a39b7ec..68caaa7cd 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -2,13 +2,25 @@ + com.apple.developer.applesignin + + Default + com.apple.security.app-sandbox com.apple.security.cs.allow-jit com.apple.security.device.bluetooth + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + com.apple.security.network.server + keychain-access-groups + + $(AppIdentifierPrefix)com.google.GIDSignIn + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist old mode 100644 new mode 100755 index e142a464b..cd6949164 --- 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 @@ -22,13 +31,36 @@ $(FLUTTER_BUILD_NUMBER) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + NSBluetoothAlwaysUsageDescription + BikeControl requires Bluetooth to connect to your devices. + NSLocalNetworkUsageDescription + This app connects to your trainer app on your local network. + NSBonjourServices + + _wahoo-fitness-tnp._tcp + _openbikecontrol._tcp + NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile MainMenu NSPrincipalClass NSApplication - NSBluetoothAlwaysUsageDescription - We need BT access because it's a BT App. + NSAccessibilityUsageDescription + BikeControl needs to send keys to your trainer app. + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.googleusercontent.apps.709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2 + bikecontrol + + + + GIDClientID + 709945926587-0iierajthibf4vhqf85fc7bbpgbdgua2.apps.googleusercontent.com diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements old mode 100644 new mode 100755 index c269f520c..c5ff71ce5 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -2,9 +2,25 @@ + com.apple.developer.applesignin + + Default + com.apple.security.app-sandbox + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.device.bluetooth + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + com.apple.security.network.server + + keychain-access-groups + + $(AppIdentifierPrefix)com.google.GIDSignIn + diff --git a/macos/assets.xcassets/AppIcon.appiconset/AppIcon-min.png b/macos/assets.xcassets/AppIcon.appiconset/AppIcon-min.png new file mode 100644 index 000000000..c3f402816 Binary files /dev/null and b/macos/assets.xcassets/AppIcon.appiconset/AppIcon-min.png differ diff --git a/macos/assets.xcassets/AppIcon.appiconset/Contents.json b/macos/assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..df789877b --- /dev/null +++ b/macos/assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,59 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "AppIcon-min.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/macos/assets.xcassets/Contents.json b/macos/assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/macos/assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/media_key_detector/.gitignore b/media_key_detector/.gitignore new file mode 100644 index 000000000..4aa0df8df --- /dev/null +++ b/media_key_detector/.gitignore @@ -0,0 +1,48 @@ +.DS_Store +.atom/ +.idea/ +.vscode/ + +.packages +.pub/ +.dart_tool/ +pubspec.lock +flutter_export_environment.sh +coverage/ + +Podfile.lock +Pods/ +.symlinks/ +**/Flutter/App.framework/ +**/Flutter/ephemeral/ +**/Flutter/Flutter.podspec +**/Flutter/Flutter.framework/ +**/Flutter/Generated.xcconfig +**/Flutter/flutter_assets/ + +ServiceDefinitions.json +xcuserdata/ +**/DerivedData/ + +local.properties +keystore.properties +.gradle/ +gradlew +gradlew.bat +gradle-wrapper.jar +.flutter-plugins-dependencies +*.iml + +generated_plugin_registrant.cc +generated_plugin_registrant.h +generated_plugin_registrant.dart +GeneratedPluginRegistrant.java +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m +GeneratedPluginRegistrant.swift +build/ +.flutter-plugins + +.project +.classpath +.settings \ No newline at end of file diff --git a/media_key_detector/README.md b/media_key_detector/README.md new file mode 100644 index 000000000..27c3a6b4f --- /dev/null +++ b/media_key_detector/README.md @@ -0,0 +1,43 @@ +# media_key_detector + +[![Very Good Ventures][logo_white]][very_good_ventures_link_dark] +[![Very Good Ventures][logo_black]][very_good_ventures_link_light] + +Developed with 💙 by [Very Good Ventures][very_good_ventures_link] 🦄 + +![coverage][coverage_badge] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +A Very Good Flutter Federated Plugin created by the [Very Good Ventures Team][very_good_ventures_link]. + +Generated by the [Very Good CLI][very_good_cli_link] 🤖 + + +### Integration tests 🧪 + +Very Good Flutter Plugin uses [fluttium][fluttium_link] for integration tests. Those tests are located +in the front facing package `media_key_detector` example. + +**❗ In order to run the integration tests, you need to have the `fluttium_cli` installed. [See how][fluttium_install].** + +To run the integration tests, run the following command from the root of the project: + +```sh +cd media_key_detector/example +fluttium test flows/test_platform_name.yaml +``` + +[coverage_badge]: media_key_detector/coverage_badge.svg +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli +[very_good_ventures_link]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core +[very_good_ventures_link_dark]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-dark-mode-only +[very_good_ventures_link_light]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-light-mode-only +[fluttium_link]: https://fluttium.dev/ +[fluttium_install]: https://fluttium.dev/docs/getting-started/installing-cli \ No newline at end of file diff --git a/media_key_detector/media_key_detector/CHANGELOG.md b/media_key_detector/media_key_detector/CHANGELOG.md new file mode 100644 index 000000000..589555b74 --- /dev/null +++ b/media_key_detector/media_key_detector/CHANGELOG.md @@ -0,0 +1,7 @@ +# 0.0.2 + +- TBD + +# 0.0.1 + +- Initial Release diff --git a/media_key_detector/media_key_detector/LICENSE b/media_key_detector/media_key_detector/LICENSE new file mode 100644 index 000000000..a4110b7e0 --- /dev/null +++ b/media_key_detector/media_key_detector/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Evan Kaiser + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/media_key_detector/media_key_detector/README.md b/media_key_detector/media_key_detector/README.md new file mode 100644 index 000000000..7d2c32b6e --- /dev/null +++ b/media_key_detector/media_key_detector/README.md @@ -0,0 +1,110 @@ +[![Pub][pub_badge]][pub_link] +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +# media_key_detector + +Triggers events when keyboard media keys are pressed. On MacOS, it registers +your app as a [Now Playable App](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app), +allowing it to respond to media events, regardless of whether the event came +from a keyboard, headset, or media remote. + +## Features + +- Captures and triggeres events for the following keys: + - Play / Pause + - Previous / Rewind + - Next / Fast-forward + +## Rationale + +In general media key capture works fine using normal keyboard approaches, such +as RawKeyListener or FocusNode. However, it only works on Windows/Linux. In +MacOS, the key events are not detected, and often will even send the events to +an inactive window, like Music. + +## Getting started + +1. Add the latest version of this package: + +- Run `flutter pub add media_key_detector` -or- +- Edit `pubspec.yaml` and then run `flutter pub get`: + +```yaml +dependencies: + media_key_detector: ^latest_version +``` + +2. Import the package + +``` +import 'package:media_key_detector/media_key_detector.dart'; +``` + +## Usage + +```dart + void _mediaKeyListener(MediaKey mediaKey) { + debugPrint('$mediaKey pressed'); + } + + @override + void initState() { + super.initState(); + mediaKeyDetector.addListener(_mediaKeyListener); + } + + @override + void dispose() { + mediaKeyDetector.removeListener(_mediaKeyListener); + super.dispose(); + } + + /// The following two methods are only really needed if you're relying on + /// the plugin to track the playing state. On MacOS/iOS, this is helpful to + /// display the status in the "Command Center". + + /// Get whether the media is playing. Note that it starts out "paused", + /// so if your app plays media on open, you should call + /// [mediaKeyDetector.setIsPlaying(true)] + Future getIsMediaPlaying() async { + return await mediaKeyDetector.getIsPlaying(); + } + + /// The app tracks the playing state when the user presses the media key, + /// but there are some cases, i.e. when a play button is pressed on the UI + /// interface, where you may need to set it yourself + Future play() async { + return await mediaKeyDetector.setIsPlaying(isPlaying: true); + } +``` + +## Code Generation + +[![Very Good Ventures][logo_white]][very_good_ventures_link_dark] +[![Very Good Ventures][logo_black]][very_good_ventures_link_light] + +Developed with 💙 by [Very Good Ventures][very_good_ventures_link] 🦄 + +A Very Good Flutter Federated Plugin created by the [Very Good Ventures Team][very_good_ventures_link]. + +Generated by the [Very Good CLI][very_good_cli_link] 🤖 + +## Support + +You can support me by buying me a coffee Buy me a coffee + +And also don't forget to star this package on GitHub + +[pub_badge]: https://img.shields.io/pub/v/media_key_detector +[pub_link]: https://pub.dev/packages/media_key_detector +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only +[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis +[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli +[very_good_ventures_link]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core +[very_good_ventures_link_dark]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-dark-mode-only +[very_good_ventures_link_light]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-light-mode-only diff --git a/media_key_detector/media_key_detector/analysis_options.yaml b/media_key_detector/media_key_detector/analysis_options.yaml new file mode 100644 index 000000000..799268d3e --- /dev/null +++ b/media_key_detector/media_key_detector/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/media_key_detector/media_key_detector/coverage_badge.svg b/media_key_detector/media_key_detector/coverage_badge.svg new file mode 100644 index 000000000..499e98ce2 --- /dev/null +++ b/media_key_detector/media_key_detector/coverage_badge.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + coverage + coverage + 100% + 100% + + diff --git a/media_key_detector/media_key_detector/example/.gitignore b/media_key_detector/media_key_detector/example/.gitignore new file mode 100644 index 000000000..8c5866d2a --- /dev/null +++ b/media_key_detector/media_key_detector/example/.gitignore @@ -0,0 +1,49 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Fluttium related files +.fluttium_*_launcher.dart \ No newline at end of file diff --git a/media_key_detector/media_key_detector/example/.metadata b/media_key_detector/media_key_detector/example/.metadata new file mode 100644 index 000000000..19d783704 --- /dev/null +++ b/media_key_detector/media_key_detector/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + channel: stable + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + - platform: android + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + - platform: ios + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + - platform: linux + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + - platform: macos + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + - platform: web + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + - platform: windows + create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/media_key_detector/media_key_detector/example/README.md b/media_key_detector/media_key_detector/example/README.md new file mode 100644 index 000000000..2fbfe4831 --- /dev/null +++ b/media_key_detector/media_key_detector/example/README.md @@ -0,0 +1,3 @@ +# media_key_detector_example + +Demonstrates how to use the media_key_detector plugin. diff --git a/media_key_detector/media_key_detector/example/analysis_options.yaml b/media_key_detector/media_key_detector/example/analysis_options.yaml new file mode 100644 index 000000000..59cf34c28 --- /dev/null +++ b/media_key_detector/media_key_detector/example/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml +linter: + rules: + public_member_api_docs: false + require_trailing_commas: false + lines_longer_than_80_chars: false diff --git a/media_key_detector/media_key_detector/example/lib/main.dart b/media_key_detector/media_key_detector/example/lib/main.dart new file mode 100644 index 000000000..812bc317e --- /dev/null +++ b/media_key_detector/media_key_detector/example/lib/main.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:media_key_detector/media_key_detector.dart'; + +void main() => runApp(const MyApp()); + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: HomePage()); + } +} + +class HomePage extends StatefulWidget { + const HomePage({super.key}); + + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + String? _platformName; + bool _isPlaying = false; + Map keyPressed = { + MediaKey.playPause: false, + MediaKey.rewind: false, + MediaKey.fastForward: false, + }; + + void _mediaKeyListener(MediaKey mediaKey) { + debugPrint('$mediaKey pressed'); + + mediaKeyDetector + .getIsPlaying() + .then((playing) => setState(() => _isPlaying = playing)); + + if (keyPressed[mediaKey] == false) { + setState(() => keyPressed[mediaKey] = true); + Timer(const Duration(seconds: 3), () { + setState(() => keyPressed[mediaKey] = false); + }); + } + } + + Future _togglePlayPause() async { + setState(() => _isPlaying = !_isPlaying); + await mediaKeyDetector.setIsPlaying(isPlaying: _isPlaying); + } + + @override + void initState() { + super.initState(); + mediaKeyDetector.addListener(_mediaKeyListener); + } + + @override + void dispose() { + mediaKeyDetector.removeListener(_mediaKeyListener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('MediaKeyDetector Example')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Press a Media button on your IO device to highlight the corresponding icon.'), + const Text( + 'Press the play/pause button to send now playing info to plugin.'), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.fast_rewind_rounded, + size: 40, + color: (keyPressed[MediaKey.rewind] ?? false) + ? colors.inversePrimary + : colors.onBackground, + ), + IconButton.filled( + onPressed: _togglePlayPause, + style: + IconButton.styleFrom(backgroundColor: colors.secondary), + icon: Icon( + _isPlaying + ? Icons.pause_circle_rounded + : Icons.play_circle_rounded, + size: 40, + color: (keyPressed[MediaKey.playPause] ?? false) + ? colors.inversePrimary + : colors.onPrimary, + ), + ), + Icon( + Icons.fast_forward_rounded, + size: 40, + color: (keyPressed[MediaKey.fastForward] ?? false) + ? colors.inversePrimary + : colors.onBackground, + ), + ], + ), + Text('Is currently playing: $_isPlaying'), + if (_platformName == null) + const SizedBox.shrink() + else + Text( + 'Platform Name: $_platformName', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () async { + if (!context.mounted) return; + try { + final result = await getPlatformName(); + setState(() => _platformName = result); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Theme.of(context).primaryColor, + content: Text('$error'), + ), + ); + } + }, + child: const Text('Get Platform Name'), + ), + ], + ), + ), + ); + } +} diff --git a/media_key_detector/media_key_detector/example/linux/.gitignore b/media_key_detector/media_key_detector/example/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/media_key_detector/media_key_detector/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/media_key_detector/media_key_detector/example/linux/CMakeLists.txt b/media_key_detector/media_key_detector/example/linux/CMakeLists.txt new file mode 100644 index 000000000..3337c386a --- /dev/null +++ b/media_key_detector/media_key_detector/example/linux/CMakeLists.txt @@ -0,0 +1,106 @@ +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +set(BINARY_NAME "example") +set(APPLICATION_ID "com.example.verygoodcore_example") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Application build +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) +apply_standard_settings(${BINARY_NAME}) +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +add_dependencies(${BINARY_NAME} flutter_assemble) +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/media_key_detector/media_key_detector/example/linux/flutter/CMakeLists.txt b/media_key_detector/media_key_detector/example/linux/flutter/CMakeLists.txt new file mode 100644 index 000000000..4f48a7ced --- /dev/null +++ b/media_key_detector/media_key_detector/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) +pkg_check_modules(BLKID REQUIRED IMPORTED_TARGET blkid) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO + PkgConfig::BLKID +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + linux-x64 ${CMAKE_BUILD_TYPE} +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/media_key_detector/media_key_detector/example/linux/flutter/generated_plugins.cmake b/media_key_detector/media_key_detector/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..6f3c49242 --- /dev/null +++ b/media_key_detector/media_key_detector/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_key_detector_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/media_key_detector/media_key_detector/example/linux/main.cc b/media_key_detector/media_key_detector/example/linux/main.cc new file mode 100644 index 000000000..88a5fd45c --- /dev/null +++ b/media_key_detector/media_key_detector/example/linux/main.cc @@ -0,0 +1,15 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "my_application.h" + +int main(int argc, char** argv) { + // Only X11 is currently supported. + // Wayland support is being developed: + // https://github.com/flutter/flutter/issues/57932. + gdk_set_allowed_backends("x11"); + + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/media_key_detector/media_key_detector/example/linux/my_application.cc b/media_key_detector/media_key_detector/example/linux/my_application.cc new file mode 100644 index 000000000..9cb411ba4 --- /dev/null +++ b/media_key_detector/media_key_detector/example/linux/my_application.cc @@ -0,0 +1,49 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "my_application.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new( + my_application_get_type(), "application-id", APPLICATION_ID, nullptr)); +} diff --git a/media_key_detector/media_key_detector/example/linux/my_application.h b/media_key_detector/media_key_detector/example/linux/my_application.h new file mode 100644 index 000000000..6e9f0c3ff --- /dev/null +++ b/media_key_detector/media_key_detector/example/linux/my_application.h @@ -0,0 +1,22 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/media_key_detector/media_key_detector/example/macos/.gitignore b/media_key_detector/media_key_detector/example/macos/.gitignore new file mode 100644 index 000000000..746adbb6b --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/media_key_detector/media_key_detector/example/macos/Flutter/Flutter-Debug.xcconfig b/media_key_detector/media_key_detector/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 000000000..4b81f9b2d --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/media_key_detector/media_key_detector/example/macos/Flutter/Flutter-Release.xcconfig b/media_key_detector/media_key_detector/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 000000000..5caa9d157 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/media_key_detector/media_key_detector/example/macos/Podfile b/media_key_detector/media_key_detector/example/macos/Podfile new file mode 100644 index 000000000..049abe295 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/project.pbxproj b/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..9d02f0359 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,633 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 8056B9E1FA9168F10A6879E3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF88737E4227AF4483D73A72 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 082C0DA75B3DC0A853AE53D1 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 604426B4C55353DB93E63726 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 7CC079B1077C0BC3449BC7C1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + BF88737E4227AF4483D73A72 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8056B9E1FA9168F10A6879E3 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0EEA7CE099CD931C8BF18132 /* Pods */ = { + isa = PBXGroup; + children = ( + 604426B4C55353DB93E63726 /* Pods-Runner.debug.xcconfig */, + 7CC079B1077C0BC3449BC7C1 /* Pods-Runner.release.xcconfig */, + 082C0DA75B3DC0A853AE53D1 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 0EEA7CE099CD931C8BF18132 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + BF88737E4227AF4483D73A72 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + BA51270DFB65A51AF6561C86 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 01C1BE47E39E4FF65E75DFDD /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 01C1BE47E39E4FF65E75DFDD /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + BA51270DFB65A51AF6561C86 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..5b055a3a3 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/media_key_detector/media_key_detector/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/media_key_detector/media_key_detector/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..21a3cc14c --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/media_key_detector/media_key_detector/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/media_key_detector/media_key_detector/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/media_key_detector/media_key_detector/example/macos/Runner/AppDelegate.swift b/media_key_detector/media_key_detector/example/macos/Runner/AppDelegate.swift new file mode 100644 index 000000000..d53ef6437 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..a2ec33f19 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 000000000..82b6f9d9a Binary files /dev/null and b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 000000000..13b35eba5 Binary files /dev/null and b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 000000000..0a3f5fa40 Binary files /dev/null and b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 000000000..bdb57226d Binary files /dev/null and b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 000000000..f083318e0 Binary files /dev/null and b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 000000000..326c0e72c Binary files /dev/null and b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 000000000..2f1632cfd Binary files /dev/null and b/media_key_detector/media_key_detector/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Base.lproj/MainMenu.xib b/media_key_detector/media_key_detector/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 000000000..80e867a4e --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Configs/AppInfo.xcconfig b/media_key_detector/media_key_detector/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 000000000..c4c4609bb --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.myplugin.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2023 com.example.verygoodcore. All rights reserved. diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Configs/Debug.xcconfig b/media_key_detector/media_key_detector/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 000000000..36b0fd946 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Configs/Release.xcconfig b/media_key_detector/media_key_detector/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 000000000..dff4f4956 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Configs/Warnings.xcconfig b/media_key_detector/media_key_detector/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 000000000..42bcbf478 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/media_key_detector/media_key_detector/example/macos/Runner/DebugProfile.entitlements b/media_key_detector/media_key_detector/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 000000000..dddb8a30c --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Info.plist b/media_key_detector/media_key_detector/example/macos/Runner/Info.plist new file mode 100644 index 000000000..4789daa6a --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/media_key_detector/media_key_detector/example/macos/Runner/MainFlutterWindow.swift b/media_key_detector/media_key_detector/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 000000000..2722837ec --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/media_key_detector/media_key_detector/example/macos/Runner/Release.entitlements b/media_key_detector/media_key_detector/example/macos/Runner/Release.entitlements new file mode 100644 index 000000000..852fa1a47 --- /dev/null +++ b/media_key_detector/media_key_detector/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/media_key_detector/media_key_detector/example/pubspec.yaml b/media_key_detector/media_key_detector/example/pubspec.yaml new file mode 100644 index 000000000..50d03af5a --- /dev/null +++ b/media_key_detector/media_key_detector/example/pubspec.yaml @@ -0,0 +1,30 @@ +name: media_key_detector_example +description: Demonstrates how to use the media_key_detector plugin. +version: 0.1.0+1 +publish_to: none + +environment: + sdk: "^3.3.0" + +dependencies: + flutter: + sdk: flutter + media_key_detector: + # When depending on this package from a real application you should use: + # media_key_detector: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + +dev_dependencies: + flutter_driver: + sdk: flutter + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + very_good_analysis: ^5.1.0 + +flutter: + uses-material-design: true diff --git a/media_key_detector/media_key_detector/example/windows/.gitignore b/media_key_detector/media_key_detector/example/windows/.gitignore new file mode 100644 index 000000000..d492d0d98 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/media_key_detector/media_key_detector/example/windows/CMakeLists.txt b/media_key_detector/media_key_detector/example/windows/CMakeLists.txt new file mode 100644 index 000000000..1633297a0 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.14) +project(example LANGUAGES CXX) + +set(BINARY_NAME "example") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() + +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/media_key_detector/media_key_detector/example/windows/flutter/CMakeLists.txt b/media_key_detector/media_key_detector/example/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000..b2e4bd8d6 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,103 @@ +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/media_key_detector/media_key_detector/example/windows/flutter/generated_plugins.cmake b/media_key_detector/media_key_detector/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..aa414b8e4 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_key_detector_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/media_key_detector/media_key_detector/example/windows/runner/CMakeLists.txt b/media_key_detector/media_key_detector/example/windows/runner/CMakeLists.txt new file mode 100644 index 000000000..de2d8916b --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) +apply_standard_settings(${BINARY_NAME}) +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/media_key_detector/media_key_detector/example/windows/runner/Runner.rc b/media_key_detector/media_key_detector/example/windows/runner/Runner.rc new file mode 100644 index 000000000..f11b007b1 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "dev.flutter.plugins" "\0" + VALUE "FileDescription", "example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 dev.flutter.plugins. All rights reserved." "\0" + VALUE "OriginalFilename", "example.exe" "\0" + VALUE "ProductName", "example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/media_key_detector/media_key_detector/example/windows/runner/flutter_window.cpp b/media_key_detector/media_key_detector/example/windows/runner/flutter_window.cpp new file mode 100644 index 000000000..b43b9095e --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/flutter_window.cpp @@ -0,0 +1,61 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/media_key_detector/media_key_detector/example/windows/runner/flutter_window.h b/media_key_detector/media_key_detector/example/windows/runner/flutter_window.h new file mode 100644 index 000000000..6da0652f0 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/media_key_detector/media_key_detector/example/windows/runner/main.cpp b/media_key_detector/media_key_detector/example/windows/runner/main.cpp new file mode 100644 index 000000000..bcb57b0e2 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/media_key_detector/media_key_detector/example/windows/runner/resource.h b/media_key_detector/media_key_detector/example/windows/runner/resource.h new file mode 100644 index 000000000..d7b448fc5 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +// +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/media_key_detector/media_key_detector/example/windows/runner/resources/app_icon.ico b/media_key_detector/media_key_detector/example/windows/runner/resources/app_icon.ico new file mode 100644 index 000000000..c04e20caf Binary files /dev/null and b/media_key_detector/media_key_detector/example/windows/runner/resources/app_icon.ico differ diff --git a/media_key_detector/media_key_detector/example/windows/runner/runner.exe.manifest b/media_key_detector/media_key_detector/example/windows/runner/runner.exe.manifest new file mode 100644 index 000000000..c977c4a42 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/media_key_detector/media_key_detector/example/windows/runner/utils.cpp b/media_key_detector/media_key_detector/example/windows/runner/utils.cpp new file mode 100644 index 000000000..d19bdbbcc --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + if (target_length == 0) { + return std::string(); + } + std::string utf8_string; + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/media_key_detector/media_key_detector/example/windows/runner/utils.h b/media_key_detector/media_key_detector/example/windows/runner/utils.h new file mode 100644 index 000000000..3879d5475 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/media_key_detector/media_key_detector/example/windows/runner/win32_window.cpp b/media_key_detector/media_key_detector/example/windows/runner/win32_window.cpp new file mode 100644 index 000000000..c10f08dc7 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/media_key_detector/media_key_detector/example/windows/runner/win32_window.h b/media_key_detector/media_key_detector/example/windows/runner/win32_window.h new file mode 100644 index 000000000..17ba43112 --- /dev/null +++ b/media_key_detector/media_key_detector/example/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/media_key_detector/media_key_detector/lib/media_key_detector.dart b/media_key_detector/media_key_detector/lib/media_key_detector.dart new file mode 100644 index 000000000..6a9c4e2ab --- /dev/null +++ b/media_key_detector/media_key_detector/lib/media_key_detector.dart @@ -0,0 +1,16 @@ +/// Plugin which allows you to detect the media buttons on the keyboard, which +/// does not fire for regular key events on MacOS +library media_key_detector; + +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +export './src/exports.dart'; + +MediaKeyDetectorPlatform get _platform => MediaKeyDetectorPlatform.instance; + +/// Returns the name of the current platform. +Future getPlatformName() async { + final platformName = await _platform.getPlatformName(); + if (platformName == null) throw Exception('Unable to get platform name.'); + return platformName; +} diff --git a/media_key_detector/media_key_detector/lib/src/exports.dart b/media_key_detector/media_key_detector/lib/src/exports.dart new file mode 100644 index 000000000..0c1b27386 --- /dev/null +++ b/media_key_detector/media_key_detector/lib/src/exports.dart @@ -0,0 +1,4 @@ +export 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart' + show MediaKey; +export './main.dart' show getPlatformName; +export './media_key_detector.dart' show MediaKeyDetector, mediaKeyDetector; diff --git a/media_key_detector/media_key_detector/lib/src/main.dart b/media_key_detector/media_key_detector/lib/src/main.dart new file mode 100644 index 000000000..8055cb4d7 --- /dev/null +++ b/media_key_detector/media_key_detector/lib/src/main.dart @@ -0,0 +1,10 @@ +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +MediaKeyDetectorPlatform get _platform => MediaKeyDetectorPlatform.instance; + +/// Returns the name of the current platform. +Future getPlatformName() async { + final platformName = await _platform.getPlatformName(); + if (platformName == null) throw Exception('Unable to get platform name.'); + return platformName; +} diff --git a/media_key_detector/media_key_detector/lib/src/media_key_detector.dart b/media_key_detector/media_key_detector/lib/src/media_key_detector.dart new file mode 100644 index 000000000..eb48472f5 --- /dev/null +++ b/media_key_detector/media_key_detector/lib/src/media_key_detector.dart @@ -0,0 +1,46 @@ +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +MediaKeyDetectorPlatform get _platform => MediaKeyDetectorPlatform.instance; + +/// Contains methods to add/remove listeners for the media key +class MediaKeyDetector { + /// Contains methods to add/remove listeners for the media key + factory MediaKeyDetector() { + return _singleton; + } + + MediaKeyDetector._internal(); + + static final MediaKeyDetector _singleton = MediaKeyDetector._internal(); + + bool _initialized = false; + + /// Listen for the media key event + void addListener(void Function(MediaKey mediaKey) listener) { + _lazilyInitialize(); + _platform.addListener(listener); + } + + /// Remove the previously registered listener + void removeListener(void Function(MediaKey mediaKey) listener) { + _lazilyInitialize(); + _platform.removeListener(listener); + } + + void _lazilyInitialize() { + if (!_initialized) { + _platform.initialize(); + _initialized = true; + } + } + + /// Get whether the active audio player is currently playing. + Future getIsPlaying() => _platform.getIsPlaying(); + + /// Set whether the active audio player is currently playing. + Future setIsPlaying({required bool isPlaying}) => + _platform.setIsPlaying(isPlaying: isPlaying); +} + +/// Global singleton instance of the [MediaKeyDetector] +final mediaKeyDetector = MediaKeyDetector._singleton; diff --git a/media_key_detector/media_key_detector/pubspec.yaml b/media_key_detector/media_key_detector/pubspec.yaml new file mode 100644 index 000000000..248559289 --- /dev/null +++ b/media_key_detector/media_key_detector/pubspec.yaml @@ -0,0 +1,42 @@ +name: media_key_detector +description: Triggers events when keyboard media keys are pressed. +version: 0.0.2 +homepage: https://github.com/holotrek/media_key_detector + +environment: + sdk: "^3.3.0" + flutter: ">=3.19.3" + +flutter: + plugin: + platforms: + linux: + default_package: media_key_detector_linux + macos: + default_package: media_key_detector_macos + ios: + default_package: media_key_detector_ios + windows: + default_package: media_key_detector_windows + +dependencies: + flutter: + sdk: flutter + + media_key_detector_ios: + path: ../media_key_detector_ios + media_key_detector_linux: + path: ../media_key_detector_linux + media_key_detector_macos: + path: ../media_key_detector_macos + media_key_detector_platform_interface: + path: ../media_key_detector_platform_interface + media_key_detector_windows: + path: ../media_key_detector_windows + +dev_dependencies: + flutter_test: + sdk: flutter + mocktail: ^1.0.3 + plugin_platform_interface: ^2.1.8 + very_good_analysis: ^5.1.0 diff --git a/media_key_detector/media_key_detector/test/media_key_detector_test.dart b/media_key_detector/media_key_detector/test/media_key_detector_test.dart new file mode 100644 index 000000000..1795190cc --- /dev/null +++ b/media_key_detector/media_key_detector/test/media_key_detector_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_key_detector/media_key_detector.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockMediaKeyDetectorPlatform extends Mock + with MockPlatformInterfaceMixin + implements MediaKeyDetectorPlatform {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MediaKeyDetector', () { + late MediaKeyDetectorPlatform mediaKeyDetectorPlatform; + + setUp(() { + mediaKeyDetectorPlatform = MockMediaKeyDetectorPlatform(); + MediaKeyDetectorPlatform.instance = mediaKeyDetectorPlatform; + }); + + group('getPlatformName', () { + test('returns correct name when platform implementation exists', + () async { + const platformName = '__test_platform__'; + when( + () => mediaKeyDetectorPlatform.getPlatformName(), + ).thenAnswer((_) async => platformName); + + final actualPlatformName = await getPlatformName(); + expect(actualPlatformName, equals(platformName)); + }); + + test('throws exception when platform implementation is missing', + () async { + when( + () => mediaKeyDetectorPlatform.getPlatformName(), + ).thenAnswer((_) async => null); + + expect(getPlatformName, throwsException); + }); + }); + }); +} diff --git a/media_key_detector/media_key_detector_ios/.gitignore b/media_key_detector/media_key_detector_ios/.gitignore new file mode 100644 index 000000000..53e92cc41 --- /dev/null +++ b/media_key_detector/media_key_detector_ios/.gitignore @@ -0,0 +1,3 @@ +.packages +.flutter-plugins +pubspec.lock diff --git a/media_key_detector/media_key_detector_ios/CHANGELOG.md b/media_key_detector/media_key_detector_ios/CHANGELOG.md new file mode 100644 index 000000000..589555b74 --- /dev/null +++ b/media_key_detector/media_key_detector_ios/CHANGELOG.md @@ -0,0 +1,7 @@ +# 0.0.2 + +- TBD + +# 0.0.1 + +- Initial Release diff --git a/media_key_detector/media_key_detector_ios/LICENSE b/media_key_detector/media_key_detector_ios/LICENSE new file mode 100644 index 000000000..a4110b7e0 --- /dev/null +++ b/media_key_detector/media_key_detector_ios/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Evan Kaiser + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/media_key_detector/media_key_detector_ios/README.md b/media_key_detector/media_key_detector_ios/README.md new file mode 100644 index 000000000..9645477ca --- /dev/null +++ b/media_key_detector/media_key_detector_ios/README.md @@ -0,0 +1,14 @@ +# media_key_detector_macos + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] + +The macos implementation of `media_key_detector`. + +## Usage + +This package is [endorsed][endorsed_link], which means you can simply use `media_key_detector` +normally. This package will be automatically included in your app when you do. + +[endorsed_link]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis diff --git a/media_key_detector/media_key_detector_ios/analysis_options.yaml b/media_key_detector/media_key_detector_ios/analysis_options.yaml new file mode 100644 index 000000000..799268d3e --- /dev/null +++ b/media_key_detector/media_key_detector_ios/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/media_key_detector/media_key_detector_ios/ios/Classes/MediaKeyDetectorPlugin.swift b/media_key_detector/media_key_detector_ios/ios/Classes/MediaKeyDetectorPlugin.swift new file mode 100644 index 000000000..6c3a5d975 --- /dev/null +++ b/media_key_detector/media_key_detector_ios/ios/Classes/MediaKeyDetectorPlugin.swift @@ -0,0 +1,143 @@ +import Flutter +import MediaPlayer +import Foundation + +public class MediaKeyDetectorPlugin: NSObject, FlutterPlugin { + private var mediaKeyHandler = MediaKeyHandler() + var isNowPlayable = false + var playPauseTarget: Any?; + var previousTrackTarget: Any?; + var nextTrackTarget: Any?; + private var player: AVPlayer? + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "media_key_detector_ios", + binaryMessenger: registrar.messenger()) + + // Setup an event channel in addition to the method channel. This will react to native events and then notify Dart code. + let eventChannel = FlutterEventChannel( + name: "media_key_detector_ios_events", + binaryMessenger: registrar.messenger()) + + let instance = MediaKeyDetectorPlugin() + + // Add the MediaKeyHandler to the event channel stream + eventChannel.setStreamHandler(instance.mediaKeyHandler) + + // Register the instance to listen to method calls from Dart + registrar.addMethodCallDelegate(instance, channel: channel) + + // Register the instance to handle the FlutterAppLifecycle + registrar.addApplicationDelegate(instance) + } + + func makeNowPlayable() throws { + + // 1) Activate audio session + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default, options: []) + try session.setActive(true) + + + var url = URL(string: "https://github.com/anars/blank-audio/raw/refs/heads/master/5-seconds-of-silence.mp3")! + + // 2) Player + let item = AVPlayerItem(url: url) + player = AVPlayer(playerItem: item) + player?.play() + + + // 2) Seed Now Playing info + var info: [String: Any] = [ + MPMediaItemPropertyTitle: "BikeControl", + MPMediaItemPropertyArtist: "BikeControl", + MPNowPlayingInfoPropertyElapsedPlaybackTime: 0, + MPMediaItemPropertyPlaybackDuration: 1337, // nonzero duration helps + MPNowPlayingInfoPropertyPlaybackRate: 1 // paused + ] + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + MPNowPlayingInfoCenter.default().playbackState = .paused + + // 3) Enable and add handlers + let cc = MPRemoteCommandCenter.shared() + cc.togglePlayPauseCommand.isEnabled = true + cc.previousTrackCommand.isEnabled = true + cc.nextTrackCommand.isEnabled = true + + + playPauseTarget = cc.togglePlayPauseCommand.addTarget { [weak self] _ in + guard let self else { return .commandFailed } + self.mediaKeyHandler.onKeyEvent(key: 0) + + let playing = MPNowPlayingInfoCenter.default().playbackState == .playing + MPNowPlayingInfoCenter.default().playbackState = playing ? .paused : .playing + info[MPNowPlayingInfoPropertyPlaybackRate] = playing ? 0 : 1 + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + return .success + } + previousTrackTarget = cc.previousTrackCommand.addTarget { [weak self] _ in + self?.mediaKeyHandler.onKeyEvent(key: 1) + return .success + } + nextTrackTarget = cc.nextTrackCommand.addTarget { [weak self] _ in + self?.mediaKeyHandler.onKeyEvent(key: 2) + return .success + } + isNowPlayable = true + } + + public func applicationWillTerminate(_ application: UIApplication) { + + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformName": + result("MacOS") + case "getIsPlaying": + result(MPNowPlayingInfoCenter.default().playbackState == .playing) + case "setIsPlaying": + guard let args = call.arguments as? Dictionary else {return} + let isPlaying = args["isPlaying"] as! Bool + if (isPlaying) { + do { + try makeNowPlayable() + } + catch { + print(error) + } + + } else { + player?.pause() + + let commandCenter = MPRemoteCommandCenter.shared() + commandCenter.togglePlayPauseCommand.removeTarget(self.playPauseTarget) + commandCenter.togglePlayPauseCommand.removeTarget(self.previousTrackTarget) + commandCenter.togglePlayPauseCommand.removeTarget(self.nextTrackTarget) + MPNowPlayingInfoCenter.default().playbackState = .stopped + } + default: + result(FlutterMethodNotImplemented) + } + } +} + +class MediaKeyHandler: NSObject, FlutterStreamHandler { + // Declare our eventSink, it will be initialized later + private var eventSink: FlutterEventSink? + + func onKeyEvent(key: Int32) { + self.eventSink?(key) + } + + func onListen(withArguments arguments: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = eventSink + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } +} diff --git a/media_key_detector/media_key_detector_ios/ios/media_key_detector_ios.podspec b/media_key_detector/media_key_detector_ios/ios/media_key_detector_ios.podspec new file mode 100644 index 000000000..8a6bc2364 --- /dev/null +++ b/media_key_detector/media_key_detector_ios/ios/media_key_detector_ios.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'media_key_detector_ios' + s.version = '0.0.1' + s.summary = 'An iOS implementation of the media_key_detector plugin.' + s.description = <<-DESC + A macOS implementation of the media_key_detector plugin. + DESC + s.homepage = 'http://example.com' + s.license = { :type => 'BSD', :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + + s.platform = :ios, '13.0' + s.ios.deployment_target = '13.0' + s.swift_version = '5.0' +end + diff --git a/media_key_detector/media_key_detector_ios/lib/media_key_detector_ios.dart b/media_key_detector/media_key_detector_ios/lib/media_key_detector_ios.dart new file mode 100644 index 000000000..355a0395a --- /dev/null +++ b/media_key_detector/media_key_detector_ios/lib/media_key_detector_ios.dart @@ -0,0 +1,47 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +/// The iOS implementation of [MediaKeyDetectorPlatform]. +class MediaKeyDetectorIOS extends MediaKeyDetectorPlatform { + final _eventChannel = const EventChannel('media_key_detector_ios_events'); + + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('media_key_detector_ios'); + + /// Registers this class as the default instance of [MediaKeyDetectorPlatform] + static void registerWith() { + MediaKeyDetectorPlatform.instance = MediaKeyDetectorIOS(); + } + + @override + void initialize() { + _eventChannel.receiveBroadcastStream().listen((event) { + final keyIdx = event as int; + MediaKey? key; + if (keyIdx > -1 && keyIdx < MediaKey.values.length) { + key = MediaKey.values[keyIdx]; + } + if (key != null) { + triggerListeners(key); + } + }); + } + + @override + Future getPlatformName() { + return methodChannel.invokeMethod('getPlatformName'); + } + + @override + Future getIsPlaying() async { + final isPlaying = await methodChannel.invokeMethod('getIsPlaying'); + return isPlaying ?? false; + } + + @override + Future setIsPlaying({required bool isPlaying}) { + return methodChannel.invokeMethod('setIsPlaying', {'isPlaying': isPlaying}); + } +} diff --git a/media_key_detector/media_key_detector_ios/pubspec.yaml b/media_key_detector/media_key_detector_ios/pubspec.yaml new file mode 100644 index 000000000..e0af74f13 --- /dev/null +++ b/media_key_detector/media_key_detector_ios/pubspec.yaml @@ -0,0 +1,27 @@ +name: media_key_detector_ios +description: iOS implementation of the media_key_detector plugin +version: 0.0.2 +homepage: https://github.com/holotrek/media_key_detector + +environment: + sdk: "^3.3.0" + flutter: ">=3.19.3" + +flutter: + plugin: + implements: media_key_detector + platforms: + ios: + pluginClass: MediaKeyDetectorPlugin + dartPluginClass: MediaKeyDetectorIOS + +dependencies: + flutter: + sdk: flutter + media_key_detector_platform_interface: + path: ../media_key_detector_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 diff --git a/media_key_detector/media_key_detector_linux/.gitignore b/media_key_detector/media_key_detector_linux/.gitignore new file mode 100644 index 000000000..e9dc58d3d --- /dev/null +++ b/media_key_detector/media_key_detector_linux/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.dart_tool/ + +.packages +.pub/ + +build/ diff --git a/media_key_detector/media_key_detector_linux/CHANGELOG.md b/media_key_detector/media_key_detector_linux/CHANGELOG.md new file mode 100644 index 000000000..589555b74 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/CHANGELOG.md @@ -0,0 +1,7 @@ +# 0.0.2 + +- TBD + +# 0.0.1 + +- Initial Release diff --git a/media_key_detector/media_key_detector_linux/LICENSE b/media_key_detector/media_key_detector_linux/LICENSE new file mode 100644 index 000000000..a4110b7e0 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Evan Kaiser + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/media_key_detector/media_key_detector_linux/README.md b/media_key_detector/media_key_detector_linux/README.md new file mode 100644 index 000000000..fa2b93789 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/README.md @@ -0,0 +1,14 @@ +# media_key_detector_linux + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] + +The linux implementation of `media_key_detector`. + +## Usage + +This package is [endorsed][endorsed_link], which means you can simply use `media_key_detector` +normally. This package will be automatically included in your app when you do. + +[endorsed_link]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis diff --git a/media_key_detector/media_key_detector_linux/analysis_options.yaml b/media_key_detector/media_key_detector_linux/analysis_options.yaml new file mode 100644 index 000000000..799268d3e --- /dev/null +++ b/media_key_detector/media_key_detector_linux/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/media_key_detector/media_key_detector_linux/lib/media_key_detector_linux.dart b/media_key_detector/media_key_detector_linux/lib/media_key_detector_linux.dart new file mode 100644 index 000000000..e84dd9c71 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/lib/media_key_detector_linux.dart @@ -0,0 +1 @@ +export 'src/media_key_detector_linux.dart'; diff --git a/media_key_detector/media_key_detector_linux/lib/src/media_key_detector_linux.dart b/media_key_detector/media_key_detector_linux/lib/src/media_key_detector_linux.dart new file mode 100644 index 000000000..3090337e1 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/lib/src/media_key_detector_linux.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +/// The Linux implementation of [MediaKeyDetectorPlatform]. +class MediaKeyDetectorLinux extends MediaKeyDetectorPlatform { + bool _isPlaying = false; + + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('media_key_detector_linux'); + + /// Registers this class as the default instance of [MediaKeyDetectorPlatform] + static void registerWith() { + MediaKeyDetectorPlatform.instance = MediaKeyDetectorLinux(); + } + + @override + void initialize() { + ServicesBinding.instance.keyboard.addHandler(defaultHandler); + } + + @override + Future getPlatformName() { + return methodChannel.invokeMethod('getPlatformName'); + } + + @override + Future getIsPlaying() async { + return _isPlaying; + } + + @override + Future setIsPlaying({required bool isPlaying}) async { + _isPlaying = isPlaying; + } +} diff --git a/media_key_detector/media_key_detector_linux/linux/.gitignore b/media_key_detector/media_key_detector_linux/linux/.gitignore new file mode 100644 index 000000000..b3eb2be16 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/linux/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/media_key_detector/media_key_detector_linux/linux/CMakeLists.txt b/media_key_detector/media_key_detector_linux/linux/CMakeLists.txt new file mode 100644 index 000000000..2d2f3ee4a --- /dev/null +++ b/media_key_detector/media_key_detector_linux/linux/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "media_key_detector_linux") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "${PROJECT_NAME}_plugin") + +list(APPEND PLUGIN_SOURCES + "media_key_detector_linux_plugin.cc" +) + +add_library(${PLUGIN_NAME} SHARED + ${PLUGIN_SOURCES} +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) diff --git a/media_key_detector/media_key_detector_linux/linux/include/media_key_detector_linux/media_key_detector_plugin.h b/media_key_detector/media_key_detector_linux/linux/include/media_key_detector_linux/media_key_detector_plugin.h new file mode 100644 index 000000000..00d790b51 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/linux/include/media_key_detector_linux/media_key_detector_plugin.h @@ -0,0 +1,25 @@ +#ifndef FLUTTER_PLUGIN_MEDIA_KEY_DETECTOR_LINUX_PLUGIN_H_ +#define FLUTTER_PLUGIN_MEDIA_KEY_DETECTOR_LINUX_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +G_DECLARE_FINAL_TYPE(FlMediaKeyDetectorPlugin, fl_media_key_detector_plugin, FL, + MEDIA_KEY_DETECTOR_PLUGIN, GObject) + +FLUTTER_PLUGIN_EXPORT FlMediaKeyDetectorPlugin* fl_media_key_detector_plugin_new( + FlPluginRegistrar* registrar); + +FLUTTER_PLUGIN_EXPORT void media_key_detector_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_MEDIA_KEY_DETECTOR_LINUX_PLUGIN_H_ \ No newline at end of file diff --git a/media_key_detector/media_key_detector_linux/linux/media_key_detector_linux_plugin.cc b/media_key_detector/media_key_detector_linux/linux/media_key_detector_linux_plugin.cc new file mode 100644 index 000000000..1d0b1dc04 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/linux/media_key_detector_linux_plugin.cc @@ -0,0 +1,68 @@ +#include "include/media_key_detector_linux/media_key_detector_plugin.h" + +#include +#include +#include + +#include + +const char kChannelName[] = "media_key_detector_linux"; +const char kGetPlatformName[] = "getPlatformName"; + +struct _FlMediaKeyDetectorPlugin { + GObject parent_instance; + + FlPluginRegistrar* registrar; + + // Connection to Flutter engine. + FlMethodChannel* channel; +}; + +G_DEFINE_TYPE(FlMediaKeyDetectorPlugin, fl_media_key_detector_plugin, g_object_get_type()) + +// Called when a method call is received from Flutter. +static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, + gpointer user_data) { + const gchar* method = fl_method_call_get_name(method_call); + + g_autoptr(FlMethodResponse) response = nullptr; + if (strcmp(method, kGetPlatformName) == 0) + response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_string("Linux"))); + else + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + + g_autoptr(GError) error = nullptr; + if (!fl_method_call_respond(method_call, response, &error)) + g_warning("Failed to send method call response: %s", error->message); +} + +static void fl_media_key_detector_plugin_dispose(GObject* object) { + G_OBJECT_CLASS(fl_media_key_detector_plugin_parent_class)->dispose(object); +} + +static void fl_media_key_detector_plugin_class_init(FlMediaKeyDetectorPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = fl_media_key_detector_plugin_dispose; +} + +FlMediaKeyDetectorPlugin* fl_media_key_detector_plugin_new(FlPluginRegistrar* registrar) { + FlMediaKeyDetectorPlugin* self = FL_MEDIA_KEY_DETECTOR_PLUGIN( + g_object_new(fl_media_key_detector_plugin_get_type(), nullptr)); + + self->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar)); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->channel = + fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), + kChannelName, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(self->channel, method_call_cb, + g_object_ref(self), g_object_unref); + + return self; +} + +static void fl_media_key_detector_plugin_init(FlMediaKeyDetectorPlugin* self) {} + +void media_key_detector_plugin_register_with_registrar(FlPluginRegistrar* registrar) { + FlMediaKeyDetectorPlugin* plugin = fl_media_key_detector_plugin_new(registrar); + g_object_unref(plugin); +} diff --git a/media_key_detector/media_key_detector_linux/pubspec.yaml b/media_key_detector/media_key_detector_linux/pubspec.yaml new file mode 100644 index 000000000..2371e7eab --- /dev/null +++ b/media_key_detector/media_key_detector_linux/pubspec.yaml @@ -0,0 +1,27 @@ +name: media_key_detector_linux +description: Linux implementation of the media_key_detector plugin +version: 0.0.2 +homepage: https://github.com/holotrek/media_key_detector + +environment: + sdk: "^3.3.0" + flutter: ">=3.19.3" + +flutter: + plugin: + implements: media_key_detector + platforms: + linux: + pluginClass: MediaKeyDetectorPlugin + dartPluginClass: MediaKeyDetectorLinux + +dependencies: + flutter: + sdk: flutter + media_key_detector_platform_interface: + path: ../media_key_detector_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 diff --git a/media_key_detector/media_key_detector_linux/test/media_key_detector_linux_test.dart b/media_key_detector/media_key_detector_linux/test/media_key_detector_linux_test.dart new file mode 100644 index 000000000..8a849e8b5 --- /dev/null +++ b/media_key_detector/media_key_detector_linux/test/media_key_detector_linux_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_key_detector_linux/media_key_detector_linux.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MediaKeyDetectorLinux', () { + const kPlatformName = 'Linux'; + late MediaKeyDetectorLinux mediaKeyDetector; + late List log; + + setUp(() async { + mediaKeyDetector = MediaKeyDetectorLinux(); + + log = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(mediaKeyDetector.methodChannel, + (methodCall) async { + log.add(methodCall); + switch (methodCall.method) { + case 'getPlatformName': + return kPlatformName; + default: + return null; + } + }); + }); + + test('can be registered', () { + MediaKeyDetectorLinux.registerWith(); + expect(MediaKeyDetectorPlatform.instance, isA()); + }); + + test('getPlatformName returns correct name', () async { + final name = await mediaKeyDetector.getPlatformName(); + expect( + log, + [isMethodCall('getPlatformName', arguments: null)], + ); + expect(name, equals(kPlatformName)); + }); + }); +} diff --git a/media_key_detector/media_key_detector_macos/.gitignore b/media_key_detector/media_key_detector_macos/.gitignore new file mode 100644 index 000000000..53e92cc41 --- /dev/null +++ b/media_key_detector/media_key_detector_macos/.gitignore @@ -0,0 +1,3 @@ +.packages +.flutter-plugins +pubspec.lock diff --git a/media_key_detector/media_key_detector_macos/CHANGELOG.md b/media_key_detector/media_key_detector_macos/CHANGELOG.md new file mode 100644 index 000000000..589555b74 --- /dev/null +++ b/media_key_detector/media_key_detector_macos/CHANGELOG.md @@ -0,0 +1,7 @@ +# 0.0.2 + +- TBD + +# 0.0.1 + +- Initial Release diff --git a/media_key_detector/media_key_detector_macos/LICENSE b/media_key_detector/media_key_detector_macos/LICENSE new file mode 100644 index 000000000..a4110b7e0 --- /dev/null +++ b/media_key_detector/media_key_detector_macos/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Evan Kaiser + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/media_key_detector/media_key_detector_macos/README.md b/media_key_detector/media_key_detector_macos/README.md new file mode 100644 index 000000000..9645477ca --- /dev/null +++ b/media_key_detector/media_key_detector_macos/README.md @@ -0,0 +1,14 @@ +# media_key_detector_macos + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] + +The macos implementation of `media_key_detector`. + +## Usage + +This package is [endorsed][endorsed_link], which means you can simply use `media_key_detector` +normally. This package will be automatically included in your app when you do. + +[endorsed_link]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis diff --git a/media_key_detector/media_key_detector_macos/analysis_options.yaml b/media_key_detector/media_key_detector_macos/analysis_options.yaml new file mode 100644 index 000000000..799268d3e --- /dev/null +++ b/media_key_detector/media_key_detector_macos/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/media_key_detector/media_key_detector_macos/lib/media_key_detector_macos.dart b/media_key_detector/media_key_detector_macos/lib/media_key_detector_macos.dart new file mode 100644 index 000000000..17f959a49 --- /dev/null +++ b/media_key_detector/media_key_detector_macos/lib/media_key_detector_macos.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +/// The MacOS implementation of [MediaKeyDetectorPlatform]. +class MediaKeyDetectorMacOS extends MediaKeyDetectorPlatform { + final _eventChannel = const EventChannel('media_key_detector_macos_events'); + + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('media_key_detector_macos'); + + /// Registers this class as the default instance of [MediaKeyDetectorPlatform] + static void registerWith() { + MediaKeyDetectorPlatform.instance = MediaKeyDetectorMacOS(); + } + + @override + void initialize() { + _eventChannel.receiveBroadcastStream().listen((event) { + final keyIdx = event as int; + MediaKey? key; + if (keyIdx > -1 && keyIdx < MediaKey.values.length) { + key = MediaKey.values[keyIdx]; + } + if (key != null) { + triggerListeners(key); + } + }); + } + + @override + Future getPlatformName() { + return methodChannel.invokeMethod('getPlatformName'); + } + + @override + Future getIsPlaying() async { + final isPlaying = await methodChannel.invokeMethod('getIsPlaying'); + return isPlaying ?? false; + } + + @override + Future setIsPlaying({required bool isPlaying}) { + return methodChannel.invokeMethod('setIsPlaying', { + 'isPlaying': isPlaying, + }); + } +} diff --git a/media_key_detector/media_key_detector_macos/macos/Classes/MediaKeyDetectorPlugin.swift b/media_key_detector/media_key_detector_macos/macos/Classes/MediaKeyDetectorPlugin.swift new file mode 100644 index 000000000..bffe46ae7 --- /dev/null +++ b/media_key_detector/media_key_detector_macos/macos/Classes/MediaKeyDetectorPlugin.swift @@ -0,0 +1,106 @@ +import FlutterMacOS +import MediaPlayer +import Foundation + +public class MediaKeyDetectorPlugin: NSObject, FlutterPlugin, FlutterAppLifecycleDelegate { + private var mediaKeyHandler = MediaKeyHandler() + var isNowPlayable = false + var playPauseTarget: Any?; + var previousTrackTarget: Any?; + var nextTrackTarget: Any?; + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "media_key_detector_macos", + binaryMessenger: registrar.messenger) + + // Setup an event channel in addition to the method channel. This will react to native events and then notify Dart code. + let eventChannel = FlutterEventChannel( + name: "media_key_detector_macos_events", + binaryMessenger: registrar.messenger) + + let instance = MediaKeyDetectorPlugin() + + // Add the MediaKeyHandler to the event channel stream + eventChannel.setStreamHandler(instance.mediaKeyHandler) + + // Register the instance to listen to method calls from Dart + registrar.addMethodCallDelegate(instance, channel: channel) + + // Register the instance to handle the FlutterAppLifecycle + registrar.addApplicationDelegate(instance) + } + + func makeNowPlayable() throws { + if (isNowPlayable) { + return + } + + let commandCenter = MPRemoteCommandCenter.shared() + self.playPauseTarget = commandCenter.togglePlayPauseCommand.addTarget { (commandEvent) -> MPRemoteCommandHandlerStatus in + self.mediaKeyHandler.onKeyEvent(key: 0); + MPNowPlayingInfoCenter.default().playbackState = MPNowPlayingInfoCenter.default().playbackState == .playing ? .paused : .playing + return .success + } + self.previousTrackTarget = commandCenter.previousTrackCommand.addTarget { (commandEvent) -> MPRemoteCommandHandlerStatus in + self.mediaKeyHandler.onKeyEvent(key: 1); + return .success + } + self.nextTrackTarget = commandCenter.nextTrackCommand.addTarget { (commandEvent) -> MPRemoteCommandHandlerStatus in + self.mediaKeyHandler.onKeyEvent(key: 2); + return .success + } + MPNowPlayingInfoCenter.default().playbackState = .playing + MPNowPlayingInfoCenter.default().playbackState = .paused + isNowPlayable = true + } + + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformName": + result("MacOS") + case "getIsPlaying": + result(MPNowPlayingInfoCenter.default().playbackState == .playing) + case "setIsPlaying": + guard let args = call.arguments as? Dictionary else {return} + let isPlaying = args["isPlaying"] as! Bool + if (isPlaying) { + do { + try makeNowPlayable() + } catch { + print(error) + } + MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused + } else { + + let commandCenter = MPRemoteCommandCenter.shared() + commandCenter.togglePlayPauseCommand.removeTarget(self.playPauseTarget) + commandCenter.togglePlayPauseCommand.removeTarget(self.previousTrackTarget) + commandCenter.togglePlayPauseCommand.removeTarget(self.nextTrackTarget) + MPNowPlayingInfoCenter.default().playbackState = .stopped + } + default: + result(FlutterMethodNotImplemented) + } + } +} + +class MediaKeyHandler: NSObject, FlutterStreamHandler { + // Declare our eventSink, it will be initialized later + private var eventSink: FlutterEventSink? + + func onKeyEvent(key: Int32) { + self.eventSink?(key) + } + + func onListen(withArguments arguments: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = eventSink + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } +} diff --git a/media_key_detector/media_key_detector_macos/macos/media_key_detector_macos.podspec b/media_key_detector/media_key_detector_macos/macos/media_key_detector_macos.podspec new file mode 100644 index 000000000..40e61f7d1 --- /dev/null +++ b/media_key_detector/media_key_detector_macos/macos/media_key_detector_macos.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'media_key_detector_macos' + s.version = '0.0.1' + s.summary = 'A macOS implementation of the media_key_detector plugin.' + s.description = <<-DESC + A macOS implementation of the media_key_detector plugin. + DESC + s.homepage = 'http://example.com' + s.license = { :type => 'BSD', :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx + s.osx.deployment_target = '10.11' + s.swift_version = '5.0' +end + diff --git a/media_key_detector/media_key_detector_macos/pubspec.yaml b/media_key_detector/media_key_detector_macos/pubspec.yaml new file mode 100644 index 000000000..98b5206b9 --- /dev/null +++ b/media_key_detector/media_key_detector_macos/pubspec.yaml @@ -0,0 +1,27 @@ +name: media_key_detector_macos +description: MacOS implementation of the media_key_detector plugin +version: 0.0.2 +homepage: https://github.com/holotrek/media_key_detector + +environment: + sdk: "^3.3.0" + flutter: ">=3.19.3" + +flutter: + plugin: + implements: media_key_detector + platforms: + macos: + pluginClass: MediaKeyDetectorPlugin + dartPluginClass: MediaKeyDetectorMacOS + +dependencies: + flutter: + sdk: flutter + media_key_detector_platform_interface: + path: ../media_key_detector_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 diff --git a/media_key_detector/media_key_detector_macos/test/media_key_detector_macos_test.dart b/media_key_detector/media_key_detector_macos/test/media_key_detector_macos_test.dart new file mode 100644 index 000000000..e0b746cf1 --- /dev/null +++ b/media_key_detector/media_key_detector_macos/test/media_key_detector_macos_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_key_detector_macos/media_key_detector_macos.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MediaKeyDetectorMacOS', () { + const kPlatformName = 'MacOS'; + late MediaKeyDetectorMacOS mediaKeyDetector; + late List log; + + setUp(() async { + mediaKeyDetector = MediaKeyDetectorMacOS(); + + log = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(mediaKeyDetector.methodChannel, + (methodCall) async { + log.add(methodCall); + switch (methodCall.method) { + case 'getPlatformName': + return kPlatformName; + default: + return null; + } + }); + }); + + test('can be registered', () { + MediaKeyDetectorMacOS.registerWith(); + expect(MediaKeyDetectorPlatform.instance, isA()); + }); + + test('getPlatformName returns correct name', () async { + final name = await mediaKeyDetector.getPlatformName(); + expect( + log, + [isMethodCall('getPlatformName', arguments: null)], + ); + expect(name, equals(kPlatformName)); + }); + }); +} diff --git a/media_key_detector/media_key_detector_platform_interface/CHANGELOG.md b/media_key_detector/media_key_detector_platform_interface/CHANGELOG.md new file mode 100644 index 000000000..589555b74 --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/CHANGELOG.md @@ -0,0 +1,7 @@ +# 0.0.2 + +- TBD + +# 0.0.1 + +- Initial Release diff --git a/media_key_detector/media_key_detector_platform_interface/LICENSE b/media_key_detector/media_key_detector_platform_interface/LICENSE new file mode 100644 index 000000000..a4110b7e0 --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Evan Kaiser + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/media_key_detector/media_key_detector_platform_interface/README.md b/media_key_detector/media_key_detector_platform_interface/README.md new file mode 100644 index 000000000..d4ef2ae11 --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/README.md @@ -0,0 +1,14 @@ +# media_key_detector_platform_interface + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] + +A common platform interface for the `media_key_detector` plugin. + +This interface allows platform-specific implementations of the `media_key_detector` plugin, as well as the plugin itself, to ensure they are supporting the same interface. + +# Usage + +To implement a new platform-specific implementation of `media_key_detector`, extend `MediaKeyDetectorPlatform` with an implementation that performs the platform-specific behavior. + +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis \ No newline at end of file diff --git a/media_key_detector/media_key_detector_platform_interface/analysis_options.yaml b/media_key_detector/media_key_detector_platform_interface/analysis_options.yaml new file mode 100644 index 000000000..799268d3e --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/media_key_detector/media_key_detector_platform_interface/lib/media_key_detector_platform_interface.dart b/media_key_detector/media_key_detector_platform_interface/lib/media_key_detector_platform_interface.dart new file mode 100644 index 000000000..a6d557217 --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/lib/media_key_detector_platform_interface.dart @@ -0,0 +1,91 @@ +/// The base interface for media_key_detector +library media_key_detector_platform_interface; + +import 'package:flutter/services.dart'; +import 'package:media_key_detector_platform_interface/src/media_key.dart'; +import 'package:media_key_detector_platform_interface/src/method_channel_media_key_detector.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +export './src/exports.dart'; + +/// The interface that implementations of media_key_detector must implement. +/// +/// Platform implementations should extend this class +/// rather than implement it as `MediaKeyDetector`. +/// Extending this class (using `extends`) ensures that the subclass will get +/// the default implementation, while platform implementations that `implements` +/// this interface will be broken by newly added [MediaKeyDetectorPlatform] +/// methods. +abstract class MediaKeyDetectorPlatform extends PlatformInterface { + /// Constructs a MediaKeyDetectorPlatform. + MediaKeyDetectorPlatform() : super(token: _token); + + static final Object _token = Object(); + + static MediaKeyDetectorPlatform _instance = MethodChannelMediaKeyDetector(); + + /// The default instance of [MediaKeyDetectorPlatform] to use. + /// + /// Defaults to [MethodChannelMediaKeyDetector]. + static MediaKeyDetectorPlatform get instance => _instance; + + /// Platform-specific plugins should set this with their own platform-specific + /// class that extends [MediaKeyDetectorPlatform] when they register + /// themselves. + static set instance(MediaKeyDetectorPlatform instance) { + PlatformInterface.verify(instance, _token); + _instance = instance; + } + + /// Return the current platform name. + Future getPlatformName(); + + /// Get whether the active audio player is currently playing. + Future getIsPlaying(); + + /// Set whether the active audio player is currently playing. + Future setIsPlaying({required bool isPlaying}); + + /// Indicates that the platform should initialize. + void initialize(); + + final List _listeners = []; + + /// Listen for the media key event + void addListener(void Function(MediaKey mediaKey) listener) { + if (!_listeners.contains(listener)) { + _listeners.add(listener); + } + } + + /// Remove the previously registered listener + void removeListener(void Function(MediaKey mediaKey) listener) { + _listeners.remove(listener); + } + + /// Trigger all listeners to indicate that the specified media key was pressed + void triggerListeners(MediaKey mediaKey) { + for (final l in _listeners) { + l(mediaKey); + } + } + + final Map _keyMap = { + LogicalKeyboardKey.mediaPlay: MediaKey.playPause, + LogicalKeyboardKey.mediaRewind: MediaKey.rewind, + LogicalKeyboardKey.mediaFastForward: MediaKey.fastForward, + LogicalKeyboardKey.audioVolumeUp: MediaKey.volumeUp, + LogicalKeyboardKey.audioVolumeDown: MediaKey.volumeDown, + }; + + /// The default handler to use if this platform doesn't need to implement any + /// platform specific code to listen for the media key event + bool defaultHandler(KeyEvent event) { + if (_keyMap.containsKey(event.logicalKey)) { + final key = _keyMap[event.logicalKey]!; + triggerListeners(key); + return true; + } + return false; + } +} diff --git a/media_key_detector/media_key_detector_platform_interface/lib/src/exports.dart b/media_key_detector/media_key_detector_platform_interface/lib/src/exports.dart new file mode 100644 index 000000000..4ce730ab9 --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/lib/src/exports.dart @@ -0,0 +1,3 @@ +export './media_key.dart' show MediaKey; +export './method_channel_media_key_detector.dart' + show MethodChannelMediaKeyDetector; diff --git a/media_key_detector/media_key_detector_platform_interface/lib/src/media_key.dart b/media_key_detector/media_key_detector_platform_interface/lib/src/media_key.dart new file mode 100644 index 000000000..1a2d45eec --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/lib/src/media_key.dart @@ -0,0 +1,17 @@ +/// An enum representing the media key detected +enum MediaKey { + /// The play/pause media button + playPause, + + /// The rewind media button + rewind, + + /// The fast-forward media button + fastForward, + + /// The volume up button + volumeUp, + + /// The volume down button + volumeDown, +} diff --git a/media_key_detector/media_key_detector_platform_interface/lib/src/method_channel_media_key_detector.dart b/media_key_detector/media_key_detector_platform_interface/lib/src/method_channel_media_key_detector.dart new file mode 100644 index 000000000..d0e4a8cdf --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/lib/src/method_channel_media_key_detector.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/services.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +/// An implementation of [MediaKeyDetectorPlatform] that uses method channels. +class MethodChannelMediaKeyDetector extends MediaKeyDetectorPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('media_key_detector'); + + @override + Future getPlatformName() { + return methodChannel.invokeMethod('getPlatformName'); + } + + @override + Future getIsPlaying() async { + final isPlaying = await methodChannel.invokeMethod('getIsPlaying'); + return isPlaying ?? false; + } + + @override + Future setIsPlaying({required bool isPlaying}) { + return methodChannel.invokeMethod('setIsPlaying', [isPlaying]); + } + + @override + void initialize() {} +} diff --git a/media_key_detector/media_key_detector_platform_interface/pubspec.yaml b/media_key_detector/media_key_detector_platform_interface/pubspec.yaml new file mode 100644 index 000000000..ae2e48c83 --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/pubspec.yaml @@ -0,0 +1,18 @@ +name: media_key_detector_platform_interface +description: A common platform interface for the media_key_detector plugin. +version: 0.0.2 +homepage: https://github.com/holotrek/media_key_detector + +environment: + sdk: "^3.3.0" + flutter: ">=3.19.3" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.1.0 + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 diff --git a/media_key_detector/media_key_detector_platform_interface/test/media_key_detector_platform_interface_test.dart b/media_key_detector/media_key_detector_platform_interface/test/media_key_detector_platform_interface_test.dart new file mode 100644 index 000000000..d7868e308 --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/test/media_key_detector_platform_interface_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +class MediaKeyDetectorMock extends MediaKeyDetectorPlatform { + static const mockPlatformName = 'Mock'; + bool isPlaying = false; + + @override + Future getPlatformName() async => mockPlatformName; + + @override + void addListener(void Function(MediaKey mediaKey) listener) {} + + @override + Future getIsPlaying() async { + return isPlaying; + } + + @override + Future setIsPlaying({required bool isPlaying}) async { + this.isPlaying = isPlaying; + } + + @override + void initialize() {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('MediaKeyDetectorPlatformInterface', () { + late MediaKeyDetectorPlatform mediaKeyDetectorPlatform; + + setUp(() { + mediaKeyDetectorPlatform = MediaKeyDetectorMock(); + MediaKeyDetectorPlatform.instance = mediaKeyDetectorPlatform; + }); + + group('getPlatformName', () { + test('returns correct name', () async { + expect( + await MediaKeyDetectorPlatform.instance.getPlatformName(), + equals(MediaKeyDetectorMock.mockPlatformName), + ); + }); + }); + }); +} diff --git a/media_key_detector/media_key_detector_platform_interface/test/src/method_channel_media_key_detector_test.dart b/media_key_detector/media_key_detector_platform_interface/test/src/method_channel_media_key_detector_test.dart new file mode 100644 index 000000000..e44b16f61 --- /dev/null +++ b/media_key_detector/media_key_detector_platform_interface/test/src/method_channel_media_key_detector_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const kPlatformName = 'platformName'; + + group('$MethodChannelMediaKeyDetector', () { + late MethodChannelMediaKeyDetector methodChannelMediaKeyDetector; + final log = []; + + setUp(() async { + methodChannelMediaKeyDetector = MethodChannelMediaKeyDetector(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + methodChannelMediaKeyDetector.methodChannel, + (methodCall) async { + log.add(methodCall); + switch (methodCall.method) { + case 'getPlatformName': + return kPlatformName; + default: + return null; + } + }, + ); + }); + + tearDown(log.clear); + + test('getPlatformName', () async { + final platformName = + await methodChannelMediaKeyDetector.getPlatformName(); + expect( + log, + [isMethodCall('getPlatformName', arguments: null)], + ); + expect(platformName, equals(kPlatformName)); + }); + }); +} diff --git a/media_key_detector/media_key_detector_windows/.gitignore b/media_key_detector/media_key_detector_windows/.gitignore new file mode 100644 index 000000000..9be145fde --- /dev/null +++ b/media_key_detector/media_key_detector_windows/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/media_key_detector/media_key_detector_windows/.metadata b/media_key_detector/media_key_detector_windows/.metadata new file mode 100644 index 000000000..8c15ad72b --- /dev/null +++ b/media_key_detector/media_key_detector_windows/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b + channel: stable + +project_type: plugin diff --git a/media_key_detector/media_key_detector_windows/CHANGELOG.md b/media_key_detector/media_key_detector_windows/CHANGELOG.md new file mode 100644 index 000000000..406c80b24 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/CHANGELOG.md @@ -0,0 +1,11 @@ +# 0.0.2 + +- Implement global media key detection using Windows RegisterHotKey API +- Add event channel support for media key events +- Media keys now work even when app is not focused +- Improved error handling for hotkey registration +- Added support for volume up and volume down hotkeys + +# 0.0.1 + +- Initial Release diff --git a/media_key_detector/media_key_detector_windows/LICENSE b/media_key_detector/media_key_detector_windows/LICENSE new file mode 100644 index 000000000..a4110b7e0 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2024 Evan Kaiser + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/media_key_detector/media_key_detector_windows/README.md b/media_key_detector/media_key_detector_windows/README.md new file mode 100644 index 000000000..0eed39893 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/README.md @@ -0,0 +1,63 @@ +# media_key_detector_windows + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] + +The windows implementation of `media_key_detector`. + +## Features + +This plugin provides global media key detection on Windows using the Windows `RegisterHotKey` API. This allows your application to respond to media keys (play/pause, next track, previous track, volume up, volume down) even when it's not the focused application. + +### Supported Media Keys + +- Play/Pause (VK_MEDIA_PLAY_PAUSE) +- Next Track (VK_MEDIA_NEXT_TRACK) +- Previous Track (VK_MEDIA_PREV_TRACK) +- Volume Up (VK_VOLUME_UP) +- Volume Down (VK_VOLUME_DOWN) + +### Implementation Details + +The plugin uses: +- `RegisterHotKey` Windows API for global hotkey registration +- Event channels for communicating media key events to Dart +- Window message handlers to process WM_HOTKEY messages + +Hotkeys are registered when `setIsPlaying(true)` is called and automatically unregistered when `setIsPlaying(false)` is called or when the plugin is destroyed. + +## Usage + +This package is [endorsed][endorsed_link], which means you can simply use `media_key_detector` +normally. This package will be automatically included in your app when you do. + +```dart +import 'package:media_key_detector/media_key_detector.dart'; + +// Enable media key detection +mediaKeyDetector.setIsPlaying(isPlaying: true); + +// Listen for media key events +mediaKeyDetector.addListener((MediaKey key) { + switch (key) { + case MediaKey.playPause: + // Handle play/pause + break; + case MediaKey.fastForward: + // Handle next track + break; + case MediaKey.rewind: + // Handle previous track + break; + case MediaKey.volumeUp: + // Handle volume up + break; + case MediaKey.volumeDown: + // Handle volume down + break; + } +}); +``` + +[endorsed_link]: https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis diff --git a/media_key_detector/media_key_detector_windows/analysis_options.yaml b/media_key_detector/media_key_detector_windows/analysis_options.yaml new file mode 100644 index 000000000..799268d3e --- /dev/null +++ b/media_key_detector/media_key_detector_windows/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/media_key_detector/media_key_detector_windows/lib/media_key_detector_windows.dart b/media_key_detector/media_key_detector_windows/lib/media_key_detector_windows.dart new file mode 100644 index 000000000..e8568237f --- /dev/null +++ b/media_key_detector/media_key_detector_windows/lib/media_key_detector_windows.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; + +/// The Windows implementation of [MediaKeyDetectorPlatform]. +class MediaKeyDetectorWindows extends MediaKeyDetectorPlatform { + bool _isPlaying = false; + final _eventChannel = const EventChannel('media_key_detector_windows_events'); + + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('media_key_detector_windows'); + + /// Registers this class as the default instance of [MediaKeyDetectorPlatform] + static void registerWith() { + MediaKeyDetectorPlatform.instance = MediaKeyDetectorWindows(); + } + + @override + void initialize() { + _eventChannel.receiveBroadcastStream().listen((event) { + final keyIdx = event as int; + MediaKey? key; + if (keyIdx > -1 && keyIdx < MediaKey.values.length) { + key = MediaKey.values[keyIdx]; + } + if (key != null) { + triggerListeners(key); + } + }); + } + + @override + Future getPlatformName() { + return methodChannel.invokeMethod('getPlatformName'); + } + + @override + Future getIsPlaying() async { + final isPlaying = await methodChannel.invokeMethod('getIsPlaying'); + return isPlaying ?? _isPlaying; + } + + @override + Future setIsPlaying({required bool isPlaying}) async { + _isPlaying = isPlaying; + await methodChannel.invokeMethod('setIsPlaying', {'isPlaying': isPlaying}); + } +} diff --git a/media_key_detector/media_key_detector_windows/pubspec.yaml b/media_key_detector/media_key_detector_windows/pubspec.yaml new file mode 100644 index 000000000..1627c5ba1 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/pubspec.yaml @@ -0,0 +1,27 @@ +name: media_key_detector_windows +description: Windows implementation of the media_key_detector plugin +version: 0.0.2 +homepage: https://github.com/holotrek/media_key_detector + +environment: + sdk: "^3.3.0" + flutter: ">=3.19.3" + +flutter: + plugin: + implements: media_key_detector + platforms: + windows: + pluginClass: MediaKeyDetectorWindows + dartPluginClass: MediaKeyDetectorWindows + +dependencies: + flutter: + sdk: flutter + media_key_detector_platform_interface: + path: ../media_key_detector_platform_interface + +dev_dependencies: + flutter_test: + sdk: flutter + very_good_analysis: ^5.1.0 diff --git a/media_key_detector/media_key_detector_windows/test/media_key_detector_windows_test.dart b/media_key_detector/media_key_detector_windows/test/media_key_detector_windows_test.dart new file mode 100644 index 000000000..e77443649 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/test/media_key_detector_windows_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:media_key_detector_platform_interface/media_key_detector_platform_interface.dart'; +import 'package:media_key_detector_windows/media_key_detector_windows.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MediaKeyDetectorWindows', () { + const kPlatformName = 'Windows'; + late MediaKeyDetectorWindows mediaKeyDetector; + late List log; + + setUp(() async { + mediaKeyDetector = MediaKeyDetectorWindows(); + + log = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(mediaKeyDetector.methodChannel, + (methodCall) async { + log.add(methodCall); + switch (methodCall.method) { + case 'getPlatformName': + return kPlatformName; + default: + return null; + } + }); + }); + + test('can be registered', () { + MediaKeyDetectorWindows.registerWith(); + expect(MediaKeyDetectorPlatform.instance, isA()); + }); + + test('getPlatformName returns correct name', () async { + final name = await mediaKeyDetector.getPlatformName(); + expect( + log, + [isMethodCall('getPlatformName', arguments: null)], + ); + expect(name, equals(kPlatformName)); + }); + }); +} diff --git a/media_key_detector/media_key_detector_windows/windows/.gitignore b/media_key_detector/media_key_detector_windows/windows/.gitignore new file mode 100644 index 000000000..b3eb2be16 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/media_key_detector/media_key_detector_windows/windows/CMakeLists.txt b/media_key_detector/media_key_detector_windows/windows/CMakeLists.txt new file mode 100644 index 000000000..28ec896f2 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/windows/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "media_key_detector_windows") +project(${PROJECT_NAME} LANGUAGES CXX) + +set(PLUGIN_NAME "${PROJECT_NAME}_plugin") + +add_library(${PLUGIN_NAME} SHARED + "media_key_detector_windows_plugin.cpp" + "include/media_key_detector_windows/media_key_detector_windows.h" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin +set(media_key_detector_bundled_libraries + "" + PARENT_SCOPE +) \ No newline at end of file diff --git a/media_key_detector/media_key_detector_windows/windows/include/media_key_detector_windows/media_key_detector_windows.h b/media_key_detector/media_key_detector_windows/windows/include/media_key_detector_windows/media_key_detector_windows.h new file mode 100644 index 000000000..3ce547bf8 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/windows/include/media_key_detector_windows/media_key_detector_windows.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_MEDIA_KEY_DETECTOR_WINDOWS_PLUGIN_H_ +#define FLUTTER_PLUGIN_MEDIA_KEY_DETECTOR_WINDOWS_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void MediaKeyDetectorWindowsRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_MEDIA_KEY_DETECTOR_WINDOWS_PLUGIN_H_ diff --git a/media_key_detector/media_key_detector_windows/windows/media_key_detector_windows_plugin.cpp b/media_key_detector/media_key_detector_windows/windows/media_key_detector_windows_plugin.cpp new file mode 100644 index 000000000..a1e993839 --- /dev/null +++ b/media_key_detector/media_key_detector_windows/windows/media_key_detector_windows_plugin.cpp @@ -0,0 +1,229 @@ +#include "include/media_key_detector_windows/media_key_detector_windows.h" + +// This must be included before many other Windows headers. +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +using flutter::EncodableValue; + +// Hotkey IDs for media keys +constexpr int HOTKEY_PLAY_PAUSE = 1; +constexpr int HOTKEY_NEXT_TRACK = 2; +constexpr int HOTKEY_PREV_TRACK = 3; +constexpr int HOTKEY_VOLUME_UP = 4; +constexpr int HOTKEY_VOLUME_DOWN = 5; + +class MediaKeyDetectorWindows : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + MediaKeyDetectorWindows(flutter::PluginRegistrarWindows *registrar); + + virtual ~MediaKeyDetectorWindows(); + + private: + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); + + // Register global hotkeys for media keys + void RegisterHotkeys(); + + // Unregister global hotkeys + void UnregisterHotkeys(); + + // Handle Windows messages + std::optional HandleWindowProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam); + + flutter::PluginRegistrarWindows *registrar_; + std::unique_ptr> event_sink_; + std::atomic is_playing_{false}; + int window_proc_id_ = -1; + bool hotkeys_registered_ = false; +}; + +// static +void MediaKeyDetectorWindows::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "media_key_detector_windows", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(registrar); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + // Set up event channel for media key events + auto event_channel = + std::make_unique>( + registrar->messenger(), "media_key_detector_windows_events", + &flutter::StandardMethodCodec::GetInstance()); + + auto event_handler = std::make_unique>( + [plugin_pointer = plugin.get()]( + const flutter::EncodableValue* arguments, + std::unique_ptr>&& events) + -> std::unique_ptr> { + plugin_pointer->event_sink_ = std::move(events); + return nullptr; + }, + [plugin_pointer = plugin.get()](const flutter::EncodableValue* arguments) + -> std::unique_ptr> { + plugin_pointer->event_sink_ = nullptr; + return nullptr; + }); + + event_channel->SetStreamHandler(std::move(event_handler)); + + registrar->AddPlugin(std::move(plugin)); +} + +MediaKeyDetectorWindows::MediaKeyDetectorWindows(flutter::PluginRegistrarWindows *registrar) + : registrar_(registrar) { + // Register a window procedure to handle hotkey messages + window_proc_id_ = registrar_->RegisterTopLevelWindowProcDelegate( + [this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + return HandleWindowProc(hwnd, message, wparam, lparam); + }); +} + +MediaKeyDetectorWindows::~MediaKeyDetectorWindows() { + UnregisterHotkeys(); + if (window_proc_id_ != -1) { + registrar_->UnregisterTopLevelWindowProcDelegate(window_proc_id_); + } +} + +void MediaKeyDetectorWindows::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) { + if (method_call.method_name().compare("getPlatformName") == 0) { + result->Success(EncodableValue("Windows")); + } else if (method_call.method_name().compare("getIsPlaying") == 0) { + result->Success(EncodableValue(is_playing_.load())); + } else if (method_call.method_name().compare("setIsPlaying") == 0) { + const auto* arguments = std::get_if(method_call.arguments()); + if (arguments) { + auto is_playing_it = arguments->find(EncodableValue("isPlaying")); + if (is_playing_it != arguments->end()) { + if (auto* is_playing = std::get_if(&is_playing_it->second)) { + is_playing_.store(*is_playing); + if (*is_playing) { + RegisterHotkeys(); + } else { + UnregisterHotkeys(); + } + result->Success(); + return; + } + } + } + result->Error("INVALID_ARGUMENT", "isPlaying argument is required"); + } else { + result->NotImplemented(); + } +} + +void MediaKeyDetectorWindows::RegisterHotkeys() { + if (hotkeys_registered_) { + return; + } + + HWND hwnd = registrar_->GetView()->GetNativeWindow(); + + // Register global hotkeys for media keys + // MOD_NOREPEAT prevents the hotkey from repeating when held down + bool play_pause_ok = RegisterHotKey(hwnd, HOTKEY_PLAY_PAUSE, MOD_NOREPEAT, VK_MEDIA_PLAY_PAUSE); + bool next_ok = RegisterHotKey(hwnd, HOTKEY_NEXT_TRACK, MOD_NOREPEAT, VK_MEDIA_NEXT_TRACK); + bool prev_ok = RegisterHotKey(hwnd, HOTKEY_PREV_TRACK, MOD_NOREPEAT, VK_MEDIA_PREV_TRACK); + bool vol_up_ok = RegisterHotKey(hwnd, HOTKEY_VOLUME_UP, MOD_NOREPEAT, VK_VOLUME_UP); + bool vol_down_ok = RegisterHotKey(hwnd, HOTKEY_VOLUME_DOWN, MOD_NOREPEAT, VK_VOLUME_DOWN); + + // If all registrations succeeded, mark as registered + // If any failed, unregister the successful ones to maintain consistent state + if (play_pause_ok && next_ok && prev_ok && vol_up_ok && vol_down_ok) { + hotkeys_registered_ = true; + } else { + // Clean up any successful registrations + if (play_pause_ok) UnregisterHotKey(hwnd, HOTKEY_PLAY_PAUSE); + if (next_ok) UnregisterHotKey(hwnd, HOTKEY_NEXT_TRACK); + if (prev_ok) UnregisterHotKey(hwnd, HOTKEY_PREV_TRACK); + if (vol_up_ok) UnregisterHotKey(hwnd, HOTKEY_VOLUME_UP); + if (vol_down_ok) UnregisterHotKey(hwnd, HOTKEY_VOLUME_DOWN); + } +} + +void MediaKeyDetectorWindows::UnregisterHotkeys() { + if (!hotkeys_registered_) { + return; + } + + HWND hwnd = registrar_->GetView()->GetNativeWindow(); + + UnregisterHotKey(hwnd, HOTKEY_PLAY_PAUSE); + UnregisterHotKey(hwnd, HOTKEY_NEXT_TRACK); + UnregisterHotKey(hwnd, HOTKEY_PREV_TRACK); + UnregisterHotKey(hwnd, HOTKEY_VOLUME_UP); + UnregisterHotKey(hwnd, HOTKEY_VOLUME_DOWN); + + hotkeys_registered_ = false; +} + +std::optional MediaKeyDetectorWindows::HandleWindowProc( + HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { + if (message == WM_HOTKEY && event_sink_) { + int key_index = -1; + + // Map hotkey ID to media key index + switch (wparam) { + case HOTKEY_PLAY_PAUSE: + key_index = 0; // MediaKey.playPause + break; + case HOTKEY_PREV_TRACK: + key_index = 1; // MediaKey.rewind + break; + case HOTKEY_NEXT_TRACK: + key_index = 2; // MediaKey.fastForward + break; + case HOTKEY_VOLUME_UP: + key_index = 3; // MediaKey.volumeUp + break; + case HOTKEY_VOLUME_DOWN: + key_index = 4; // MediaKey.volumeDown + break; + } + + if (key_index >= 0) { + event_sink_->Success(EncodableValue(key_index)); + } + + return 0; + } + + return std::nullopt; +} + +} // namespace + +void MediaKeyDetectorWindowsRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + MediaKeyDetectorWindows::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/prop b/prop new file mode 160000 index 000000000..9f8e503d2 --- /dev/null +++ b/prop @@ -0,0 +1 @@ +Subproject commit 9f8e503d2592244624c3da2c74084c0187333d7c diff --git a/prop_public/.gitignore b/prop_public/.gitignore new file mode 100644 index 000000000..dd5eb9895 --- /dev/null +++ b/prop_public/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/prop_public/.metadata b/prop_public/.metadata new file mode 100644 index 000000000..685c30f14 --- /dev/null +++ b/prop_public/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" + channel: "stable" + +project_type: package diff --git a/prop_public/CHANGELOG.md b/prop_public/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/prop_public/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/prop_public/LICENSE b/prop_public/LICENSE new file mode 100644 index 000000000..f11003a9c --- /dev/null +++ b/prop_public/LICENSE @@ -0,0 +1,92 @@ + +NON-COMMERCIAL SOFTWARE LICENSE AGREEMENT + +Version 1.0 + +Copyright (c) 2026 OpenBikeControl UG (haftungsbeschränkt). +All rights reserved. + +⸻ + +1. Definitions + +“Software” means the source code, object code, binaries, and associated documentation made available by the Licensor under this License. + +“Commercial Use” means any use of the Software, directly or indirectly, that is intended for or results in: + • monetary compensation, + • sale, licensing, or subscription fees, + • advertising or sponsorship revenue, + • inclusion in a product or service that is sold or monetized, + • distribution through paid applications or application marketplaces. + +“Licensor” means the copyright holder. + +⸻ + +2. Grant of License + +Subject to the terms of this License, the Licensor grants you a non-exclusive, non-transferable, revocable license to: + • use the Software for personal, educational, or internal evaluation purposes only; + • modify the Software for non-commercial purposes; + • redistribute the Software only in source form, free of charge, and only under this same License. + +⸻ + +3. Restrictions + +You may not, without prior written permission from the Licensor: + • use the Software for any Commercial Use; + • distribute the Software as part of a paid or monetized product or service; + • distribute the Software via application marketplaces (including but not limited to Apple App Store or Google Play) where the application itself or related services are monetized; + • sublicense, sell, rent, or lease the Software. + +⸻ + +4. Attribution + +All copies and derivative works must retain: + • this License text; + • all existing copyright notices. + +⸻ + +5. No Patent License + +This License does not grant any patent rights. + +⸻ + +6. Termination + +Any violation of this License automatically terminates your rights under this License. + +Upon termination, you must cease all use and distribution of the Software. + +⸻ + +7. Disclaimer of Warranty + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND. + +⸻ + +8. Limitation of Liability + +IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY DAMAGES ARISING FROM THE USE OF THE SOFTWARE. + +⸻ + +9. Governing Law + +This License shall be governed by the laws of [YOUR COUNTRY], excluding conflict-of-law rules. + +⸻ + +10. Commercial Licensing + +Commercial use is available under separate commercial license terms. +Contact: jonas@openbikecontrol.org + +⸻ + +End of License diff --git a/prop_public/README.md b/prop_public/README.md new file mode 100644 index 000000000..63279c1f5 --- /dev/null +++ b/prop_public/README.md @@ -0,0 +1 @@ +This is a stub package - contact me if you need the full implementation. diff --git a/prop_public/analysis_options.yaml b/prop_public/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/prop_public/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/prop_public/lib/emulators/click_emulator.dart b/prop_public/lib/emulators/click_emulator.dart new file mode 100644 index 000000000..3cff3883d --- /dev/null +++ b/prop_public/lib/emulators/click_emulator.dart @@ -0,0 +1,19 @@ +//INFO: This is a stub - contact me if you need the full implementation. + +import 'package:flutter/foundation.dart'; + +class ClickEmulator { + final ValueNotifier isStarted = ValueNotifier(false); + final ValueNotifier isConnected = ValueNotifier(false); + + Future startServer() async { + isStarted.value = true; + } + + void stop() { + isStarted.value = false; + isConnected.value = false; + } + + void writeNotification(List bytes) {} +} diff --git a/prop_public/lib/emulators/dircon/dircon.dart b/prop_public/lib/emulators/dircon/dircon.dart new file mode 100644 index 000000000..94f10f83c --- /dev/null +++ b/prop_public/lib/emulators/dircon/dircon.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:universal_ble/universal_ble.dart'; + +class DirCon { + final Socket socket; + + DirCon({required this.socket}); + + List get serviceUUIDs => []; + + List getCharacteristics(String serviceUUID) => []; + + void processWriteCallback(String characteristicUUID, List characteristicData) {} + + void handleIncomingData(List data) {} + + void sendCharacteristicNotification(String uuid, List responseData) {} +} diff --git a/prop_public/lib/emulators/ftms_emulator.dart b/prop_public/lib/emulators/ftms_emulator.dart new file mode 100644 index 000000000..222948e94 --- /dev/null +++ b/prop_public/lib/emulators/ftms_emulator.dart @@ -0,0 +1,26 @@ +//INFO: This is a stub - contact me if you need the full implementation. + +import 'package:flutter/foundation.dart'; +import 'package:universal_ble/universal_ble.dart'; + +class FtmsEmulator { + final ValueNotifier isStarted = ValueNotifier(false); + final ValueNotifier isConnected = ValueNotifier(false); + final ValueNotifier isUnlocked = ValueNotifier(false); + final ValueNotifier alreadyUnlocked = ValueNotifier(false); + final ValueNotifier waiting = ValueNotifier(false); + + DateTime get connectionDate => DateTime.now(); + + bool processCharacteristic(String characteristic, Uint8List bytes) { + return false; + } + + void setScanResult(BleDevice scanResult) {} + + Future startServer() async {} + + void stop() {} + + void handleServices(List services) {} +} diff --git a/prop_public/lib/emulators/prefs.dart b/prop_public/lib/emulators/prefs.dart new file mode 100644 index 000000000..71e9043a9 --- /dev/null +++ b/prop_public/lib/emulators/prefs.dart @@ -0,0 +1,25 @@ +//INFO: This is a stub - contact me if you need the full implementation. + +import 'package:shared_preferences/shared_preferences.dart'; + +final propPrefs = PropPrefs(); + +class PropPrefs { + late final SharedPreferences _prefs; + + void initialize(SharedPreferences prefs) { + _prefs = prefs; + } + + DateTime? getZwiftClickV2LastUnlock(String deviceId) { + final key = 'clickV2_$deviceId'; + final timestamp = _prefs.getInt('${key}_unlock_date'); + if (timestamp == null) return null; + return DateTime.fromMillisecondsSinceEpoch(timestamp); + } + + void setZwiftClickV2LastUnlock(String deviceId, DateTime dateTime) { + final key = 'clickV2_$deviceId'; + _prefs.setInt("${key}_unlock_date", dateTime.millisecondsSinceEpoch); + } +} diff --git a/prop_public/lib/emulators/shared.dart b/prop_public/lib/emulators/shared.dart new file mode 100644 index 000000000..5462cb5a6 --- /dev/null +++ b/prop_public/lib/emulators/shared.dart @@ -0,0 +1,39 @@ +//INFO: This is a stub - contact me if you need the full implementation. + +import 'package:flutter/foundation.dart'; + +class SharedLogic { + static Uint8List? handleWriteRequest(String characteristic, Uint8List value) { + return null; + } + + static Future keepAlive() async {} + + static void stopKeepAlive() {} +} + +class Logger { + static void info(String text) { + if (kDebugMode) { + print('${DateTime.now()} \x1B[32m$text\x1B[0m'); + } + } + + static void warn(String text) { + if (kDebugMode) { + print('\x1B[33m$text\x1B[0m'); + } + } + + static void error(String text) { + if (kDebugMode) { + print('\x1B[31m$text\x1B[0m'); + } + } + + static void debug(String s) { + if (kDebugMode) { + print('\x1B[34m$s\x1B[0m'); + } + } +} diff --git a/prop_public/lib/prop.dart b/prop_public/lib/prop.dart new file mode 100644 index 000000000..0f9a99da2 --- /dev/null +++ b/prop_public/lib/prop.dart @@ -0,0 +1,23 @@ +export 'emulators/click_emulator.dart'; +export 'emulators/prefs.dart'; +export 'emulators/shared.dart'; +export 'protocol/zp.pb.dart'; +export 'protocol/zp.pbenum.dart'; +export 'protocol/zwift.pb.dart'; + +String bytesToHex(List bytes, {bool spaced = false}) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : ''); +} + +String bytesToReadableHex(List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' '); +} + +List hexToBytes(String hex) { + final bytes = []; + for (var i = 0; i < hex.length; i += 2) { + final byte = hex.substring(i, i + 2); + bytes.add(int.parse(byte, radix: 16)); + } + return bytes; +} diff --git a/prop_public/lib/protocol/zp.pb.dart b/prop_public/lib/protocol/zp.pb.dart new file mode 100644 index 000000000..5396da800 --- /dev/null +++ b/prop_public/lib/protocol/zp.pb.dart @@ -0,0 +1,6146 @@ +// +// Generated code. Do not modify. +// source: zp.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +import 'zp.pbenum.dart'; + +export 'zp.pbenum.dart'; + +class TrainerEnvSim extends $pb.GeneratedMessage { + factory TrainerEnvSim({ + $core.int? simulatedWind, + $core.int? simulatedGrade, + $core.int? simulatedCW, + $core.int? simulatedCRR, + }) { + final $result = create(); + if (simulatedWind != null) { + $result.simulatedWind = simulatedWind; + } + if (simulatedGrade != null) { + $result.simulatedGrade = simulatedGrade; + } + if (simulatedCW != null) { + $result.simulatedCW = simulatedCW; + } + if (simulatedCRR != null) { + $result.simulatedCRR = simulatedCRR; + } + return $result; + } + TrainerEnvSim._() : super(); + factory TrainerEnvSim.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerEnvSim.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerEnvSim', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'simulatedWind', $pb.PbFieldType.OS3, protoName: 'simulatedWind') + ..a<$core.int>(2, _omitFieldNames ? '' : 'simulatedGrade', $pb.PbFieldType.OS3, protoName: 'simulatedGrade') + ..a<$core.int>(3, _omitFieldNames ? '' : 'simulatedCW', $pb.PbFieldType.OU3, protoName: 'simulatedCW') + ..a<$core.int>(4, _omitFieldNames ? '' : 'simulatedCRR', $pb.PbFieldType.OU3, protoName: 'simulatedCRR') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TrainerEnvSim clone() => TrainerEnvSim()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerEnvSim copyWith(void Function(TrainerEnvSim) updates) => super.copyWith((message) => updates(message as TrainerEnvSim)) as TrainerEnvSim; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerEnvSim create() => TrainerEnvSim._(); + TrainerEnvSim createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerEnvSim getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerEnvSim? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get simulatedWind => $_getIZ(0); + @$pb.TagNumber(1) + set simulatedWind($core.int v) { $_setSignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSimulatedWind() => $_has(0); + @$pb.TagNumber(1) + void clearSimulatedWind() => clearField(1); + + @$pb.TagNumber(2) + $core.int get simulatedGrade => $_getIZ(1); + @$pb.TagNumber(2) + set simulatedGrade($core.int v) { $_setSignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasSimulatedGrade() => $_has(1); + @$pb.TagNumber(2) + void clearSimulatedGrade() => clearField(2); + + @$pb.TagNumber(3) + $core.int get simulatedCW => $_getIZ(2); + @$pb.TagNumber(3) + set simulatedCW($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasSimulatedCW() => $_has(2); + @$pb.TagNumber(3) + void clearSimulatedCW() => clearField(3); + + @$pb.TagNumber(4) + $core.int get simulatedCRR => $_getIZ(3); + @$pb.TagNumber(4) + set simulatedCRR($core.int v) { $_setUnsignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasSimulatedCRR() => $_has(3); + @$pb.TagNumber(4) + void clearSimulatedCRR() => clearField(4); +} + +class TrainerBikeSim extends $pb.GeneratedMessage { + factory TrainerBikeSim({ + $core.int? simulatedRealGearRatio, + $core.int? simulatedVirtualGearRatio, + $core.int? simulatedWheelDiameter, + $core.int? simulatedBikeMass, + $core.int? simulatedRiderMass, + $core.int? simulatedFrontalArea, + $core.int? eBrake, + }) { + final $result = create(); + if (simulatedRealGearRatio != null) { + $result.simulatedRealGearRatio = simulatedRealGearRatio; + } + if (simulatedVirtualGearRatio != null) { + $result.simulatedVirtualGearRatio = simulatedVirtualGearRatio; + } + if (simulatedWheelDiameter != null) { + $result.simulatedWheelDiameter = simulatedWheelDiameter; + } + if (simulatedBikeMass != null) { + $result.simulatedBikeMass = simulatedBikeMass; + } + if (simulatedRiderMass != null) { + $result.simulatedRiderMass = simulatedRiderMass; + } + if (simulatedFrontalArea != null) { + $result.simulatedFrontalArea = simulatedFrontalArea; + } + if (eBrake != null) { + $result.eBrake = eBrake; + } + return $result; + } + TrainerBikeSim._() : super(); + factory TrainerBikeSim.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerBikeSim.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerBikeSim', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'simulatedRealGearRatio', $pb.PbFieldType.OU3, protoName: 'simulatedRealGearRatio') + ..a<$core.int>(2, _omitFieldNames ? '' : 'simulatedVirtualGearRatio', $pb.PbFieldType.OU3, protoName: 'simulatedVirtualGearRatio') + ..a<$core.int>(3, _omitFieldNames ? '' : 'simulatedWheelDiameter', $pb.PbFieldType.OU3, protoName: 'simulatedWheelDiameter') + ..a<$core.int>(4, _omitFieldNames ? '' : 'simulatedBikeMass', $pb.PbFieldType.OU3, protoName: 'simulatedBikeMass') + ..a<$core.int>(5, _omitFieldNames ? '' : 'simulatedRiderMass', $pb.PbFieldType.OU3, protoName: 'simulatedRiderMass') + ..a<$core.int>(6, _omitFieldNames ? '' : 'simulatedFrontalArea', $pb.PbFieldType.OU3, protoName: 'simulatedFrontalArea') + ..a<$core.int>(7, _omitFieldNames ? '' : 'eBrake', $pb.PbFieldType.OU3, protoName: 'eBrake') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TrainerBikeSim clone() => TrainerBikeSim()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerBikeSim copyWith(void Function(TrainerBikeSim) updates) => super.copyWith((message) => updates(message as TrainerBikeSim)) as TrainerBikeSim; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerBikeSim create() => TrainerBikeSim._(); + TrainerBikeSim createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerBikeSim getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerBikeSim? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get simulatedRealGearRatio => $_getIZ(0); + @$pb.TagNumber(1) + set simulatedRealGearRatio($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSimulatedRealGearRatio() => $_has(0); + @$pb.TagNumber(1) + void clearSimulatedRealGearRatio() => clearField(1); + + @$pb.TagNumber(2) + $core.int get simulatedVirtualGearRatio => $_getIZ(1); + @$pb.TagNumber(2) + set simulatedVirtualGearRatio($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasSimulatedVirtualGearRatio() => $_has(1); + @$pb.TagNumber(2) + void clearSimulatedVirtualGearRatio() => clearField(2); + + @$pb.TagNumber(3) + $core.int get simulatedWheelDiameter => $_getIZ(2); + @$pb.TagNumber(3) + set simulatedWheelDiameter($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasSimulatedWheelDiameter() => $_has(2); + @$pb.TagNumber(3) + void clearSimulatedWheelDiameter() => clearField(3); + + @$pb.TagNumber(4) + $core.int get simulatedBikeMass => $_getIZ(3); + @$pb.TagNumber(4) + set simulatedBikeMass($core.int v) { $_setUnsignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasSimulatedBikeMass() => $_has(3); + @$pb.TagNumber(4) + void clearSimulatedBikeMass() => clearField(4); + + @$pb.TagNumber(5) + $core.int get simulatedRiderMass => $_getIZ(4); + @$pb.TagNumber(5) + set simulatedRiderMass($core.int v) { $_setUnsignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasSimulatedRiderMass() => $_has(4); + @$pb.TagNumber(5) + void clearSimulatedRiderMass() => clearField(5); + + @$pb.TagNumber(6) + $core.int get simulatedFrontalArea => $_getIZ(5); + @$pb.TagNumber(6) + set simulatedFrontalArea($core.int v) { $_setUnsignedInt32(5, v); } + @$pb.TagNumber(6) + $core.bool hasSimulatedFrontalArea() => $_has(5); + @$pb.TagNumber(6) + void clearSimulatedFrontalArea() => clearField(6); + + @$pb.TagNumber(7) + $core.int get eBrake => $_getIZ(6); + @$pb.TagNumber(7) + set eBrake($core.int v) { $_setUnsignedInt32(6, v); } + @$pb.TagNumber(7) + $core.bool hasEBrake() => $_has(6); + @$pb.TagNumber(7) + void clearEBrake() => clearField(7); +} + +class ControllerAnalogEvent extends $pb.GeneratedMessage { + factory ControllerAnalogEvent({ + $core.int? sensorId, + $core.int? value, + }) { + final $result = create(); + if (sensorId != null) { + $result.sensorId = sensorId; + } + if (value != null) { + $result.value = value; + } + return $result; + } + ControllerAnalogEvent._() : super(); + factory ControllerAnalogEvent.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ControllerAnalogEvent.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ControllerAnalogEvent', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'sensorId', $pb.PbFieldType.OU3, protoName: 'sensorId') + ..a<$core.int>(2, _omitFieldNames ? '' : 'value', $pb.PbFieldType.OS3) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + ControllerAnalogEvent clone() => ControllerAnalogEvent()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + ControllerAnalogEvent copyWith(void Function(ControllerAnalogEvent) updates) => super.copyWith((message) => updates(message as ControllerAnalogEvent)) as ControllerAnalogEvent; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ControllerAnalogEvent create() => ControllerAnalogEvent._(); + ControllerAnalogEvent createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static ControllerAnalogEvent getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ControllerAnalogEvent? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get sensorId => $_getIZ(0); + @$pb.TagNumber(1) + set sensorId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSensorId() => $_has(0); + @$pb.TagNumber(1) + void clearSensorId() => clearField(1); + + @$pb.TagNumber(2) + $core.int get value => $_getIZ(1); + @$pb.TagNumber(2) + set value($core.int v) { $_setSignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasValue() => $_has(1); + @$pb.TagNumber(2) + void clearValue() => clearField(2); +} + +class InputAnalogRange extends $pb.GeneratedMessage { + factory InputAnalogRange({ + $core.int? sensorId, + $core.int? minAnalogValue, + $core.int? maxAnalogValue, + }) { + final $result = create(); + if (sensorId != null) { + $result.sensorId = sensorId; + } + if (minAnalogValue != null) { + $result.minAnalogValue = minAnalogValue; + } + if (maxAnalogValue != null) { + $result.maxAnalogValue = maxAnalogValue; + } + return $result; + } + InputAnalogRange._() : super(); + factory InputAnalogRange.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory InputAnalogRange.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'InputAnalogRange', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'sensorId', $pb.PbFieldType.OU3, protoName: 'sensorId') + ..a<$core.int>(2, _omitFieldNames ? '' : 'minAnalogValue', $pb.PbFieldType.OS3, protoName: 'minAnalogValue') + ..a<$core.int>(3, _omitFieldNames ? '' : 'maxAnalogValue', $pb.PbFieldType.OS3, protoName: 'maxAnalogValue') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + InputAnalogRange clone() => InputAnalogRange()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + InputAnalogRange copyWith(void Function(InputAnalogRange) updates) => super.copyWith((message) => updates(message as InputAnalogRange)) as InputAnalogRange; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static InputAnalogRange create() => InputAnalogRange._(); + InputAnalogRange createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static InputAnalogRange getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static InputAnalogRange? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get sensorId => $_getIZ(0); + @$pb.TagNumber(1) + set sensorId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSensorId() => $_has(0); + @$pb.TagNumber(1) + void clearSensorId() => clearField(1); + + @$pb.TagNumber(2) + $core.int get minAnalogValue => $_getIZ(1); + @$pb.TagNumber(2) + set minAnalogValue($core.int v) { $_setSignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasMinAnalogValue() => $_has(1); + @$pb.TagNumber(2) + void clearMinAnalogValue() => clearField(2); + + @$pb.TagNumber(3) + $core.int get maxAnalogValue => $_getIZ(2); + @$pb.TagNumber(3) + set maxAnalogValue($core.int v) { $_setSignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasMaxAnalogValue() => $_has(2); + @$pb.TagNumber(3) + void clearMaxAnalogValue() => clearField(3); +} + +class InputAnalogDeadzone extends $pb.GeneratedMessage { + factory InputAnalogDeadzone({ + $core.int? sensorId, + $core.int? negDeadzoneValue, + $core.int? posDeadzoneValue, + }) { + final $result = create(); + if (sensorId != null) { + $result.sensorId = sensorId; + } + if (negDeadzoneValue != null) { + $result.negDeadzoneValue = negDeadzoneValue; + } + if (posDeadzoneValue != null) { + $result.posDeadzoneValue = posDeadzoneValue; + } + return $result; + } + InputAnalogDeadzone._() : super(); + factory InputAnalogDeadzone.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory InputAnalogDeadzone.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'InputAnalogDeadzone', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'sensorId', $pb.PbFieldType.OU3, protoName: 'sensorId') + ..a<$core.int>(2, _omitFieldNames ? '' : 'negDeadzoneValue', $pb.PbFieldType.OS3, protoName: 'negDeadzoneValue') + ..a<$core.int>(3, _omitFieldNames ? '' : 'posDeadzoneValue', $pb.PbFieldType.OS3, protoName: 'posDeadzoneValue') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + InputAnalogDeadzone clone() => InputAnalogDeadzone()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + InputAnalogDeadzone copyWith(void Function(InputAnalogDeadzone) updates) => super.copyWith((message) => updates(message as InputAnalogDeadzone)) as InputAnalogDeadzone; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static InputAnalogDeadzone create() => InputAnalogDeadzone._(); + InputAnalogDeadzone createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static InputAnalogDeadzone getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static InputAnalogDeadzone? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get sensorId => $_getIZ(0); + @$pb.TagNumber(1) + set sensorId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSensorId() => $_has(0); + @$pb.TagNumber(1) + void clearSensorId() => clearField(1); + + @$pb.TagNumber(2) + $core.int get negDeadzoneValue => $_getIZ(1); + @$pb.TagNumber(2) + set negDeadzoneValue($core.int v) { $_setSignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasNegDeadzoneValue() => $_has(1); + @$pb.TagNumber(2) + void clearNegDeadzoneValue() => clearField(2); + + @$pb.TagNumber(3) + $core.int get posDeadzoneValue => $_getIZ(2); + @$pb.TagNumber(3) + set posDeadzoneValue($core.int v) { $_setSignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasPosDeadzoneValue() => $_has(2); + @$pb.TagNumber(3) + void clearPosDeadzoneValue() => clearField(3); +} + +class WifiNetwork extends $pb.GeneratedMessage { + factory WifiNetwork({ + $core.int? networkId, + $core.List<$core.int>? ssid, + $core.List<$core.int>? password, + }) { + final $result = create(); + if (networkId != null) { + $result.networkId = networkId; + } + if (ssid != null) { + $result.ssid = ssid; + } + if (password != null) { + $result.password = password; + } + return $result; + } + WifiNetwork._() : super(); + factory WifiNetwork.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory WifiNetwork.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'WifiNetwork', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'networkId', $pb.PbFieldType.OU3, protoName: 'networkId') + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'ssid', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'password', $pb.PbFieldType.OY) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + WifiNetwork clone() => WifiNetwork()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + WifiNetwork copyWith(void Function(WifiNetwork) updates) => super.copyWith((message) => updates(message as WifiNetwork)) as WifiNetwork; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WifiNetwork create() => WifiNetwork._(); + WifiNetwork createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static WifiNetwork getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static WifiNetwork? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get networkId => $_getIZ(0); + @$pb.TagNumber(1) + set networkId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasNetworkId() => $_has(0); + @$pb.TagNumber(1) + void clearNetworkId() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get ssid => $_getN(1); + @$pb.TagNumber(2) + set ssid($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasSsid() => $_has(1); + @$pb.TagNumber(2) + void clearSsid() => clearField(2); + + @$pb.TagNumber(3) + $core.List<$core.int> get password => $_getN(2); + @$pb.TagNumber(3) + set password($core.List<$core.int> v) { $_setBytes(2, v); } + @$pb.TagNumber(3) + $core.bool hasPassword() => $_has(2); + @$pb.TagNumber(3) + void clearPassword() => clearField(3); +} + +class WifiRegionCode extends $pb.GeneratedMessage { + factory WifiRegionCode({ + WifiRegionCode_RegionCodeType? regionCodeType, + $core.List<$core.int>? regionCode, + }) { + final $result = create(); + if (regionCodeType != null) { + $result.regionCodeType = regionCodeType; + } + if (regionCode != null) { + $result.regionCode = regionCode; + } + return $result; + } + WifiRegionCode._() : super(); + factory WifiRegionCode.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory WifiRegionCode.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'WifiRegionCode', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'regionCodeType', $pb.PbFieldType.OE, protoName: 'regionCodeType', defaultOrMaker: WifiRegionCode_RegionCodeType.ALPHA_2, valueOf: WifiRegionCode_RegionCodeType.valueOf, enumValues: WifiRegionCode_RegionCodeType.values) + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'regionCode', $pb.PbFieldType.OY, protoName: 'regionCode') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + WifiRegionCode clone() => WifiRegionCode()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + WifiRegionCode copyWith(void Function(WifiRegionCode) updates) => super.copyWith((message) => updates(message as WifiRegionCode)) as WifiRegionCode; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WifiRegionCode create() => WifiRegionCode._(); + WifiRegionCode createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static WifiRegionCode getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static WifiRegionCode? _defaultInstance; + + @$pb.TagNumber(1) + WifiRegionCode_RegionCodeType get regionCodeType => $_getN(0); + @$pb.TagNumber(1) + set regionCodeType(WifiRegionCode_RegionCodeType v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasRegionCodeType() => $_has(0); + @$pb.TagNumber(1) + void clearRegionCodeType() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get regionCode => $_getN(1); + @$pb.TagNumber(2) + set regionCode($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasRegionCode() => $_has(1); + @$pb.TagNumber(2) + void clearRegionCode() => clearField(2); +} + +class WifiNetworkDetails extends $pb.GeneratedMessage { + factory WifiNetworkDetails({ + $core.int? networkId, + $core.List<$core.int>? bssid, + $core.List<$core.int>? ssid, + $core.int? securityType, + $core.int? band, + $core.int? rssi, + }) { + final $result = create(); + if (networkId != null) { + $result.networkId = networkId; + } + if (bssid != null) { + $result.bssid = bssid; + } + if (ssid != null) { + $result.ssid = ssid; + } + if (securityType != null) { + $result.securityType = securityType; + } + if (band != null) { + $result.band = band; + } + if (rssi != null) { + $result.rssi = rssi; + } + return $result; + } + WifiNetworkDetails._() : super(); + factory WifiNetworkDetails.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory WifiNetworkDetails.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'WifiNetworkDetails', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'networkId', $pb.PbFieldType.OU3, protoName: 'networkId') + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'bssid', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'ssid', $pb.PbFieldType.OY) + ..a<$core.int>(4, _omitFieldNames ? '' : 'securityType', $pb.PbFieldType.OU3, protoName: 'securityType') + ..a<$core.int>(5, _omitFieldNames ? '' : 'band', $pb.PbFieldType.OU3) + ..a<$core.int>(6, _omitFieldNames ? '' : 'rssi', $pb.PbFieldType.OS3) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + WifiNetworkDetails clone() => WifiNetworkDetails()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + WifiNetworkDetails copyWith(void Function(WifiNetworkDetails) updates) => super.copyWith((message) => updates(message as WifiNetworkDetails)) as WifiNetworkDetails; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WifiNetworkDetails create() => WifiNetworkDetails._(); + WifiNetworkDetails createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static WifiNetworkDetails getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static WifiNetworkDetails? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get networkId => $_getIZ(0); + @$pb.TagNumber(1) + set networkId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasNetworkId() => $_has(0); + @$pb.TagNumber(1) + void clearNetworkId() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get bssid => $_getN(1); + @$pb.TagNumber(2) + set bssid($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasBssid() => $_has(1); + @$pb.TagNumber(2) + void clearBssid() => clearField(2); + + @$pb.TagNumber(3) + $core.List<$core.int> get ssid => $_getN(2); + @$pb.TagNumber(3) + set ssid($core.List<$core.int> v) { $_setBytes(2, v); } + @$pb.TagNumber(3) + $core.bool hasSsid() => $_has(2); + @$pb.TagNumber(3) + void clearSsid() => clearField(3); + + @$pb.TagNumber(4) + $core.int get securityType => $_getIZ(3); + @$pb.TagNumber(4) + set securityType($core.int v) { $_setUnsignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasSecurityType() => $_has(3); + @$pb.TagNumber(4) + void clearSecurityType() => clearField(4); + + @$pb.TagNumber(5) + $core.int get band => $_getIZ(4); + @$pb.TagNumber(5) + set band($core.int v) { $_setUnsignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasBand() => $_has(4); + @$pb.TagNumber(5) + void clearBand() => clearField(5); + + @$pb.TagNumber(6) + $core.int get rssi => $_getIZ(5); + @$pb.TagNumber(6) + set rssi($core.int v) { $_setSignedInt32(5, v); } + @$pb.TagNumber(6) + $core.bool hasRssi() => $_has(5); + @$pb.TagNumber(6) + void clearRssi() => clearField(6); +} + +class SensorInfo extends $pb.GeneratedMessage { + factory SensorInfo({ + $core.int? relayAssignedId, + $core.List<$core.int>? sensorName, + $core.List<$core.int>? sensorAddress, + InterfaceType? interfaceType, + SensorConnectionStatus? connectionStatus, + $core.Iterable? deviceTypes, + $core.bool? supportsZp, + }) { + final $result = create(); + if (relayAssignedId != null) { + $result.relayAssignedId = relayAssignedId; + } + if (sensorName != null) { + $result.sensorName = sensorName; + } + if (sensorAddress != null) { + $result.sensorAddress = sensorAddress; + } + if (interfaceType != null) { + $result.interfaceType = interfaceType; + } + if (connectionStatus != null) { + $result.connectionStatus = connectionStatus; + } + if (deviceTypes != null) { + $result.deviceTypes.addAll(deviceTypes); + } + if (supportsZp != null) { + $result.supportsZp = supportsZp; + } + return $result; + } + SensorInfo._() : super(); + factory SensorInfo.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SensorInfo.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SensorInfo', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'relayAssignedId', $pb.PbFieldType.OU3, protoName: 'relayAssignedId') + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'sensorName', $pb.PbFieldType.OY, protoName: 'sensorName') + ..a<$core.List<$core.int>>(4, _omitFieldNames ? '' : 'sensorAddress', $pb.PbFieldType.OY, protoName: 'sensorAddress') + ..e(5, _omitFieldNames ? '' : 'interfaceType', $pb.PbFieldType.OE, protoName: 'interfaceType', defaultOrMaker: InterfaceType.INTERFACE_BLE, valueOf: InterfaceType.valueOf, enumValues: InterfaceType.values) + ..e(6, _omitFieldNames ? '' : 'connectionStatus', $pb.PbFieldType.OE, protoName: 'connectionStatus', defaultOrMaker: SensorConnectionStatus.SENSOR_STATUS_DISCOVERED, valueOf: SensorConnectionStatus.valueOf, enumValues: SensorConnectionStatus.values) + ..pc(7, _omitFieldNames ? '' : 'deviceTypes', $pb.PbFieldType.PE, protoName: 'deviceTypes', valueOf: DeviceType.valueOf, enumValues: DeviceType.values, defaultEnumValue: DeviceType.UNDEFINED) + ..aOB(8, _omitFieldNames ? '' : 'supportsZp', protoName: 'supportsZp') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SensorInfo clone() => SensorInfo()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SensorInfo copyWith(void Function(SensorInfo) updates) => super.copyWith((message) => updates(message as SensorInfo)) as SensorInfo; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SensorInfo create() => SensorInfo._(); + SensorInfo createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SensorInfo getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SensorInfo? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get relayAssignedId => $_getIZ(0); + @$pb.TagNumber(1) + set relayAssignedId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasRelayAssignedId() => $_has(0); + @$pb.TagNumber(1) + void clearRelayAssignedId() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get sensorName => $_getN(1); + @$pb.TagNumber(2) + set sensorName($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasSensorName() => $_has(1); + @$pb.TagNumber(2) + void clearSensorName() => clearField(2); + + @$pb.TagNumber(4) + $core.List<$core.int> get sensorAddress => $_getN(2); + @$pb.TagNumber(4) + set sensorAddress($core.List<$core.int> v) { $_setBytes(2, v); } + @$pb.TagNumber(4) + $core.bool hasSensorAddress() => $_has(2); + @$pb.TagNumber(4) + void clearSensorAddress() => clearField(4); + + @$pb.TagNumber(5) + InterfaceType get interfaceType => $_getN(3); + @$pb.TagNumber(5) + set interfaceType(InterfaceType v) { setField(5, v); } + @$pb.TagNumber(5) + $core.bool hasInterfaceType() => $_has(3); + @$pb.TagNumber(5) + void clearInterfaceType() => clearField(5); + + @$pb.TagNumber(6) + SensorConnectionStatus get connectionStatus => $_getN(4); + @$pb.TagNumber(6) + set connectionStatus(SensorConnectionStatus v) { setField(6, v); } + @$pb.TagNumber(6) + $core.bool hasConnectionStatus() => $_has(4); + @$pb.TagNumber(6) + void clearConnectionStatus() => clearField(6); + + @$pb.TagNumber(7) + $core.List get deviceTypes => $_getList(5); + + @$pb.TagNumber(8) + $core.bool get supportsZp => $_getBF(6); + @$pb.TagNumber(8) + set supportsZp($core.bool v) { $_setBool(6, v); } + @$pb.TagNumber(8) + $core.bool hasSupportsZp() => $_has(6); + @$pb.TagNumber(8) + void clearSupportsZp() => clearField(8); +} + +class SensorInfoList extends $pb.GeneratedMessage { + factory SensorInfoList({ + $core.Iterable? sensorInfo, + }) { + final $result = create(); + if (sensorInfo != null) { + $result.sensorInfo.addAll(sensorInfo); + } + return $result; + } + SensorInfoList._() : super(); + factory SensorInfoList.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SensorInfoList.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SensorInfoList', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..pc(1, _omitFieldNames ? '' : 'sensorInfo', $pb.PbFieldType.PM, protoName: 'sensorInfo', subBuilder: SensorInfo.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') + SensorInfoList clone() => SensorInfoList()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SensorInfoList copyWith(void Function(SensorInfoList) updates) => super.copyWith((message) => updates(message as SensorInfoList)) as SensorInfoList; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SensorInfoList create() => SensorInfoList._(); + SensorInfoList createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SensorInfoList getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SensorInfoList? _defaultInstance; + + @$pb.TagNumber(1) + $core.List get sensorInfo => $_getList(0); +} + +class DeviceUpdatePage extends $pb.GeneratedMessage { + factory DeviceUpdatePage({ + $core.int? updateStatus, + $core.int? newVersion, + }) { + final $result = create(); + if (updateStatus != null) { + $result.updateStatus = updateStatus; + } + if (newVersion != null) { + $result.newVersion = newVersion; + } + return $result; + } + DeviceUpdatePage._() : super(); + factory DeviceUpdatePage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DeviceUpdatePage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DeviceUpdatePage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'updateStatus', $pb.PbFieldType.OU3, protoName: 'updateStatus') + ..a<$core.int>(2, _omitFieldNames ? '' : 'newVersion', $pb.PbFieldType.OU3, protoName: 'newVersion') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DeviceUpdatePage clone() => DeviceUpdatePage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DeviceUpdatePage copyWith(void Function(DeviceUpdatePage) updates) => super.copyWith((message) => updates(message as DeviceUpdatePage)) as DeviceUpdatePage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DeviceUpdatePage create() => DeviceUpdatePage._(); + DeviceUpdatePage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DeviceUpdatePage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DeviceUpdatePage? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get updateStatus => $_getIZ(0); + @$pb.TagNumber(1) + set updateStatus($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasUpdateStatus() => $_has(0); + @$pb.TagNumber(1) + void clearUpdateStatus() => clearField(1); + + @$pb.TagNumber(2) + $core.int get newVersion => $_getIZ(1); + @$pb.TagNumber(2) + set newVersion($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasNewVersion() => $_has(1); + @$pb.TagNumber(2) + void clearNewVersion() => clearField(2); +} + +class DateTimePage extends $pb.GeneratedMessage { + factory DateTimePage({ + $core.int? utcDateTime, + }) { + final $result = create(); + if (utcDateTime != null) { + $result.utcDateTime = utcDateTime; + } + return $result; + } + DateTimePage._() : super(); + factory DateTimePage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DateTimePage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DateTimePage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'utcDateTime', $pb.PbFieldType.OU3, protoName: 'utcDateTime') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DateTimePage clone() => DateTimePage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DateTimePage copyWith(void Function(DateTimePage) updates) => super.copyWith((message) => updates(message as DateTimePage)) as DateTimePage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DateTimePage create() => DateTimePage._(); + DateTimePage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DateTimePage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DateTimePage? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get utcDateTime => $_getIZ(0); + @$pb.TagNumber(1) + set utcDateTime($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasUtcDateTime() => $_has(0); + @$pb.TagNumber(1) + void clearUtcDateTime() => clearField(1); +} + +class BleSecurityPage extends $pb.GeneratedMessage { + factory BleSecurityPage({ + BleSecureConnectionStatus? secureConnectionStatus, + BleSecureConnectionWindowStatus? secureConnectionWindowStatus, + }) { + final $result = create(); + if (secureConnectionStatus != null) { + $result.secureConnectionStatus = secureConnectionStatus; + } + if (secureConnectionWindowStatus != null) { + $result.secureConnectionWindowStatus = secureConnectionWindowStatus; + } + return $result; + } + BleSecurityPage._() : super(); + factory BleSecurityPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory BleSecurityPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BleSecurityPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'secureConnectionStatus', $pb.PbFieldType.OE, protoName: 'secureConnectionStatus', defaultOrMaker: BleSecureConnectionStatus.BLE_CONNECTION_SECURITY_STATUS_NONE, valueOf: BleSecureConnectionStatus.valueOf, enumValues: BleSecureConnectionStatus.values) + ..e(2, _omitFieldNames ? '' : 'secureConnectionWindowStatus', $pb.PbFieldType.OE, protoName: 'secureConnectionWindowStatus', defaultOrMaker: BleSecureConnectionWindowStatus.BLE_SECURE_CONNECTION_WINDOW_STATUS_CLOSED, valueOf: BleSecureConnectionWindowStatus.valueOf, enumValues: BleSecureConnectionWindowStatus.values) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + BleSecurityPage clone() => BleSecurityPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + BleSecurityPage copyWith(void Function(BleSecurityPage) updates) => super.copyWith((message) => updates(message as BleSecurityPage)) as BleSecurityPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BleSecurityPage create() => BleSecurityPage._(); + BleSecurityPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static BleSecurityPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static BleSecurityPage? _defaultInstance; + + @$pb.TagNumber(1) + BleSecureConnectionStatus get secureConnectionStatus => $_getN(0); + @$pb.TagNumber(1) + set secureConnectionStatus(BleSecureConnectionStatus v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasSecureConnectionStatus() => $_has(0); + @$pb.TagNumber(1) + void clearSecureConnectionStatus() => clearField(1); + + @$pb.TagNumber(2) + BleSecureConnectionWindowStatus get secureConnectionWindowStatus => $_getN(1); + @$pb.TagNumber(2) + set secureConnectionWindowStatus(BleSecureConnectionWindowStatus v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasSecureConnectionWindowStatus() => $_has(1); + @$pb.TagNumber(2) + void clearSecureConnectionWindowStatus() => clearField(2); +} + +class DevInfoPage_DeviceCapabilities extends $pb.GeneratedMessage { + factory DevInfoPage_DeviceCapabilities({ + $core.int? deviceType, + $core.int? capabilities, + }) { + final $result = create(); + if (deviceType != null) { + $result.deviceType = deviceType; + } + if (capabilities != null) { + $result.capabilities = capabilities; + } + return $result; + } + DevInfoPage_DeviceCapabilities._() : super(); + factory DevInfoPage_DeviceCapabilities.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DevInfoPage_DeviceCapabilities.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DevInfoPage.DeviceCapabilities', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'deviceType', $pb.PbFieldType.QU3, protoName: 'deviceType') + ..a<$core.int>(2, _omitFieldNames ? '' : 'capabilities', $pb.PbFieldType.QU3) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DevInfoPage_DeviceCapabilities clone() => DevInfoPage_DeviceCapabilities()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DevInfoPage_DeviceCapabilities copyWith(void Function(DevInfoPage_DeviceCapabilities) updates) => super.copyWith((message) => updates(message as DevInfoPage_DeviceCapabilities)) as DevInfoPage_DeviceCapabilities; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DevInfoPage_DeviceCapabilities create() => DevInfoPage_DeviceCapabilities._(); + DevInfoPage_DeviceCapabilities createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DevInfoPage_DeviceCapabilities getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DevInfoPage_DeviceCapabilities? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get deviceType => $_getIZ(0); + @$pb.TagNumber(1) + set deviceType($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasDeviceType() => $_has(0); + @$pb.TagNumber(1) + void clearDeviceType() => clearField(1); + + @$pb.TagNumber(2) + $core.int get capabilities => $_getIZ(1); + @$pb.TagNumber(2) + set capabilities($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasCapabilities() => $_has(1); + @$pb.TagNumber(2) + void clearCapabilities() => clearField(2); +} + +/// Page ID 0x000 +class DevInfoPage extends $pb.GeneratedMessage { + factory DevInfoPage({ + $core.int? protocolVersion, + $core.List<$core.int>? systemFwVersion, + $core.List<$core.int>? deviceName, + $core.List<$core.int>? serialNumber, + $core.List<$core.int>? systemHwRevision, + $core.Iterable? deviceCapabilities, + $core.int? manufacturerId, + $core.int? productId, + $core.List<$core.int>? deviceUid, + }) { + final $result = create(); + if (protocolVersion != null) { + $result.protocolVersion = protocolVersion; + } + if (systemFwVersion != null) { + $result.systemFwVersion = systemFwVersion; + } + if (deviceName != null) { + $result.deviceName = deviceName; + } + if (serialNumber != null) { + $result.serialNumber = serialNumber; + } + if (systemHwRevision != null) { + $result.systemHwRevision = systemHwRevision; + } + if (deviceCapabilities != null) { + $result.deviceCapabilities.addAll(deviceCapabilities); + } + if (manufacturerId != null) { + $result.manufacturerId = manufacturerId; + } + if (productId != null) { + $result.productId = productId; + } + if (deviceUid != null) { + $result.deviceUid = deviceUid; + } + return $result; + } + DevInfoPage._() : super(); + factory DevInfoPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DevInfoPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DevInfoPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'protocolVersion', $pb.PbFieldType.OU3, protoName: 'protocolVersion') + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'systemFwVersion', $pb.PbFieldType.OY, protoName: 'systemFwVersion') + ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'deviceName', $pb.PbFieldType.OY, protoName: 'deviceName') + ..a<$core.List<$core.int>>(6, _omitFieldNames ? '' : 'serialNumber', $pb.PbFieldType.OY, protoName: 'serialNumber') + ..a<$core.List<$core.int>>(7, _omitFieldNames ? '' : 'systemHwRevision', $pb.PbFieldType.OY, protoName: 'systemHwRevision') + ..pc(8, _omitFieldNames ? '' : 'deviceCapabilities', $pb.PbFieldType.PM, protoName: 'deviceCapabilities', subBuilder: DevInfoPage_DeviceCapabilities.create) + ..a<$core.int>(9, _omitFieldNames ? '' : 'manufacturerId', $pb.PbFieldType.OU3, protoName: 'manufacturerId') + ..a<$core.int>(10, _omitFieldNames ? '' : 'productId', $pb.PbFieldType.OU3, protoName: 'productId') + ..a<$core.List<$core.int>>(11, _omitFieldNames ? '' : 'deviceUid', $pb.PbFieldType.OY, protoName: 'deviceUid') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DevInfoPage clone() => DevInfoPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DevInfoPage copyWith(void Function(DevInfoPage) updates) => super.copyWith((message) => updates(message as DevInfoPage)) as DevInfoPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DevInfoPage create() => DevInfoPage._(); + DevInfoPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DevInfoPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DevInfoPage? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get protocolVersion => $_getIZ(0); + @$pb.TagNumber(1) + set protocolVersion($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasProtocolVersion() => $_has(0); + @$pb.TagNumber(1) + void clearProtocolVersion() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get systemFwVersion => $_getN(1); + @$pb.TagNumber(2) + set systemFwVersion($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasSystemFwVersion() => $_has(1); + @$pb.TagNumber(2) + void clearSystemFwVersion() => clearField(2); + + @$pb.TagNumber(3) + $core.List<$core.int> get deviceName => $_getN(2); + @$pb.TagNumber(3) + set deviceName($core.List<$core.int> v) { $_setBytes(2, v); } + @$pb.TagNumber(3) + $core.bool hasDeviceName() => $_has(2); + @$pb.TagNumber(3) + void clearDeviceName() => clearField(3); + + @$pb.TagNumber(6) + $core.List<$core.int> get serialNumber => $_getN(3); + @$pb.TagNumber(6) + set serialNumber($core.List<$core.int> v) { $_setBytes(3, v); } + @$pb.TagNumber(6) + $core.bool hasSerialNumber() => $_has(3); + @$pb.TagNumber(6) + void clearSerialNumber() => clearField(6); + + @$pb.TagNumber(7) + $core.List<$core.int> get systemHwRevision => $_getN(4); + @$pb.TagNumber(7) + set systemHwRevision($core.List<$core.int> v) { $_setBytes(4, v); } + @$pb.TagNumber(7) + $core.bool hasSystemHwRevision() => $_has(4); + @$pb.TagNumber(7) + void clearSystemHwRevision() => clearField(7); + + @$pb.TagNumber(8) + $core.List get deviceCapabilities => $_getList(5); + + @$pb.TagNumber(9) + $core.int get manufacturerId => $_getIZ(6); + @$pb.TagNumber(9) + set manufacturerId($core.int v) { $_setUnsignedInt32(6, v); } + @$pb.TagNumber(9) + $core.bool hasManufacturerId() => $_has(6); + @$pb.TagNumber(9) + void clearManufacturerId() => clearField(9); + + @$pb.TagNumber(10) + $core.int get productId => $_getIZ(7); + @$pb.TagNumber(10) + set productId($core.int v) { $_setUnsignedInt32(7, v); } + @$pb.TagNumber(10) + $core.bool hasProductId() => $_has(7); + @$pb.TagNumber(10) + void clearProductId() => clearField(10); + + @$pb.TagNumber(11) + $core.List<$core.int> get deviceUid => $_getN(8); + @$pb.TagNumber(11) + set deviceUid($core.List<$core.int> v) { $_setBytes(8, v); } + @$pb.TagNumber(11) + $core.bool hasDeviceUid() => $_has(8); + @$pb.TagNumber(11) + void clearDeviceUid() => clearField(11); +} + +/// Page ID 0x010 +class ClientServerCfgPage extends $pb.GeneratedMessage { + factory ClientServerCfgPage({ + $core.int? notifications, + }) { + final $result = create(); + if (notifications != null) { + $result.notifications = notifications; + } + return $result; + } + ClientServerCfgPage._() : super(); + factory ClientServerCfgPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ClientServerCfgPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ClientServerCfgPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'notifications', $pb.PbFieldType.OU3) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + ClientServerCfgPage clone() => ClientServerCfgPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + ClientServerCfgPage copyWith(void Function(ClientServerCfgPage) updates) => super.copyWith((message) => updates(message as ClientServerCfgPage)) as ClientServerCfgPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ClientServerCfgPage create() => ClientServerCfgPage._(); + ClientServerCfgPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static ClientServerCfgPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ClientServerCfgPage? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get notifications => $_getIZ(0); + @$pb.TagNumber(1) + set notifications($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasNotifications() => $_has(0); + @$pb.TagNumber(1) + void clearNotifications() => clearField(1); +} + +/// Page ID 0x020 +class TrainerSimulationParam extends $pb.GeneratedMessage { + factory TrainerSimulationParam({ + $core.int? configuredResistance, + $core.int? ergPower, + $core.int? averagingWindow, + $core.int? simulatedWind, + $core.int? simulatedGrade, + $core.int? simulatedRealGearRatio, + $core.int? simulatedVirtualGearRatio, + $core.int? simulatedCW, + $core.int? simulatedWheelDiameter, + $core.int? simulatedBikeMass, + $core.int? simulatedRiderMass, + $core.int? simulatedCRR, + $core.int? simulatedFrontalArea, + $core.int? simulatedEBrake, + }) { + final $result = create(); + if (configuredResistance != null) { + $result.configuredResistance = configuredResistance; + } + if (ergPower != null) { + $result.ergPower = ergPower; + } + if (averagingWindow != null) { + $result.averagingWindow = averagingWindow; + } + if (simulatedWind != null) { + $result.simulatedWind = simulatedWind; + } + if (simulatedGrade != null) { + $result.simulatedGrade = simulatedGrade; + } + if (simulatedRealGearRatio != null) { + $result.simulatedRealGearRatio = simulatedRealGearRatio; + } + if (simulatedVirtualGearRatio != null) { + $result.simulatedVirtualGearRatio = simulatedVirtualGearRatio; + } + if (simulatedCW != null) { + $result.simulatedCW = simulatedCW; + } + if (simulatedWheelDiameter != null) { + $result.simulatedWheelDiameter = simulatedWheelDiameter; + } + if (simulatedBikeMass != null) { + $result.simulatedBikeMass = simulatedBikeMass; + } + if (simulatedRiderMass != null) { + $result.simulatedRiderMass = simulatedRiderMass; + } + if (simulatedCRR != null) { + $result.simulatedCRR = simulatedCRR; + } + if (simulatedFrontalArea != null) { + $result.simulatedFrontalArea = simulatedFrontalArea; + } + if (simulatedEBrake != null) { + $result.simulatedEBrake = simulatedEBrake; + } + return $result; + } + TrainerSimulationParam._() : super(); + factory TrainerSimulationParam.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerSimulationParam.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerSimulationParam', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'configuredResistance', $pb.PbFieldType.OU3, protoName: 'configuredResistance') + ..a<$core.int>(2, _omitFieldNames ? '' : 'ergPower', $pb.PbFieldType.OU3, protoName: 'ergPower') + ..a<$core.int>(3, _omitFieldNames ? '' : 'averagingWindow', $pb.PbFieldType.OU3, protoName: 'averagingWindow') + ..a<$core.int>(4, _omitFieldNames ? '' : 'simulatedWind', $pb.PbFieldType.OS3, protoName: 'simulatedWind') + ..a<$core.int>(5, _omitFieldNames ? '' : 'simulatedGrade', $pb.PbFieldType.OS3, protoName: 'simulatedGrade') + ..a<$core.int>(6, _omitFieldNames ? '' : 'simulatedRealGearRatio', $pb.PbFieldType.OU3, protoName: 'simulatedRealGearRatio') + ..a<$core.int>(7, _omitFieldNames ? '' : 'simulatedVirtualGearRatio', $pb.PbFieldType.OU3, protoName: 'simulatedVirtualGearRatio') + ..a<$core.int>(8, _omitFieldNames ? '' : 'simulatedCW', $pb.PbFieldType.OU3, protoName: 'simulatedCW') + ..a<$core.int>(9, _omitFieldNames ? '' : 'simulatedWheelDiameter', $pb.PbFieldType.OU3, protoName: 'simulatedWheelDiameter') + ..a<$core.int>(10, _omitFieldNames ? '' : 'simulatedBikeMass', $pb.PbFieldType.OU3, protoName: 'simulatedBikeMass') + ..a<$core.int>(11, _omitFieldNames ? '' : 'simulatedRiderMass', $pb.PbFieldType.OU3, protoName: 'simulatedRiderMass') + ..a<$core.int>(12, _omitFieldNames ? '' : 'simulatedCRR', $pb.PbFieldType.OU3, protoName: 'simulatedCRR') + ..a<$core.int>(13, _omitFieldNames ? '' : 'simulatedFrontalArea', $pb.PbFieldType.OU3, protoName: 'simulatedFrontalArea') + ..a<$core.int>(14, _omitFieldNames ? '' : 'simulatedEBrake', $pb.PbFieldType.OU3, protoName: 'simulatedEBrake') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TrainerSimulationParam clone() => TrainerSimulationParam()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerSimulationParam copyWith(void Function(TrainerSimulationParam) updates) => super.copyWith((message) => updates(message as TrainerSimulationParam)) as TrainerSimulationParam; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerSimulationParam create() => TrainerSimulationParam._(); + TrainerSimulationParam createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerSimulationParam getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerSimulationParam? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get configuredResistance => $_getIZ(0); + @$pb.TagNumber(1) + set configuredResistance($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasConfiguredResistance() => $_has(0); + @$pb.TagNumber(1) + void clearConfiguredResistance() => clearField(1); + + @$pb.TagNumber(2) + $core.int get ergPower => $_getIZ(1); + @$pb.TagNumber(2) + set ergPower($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasErgPower() => $_has(1); + @$pb.TagNumber(2) + void clearErgPower() => clearField(2); + + @$pb.TagNumber(3) + $core.int get averagingWindow => $_getIZ(2); + @$pb.TagNumber(3) + set averagingWindow($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasAveragingWindow() => $_has(2); + @$pb.TagNumber(3) + void clearAveragingWindow() => clearField(3); + + @$pb.TagNumber(4) + $core.int get simulatedWind => $_getIZ(3); + @$pb.TagNumber(4) + set simulatedWind($core.int v) { $_setSignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasSimulatedWind() => $_has(3); + @$pb.TagNumber(4) + void clearSimulatedWind() => clearField(4); + + @$pb.TagNumber(5) + $core.int get simulatedGrade => $_getIZ(4); + @$pb.TagNumber(5) + set simulatedGrade($core.int v) { $_setSignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasSimulatedGrade() => $_has(4); + @$pb.TagNumber(5) + void clearSimulatedGrade() => clearField(5); + + @$pb.TagNumber(6) + $core.int get simulatedRealGearRatio => $_getIZ(5); + @$pb.TagNumber(6) + set simulatedRealGearRatio($core.int v) { $_setUnsignedInt32(5, v); } + @$pb.TagNumber(6) + $core.bool hasSimulatedRealGearRatio() => $_has(5); + @$pb.TagNumber(6) + void clearSimulatedRealGearRatio() => clearField(6); + + @$pb.TagNumber(7) + $core.int get simulatedVirtualGearRatio => $_getIZ(6); + @$pb.TagNumber(7) + set simulatedVirtualGearRatio($core.int v) { $_setUnsignedInt32(6, v); } + @$pb.TagNumber(7) + $core.bool hasSimulatedVirtualGearRatio() => $_has(6); + @$pb.TagNumber(7) + void clearSimulatedVirtualGearRatio() => clearField(7); + + @$pb.TagNumber(8) + $core.int get simulatedCW => $_getIZ(7); + @$pb.TagNumber(8) + set simulatedCW($core.int v) { $_setUnsignedInt32(7, v); } + @$pb.TagNumber(8) + $core.bool hasSimulatedCW() => $_has(7); + @$pb.TagNumber(8) + void clearSimulatedCW() => clearField(8); + + @$pb.TagNumber(9) + $core.int get simulatedWheelDiameter => $_getIZ(8); + @$pb.TagNumber(9) + set simulatedWheelDiameter($core.int v) { $_setUnsignedInt32(8, v); } + @$pb.TagNumber(9) + $core.bool hasSimulatedWheelDiameter() => $_has(8); + @$pb.TagNumber(9) + void clearSimulatedWheelDiameter() => clearField(9); + + @$pb.TagNumber(10) + $core.int get simulatedBikeMass => $_getIZ(9); + @$pb.TagNumber(10) + set simulatedBikeMass($core.int v) { $_setUnsignedInt32(9, v); } + @$pb.TagNumber(10) + $core.bool hasSimulatedBikeMass() => $_has(9); + @$pb.TagNumber(10) + void clearSimulatedBikeMass() => clearField(10); + + @$pb.TagNumber(11) + $core.int get simulatedRiderMass => $_getIZ(10); + @$pb.TagNumber(11) + set simulatedRiderMass($core.int v) { $_setUnsignedInt32(10, v); } + @$pb.TagNumber(11) + $core.bool hasSimulatedRiderMass() => $_has(10); + @$pb.TagNumber(11) + void clearSimulatedRiderMass() => clearField(11); + + @$pb.TagNumber(12) + $core.int get simulatedCRR => $_getIZ(11); + @$pb.TagNumber(12) + set simulatedCRR($core.int v) { $_setUnsignedInt32(11, v); } + @$pb.TagNumber(12) + $core.bool hasSimulatedCRR() => $_has(11); + @$pb.TagNumber(12) + void clearSimulatedCRR() => clearField(12); + + @$pb.TagNumber(13) + $core.int get simulatedFrontalArea => $_getIZ(12); + @$pb.TagNumber(13) + set simulatedFrontalArea($core.int v) { $_setUnsignedInt32(12, v); } + @$pb.TagNumber(13) + $core.bool hasSimulatedFrontalArea() => $_has(12); + @$pb.TagNumber(13) + void clearSimulatedFrontalArea() => clearField(13); + + @$pb.TagNumber(14) + $core.int get simulatedEBrake => $_getIZ(13); + @$pb.TagNumber(14) + set simulatedEBrake($core.int v) { $_setUnsignedInt32(13, v); } + @$pb.TagNumber(14) + $core.bool hasSimulatedEBrake() => $_has(13); + @$pb.TagNumber(14) + void clearSimulatedEBrake() => clearField(14); +} + +/// Page ID 0x022 +class TrainerOptions extends $pb.GeneratedMessage { + factory TrainerOptions({ + $core.bool? highSpeedDataEnabled, + $core.bool? ergPowerSmoothingEnabled, + $core.int? virtualShiftingMode, + }) { + final $result = create(); + if (highSpeedDataEnabled != null) { + $result.highSpeedDataEnabled = highSpeedDataEnabled; + } + if (ergPowerSmoothingEnabled != null) { + $result.ergPowerSmoothingEnabled = ergPowerSmoothingEnabled; + } + if (virtualShiftingMode != null) { + $result.virtualShiftingMode = virtualShiftingMode; + } + return $result; + } + TrainerOptions._() : super(); + factory TrainerOptions.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerOptions.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerOptions', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..aOB(1, _omitFieldNames ? '' : 'highSpeedDataEnabled', protoName: 'highSpeedDataEnabled') + ..aOB(2, _omitFieldNames ? '' : 'ergPowerSmoothingEnabled', protoName: 'ergPowerSmoothingEnabled') + ..a<$core.int>(3, _omitFieldNames ? '' : 'virtualShiftingMode', $pb.PbFieldType.OU3, protoName: 'virtualShiftingMode') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TrainerOptions clone() => TrainerOptions()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerOptions copyWith(void Function(TrainerOptions) updates) => super.copyWith((message) => updates(message as TrainerOptions)) as TrainerOptions; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerOptions create() => TrainerOptions._(); + TrainerOptions createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerOptions getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerOptions? _defaultInstance; + + @$pb.TagNumber(1) + $core.bool get highSpeedDataEnabled => $_getBF(0); + @$pb.TagNumber(1) + set highSpeedDataEnabled($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasHighSpeedDataEnabled() => $_has(0); + @$pb.TagNumber(1) + void clearHighSpeedDataEnabled() => clearField(1); + + @$pb.TagNumber(2) + $core.bool get ergPowerSmoothingEnabled => $_getBF(1); + @$pb.TagNumber(2) + set ergPowerSmoothingEnabled($core.bool v) { $_setBool(1, v); } + @$pb.TagNumber(2) + $core.bool hasErgPowerSmoothingEnabled() => $_has(1); + @$pb.TagNumber(2) + void clearErgPowerSmoothingEnabled() => clearField(2); + + @$pb.TagNumber(3) + $core.int get virtualShiftingMode => $_getIZ(2); + @$pb.TagNumber(3) + set virtualShiftingMode($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasVirtualShiftingMode() => $_has(2); + @$pb.TagNumber(3) + void clearVirtualShiftingMode() => clearField(3); +} + +/// Page ID 0x020 and 0x022 (Get Response) +class TrainerCfgPage extends $pb.GeneratedMessage { + factory TrainerCfgPage({ + TrainerMode? trainerMode, + $core.int? configuredResistance, + $core.int? ergPower, + $core.int? averagingWindow, + $core.int? simulatedWind, + $core.int? simulatedGrade, + $core.int? simulatedRealGearRatio, + $core.int? simulatedVirtualGearRatio, + $core.int? simulatedCW, + $core.int? simulatedWheelDiameter, + $core.int? simulatedBikeMass, + $core.int? simulatedRiderMass, + $core.int? simulatedCRR, + $core.int? simulatedFrontalArea, + $core.int? simulatedEBrake, + $core.bool? highSpeedDataEnabled, + $core.bool? ergPowerSmoothingEnabled, + $core.int? virtualShiftingMode, + }) { + final $result = create(); + if (trainerMode != null) { + $result.trainerMode = trainerMode; + } + if (configuredResistance != null) { + $result.configuredResistance = configuredResistance; + } + if (ergPower != null) { + $result.ergPower = ergPower; + } + if (averagingWindow != null) { + $result.averagingWindow = averagingWindow; + } + if (simulatedWind != null) { + $result.simulatedWind = simulatedWind; + } + if (simulatedGrade != null) { + $result.simulatedGrade = simulatedGrade; + } + if (simulatedRealGearRatio != null) { + $result.simulatedRealGearRatio = simulatedRealGearRatio; + } + if (simulatedVirtualGearRatio != null) { + $result.simulatedVirtualGearRatio = simulatedVirtualGearRatio; + } + if (simulatedCW != null) { + $result.simulatedCW = simulatedCW; + } + if (simulatedWheelDiameter != null) { + $result.simulatedWheelDiameter = simulatedWheelDiameter; + } + if (simulatedBikeMass != null) { + $result.simulatedBikeMass = simulatedBikeMass; + } + if (simulatedRiderMass != null) { + $result.simulatedRiderMass = simulatedRiderMass; + } + if (simulatedCRR != null) { + $result.simulatedCRR = simulatedCRR; + } + if (simulatedFrontalArea != null) { + $result.simulatedFrontalArea = simulatedFrontalArea; + } + if (simulatedEBrake != null) { + $result.simulatedEBrake = simulatedEBrake; + } + if (highSpeedDataEnabled != null) { + $result.highSpeedDataEnabled = highSpeedDataEnabled; + } + if (ergPowerSmoothingEnabled != null) { + $result.ergPowerSmoothingEnabled = ergPowerSmoothingEnabled; + } + if (virtualShiftingMode != null) { + $result.virtualShiftingMode = virtualShiftingMode; + } + return $result; + } + TrainerCfgPage._() : super(); + factory TrainerCfgPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerCfgPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerCfgPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'trainerMode', $pb.PbFieldType.OE, protoName: 'trainerMode', defaultOrMaker: TrainerMode.MODE_UNKNOWN, valueOf: TrainerMode.valueOf, enumValues: TrainerMode.values) + ..a<$core.int>(2, _omitFieldNames ? '' : 'configuredResistance', $pb.PbFieldType.OU3, protoName: 'configuredResistance') + ..a<$core.int>(3, _omitFieldNames ? '' : 'ergPower', $pb.PbFieldType.OU3, protoName: 'ergPower') + ..a<$core.int>(4, _omitFieldNames ? '' : 'averagingWindow', $pb.PbFieldType.OU3, protoName: 'averagingWindow') + ..a<$core.int>(5, _omitFieldNames ? '' : 'simulatedWind', $pb.PbFieldType.OS3, protoName: 'simulatedWind') + ..a<$core.int>(6, _omitFieldNames ? '' : 'simulatedGrade', $pb.PbFieldType.OS3, protoName: 'simulatedGrade') + ..a<$core.int>(7, _omitFieldNames ? '' : 'simulatedRealGearRatio', $pb.PbFieldType.OU3, protoName: 'simulatedRealGearRatio') + ..a<$core.int>(8, _omitFieldNames ? '' : 'simulatedVirtualGearRatio', $pb.PbFieldType.OU3, protoName: 'simulatedVirtualGearRatio') + ..a<$core.int>(9, _omitFieldNames ? '' : 'simulatedCW', $pb.PbFieldType.OU3, protoName: 'simulatedCW') + ..a<$core.int>(10, _omitFieldNames ? '' : 'simulatedWheelDiameter', $pb.PbFieldType.OU3, protoName: 'simulatedWheelDiameter') + ..a<$core.int>(11, _omitFieldNames ? '' : 'simulatedBikeMass', $pb.PbFieldType.OU3, protoName: 'simulatedBikeMass') + ..a<$core.int>(12, _omitFieldNames ? '' : 'simulatedRiderMass', $pb.PbFieldType.OU3, protoName: 'simulatedRiderMass') + ..a<$core.int>(13, _omitFieldNames ? '' : 'simulatedCRR', $pb.PbFieldType.OU3, protoName: 'simulatedCRR') + ..a<$core.int>(14, _omitFieldNames ? '' : 'simulatedFrontalArea', $pb.PbFieldType.OU3, protoName: 'simulatedFrontalArea') + ..a<$core.int>(15, _omitFieldNames ? '' : 'simulatedEBrake', $pb.PbFieldType.OU3, protoName: 'simulatedEBrake') + ..aOB(16, _omitFieldNames ? '' : 'highSpeedDataEnabled', protoName: 'highSpeedDataEnabled') + ..aOB(17, _omitFieldNames ? '' : 'ergPowerSmoothingEnabled', protoName: 'ergPowerSmoothingEnabled') + ..a<$core.int>(18, _omitFieldNames ? '' : 'virtualShiftingMode', $pb.PbFieldType.OU3, protoName: 'virtualShiftingMode') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TrainerCfgPage clone() => TrainerCfgPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerCfgPage copyWith(void Function(TrainerCfgPage) updates) => super.copyWith((message) => updates(message as TrainerCfgPage)) as TrainerCfgPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerCfgPage create() => TrainerCfgPage._(); + TrainerCfgPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerCfgPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerCfgPage? _defaultInstance; + + @$pb.TagNumber(1) + TrainerMode get trainerMode => $_getN(0); + @$pb.TagNumber(1) + set trainerMode(TrainerMode v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasTrainerMode() => $_has(0); + @$pb.TagNumber(1) + void clearTrainerMode() => clearField(1); + + @$pb.TagNumber(2) + $core.int get configuredResistance => $_getIZ(1); + @$pb.TagNumber(2) + set configuredResistance($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasConfiguredResistance() => $_has(1); + @$pb.TagNumber(2) + void clearConfiguredResistance() => clearField(2); + + @$pb.TagNumber(3) + $core.int get ergPower => $_getIZ(2); + @$pb.TagNumber(3) + set ergPower($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasErgPower() => $_has(2); + @$pb.TagNumber(3) + void clearErgPower() => clearField(3); + + @$pb.TagNumber(4) + $core.int get averagingWindow => $_getIZ(3); + @$pb.TagNumber(4) + set averagingWindow($core.int v) { $_setUnsignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasAveragingWindow() => $_has(3); + @$pb.TagNumber(4) + void clearAveragingWindow() => clearField(4); + + @$pb.TagNumber(5) + $core.int get simulatedWind => $_getIZ(4); + @$pb.TagNumber(5) + set simulatedWind($core.int v) { $_setSignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasSimulatedWind() => $_has(4); + @$pb.TagNumber(5) + void clearSimulatedWind() => clearField(5); + + @$pb.TagNumber(6) + $core.int get simulatedGrade => $_getIZ(5); + @$pb.TagNumber(6) + set simulatedGrade($core.int v) { $_setSignedInt32(5, v); } + @$pb.TagNumber(6) + $core.bool hasSimulatedGrade() => $_has(5); + @$pb.TagNumber(6) + void clearSimulatedGrade() => clearField(6); + + @$pb.TagNumber(7) + $core.int get simulatedRealGearRatio => $_getIZ(6); + @$pb.TagNumber(7) + set simulatedRealGearRatio($core.int v) { $_setUnsignedInt32(6, v); } + @$pb.TagNumber(7) + $core.bool hasSimulatedRealGearRatio() => $_has(6); + @$pb.TagNumber(7) + void clearSimulatedRealGearRatio() => clearField(7); + + @$pb.TagNumber(8) + $core.int get simulatedVirtualGearRatio => $_getIZ(7); + @$pb.TagNumber(8) + set simulatedVirtualGearRatio($core.int v) { $_setUnsignedInt32(7, v); } + @$pb.TagNumber(8) + $core.bool hasSimulatedVirtualGearRatio() => $_has(7); + @$pb.TagNumber(8) + void clearSimulatedVirtualGearRatio() => clearField(8); + + @$pb.TagNumber(9) + $core.int get simulatedCW => $_getIZ(8); + @$pb.TagNumber(9) + set simulatedCW($core.int v) { $_setUnsignedInt32(8, v); } + @$pb.TagNumber(9) + $core.bool hasSimulatedCW() => $_has(8); + @$pb.TagNumber(9) + void clearSimulatedCW() => clearField(9); + + @$pb.TagNumber(10) + $core.int get simulatedWheelDiameter => $_getIZ(9); + @$pb.TagNumber(10) + set simulatedWheelDiameter($core.int v) { $_setUnsignedInt32(9, v); } + @$pb.TagNumber(10) + $core.bool hasSimulatedWheelDiameter() => $_has(9); + @$pb.TagNumber(10) + void clearSimulatedWheelDiameter() => clearField(10); + + @$pb.TagNumber(11) + $core.int get simulatedBikeMass => $_getIZ(10); + @$pb.TagNumber(11) + set simulatedBikeMass($core.int v) { $_setUnsignedInt32(10, v); } + @$pb.TagNumber(11) + $core.bool hasSimulatedBikeMass() => $_has(10); + @$pb.TagNumber(11) + void clearSimulatedBikeMass() => clearField(11); + + @$pb.TagNumber(12) + $core.int get simulatedRiderMass => $_getIZ(11); + @$pb.TagNumber(12) + set simulatedRiderMass($core.int v) { $_setUnsignedInt32(11, v); } + @$pb.TagNumber(12) + $core.bool hasSimulatedRiderMass() => $_has(11); + @$pb.TagNumber(12) + void clearSimulatedRiderMass() => clearField(12); + + @$pb.TagNumber(13) + $core.int get simulatedCRR => $_getIZ(12); + @$pb.TagNumber(13) + set simulatedCRR($core.int v) { $_setUnsignedInt32(12, v); } + @$pb.TagNumber(13) + $core.bool hasSimulatedCRR() => $_has(12); + @$pb.TagNumber(13) + void clearSimulatedCRR() => clearField(13); + + @$pb.TagNumber(14) + $core.int get simulatedFrontalArea => $_getIZ(13); + @$pb.TagNumber(14) + set simulatedFrontalArea($core.int v) { $_setUnsignedInt32(13, v); } + @$pb.TagNumber(14) + $core.bool hasSimulatedFrontalArea() => $_has(13); + @$pb.TagNumber(14) + void clearSimulatedFrontalArea() => clearField(14); + + @$pb.TagNumber(15) + $core.int get simulatedEBrake => $_getIZ(14); + @$pb.TagNumber(15) + set simulatedEBrake($core.int v) { $_setUnsignedInt32(14, v); } + @$pb.TagNumber(15) + $core.bool hasSimulatedEBrake() => $_has(14); + @$pb.TagNumber(15) + void clearSimulatedEBrake() => clearField(15); + + @$pb.TagNumber(16) + $core.bool get highSpeedDataEnabled => $_getBF(15); + @$pb.TagNumber(16) + set highSpeedDataEnabled($core.bool v) { $_setBool(15, v); } + @$pb.TagNumber(16) + $core.bool hasHighSpeedDataEnabled() => $_has(15); + @$pb.TagNumber(16) + void clearHighSpeedDataEnabled() => clearField(16); + + @$pb.TagNumber(17) + $core.bool get ergPowerSmoothingEnabled => $_getBF(16); + @$pb.TagNumber(17) + set ergPowerSmoothingEnabled($core.bool v) { $_setBool(16, v); } + @$pb.TagNumber(17) + $core.bool hasErgPowerSmoothingEnabled() => $_has(16); + @$pb.TagNumber(17) + void clearErgPowerSmoothingEnabled() => clearField(17); + + @$pb.TagNumber(18) + $core.int get virtualShiftingMode => $_getIZ(17); + @$pb.TagNumber(18) + set virtualShiftingMode($core.int v) { $_setUnsignedInt32(17, v); } + @$pb.TagNumber(18) + $core.bool hasVirtualShiftingMode() => $_has(17); + @$pb.TagNumber(18) + void clearVirtualShiftingMode() => clearField(18); +} + +/// Page ID 0x021 +class TrainerGearIndexConfigPage extends $pb.GeneratedMessage { + factory TrainerGearIndexConfigPage({ + $core.int? frontGearIdx, + $core.int? frontGearIdxMax, + $core.int? frontGearIdxMin, + $core.int? rearGearIdx, + $core.int? rearGearIdxMax, + $core.int? rearGearIdxMin, + }) { + final $result = create(); + if (frontGearIdx != null) { + $result.frontGearIdx = frontGearIdx; + } + if (frontGearIdxMax != null) { + $result.frontGearIdxMax = frontGearIdxMax; + } + if (frontGearIdxMin != null) { + $result.frontGearIdxMin = frontGearIdxMin; + } + if (rearGearIdx != null) { + $result.rearGearIdx = rearGearIdx; + } + if (rearGearIdxMax != null) { + $result.rearGearIdxMax = rearGearIdxMax; + } + if (rearGearIdxMin != null) { + $result.rearGearIdxMin = rearGearIdxMin; + } + return $result; + } + TrainerGearIndexConfigPage._() : super(); + factory TrainerGearIndexConfigPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerGearIndexConfigPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerGearIndexConfigPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'frontGearIdx', $pb.PbFieldType.OU3, protoName: 'frontGearIdx') + ..a<$core.int>(2, _omitFieldNames ? '' : 'frontGearIdxMax', $pb.PbFieldType.OU3, protoName: 'frontGearIdxMax') + ..a<$core.int>(3, _omitFieldNames ? '' : 'frontGearIdxMin', $pb.PbFieldType.OU3, protoName: 'frontGearIdxMin') + ..a<$core.int>(4, _omitFieldNames ? '' : 'rearGearIdx', $pb.PbFieldType.OU3, protoName: 'rearGearIdx') + ..a<$core.int>(5, _omitFieldNames ? '' : 'rearGearIdxMax', $pb.PbFieldType.OU3, protoName: 'rearGearIdxMax') + ..a<$core.int>(6, _omitFieldNames ? '' : 'rearGearIdxMin', $pb.PbFieldType.OU3, protoName: 'rearGearIdxMin') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TrainerGearIndexConfigPage clone() => TrainerGearIndexConfigPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerGearIndexConfigPage copyWith(void Function(TrainerGearIndexConfigPage) updates) => super.copyWith((message) => updates(message as TrainerGearIndexConfigPage)) as TrainerGearIndexConfigPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerGearIndexConfigPage create() => TrainerGearIndexConfigPage._(); + TrainerGearIndexConfigPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerGearIndexConfigPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerGearIndexConfigPage? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get frontGearIdx => $_getIZ(0); + @$pb.TagNumber(1) + set frontGearIdx($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasFrontGearIdx() => $_has(0); + @$pb.TagNumber(1) + void clearFrontGearIdx() => clearField(1); + + @$pb.TagNumber(2) + $core.int get frontGearIdxMax => $_getIZ(1); + @$pb.TagNumber(2) + set frontGearIdxMax($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasFrontGearIdxMax() => $_has(1); + @$pb.TagNumber(2) + void clearFrontGearIdxMax() => clearField(2); + + @$pb.TagNumber(3) + $core.int get frontGearIdxMin => $_getIZ(2); + @$pb.TagNumber(3) + set frontGearIdxMin($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasFrontGearIdxMin() => $_has(2); + @$pb.TagNumber(3) + void clearFrontGearIdxMin() => clearField(3); + + @$pb.TagNumber(4) + $core.int get rearGearIdx => $_getIZ(3); + @$pb.TagNumber(4) + set rearGearIdx($core.int v) { $_setUnsignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasRearGearIdx() => $_has(3); + @$pb.TagNumber(4) + void clearRearGearIdx() => clearField(4); + + @$pb.TagNumber(5) + $core.int get rearGearIdxMax => $_getIZ(4); + @$pb.TagNumber(5) + set rearGearIdxMax($core.int v) { $_setUnsignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasRearGearIdxMax() => $_has(4); + @$pb.TagNumber(5) + void clearRearGearIdxMax() => clearField(5); + + @$pb.TagNumber(6) + $core.int get rearGearIdxMin => $_getIZ(5); + @$pb.TagNumber(6) + set rearGearIdxMin($core.int v) { $_setUnsignedInt32(5, v); } + @$pb.TagNumber(6) + $core.bool hasRearGearIdxMin() => $_has(5); + @$pb.TagNumber(6) + void clearRearGearIdxMin() => clearField(6); +} + +/// Page ID 0x023 +class DeviceTiltConfigPage extends $pb.GeneratedMessage { + factory DeviceTiltConfigPage({ + $core.bool? tiltEnabled, + $core.int? tiltGradientMin, + $core.int? tiltGradientMax, + $core.int? tiltGradient, + }) { + final $result = create(); + if (tiltEnabled != null) { + $result.tiltEnabled = tiltEnabled; + } + if (tiltGradientMin != null) { + $result.tiltGradientMin = tiltGradientMin; + } + if (tiltGradientMax != null) { + $result.tiltGradientMax = tiltGradientMax; + } + if (tiltGradient != null) { + $result.tiltGradient = tiltGradient; + } + return $result; + } + DeviceTiltConfigPage._() : super(); + factory DeviceTiltConfigPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DeviceTiltConfigPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DeviceTiltConfigPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..aOB(1, _omitFieldNames ? '' : 'tiltEnabled', protoName: 'tiltEnabled') + ..a<$core.int>(2, _omitFieldNames ? '' : 'tiltGradientMin', $pb.PbFieldType.OS3, protoName: 'tiltGradientMin') + ..a<$core.int>(3, _omitFieldNames ? '' : 'tiltGradientMax', $pb.PbFieldType.OS3, protoName: 'tiltGradientMax') + ..a<$core.int>(4, _omitFieldNames ? '' : 'tiltGradient', $pb.PbFieldType.OS3, protoName: 'tiltGradient') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DeviceTiltConfigPage clone() => DeviceTiltConfigPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DeviceTiltConfigPage copyWith(void Function(DeviceTiltConfigPage) updates) => super.copyWith((message) => updates(message as DeviceTiltConfigPage)) as DeviceTiltConfigPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DeviceTiltConfigPage create() => DeviceTiltConfigPage._(); + DeviceTiltConfigPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DeviceTiltConfigPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DeviceTiltConfigPage? _defaultInstance; + + @$pb.TagNumber(1) + $core.bool get tiltEnabled => $_getBF(0); + @$pb.TagNumber(1) + set tiltEnabled($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasTiltEnabled() => $_has(0); + @$pb.TagNumber(1) + void clearTiltEnabled() => clearField(1); + + @$pb.TagNumber(2) + $core.int get tiltGradientMin => $_getIZ(1); + @$pb.TagNumber(2) + set tiltGradientMin($core.int v) { $_setSignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasTiltGradientMin() => $_has(1); + @$pb.TagNumber(2) + void clearTiltGradientMin() => clearField(2); + + @$pb.TagNumber(3) + $core.int get tiltGradientMax => $_getIZ(2); + @$pb.TagNumber(3) + set tiltGradientMax($core.int v) { $_setSignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasTiltGradientMax() => $_has(2); + @$pb.TagNumber(3) + void clearTiltGradientMax() => clearField(3); + + @$pb.TagNumber(4) + $core.int get tiltGradient => $_getIZ(3); + @$pb.TagNumber(4) + set tiltGradient($core.int v) { $_setSignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasTiltGradient() => $_has(3); + @$pb.TagNumber(4) + void clearTiltGradient() => clearField(4); +} + +/// Page ID 0x040 +class ControllerInputConfigPage extends $pb.GeneratedMessage { + factory ControllerInputConfigPage({ + $core.int? supportedDigitalInputs, + $core.int? supportedAnalogInputs, + $core.Iterable? analogInputRange, + $core.Iterable? analogDeadZone, + }) { + final $result = create(); + if (supportedDigitalInputs != null) { + $result.supportedDigitalInputs = supportedDigitalInputs; + } + if (supportedAnalogInputs != null) { + $result.supportedAnalogInputs = supportedAnalogInputs; + } + if (analogInputRange != null) { + $result.analogInputRange.addAll(analogInputRange); + } + if (analogDeadZone != null) { + $result.analogDeadZone.addAll(analogDeadZone); + } + return $result; + } + ControllerInputConfigPage._() : super(); + factory ControllerInputConfigPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ControllerInputConfigPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ControllerInputConfigPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'supportedDigitalInputs', $pb.PbFieldType.OU3, protoName: 'supportedDigitalInputs') + ..a<$core.int>(2, _omitFieldNames ? '' : 'supportedAnalogInputs', $pb.PbFieldType.OU3, protoName: 'supportedAnalogInputs') + ..pc(3, _omitFieldNames ? '' : 'analogInputRange', $pb.PbFieldType.PM, protoName: 'analogInputRange', subBuilder: InputAnalogRange.create) + ..pc(4, _omitFieldNames ? '' : 'analogDeadZone', $pb.PbFieldType.PM, protoName: 'analogDeadZone', subBuilder: InputAnalogDeadzone.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') + ControllerInputConfigPage clone() => ControllerInputConfigPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + ControllerInputConfigPage copyWith(void Function(ControllerInputConfigPage) updates) => super.copyWith((message) => updates(message as ControllerInputConfigPage)) as ControllerInputConfigPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ControllerInputConfigPage create() => ControllerInputConfigPage._(); + ControllerInputConfigPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static ControllerInputConfigPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ControllerInputConfigPage? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get supportedDigitalInputs => $_getIZ(0); + @$pb.TagNumber(1) + set supportedDigitalInputs($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSupportedDigitalInputs() => $_has(0); + @$pb.TagNumber(1) + void clearSupportedDigitalInputs() => clearField(1); + + @$pb.TagNumber(2) + $core.int get supportedAnalogInputs => $_getIZ(1); + @$pb.TagNumber(2) + set supportedAnalogInputs($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasSupportedAnalogInputs() => $_has(1); + @$pb.TagNumber(2) + void clearSupportedAnalogInputs() => clearField(2); + + @$pb.TagNumber(3) + $core.List get analogInputRange => $_getList(2); + + @$pb.TagNumber(4) + $core.List get analogDeadZone => $_getList(3); +} + +/// Page 0x041 +class IpTransportPage extends $pb.GeneratedMessage { + factory IpTransportPage({ + $core.int? ipv4Address, + $core.int? tcpPort, + }) { + final $result = create(); + if (ipv4Address != null) { + $result.ipv4Address = ipv4Address; + } + if (tcpPort != null) { + $result.tcpPort = tcpPort; + } + return $result; + } + IpTransportPage._() : super(); + factory IpTransportPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory IpTransportPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'IpTransportPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'ipv4Address', $pb.PbFieldType.OU3, protoName: 'ipv4Address') + ..a<$core.int>(2, _omitFieldNames ? '' : 'tcpPort', $pb.PbFieldType.OU3, protoName: 'tcpPort') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + IpTransportPage clone() => IpTransportPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + IpTransportPage copyWith(void Function(IpTransportPage) updates) => super.copyWith((message) => updates(message as IpTransportPage)) as IpTransportPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static IpTransportPage create() => IpTransportPage._(); + IpTransportPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static IpTransportPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static IpTransportPage? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get ipv4Address => $_getIZ(0); + @$pb.TagNumber(1) + set ipv4Address($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasIpv4Address() => $_has(0); + @$pb.TagNumber(1) + void clearIpv4Address() => clearField(1); + + @$pb.TagNumber(2) + $core.int get tcpPort => $_getIZ(1); + @$pb.TagNumber(2) + set tcpPort($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasTcpPort() => $_has(1); + @$pb.TagNumber(2) + void clearTcpPort() => clearField(2); +} + +/// Page 0x042 +class WifiConfigurationPage extends $pb.GeneratedMessage { + factory WifiConfigurationPage({ + $core.bool? wifiEnabled, + $core.int? wifiStatus, + $core.List<$core.int>? wifiSsid, + $core.int? wifiBand, + $core.int? wifiRssi, + $core.List<$core.int>? wifiRegionCode, + }) { + final $result = create(); + if (wifiEnabled != null) { + $result.wifiEnabled = wifiEnabled; + } + if (wifiStatus != null) { + $result.wifiStatus = wifiStatus; + } + if (wifiSsid != null) { + $result.wifiSsid = wifiSsid; + } + if (wifiBand != null) { + $result.wifiBand = wifiBand; + } + if (wifiRssi != null) { + $result.wifiRssi = wifiRssi; + } + if (wifiRegionCode != null) { + $result.wifiRegionCode = wifiRegionCode; + } + return $result; + } + WifiConfigurationPage._() : super(); + factory WifiConfigurationPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory WifiConfigurationPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'WifiConfigurationPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..aOB(1, _omitFieldNames ? '' : 'wifiEnabled', protoName: 'wifiEnabled') + ..a<$core.int>(2, _omitFieldNames ? '' : 'wifiStatus', $pb.PbFieldType.OU3, protoName: 'wifiStatus') + ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'wifiSsid', $pb.PbFieldType.OY, protoName: 'wifiSsid') + ..a<$core.int>(4, _omitFieldNames ? '' : 'wifiBand', $pb.PbFieldType.OU3, protoName: 'wifiBand') + ..a<$core.int>(5, _omitFieldNames ? '' : 'wifiRssi', $pb.PbFieldType.OS3, protoName: 'wifiRssi') + ..a<$core.List<$core.int>>(6, _omitFieldNames ? '' : 'wifiRegionCode', $pb.PbFieldType.OY, protoName: 'wifiRegionCode') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + WifiConfigurationPage clone() => WifiConfigurationPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + WifiConfigurationPage copyWith(void Function(WifiConfigurationPage) updates) => super.copyWith((message) => updates(message as WifiConfigurationPage)) as WifiConfigurationPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WifiConfigurationPage create() => WifiConfigurationPage._(); + WifiConfigurationPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static WifiConfigurationPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static WifiConfigurationPage? _defaultInstance; + + @$pb.TagNumber(1) + $core.bool get wifiEnabled => $_getBF(0); + @$pb.TagNumber(1) + set wifiEnabled($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasWifiEnabled() => $_has(0); + @$pb.TagNumber(1) + void clearWifiEnabled() => clearField(1); + + @$pb.TagNumber(2) + $core.int get wifiStatus => $_getIZ(1); + @$pb.TagNumber(2) + set wifiStatus($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasWifiStatus() => $_has(1); + @$pb.TagNumber(2) + void clearWifiStatus() => clearField(2); + + @$pb.TagNumber(3) + $core.List<$core.int> get wifiSsid => $_getN(2); + @$pb.TagNumber(3) + set wifiSsid($core.List<$core.int> v) { $_setBytes(2, v); } + @$pb.TagNumber(3) + $core.bool hasWifiSsid() => $_has(2); + @$pb.TagNumber(3) + void clearWifiSsid() => clearField(3); + + @$pb.TagNumber(4) + $core.int get wifiBand => $_getIZ(3); + @$pb.TagNumber(4) + set wifiBand($core.int v) { $_setUnsignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasWifiBand() => $_has(3); + @$pb.TagNumber(4) + void clearWifiBand() => clearField(4); + + @$pb.TagNumber(5) + $core.int get wifiRssi => $_getIZ(4); + @$pb.TagNumber(5) + set wifiRssi($core.int v) { $_setSignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasWifiRssi() => $_has(4); + @$pb.TagNumber(5) + void clearWifiRssi() => clearField(5); + + @$pb.TagNumber(6) + $core.List<$core.int> get wifiRegionCode => $_getN(5); + @$pb.TagNumber(6) + set wifiRegionCode($core.List<$core.int> v) { $_setBytes(5, v); } + @$pb.TagNumber(6) + $core.bool hasWifiRegionCode() => $_has(5); + @$pb.TagNumber(6) + void clearWifiRegionCode() => clearField(6); +} + +/// Page ID 0x050 +class SensorRelayDataPage extends $pb.GeneratedMessage { + factory SensorRelayDataPage({ + $core.Iterable<$core.int>? supportedSensors, + $core.Iterable<$core.int>? pairedSensors, + }) { + final $result = create(); + if (supportedSensors != null) { + $result.supportedSensors.addAll(supportedSensors); + } + if (pairedSensors != null) { + $result.pairedSensors.addAll(pairedSensors); + } + return $result; + } + SensorRelayDataPage._() : super(); + factory SensorRelayDataPage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SensorRelayDataPage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SensorRelayDataPage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..p<$core.int>(1, _omitFieldNames ? '' : 'supportedSensors', $pb.PbFieldType.PU3, protoName: 'supportedSensors') + ..p<$core.int>(2, _omitFieldNames ? '' : 'pairedSensors', $pb.PbFieldType.PU3, protoName: 'pairedSensors') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SensorRelayDataPage clone() => SensorRelayDataPage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SensorRelayDataPage copyWith(void Function(SensorRelayDataPage) updates) => super.copyWith((message) => updates(message as SensorRelayDataPage)) as SensorRelayDataPage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SensorRelayDataPage create() => SensorRelayDataPage._(); + SensorRelayDataPage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SensorRelayDataPage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SensorRelayDataPage? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.int> get supportedSensors => $_getList(0); + + @$pb.TagNumber(2) + $core.List<$core.int> get pairedSensors => $_getList(1); +} + +/// OpCode 0x00 +class Get extends $pb.GeneratedMessage { + factory Get({ + $core.int? dataObjectId, + }) { + final $result = create(); + if (dataObjectId != null) { + $result.dataObjectId = dataObjectId; + } + return $result; + } + Get._() : super(); + factory Get.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Get.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Get', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'dataObjectId', $pb.PbFieldType.QU3, protoName: 'dataObjectId') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Get clone() => Get()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Get copyWith(void Function(Get) updates) => super.copyWith((message) => updates(message as Get)) as Get; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Get create() => Get._(); + Get createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Get getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Get? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get dataObjectId => $_getIZ(0); + @$pb.TagNumber(1) + set dataObjectId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasDataObjectId() => $_has(0); + @$pb.TagNumber(1) + void clearDataObjectId() => clearField(1); +} + +/// OpCode 0x01 +class DevInfo extends $pb.GeneratedMessage { + factory DevInfo({ + DevInfoPage? devInfo, + }) { + final $result = create(); + if (devInfo != null) { + $result.devInfo = devInfo; + } + return $result; + } + DevInfo._() : super(); + factory DevInfo.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DevInfo.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DevInfo', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..aQM(1, _omitFieldNames ? '' : 'devInfo', protoName: 'devInfo', subBuilder: DevInfoPage.create) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DevInfo clone() => DevInfo()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DevInfo copyWith(void Function(DevInfo) updates) => super.copyWith((message) => updates(message as DevInfo)) as DevInfo; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DevInfo create() => DevInfo._(); + DevInfo createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DevInfo getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DevInfo? _defaultInstance; + + @$pb.TagNumber(1) + DevInfoPage get devInfo => $_getN(0); + @$pb.TagNumber(1) + set devInfo(DevInfoPage v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDevInfo() => $_has(0); + @$pb.TagNumber(1) + void clearDevInfo() => clearField(1); + @$pb.TagNumber(1) + DevInfoPage ensureDevInfo() => $_ensure(0); +} + +enum BleSecurityRequest_Request { + startSecureConnection, + notSet +} + +/// OpCode 0x02 +class BleSecurityRequest extends $pb.GeneratedMessage { + factory BleSecurityRequest({ + $core.bool? startSecureConnection, + }) { + final $result = create(); + if (startSecureConnection != null) { + $result.startSecureConnection = startSecureConnection; + } + return $result; + } + BleSecurityRequest._() : super(); + factory BleSecurityRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory BleSecurityRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, BleSecurityRequest_Request> _BleSecurityRequest_RequestByTag = { + 1 : BleSecurityRequest_Request.startSecureConnection, + 0 : BleSecurityRequest_Request.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BleSecurityRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1]) + ..aOB(1, _omitFieldNames ? '' : 'startSecureConnection', protoName: 'startSecureConnection') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + BleSecurityRequest clone() => BleSecurityRequest()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + BleSecurityRequest copyWith(void Function(BleSecurityRequest) updates) => super.copyWith((message) => updates(message as BleSecurityRequest)) as BleSecurityRequest; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BleSecurityRequest create() => BleSecurityRequest._(); + BleSecurityRequest createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static BleSecurityRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static BleSecurityRequest? _defaultInstance; + + BleSecurityRequest_Request whichRequest() => _BleSecurityRequest_RequestByTag[$_whichOneof(0)]!; + void clearRequest() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $core.bool get startSecureConnection => $_getBF(0); + @$pb.TagNumber(1) + set startSecureConnection($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasStartSecureConnection() => $_has(0); + @$pb.TagNumber(1) + void clearStartSecureConnection() => clearField(1); +} + +/// OpCode 0x03 +class TrainerNotification extends $pb.GeneratedMessage { + factory TrainerNotification({ + $core.int? power, + $core.int? cadence, + $core.int? bikeSpeed, + $core.int? averagedPower, + $core.int? wheelSpeed, + $core.int? calculatedRealGearRatio, + }) { + final $result = create(); + if (power != null) { + $result.power = power; + } + if (cadence != null) { + $result.cadence = cadence; + } + if (bikeSpeed != null) { + $result.bikeSpeed = bikeSpeed; + } + if (averagedPower != null) { + $result.averagedPower = averagedPower; + } + if (wheelSpeed != null) { + $result.wheelSpeed = wheelSpeed; + } + if (calculatedRealGearRatio != null) { + $result.calculatedRealGearRatio = calculatedRealGearRatio; + } + return $result; + } + TrainerNotification._() : super(); + factory TrainerNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'power', $pb.PbFieldType.QU3) + ..a<$core.int>(2, _omitFieldNames ? '' : 'cadence', $pb.PbFieldType.QU3) + ..a<$core.int>(3, _omitFieldNames ? '' : 'bikeSpeed', $pb.PbFieldType.QU3, protoName: 'bikeSpeed') + ..a<$core.int>(4, _omitFieldNames ? '' : 'averagedPower', $pb.PbFieldType.QU3, protoName: 'averagedPower') + ..a<$core.int>(5, _omitFieldNames ? '' : 'wheelSpeed', $pb.PbFieldType.QU3, protoName: 'wheelSpeed') + ..a<$core.int>(6, _omitFieldNames ? '' : 'calculatedRealGearRatio', $pb.PbFieldType.OU3, protoName: 'calculatedRealGearRatio') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TrainerNotification clone() => TrainerNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerNotification copyWith(void Function(TrainerNotification) updates) => super.copyWith((message) => updates(message as TrainerNotification)) as TrainerNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerNotification create() => TrainerNotification._(); + TrainerNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerNotification? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get power => $_getIZ(0); + @$pb.TagNumber(1) + set power($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasPower() => $_has(0); + @$pb.TagNumber(1) + void clearPower() => clearField(1); + + @$pb.TagNumber(2) + $core.int get cadence => $_getIZ(1); + @$pb.TagNumber(2) + set cadence($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasCadence() => $_has(1); + @$pb.TagNumber(2) + void clearCadence() => clearField(2); + + @$pb.TagNumber(3) + $core.int get bikeSpeed => $_getIZ(2); + @$pb.TagNumber(3) + set bikeSpeed($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasBikeSpeed() => $_has(2); + @$pb.TagNumber(3) + void clearBikeSpeed() => clearField(3); + + @$pb.TagNumber(4) + $core.int get averagedPower => $_getIZ(3); + @$pb.TagNumber(4) + set averagedPower($core.int v) { $_setUnsignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasAveragedPower() => $_has(3); + @$pb.TagNumber(4) + void clearAveragedPower() => clearField(4); + + @$pb.TagNumber(5) + $core.int get wheelSpeed => $_getIZ(4); + @$pb.TagNumber(5) + set wheelSpeed($core.int v) { $_setUnsignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasWheelSpeed() => $_has(4); + @$pb.TagNumber(5) + void clearWheelSpeed() => clearField(5); + + @$pb.TagNumber(6) + $core.int get calculatedRealGearRatio => $_getIZ(5); + @$pb.TagNumber(6) + set calculatedRealGearRatio($core.int v) { $_setUnsignedInt32(5, v); } + @$pb.TagNumber(6) + $core.bool hasCalculatedRealGearRatio() => $_has(5); + @$pb.TagNumber(6) + void clearCalculatedRealGearRatio() => clearField(6); +} + +enum TrainerConfigSet_Config { + configuredResistance, + ergPower, + envSim, + bikeSim, + averagingWindow, + trainerOptions, + notSet +} + +/// OpCode 0x04 +class TrainerConfigSet extends $pb.GeneratedMessage { + factory TrainerConfigSet({ + $core.int? configuredResistance, + $core.int? ergPower, + TrainerEnvSim? envSim, + TrainerBikeSim? bikeSim, + $core.int? averagingWindow, + TrainerOptions? trainerOptions, + }) { + final $result = create(); + if (configuredResistance != null) { + $result.configuredResistance = configuredResistance; + } + if (ergPower != null) { + $result.ergPower = ergPower; + } + if (envSim != null) { + $result.envSim = envSim; + } + if (bikeSim != null) { + $result.bikeSim = bikeSim; + } + if (averagingWindow != null) { + $result.averagingWindow = averagingWindow; + } + if (trainerOptions != null) { + $result.trainerOptions = trainerOptions; + } + return $result; + } + TrainerConfigSet._() : super(); + factory TrainerConfigSet.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerConfigSet.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, TrainerConfigSet_Config> _TrainerConfigSet_ConfigByTag = { + 2 : TrainerConfigSet_Config.configuredResistance, + 3 : TrainerConfigSet_Config.ergPower, + 4 : TrainerConfigSet_Config.envSim, + 5 : TrainerConfigSet_Config.bikeSim, + 6 : TrainerConfigSet_Config.averagingWindow, + 7 : TrainerConfigSet_Config.trainerOptions, + 0 : TrainerConfigSet_Config.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerConfigSet', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [2, 3, 4, 5, 6, 7]) + ..a<$core.int>(2, _omitFieldNames ? '' : 'configuredResistance', $pb.PbFieldType.OU3, protoName: 'configuredResistance') + ..a<$core.int>(3, _omitFieldNames ? '' : 'ergPower', $pb.PbFieldType.OU3, protoName: 'ergPower') + ..aOM(4, _omitFieldNames ? '' : 'envSim', protoName: 'envSim', subBuilder: TrainerEnvSim.create) + ..aOM(5, _omitFieldNames ? '' : 'bikeSim', protoName: 'bikeSim', subBuilder: TrainerBikeSim.create) + ..a<$core.int>(6, _omitFieldNames ? '' : 'averagingWindow', $pb.PbFieldType.OU3, protoName: 'averagingWindow') + ..aOM(7, _omitFieldNames ? '' : 'trainerOptions', protoName: 'trainerOptions', subBuilder: TrainerOptions.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') + TrainerConfigSet clone() => TrainerConfigSet()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerConfigSet copyWith(void Function(TrainerConfigSet) updates) => super.copyWith((message) => updates(message as TrainerConfigSet)) as TrainerConfigSet; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerConfigSet create() => TrainerConfigSet._(); + TrainerConfigSet createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerConfigSet getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerConfigSet? _defaultInstance; + + TrainerConfigSet_Config whichConfig() => _TrainerConfigSet_ConfigByTag[$_whichOneof(0)]!; + void clearConfig() => clearField($_whichOneof(0)); + + @$pb.TagNumber(2) + $core.int get configuredResistance => $_getIZ(0); + @$pb.TagNumber(2) + set configuredResistance($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(2) + $core.bool hasConfiguredResistance() => $_has(0); + @$pb.TagNumber(2) + void clearConfiguredResistance() => clearField(2); + + @$pb.TagNumber(3) + $core.int get ergPower => $_getIZ(1); + @$pb.TagNumber(3) + set ergPower($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(3) + $core.bool hasErgPower() => $_has(1); + @$pb.TagNumber(3) + void clearErgPower() => clearField(3); + + @$pb.TagNumber(4) + TrainerEnvSim get envSim => $_getN(2); + @$pb.TagNumber(4) + set envSim(TrainerEnvSim v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasEnvSim() => $_has(2); + @$pb.TagNumber(4) + void clearEnvSim() => clearField(4); + @$pb.TagNumber(4) + TrainerEnvSim ensureEnvSim() => $_ensure(2); + + @$pb.TagNumber(5) + TrainerBikeSim get bikeSim => $_getN(3); + @$pb.TagNumber(5) + set bikeSim(TrainerBikeSim v) { setField(5, v); } + @$pb.TagNumber(5) + $core.bool hasBikeSim() => $_has(3); + @$pb.TagNumber(5) + void clearBikeSim() => clearField(5); + @$pb.TagNumber(5) + TrainerBikeSim ensureBikeSim() => $_ensure(3); + + @$pb.TagNumber(6) + $core.int get averagingWindow => $_getIZ(4); + @$pb.TagNumber(6) + set averagingWindow($core.int v) { $_setUnsignedInt32(4, v); } + @$pb.TagNumber(6) + $core.bool hasAveragingWindow() => $_has(4); + @$pb.TagNumber(6) + void clearAveragingWindow() => clearField(6); + + @$pb.TagNumber(7) + TrainerOptions get trainerOptions => $_getN(5); + @$pb.TagNumber(7) + set trainerOptions(TrainerOptions v) { setField(7, v); } + @$pb.TagNumber(7) + $core.bool hasTrainerOptions() => $_has(5); + @$pb.TagNumber(7) + void clearTrainerOptions() => clearField(7); + @$pb.TagNumber(7) + TrainerOptions ensureTrainerOptions() => $_ensure(5); +} + +/// OpCode 0x05 +class TrainerConfigStatus extends $pb.GeneratedMessage { + factory TrainerConfigStatus({ + TrainerCfgPage? trainerCfg, + }) { + final $result = create(); + if (trainerCfg != null) { + $result.trainerCfg = trainerCfg; + } + return $result; + } + TrainerConfigStatus._() : super(); + factory TrainerConfigStatus.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TrainerConfigStatus.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TrainerConfigStatus', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..aQM(1, _omitFieldNames ? '' : 'trainerCfg', protoName: 'trainerCfg', subBuilder: TrainerCfgPage.create) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TrainerConfigStatus clone() => TrainerConfigStatus()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TrainerConfigStatus copyWith(void Function(TrainerConfigStatus) updates) => super.copyWith((message) => updates(message as TrainerConfigStatus)) as TrainerConfigStatus; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TrainerConfigStatus create() => TrainerConfigStatus._(); + TrainerConfigStatus createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TrainerConfigStatus getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TrainerConfigStatus? _defaultInstance; + + @$pb.TagNumber(1) + TrainerCfgPage get trainerCfg => $_getN(0); + @$pb.TagNumber(1) + set trainerCfg(TrainerCfgPage v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasTrainerCfg() => $_has(0); + @$pb.TagNumber(1) + void clearTrainerCfg() => clearField(1); + @$pb.TagNumber(1) + TrainerCfgPage ensureTrainerCfg() => $_ensure(0); +} + +enum DevInfoSet_DevInfo { + deviceName, + notSet +} + +/// OpCode 0x0C +class DevInfoSet extends $pb.GeneratedMessage { + factory DevInfoSet({ + $core.List<$core.int>? deviceName, + }) { + final $result = create(); + if (deviceName != null) { + $result.deviceName = deviceName; + } + return $result; + } + DevInfoSet._() : super(); + factory DevInfoSet.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DevInfoSet.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, DevInfoSet_DevInfo> _DevInfoSet_DevInfoByTag = { + 1 : DevInfoSet_DevInfo.deviceName, + 0 : DevInfoSet_DevInfo.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DevInfoSet', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1]) + ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'deviceName', $pb.PbFieldType.OY, protoName: 'deviceName') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DevInfoSet clone() => DevInfoSet()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DevInfoSet copyWith(void Function(DevInfoSet) updates) => super.copyWith((message) => updates(message as DevInfoSet)) as DevInfoSet; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DevInfoSet create() => DevInfoSet._(); + DevInfoSet createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DevInfoSet getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DevInfoSet? _defaultInstance; + + DevInfoSet_DevInfo whichDevInfo() => _DevInfoSet_DevInfoByTag[$_whichOneof(0)]!; + void clearDevInfo() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $core.List<$core.int> get deviceName => $_getN(0); + @$pb.TagNumber(1) + set deviceName($core.List<$core.int> v) { $_setBytes(0, v); } + @$pb.TagNumber(1) + $core.bool hasDeviceName() => $_has(0); + @$pb.TagNumber(1) + void clearDeviceName() => clearField(1); +} + +/// OpCode 0x18 +class Reset extends $pb.GeneratedMessage { + factory Reset({ + $core.int? resetProcedure, + }) { + final $result = create(); + if (resetProcedure != null) { + $result.resetProcedure = resetProcedure; + } + return $result; + } + Reset._() : super(); + factory Reset.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Reset.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Reset', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'resetProcedure', $pb.PbFieldType.OU3, protoName: 'resetProcedure') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Reset clone() => Reset()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Reset copyWith(void Function(Reset) updates) => super.copyWith((message) => updates(message as Reset)) as Reset; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Reset create() => Reset._(); + Reset createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Reset getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Reset? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get resetProcedure => $_getIZ(0); + @$pb.TagNumber(1) + set resetProcedure($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasResetProcedure() => $_has(0); + @$pb.TagNumber(1) + void clearResetProcedure() => clearField(1); +} + +enum BatteryNotification_Alert { + newChgState, + newPercLevel, + notSet +} + +/// OpCode 0x19 +class BatteryNotification extends $pb.GeneratedMessage { + factory BatteryNotification({ + ChargingState? newChgState, + $core.int? newPercLevel, + $core.int? source, + }) { + final $result = create(); + if (newChgState != null) { + $result.newChgState = newChgState; + } + if (newPercLevel != null) { + $result.newPercLevel = newPercLevel; + } + if (source != null) { + $result.source = source; + } + return $result; + } + BatteryNotification._() : super(); + factory BatteryNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory BatteryNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, BatteryNotification_Alert> _BatteryNotification_AlertByTag = { + 1 : BatteryNotification_Alert.newChgState, + 2 : BatteryNotification_Alert.newPercLevel, + 0 : BatteryNotification_Alert.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BatteryNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..e(1, _omitFieldNames ? '' : 'newChgState', $pb.PbFieldType.OE, protoName: 'newChgState', defaultOrMaker: ChargingState.CHARGING_IDLE, valueOf: ChargingState.valueOf, enumValues: ChargingState.values) + ..a<$core.int>(2, _omitFieldNames ? '' : 'newPercLevel', $pb.PbFieldType.OU3, protoName: 'newPercLevel') + ..a<$core.int>(3, _omitFieldNames ? '' : 'source', $pb.PbFieldType.OU3) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + BatteryNotification clone() => BatteryNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + BatteryNotification copyWith(void Function(BatteryNotification) updates) => super.copyWith((message) => updates(message as BatteryNotification)) as BatteryNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BatteryNotification create() => BatteryNotification._(); + BatteryNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static BatteryNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static BatteryNotification? _defaultInstance; + + BatteryNotification_Alert whichAlert() => _BatteryNotification_AlertByTag[$_whichOneof(0)]!; + void clearAlert() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + ChargingState get newChgState => $_getN(0); + @$pb.TagNumber(1) + set newChgState(ChargingState v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasNewChgState() => $_has(0); + @$pb.TagNumber(1) + void clearNewChgState() => clearField(1); + + @$pb.TagNumber(2) + $core.int get newPercLevel => $_getIZ(1); + @$pb.TagNumber(2) + set newPercLevel($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasNewPercLevel() => $_has(1); + @$pb.TagNumber(2) + void clearNewPercLevel() => clearField(2); + + @$pb.TagNumber(3) + $core.int get source => $_getIZ(2); + @$pb.TagNumber(3) + set source($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasSource() => $_has(2); + @$pb.TagNumber(3) + void clearSource() => clearField(3); +} + +/// OpCode 0x1A +class BatteryStatus extends $pb.GeneratedMessage { + factory BatteryStatus({ + ChargingState? chgState, + $core.int? percLevel, + $core.int? timeToEmpty, + $core.int? timeToFull, + }) { + final $result = create(); + if (chgState != null) { + $result.chgState = chgState; + } + if (percLevel != null) { + $result.percLevel = percLevel; + } + if (timeToEmpty != null) { + $result.timeToEmpty = timeToEmpty; + } + if (timeToFull != null) { + $result.timeToFull = timeToFull; + } + return $result; + } + BatteryStatus._() : super(); + factory BatteryStatus.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory BatteryStatus.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'BatteryStatus', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'chgState', $pb.PbFieldType.QE, protoName: 'chgState', defaultOrMaker: ChargingState.CHARGING_IDLE, valueOf: ChargingState.valueOf, enumValues: ChargingState.values) + ..a<$core.int>(2, _omitFieldNames ? '' : 'percLevel', $pb.PbFieldType.QU3, protoName: 'percLevel') + ..a<$core.int>(3, _omitFieldNames ? '' : 'timeToEmpty', $pb.PbFieldType.QU3, protoName: 'timeToEmpty') + ..a<$core.int>(4, _omitFieldNames ? '' : 'timeToFull', $pb.PbFieldType.QU3, protoName: 'timeToFull') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + BatteryStatus clone() => BatteryStatus()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + BatteryStatus copyWith(void Function(BatteryStatus) updates) => super.copyWith((message) => updates(message as BatteryStatus)) as BatteryStatus; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BatteryStatus create() => BatteryStatus._(); + BatteryStatus createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static BatteryStatus getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static BatteryStatus? _defaultInstance; + + @$pb.TagNumber(1) + ChargingState get chgState => $_getN(0); + @$pb.TagNumber(1) + set chgState(ChargingState v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasChgState() => $_has(0); + @$pb.TagNumber(1) + void clearChgState() => clearField(1); + + @$pb.TagNumber(2) + $core.int get percLevel => $_getIZ(1); + @$pb.TagNumber(2) + set percLevel($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasPercLevel() => $_has(1); + @$pb.TagNumber(2) + void clearPercLevel() => clearField(2); + + @$pb.TagNumber(3) + $core.int get timeToEmpty => $_getIZ(2); + @$pb.TagNumber(3) + set timeToEmpty($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasTimeToEmpty() => $_has(2); + @$pb.TagNumber(3) + void clearTimeToEmpty() => clearField(3); + + @$pb.TagNumber(4) + $core.int get timeToFull => $_getIZ(3); + @$pb.TagNumber(4) + set timeToFull($core.int v) { $_setUnsignedInt32(3, v); } + @$pb.TagNumber(4) + $core.bool hasTimeToFull() => $_has(3); + @$pb.TagNumber(4) + void clearTimeToFull() => clearField(4); +} + +/// OpCode 0x23 +class ControllerNotification extends $pb.GeneratedMessage { + factory ControllerNotification({ + $core.int? buttonEvent, + $core.Iterable? analogEventList, + }) { + final $result = create(); + if (buttonEvent != null) { + $result.buttonEvent = buttonEvent; + } + if (analogEventList != null) { + $result.analogEventList.addAll(analogEventList); + } + return $result; + } + ControllerNotification._() : super(); + factory ControllerNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory ControllerNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ControllerNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'buttonEvent', $pb.PbFieldType.OU3, protoName: 'buttonEvent') + ..pc(3, _omitFieldNames ? '' : 'analogEventList', $pb.PbFieldType.PM, protoName: 'analogEventList', subBuilder: ControllerAnalogEvent.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') + ControllerNotification clone() => ControllerNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + ControllerNotification copyWith(void Function(ControllerNotification) updates) => super.copyWith((message) => updates(message as ControllerNotification)) as ControllerNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ControllerNotification create() => ControllerNotification._(); + ControllerNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static ControllerNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static ControllerNotification? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get buttonEvent => $_getIZ(0); + @$pb.TagNumber(1) + set buttonEvent($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasButtonEvent() => $_has(0); + @$pb.TagNumber(1) + void clearButtonEvent() => clearField(1); + + @$pb.TagNumber(3) + $core.List get analogEventList => $_getList(1); +} + +class LogDataNotification_ConnectionParameters extends $pb.GeneratedMessage { + factory LogDataNotification_ConnectionParameters({ + $core.int? interval, + $core.int? latency, + $core.int? supervisorTimeout, + $core.List<$core.int>? bleChannelMap, + $core.int? mtuSize, + }) { + final $result = create(); + if (interval != null) { + $result.interval = interval; + } + if (latency != null) { + $result.latency = latency; + } + if (supervisorTimeout != null) { + $result.supervisorTimeout = supervisorTimeout; + } + if (bleChannelMap != null) { + $result.bleChannelMap = bleChannelMap; + } + if (mtuSize != null) { + $result.mtuSize = mtuSize; + } + return $result; + } + LogDataNotification_ConnectionParameters._() : super(); + factory LogDataNotification_ConnectionParameters.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory LogDataNotification_ConnectionParameters.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'LogDataNotification.ConnectionParameters', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'interval', $pb.PbFieldType.QU3) + ..a<$core.int>(2, _omitFieldNames ? '' : 'latency', $pb.PbFieldType.QU3) + ..a<$core.int>(3, _omitFieldNames ? '' : 'supervisorTimeout', $pb.PbFieldType.QU3, protoName: 'supervisorTimeout') + ..a<$core.List<$core.int>>(4, _omitFieldNames ? '' : 'bleChannelMap', $pb.PbFieldType.QY, protoName: 'bleChannelMap') + ..a<$core.int>(5, _omitFieldNames ? '' : 'mtuSize', $pb.PbFieldType.OU3, protoName: 'mtuSize') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + LogDataNotification_ConnectionParameters clone() => LogDataNotification_ConnectionParameters()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + LogDataNotification_ConnectionParameters copyWith(void Function(LogDataNotification_ConnectionParameters) updates) => super.copyWith((message) => updates(message as LogDataNotification_ConnectionParameters)) as LogDataNotification_ConnectionParameters; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static LogDataNotification_ConnectionParameters create() => LogDataNotification_ConnectionParameters._(); + LogDataNotification_ConnectionParameters createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static LogDataNotification_ConnectionParameters getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static LogDataNotification_ConnectionParameters? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get interval => $_getIZ(0); + @$pb.TagNumber(1) + set interval($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasInterval() => $_has(0); + @$pb.TagNumber(1) + void clearInterval() => clearField(1); + + @$pb.TagNumber(2) + $core.int get latency => $_getIZ(1); + @$pb.TagNumber(2) + set latency($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasLatency() => $_has(1); + @$pb.TagNumber(2) + void clearLatency() => clearField(2); + + @$pb.TagNumber(3) + $core.int get supervisorTimeout => $_getIZ(2); + @$pb.TagNumber(3) + set supervisorTimeout($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasSupervisorTimeout() => $_has(2); + @$pb.TagNumber(3) + void clearSupervisorTimeout() => clearField(3); + + @$pb.TagNumber(4) + $core.List<$core.int> get bleChannelMap => $_getN(3); + @$pb.TagNumber(4) + set bleChannelMap($core.List<$core.int> v) { $_setBytes(3, v); } + @$pb.TagNumber(4) + $core.bool hasBleChannelMap() => $_has(3); + @$pb.TagNumber(4) + void clearBleChannelMap() => clearField(4); + + @$pb.TagNumber(5) + $core.int get mtuSize => $_getIZ(4); + @$pb.TagNumber(5) + set mtuSize($core.int v) { $_setUnsignedInt32(4, v); } + @$pb.TagNumber(5) + $core.bool hasMtuSize() => $_has(4); + @$pb.TagNumber(5) + void clearMtuSize() => clearField(5); +} + +class LogDataNotification_EnergySurveySummary extends $pb.GeneratedMessage { + factory LogDataNotification_EnergySurveySummary({ + $core.int? samples, + $core.List<$core.int>? averageEnergy, + }) { + final $result = create(); + if (samples != null) { + $result.samples = samples; + } + if (averageEnergy != null) { + $result.averageEnergy = averageEnergy; + } + return $result; + } + LogDataNotification_EnergySurveySummary._() : super(); + factory LogDataNotification_EnergySurveySummary.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory LogDataNotification_EnergySurveySummary.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'LogDataNotification.EnergySurveySummary', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'samples', $pb.PbFieldType.QU3) + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'averageEnergy', $pb.PbFieldType.QY, protoName: 'averageEnergy') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + LogDataNotification_EnergySurveySummary clone() => LogDataNotification_EnergySurveySummary()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + LogDataNotification_EnergySurveySummary copyWith(void Function(LogDataNotification_EnergySurveySummary) updates) => super.copyWith((message) => updates(message as LogDataNotification_EnergySurveySummary)) as LogDataNotification_EnergySurveySummary; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static LogDataNotification_EnergySurveySummary create() => LogDataNotification_EnergySurveySummary._(); + LogDataNotification_EnergySurveySummary createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static LogDataNotification_EnergySurveySummary getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static LogDataNotification_EnergySurveySummary? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get samples => $_getIZ(0); + @$pb.TagNumber(1) + set samples($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSamples() => $_has(0); + @$pb.TagNumber(1) + void clearSamples() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get averageEnergy => $_getN(1); + @$pb.TagNumber(2) + set averageEnergy($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasAverageEnergy() => $_has(1); + @$pb.TagNumber(2) + void clearAverageEnergy() => clearField(2); +} + +enum LogDataNotification_DataLogObject_Event { + logData, + connectionParam, + energySurvey, + logString, + notSet +} + +class LogDataNotification_DataLogObject extends $pb.GeneratedMessage { + factory LogDataNotification_DataLogObject({ + $core.List<$core.int>? logData, + LogDataNotification_ConnectionParameters? connectionParam, + LogDataNotification_EnergySurveySummary? energySurvey, + $core.List<$core.int>? logString, + }) { + final $result = create(); + if (logData != null) { + $result.logData = logData; + } + if (connectionParam != null) { + $result.connectionParam = connectionParam; + } + if (energySurvey != null) { + $result.energySurvey = energySurvey; + } + if (logString != null) { + $result.logString = logString; + } + return $result; + } + LogDataNotification_DataLogObject._() : super(); + factory LogDataNotification_DataLogObject.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory LogDataNotification_DataLogObject.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, LogDataNotification_DataLogObject_Event> _LogDataNotification_DataLogObject_EventByTag = { + 1 : LogDataNotification_DataLogObject_Event.logData, + 2 : LogDataNotification_DataLogObject_Event.connectionParam, + 3 : LogDataNotification_DataLogObject_Event.energySurvey, + 4 : LogDataNotification_DataLogObject_Event.logString, + 0 : LogDataNotification_DataLogObject_Event.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'LogDataNotification.DataLogObject', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2, 3, 4]) + ..a<$core.List<$core.int>>(1, _omitFieldNames ? '' : 'logData', $pb.PbFieldType.OY, protoName: 'logData') + ..aOM(2, _omitFieldNames ? '' : 'connectionParam', protoName: 'connectionParam', subBuilder: LogDataNotification_ConnectionParameters.create) + ..aOM(3, _omitFieldNames ? '' : 'energySurvey', protoName: 'energySurvey', subBuilder: LogDataNotification_EnergySurveySummary.create) + ..a<$core.List<$core.int>>(4, _omitFieldNames ? '' : 'logString', $pb.PbFieldType.OY, protoName: 'logString') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + LogDataNotification_DataLogObject clone() => LogDataNotification_DataLogObject()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + LogDataNotification_DataLogObject copyWith(void Function(LogDataNotification_DataLogObject) updates) => super.copyWith((message) => updates(message as LogDataNotification_DataLogObject)) as LogDataNotification_DataLogObject; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static LogDataNotification_DataLogObject create() => LogDataNotification_DataLogObject._(); + LogDataNotification_DataLogObject createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static LogDataNotification_DataLogObject getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static LogDataNotification_DataLogObject? _defaultInstance; + + LogDataNotification_DataLogObject_Event whichEvent() => _LogDataNotification_DataLogObject_EventByTag[$_whichOneof(0)]!; + void clearEvent() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $core.List<$core.int> get logData => $_getN(0); + @$pb.TagNumber(1) + set logData($core.List<$core.int> v) { $_setBytes(0, v); } + @$pb.TagNumber(1) + $core.bool hasLogData() => $_has(0); + @$pb.TagNumber(1) + void clearLogData() => clearField(1); + + @$pb.TagNumber(2) + LogDataNotification_ConnectionParameters get connectionParam => $_getN(1); + @$pb.TagNumber(2) + set connectionParam(LogDataNotification_ConnectionParameters v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasConnectionParam() => $_has(1); + @$pb.TagNumber(2) + void clearConnectionParam() => clearField(2); + @$pb.TagNumber(2) + LogDataNotification_ConnectionParameters ensureConnectionParam() => $_ensure(1); + + @$pb.TagNumber(3) + LogDataNotification_EnergySurveySummary get energySurvey => $_getN(2); + @$pb.TagNumber(3) + set energySurvey(LogDataNotification_EnergySurveySummary v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasEnergySurvey() => $_has(2); + @$pb.TagNumber(3) + void clearEnergySurvey() => clearField(3); + @$pb.TagNumber(3) + LogDataNotification_EnergySurveySummary ensureEnergySurvey() => $_ensure(2); + + @$pb.TagNumber(4) + $core.List<$core.int> get logString => $_getN(3); + @$pb.TagNumber(4) + set logString($core.List<$core.int> v) { $_setBytes(3, v); } + @$pb.TagNumber(4) + $core.bool hasLogString() => $_has(3); + @$pb.TagNumber(4) + void clearLogString() => clearField(4); +} + +class LogDataNotification extends $pb.GeneratedMessage { + factory LogDataNotification({ + LogLevel? logLevel, + LogDataNotification_DataLogObject? dataLogObject, + }) { + final $result = create(); + if (logLevel != null) { + $result.logLevel = logLevel; + } + if (dataLogObject != null) { + $result.dataLogObject = dataLogObject; + } + return $result; + } + LogDataNotification._() : super(); + factory LogDataNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory LogDataNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'LogDataNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'logLevel', $pb.PbFieldType.QE, protoName: 'logLevel', defaultOrMaker: LogLevel.LOGLEVEL_OFF, valueOf: LogLevel.valueOf, enumValues: LogLevel.values) + ..aQM(2, _omitFieldNames ? '' : 'dataLogObject', protoName: 'dataLogObject', subBuilder: LogDataNotification_DataLogObject.create) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + LogDataNotification clone() => LogDataNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + LogDataNotification copyWith(void Function(LogDataNotification) updates) => super.copyWith((message) => updates(message as LogDataNotification)) as LogDataNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static LogDataNotification create() => LogDataNotification._(); + LogDataNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static LogDataNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static LogDataNotification? _defaultInstance; + + @$pb.TagNumber(1) + LogLevel get logLevel => $_getN(0); + @$pb.TagNumber(1) + set logLevel(LogLevel v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasLogLevel() => $_has(0); + @$pb.TagNumber(1) + void clearLogLevel() => clearField(1); + + @$pb.TagNumber(2) + LogDataNotification_DataLogObject get dataLogObject => $_getN(1); + @$pb.TagNumber(2) + set dataLogObject(LogDataNotification_DataLogObject v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasDataLogObject() => $_has(1); + @$pb.TagNumber(2) + void clearDataLogObject() => clearField(2); + @$pb.TagNumber(2) + LogDataNotification_DataLogObject ensureDataLogObject() => $_ensure(1); +} + +class SpindownRequest_StartSpindown extends $pb.GeneratedMessage { + factory SpindownRequest_StartSpindown({ + $core.bool? enable, + }) { + final $result = create(); + if (enable != null) { + $result.enable = enable; + } + return $result; + } + SpindownRequest_StartSpindown._() : super(); + factory SpindownRequest_StartSpindown.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SpindownRequest_StartSpindown.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SpindownRequest.StartSpindown', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.bool>(1, _omitFieldNames ? '' : 'enable', $pb.PbFieldType.QB) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SpindownRequest_StartSpindown clone() => SpindownRequest_StartSpindown()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SpindownRequest_StartSpindown copyWith(void Function(SpindownRequest_StartSpindown) updates) => super.copyWith((message) => updates(message as SpindownRequest_StartSpindown)) as SpindownRequest_StartSpindown; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SpindownRequest_StartSpindown create() => SpindownRequest_StartSpindown._(); + SpindownRequest_StartSpindown createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SpindownRequest_StartSpindown getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SpindownRequest_StartSpindown? _defaultInstance; + + @$pb.TagNumber(1) + $core.bool get enable => $_getBF(0); + @$pb.TagNumber(1) + set enable($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasEnable() => $_has(0); + @$pb.TagNumber(1) + void clearEnable() => clearField(1); +} + +class SpindownRequest_IgnoreSpindown extends $pb.GeneratedMessage { + factory SpindownRequest_IgnoreSpindown({ + $core.bool? enable, + }) { + final $result = create(); + if (enable != null) { + $result.enable = enable; + } + return $result; + } + SpindownRequest_IgnoreSpindown._() : super(); + factory SpindownRequest_IgnoreSpindown.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SpindownRequest_IgnoreSpindown.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SpindownRequest.IgnoreSpindown', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.bool>(1, _omitFieldNames ? '' : 'enable', $pb.PbFieldType.QB) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SpindownRequest_IgnoreSpindown clone() => SpindownRequest_IgnoreSpindown()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SpindownRequest_IgnoreSpindown copyWith(void Function(SpindownRequest_IgnoreSpindown) updates) => super.copyWith((message) => updates(message as SpindownRequest_IgnoreSpindown)) as SpindownRequest_IgnoreSpindown; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SpindownRequest_IgnoreSpindown create() => SpindownRequest_IgnoreSpindown._(); + SpindownRequest_IgnoreSpindown createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SpindownRequest_IgnoreSpindown getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SpindownRequest_IgnoreSpindown? _defaultInstance; + + @$pb.TagNumber(1) + $core.bool get enable => $_getBF(0); + @$pb.TagNumber(1) + set enable($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasEnable() => $_has(0); + @$pb.TagNumber(1) + void clearEnable() => clearField(1); +} + +enum SpindownRequest_Request { + startSpindown, + ignoreSpindown, + notSet +} + +/// OpCode 0x3A +class SpindownRequest extends $pb.GeneratedMessage { + factory SpindownRequest({ + SpindownRequest_StartSpindown? startSpindown, + SpindownRequest_IgnoreSpindown? ignoreSpindown, + }) { + final $result = create(); + if (startSpindown != null) { + $result.startSpindown = startSpindown; + } + if (ignoreSpindown != null) { + $result.ignoreSpindown = ignoreSpindown; + } + return $result; + } + SpindownRequest._() : super(); + factory SpindownRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SpindownRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, SpindownRequest_Request> _SpindownRequest_RequestByTag = { + 1 : SpindownRequest_Request.startSpindown, + 2 : SpindownRequest_Request.ignoreSpindown, + 0 : SpindownRequest_Request.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SpindownRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'startSpindown', protoName: 'startSpindown', subBuilder: SpindownRequest_StartSpindown.create) + ..aOM(2, _omitFieldNames ? '' : 'ignoreSpindown', protoName: 'ignoreSpindown', subBuilder: SpindownRequest_IgnoreSpindown.create) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SpindownRequest clone() => SpindownRequest()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SpindownRequest copyWith(void Function(SpindownRequest) updates) => super.copyWith((message) => updates(message as SpindownRequest)) as SpindownRequest; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SpindownRequest create() => SpindownRequest._(); + SpindownRequest createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SpindownRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SpindownRequest? _defaultInstance; + + SpindownRequest_Request whichRequest() => _SpindownRequest_RequestByTag[$_whichOneof(0)]!; + void clearRequest() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + SpindownRequest_StartSpindown get startSpindown => $_getN(0); + @$pb.TagNumber(1) + set startSpindown(SpindownRequest_StartSpindown v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasStartSpindown() => $_has(0); + @$pb.TagNumber(1) + void clearStartSpindown() => clearField(1); + @$pb.TagNumber(1) + SpindownRequest_StartSpindown ensureStartSpindown() => $_ensure(0); + + @$pb.TagNumber(2) + SpindownRequest_IgnoreSpindown get ignoreSpindown => $_getN(1); + @$pb.TagNumber(2) + set ignoreSpindown(SpindownRequest_IgnoreSpindown v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasIgnoreSpindown() => $_has(1); + @$pb.TagNumber(2) + void clearIgnoreSpindown() => clearField(2); + @$pb.TagNumber(2) + SpindownRequest_IgnoreSpindown ensureIgnoreSpindown() => $_ensure(1); +} + +class SpindownNotification_ManualSpindownStatus extends $pb.GeneratedMessage { + factory SpindownNotification_ManualSpindownStatus({ + SpindownStatus? spindownStatus, + $core.int? targetSpeedLow, + $core.int? targetSpeedHigh, + }) { + final $result = create(); + if (spindownStatus != null) { + $result.spindownStatus = spindownStatus; + } + if (targetSpeedLow != null) { + $result.targetSpeedLow = targetSpeedLow; + } + if (targetSpeedHigh != null) { + $result.targetSpeedHigh = targetSpeedHigh; + } + return $result; + } + SpindownNotification_ManualSpindownStatus._() : super(); + factory SpindownNotification_ManualSpindownStatus.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SpindownNotification_ManualSpindownStatus.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SpindownNotification.ManualSpindownStatus', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'spindownStatus', $pb.PbFieldType.QE, protoName: 'spindownStatus', defaultOrMaker: SpindownStatus.SPINDOWN_IDLE, valueOf: SpindownStatus.valueOf, enumValues: SpindownStatus.values) + ..a<$core.int>(2, _omitFieldNames ? '' : 'targetSpeedLow', $pb.PbFieldType.OU3, protoName: 'targetSpeedLow') + ..a<$core.int>(3, _omitFieldNames ? '' : 'targetSpeedHigh', $pb.PbFieldType.OU3, protoName: 'targetSpeedHigh') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SpindownNotification_ManualSpindownStatus clone() => SpindownNotification_ManualSpindownStatus()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SpindownNotification_ManualSpindownStatus copyWith(void Function(SpindownNotification_ManualSpindownStatus) updates) => super.copyWith((message) => updates(message as SpindownNotification_ManualSpindownStatus)) as SpindownNotification_ManualSpindownStatus; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SpindownNotification_ManualSpindownStatus create() => SpindownNotification_ManualSpindownStatus._(); + SpindownNotification_ManualSpindownStatus createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SpindownNotification_ManualSpindownStatus getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SpindownNotification_ManualSpindownStatus? _defaultInstance; + + @$pb.TagNumber(1) + SpindownStatus get spindownStatus => $_getN(0); + @$pb.TagNumber(1) + set spindownStatus(SpindownStatus v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasSpindownStatus() => $_has(0); + @$pb.TagNumber(1) + void clearSpindownStatus() => clearField(1); + + @$pb.TagNumber(2) + $core.int get targetSpeedLow => $_getIZ(1); + @$pb.TagNumber(2) + set targetSpeedLow($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasTargetSpeedLow() => $_has(1); + @$pb.TagNumber(2) + void clearTargetSpeedLow() => clearField(2); + + @$pb.TagNumber(3) + $core.int get targetSpeedHigh => $_getIZ(2); + @$pb.TagNumber(3) + set targetSpeedHigh($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasTargetSpeedHigh() => $_has(2); + @$pb.TagNumber(3) + void clearTargetSpeedHigh() => clearField(3); +} + +class SpindownNotification_AutoSpindownStatus extends $pb.GeneratedMessage { + factory SpindownNotification_AutoSpindownStatus({ + $core.bool? completed, + }) { + final $result = create(); + if (completed != null) { + $result.completed = completed; + } + return $result; + } + SpindownNotification_AutoSpindownStatus._() : super(); + factory SpindownNotification_AutoSpindownStatus.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SpindownNotification_AutoSpindownStatus.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SpindownNotification.AutoSpindownStatus', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.bool>(1, _omitFieldNames ? '' : 'completed', $pb.PbFieldType.QB) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SpindownNotification_AutoSpindownStatus clone() => SpindownNotification_AutoSpindownStatus()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SpindownNotification_AutoSpindownStatus copyWith(void Function(SpindownNotification_AutoSpindownStatus) updates) => super.copyWith((message) => updates(message as SpindownNotification_AutoSpindownStatus)) as SpindownNotification_AutoSpindownStatus; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SpindownNotification_AutoSpindownStatus create() => SpindownNotification_AutoSpindownStatus._(); + SpindownNotification_AutoSpindownStatus createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SpindownNotification_AutoSpindownStatus getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SpindownNotification_AutoSpindownStatus? _defaultInstance; + + @$pb.TagNumber(1) + $core.bool get completed => $_getBF(0); + @$pb.TagNumber(1) + set completed($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasCompleted() => $_has(0); + @$pb.TagNumber(1) + void clearCompleted() => clearField(1); +} + +enum SpindownNotification_Event { + manualSpindownStatus, + autoSpindownStatus, + notSet +} + +/// OpCode 0x3B +class SpindownNotification extends $pb.GeneratedMessage { + factory SpindownNotification({ + SpindownNotification_ManualSpindownStatus? manualSpindownStatus, + SpindownNotification_AutoSpindownStatus? autoSpindownStatus, + }) { + final $result = create(); + if (manualSpindownStatus != null) { + $result.manualSpindownStatus = manualSpindownStatus; + } + if (autoSpindownStatus != null) { + $result.autoSpindownStatus = autoSpindownStatus; + } + return $result; + } + SpindownNotification._() : super(); + factory SpindownNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SpindownNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, SpindownNotification_Event> _SpindownNotification_EventByTag = { + 1 : SpindownNotification_Event.manualSpindownStatus, + 2 : SpindownNotification_Event.autoSpindownStatus, + 0 : SpindownNotification_Event.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SpindownNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'manualSpindownStatus', protoName: 'manualSpindownStatus', subBuilder: SpindownNotification_ManualSpindownStatus.create) + ..aOM(2, _omitFieldNames ? '' : 'autoSpindownStatus', protoName: 'autoSpindownStatus', subBuilder: SpindownNotification_AutoSpindownStatus.create) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SpindownNotification clone() => SpindownNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SpindownNotification copyWith(void Function(SpindownNotification) updates) => super.copyWith((message) => updates(message as SpindownNotification)) as SpindownNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SpindownNotification create() => SpindownNotification._(); + SpindownNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SpindownNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SpindownNotification? _defaultInstance; + + SpindownNotification_Event whichEvent() => _SpindownNotification_EventByTag[$_whichOneof(0)]!; + void clearEvent() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + SpindownNotification_ManualSpindownStatus get manualSpindownStatus => $_getN(0); + @$pb.TagNumber(1) + set manualSpindownStatus(SpindownNotification_ManualSpindownStatus v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasManualSpindownStatus() => $_has(0); + @$pb.TagNumber(1) + void clearManualSpindownStatus() => clearField(1); + @$pb.TagNumber(1) + SpindownNotification_ManualSpindownStatus ensureManualSpindownStatus() => $_ensure(0); + + @$pb.TagNumber(2) + SpindownNotification_AutoSpindownStatus get autoSpindownStatus => $_getN(1); + @$pb.TagNumber(2) + set autoSpindownStatus(SpindownNotification_AutoSpindownStatus v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasAutoSpindownStatus() => $_has(1); + @$pb.TagNumber(2) + void clearAutoSpindownStatus() => clearField(2); + @$pb.TagNumber(2) + SpindownNotification_AutoSpindownStatus ensureAutoSpindownStatus() => $_ensure(1); +} + +/// OpCode 0x3C +class GetResponse extends $pb.GeneratedMessage { + factory GetResponse({ + $core.int? dataObjectId, + $core.List<$core.int>? dataObjectData, + }) { + final $result = create(); + if (dataObjectId != null) { + $result.dataObjectId = dataObjectId; + } + if (dataObjectData != null) { + $result.dataObjectData = dataObjectData; + } + return $result; + } + GetResponse._() : super(); + factory GetResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory GetResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GetResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'dataObjectId', $pb.PbFieldType.QU3, protoName: 'dataObjectId') + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'dataObjectData', $pb.PbFieldType.OY, protoName: 'dataObjectData') + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + GetResponse clone() => GetResponse()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + GetResponse copyWith(void Function(GetResponse) updates) => super.copyWith((message) => updates(message as GetResponse)) as GetResponse; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GetResponse create() => GetResponse._(); + GetResponse createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GetResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static GetResponse? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get dataObjectId => $_getIZ(0); + @$pb.TagNumber(1) + set dataObjectId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasDataObjectId() => $_has(0); + @$pb.TagNumber(1) + void clearDataObjectId() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get dataObjectData => $_getN(1); + @$pb.TagNumber(2) + set dataObjectData($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasDataObjectData() => $_has(1); + @$pb.TagNumber(2) + void clearDataObjectData() => clearField(2); +} + +/// OpCode 0x3E +class StatusResponse extends $pb.GeneratedMessage { + factory StatusResponse({ + $core.int? command, + $core.int? status, + }) { + final $result = create(); + if (command != null) { + $result.command = command; + } + if (status != null) { + $result.status = status; + } + return $result; + } + StatusResponse._() : super(); + factory StatusResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory StatusResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'StatusResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'command', $pb.PbFieldType.QU3) + ..a<$core.int>(2, _omitFieldNames ? '' : 'status', $pb.PbFieldType.QU3) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + StatusResponse clone() => StatusResponse()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + StatusResponse copyWith(void Function(StatusResponse) updates) => super.copyWith((message) => updates(message as StatusResponse)) as StatusResponse; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static StatusResponse create() => StatusResponse._(); + StatusResponse createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static StatusResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static StatusResponse? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get command => $_getIZ(0); + @$pb.TagNumber(1) + set command($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasCommand() => $_has(0); + @$pb.TagNumber(1) + void clearCommand() => clearField(1); + + @$pb.TagNumber(2) + $core.int get status => $_getIZ(1); + @$pb.TagNumber(2) + set status($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasStatus() => $_has(1); + @$pb.TagNumber(2) + void clearStatus() => clearField(2); +} + +enum Set_Set { + devInfo, + trainerSimParam, + trainerOptions, + deviceTiltConfig, + controllerInputConfig, + trainerGearIndexConfig, + wifiConfig, + dateTimeConfig, + notSet +} + +/// OpCode 0x3F +class Set extends $pb.GeneratedMessage { + factory Set({ + $core.int? options, + $core.int? msgId, + DevInfoSet? devInfo, + TrainerSimulationParam? trainerSimParam, + TrainerOptions? trainerOptions, + DeviceTiltConfigPage? deviceTiltConfig, + ControllerInputConfigPage? controllerInputConfig, + TrainerGearIndexConfigPage? trainerGearIndexConfig, + WifiConfigurationPage? wifiConfig, + DateTimePage? dateTimeConfig, + }) { + final $result = create(); + if (options != null) { + $result.options = options; + } + if (msgId != null) { + $result.msgId = msgId; + } + if (devInfo != null) { + $result.devInfo = devInfo; + } + if (trainerSimParam != null) { + $result.trainerSimParam = trainerSimParam; + } + if (trainerOptions != null) { + $result.trainerOptions = trainerOptions; + } + if (deviceTiltConfig != null) { + $result.deviceTiltConfig = deviceTiltConfig; + } + if (controllerInputConfig != null) { + $result.controllerInputConfig = controllerInputConfig; + } + if (trainerGearIndexConfig != null) { + $result.trainerGearIndexConfig = trainerGearIndexConfig; + } + if (wifiConfig != null) { + $result.wifiConfig = wifiConfig; + } + if (dateTimeConfig != null) { + $result.dateTimeConfig = dateTimeConfig; + } + return $result; + } + Set._() : super(); + factory Set.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory Set.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, Set_Set> _Set_SetByTag = { + 3 : Set_Set.devInfo, + 4 : Set_Set.trainerSimParam, + 5 : Set_Set.trainerOptions, + 6 : Set_Set.deviceTiltConfig, + 7 : Set_Set.controllerInputConfig, + 8 : Set_Set.trainerGearIndexConfig, + 10 : Set_Set.wifiConfig, + 11 : Set_Set.dateTimeConfig, + 0 : Set_Set.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'Set', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [3, 4, 5, 6, 7, 8, 10, 11]) + ..a<$core.int>(1, _omitFieldNames ? '' : 'options', $pb.PbFieldType.OU3) + ..a<$core.int>(2, _omitFieldNames ? '' : 'msgId', $pb.PbFieldType.OU3, protoName: 'msgId') + ..aOM(3, _omitFieldNames ? '' : 'devInfo', protoName: 'devInfo', subBuilder: DevInfoSet.create) + ..aOM(4, _omitFieldNames ? '' : 'trainerSimParam', protoName: 'trainerSimParam', subBuilder: TrainerSimulationParam.create) + ..aOM(5, _omitFieldNames ? '' : 'trainerOptions', protoName: 'trainerOptions', subBuilder: TrainerOptions.create) + ..aOM(6, _omitFieldNames ? '' : 'deviceTiltConfig', protoName: 'deviceTiltConfig', subBuilder: DeviceTiltConfigPage.create) + ..aOM(7, _omitFieldNames ? '' : 'controllerInputConfig', protoName: 'controllerInputConfig', subBuilder: ControllerInputConfigPage.create) + ..aOM(8, _omitFieldNames ? '' : 'trainerGearIndexConfig', protoName: 'trainerGearIndexConfig', subBuilder: TrainerGearIndexConfigPage.create) + ..aOM(10, _omitFieldNames ? '' : 'wifiConfig', protoName: 'wifiConfig', subBuilder: WifiConfigurationPage.create) + ..aOM(11, _omitFieldNames ? '' : 'dateTimeConfig', protoName: 'dateTimeConfig', subBuilder: DateTimePage.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') + Set clone() => Set()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Set copyWith(void Function(Set) updates) => super.copyWith((message) => updates(message as Set)) as Set; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Set create() => Set._(); + Set createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Set getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Set? _defaultInstance; + + Set_Set whichSet() => _Set_SetByTag[$_whichOneof(0)]!; + void clearSet() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $core.int get options => $_getIZ(0); + @$pb.TagNumber(1) + set options($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasOptions() => $_has(0); + @$pb.TagNumber(1) + void clearOptions() => clearField(1); + + @$pb.TagNumber(2) + $core.int get msgId => $_getIZ(1); + @$pb.TagNumber(2) + set msgId($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasMsgId() => $_has(1); + @$pb.TagNumber(2) + void clearMsgId() => clearField(2); + + @$pb.TagNumber(3) + DevInfoSet get devInfo => $_getN(2); + @$pb.TagNumber(3) + set devInfo(DevInfoSet v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasDevInfo() => $_has(2); + @$pb.TagNumber(3) + void clearDevInfo() => clearField(3); + @$pb.TagNumber(3) + DevInfoSet ensureDevInfo() => $_ensure(2); + + @$pb.TagNumber(4) + TrainerSimulationParam get trainerSimParam => $_getN(3); + @$pb.TagNumber(4) + set trainerSimParam(TrainerSimulationParam v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasTrainerSimParam() => $_has(3); + @$pb.TagNumber(4) + void clearTrainerSimParam() => clearField(4); + @$pb.TagNumber(4) + TrainerSimulationParam ensureTrainerSimParam() => $_ensure(3); + + @$pb.TagNumber(5) + TrainerOptions get trainerOptions => $_getN(4); + @$pb.TagNumber(5) + set trainerOptions(TrainerOptions v) { setField(5, v); } + @$pb.TagNumber(5) + $core.bool hasTrainerOptions() => $_has(4); + @$pb.TagNumber(5) + void clearTrainerOptions() => clearField(5); + @$pb.TagNumber(5) + TrainerOptions ensureTrainerOptions() => $_ensure(4); + + @$pb.TagNumber(6) + DeviceTiltConfigPage get deviceTiltConfig => $_getN(5); + @$pb.TagNumber(6) + set deviceTiltConfig(DeviceTiltConfigPage v) { setField(6, v); } + @$pb.TagNumber(6) + $core.bool hasDeviceTiltConfig() => $_has(5); + @$pb.TagNumber(6) + void clearDeviceTiltConfig() => clearField(6); + @$pb.TagNumber(6) + DeviceTiltConfigPage ensureDeviceTiltConfig() => $_ensure(5); + + @$pb.TagNumber(7) + ControllerInputConfigPage get controllerInputConfig => $_getN(6); + @$pb.TagNumber(7) + set controllerInputConfig(ControllerInputConfigPage v) { setField(7, v); } + @$pb.TagNumber(7) + $core.bool hasControllerInputConfig() => $_has(6); + @$pb.TagNumber(7) + void clearControllerInputConfig() => clearField(7); + @$pb.TagNumber(7) + ControllerInputConfigPage ensureControllerInputConfig() => $_ensure(6); + + @$pb.TagNumber(8) + TrainerGearIndexConfigPage get trainerGearIndexConfig => $_getN(7); + @$pb.TagNumber(8) + set trainerGearIndexConfig(TrainerGearIndexConfigPage v) { setField(8, v); } + @$pb.TagNumber(8) + $core.bool hasTrainerGearIndexConfig() => $_has(7); + @$pb.TagNumber(8) + void clearTrainerGearIndexConfig() => clearField(8); + @$pb.TagNumber(8) + TrainerGearIndexConfigPage ensureTrainerGearIndexConfig() => $_ensure(7); + + @$pb.TagNumber(10) + WifiConfigurationPage get wifiConfig => $_getN(8); + @$pb.TagNumber(10) + set wifiConfig(WifiConfigurationPage v) { setField(10, v); } + @$pb.TagNumber(10) + $core.bool hasWifiConfig() => $_has(8); + @$pb.TagNumber(10) + void clearWifiConfig() => clearField(10); + @$pb.TagNumber(10) + WifiConfigurationPage ensureWifiConfig() => $_ensure(8); + + @$pb.TagNumber(11) + DateTimePage get dateTimeConfig => $_getN(9); + @$pb.TagNumber(11) + set dateTimeConfig(DateTimePage v) { setField(11, v); } + @$pb.TagNumber(11) + $core.bool hasDateTimeConfig() => $_has(9); + @$pb.TagNumber(11) + void clearDateTimeConfig() => clearField(11); + @$pb.TagNumber(11) + DateTimePage ensureDateTimeConfig() => $_ensure(9); +} + +enum SetResponse_Response_Page { + devInfo, + trainerSimParam, + trainerOptions, + deviceTiltConfig, + controllerInputConfig, + trainerGearIndexConfig, + wifiConfig, + dateTimeConfig, + notSet +} + +class SetResponse_Response extends $pb.GeneratedMessage { + factory SetResponse_Response({ + DevInfoPage? devInfo, + TrainerSimulationParam? trainerSimParam, + TrainerOptions? trainerOptions, + DeviceTiltConfigPage? deviceTiltConfig, + ControllerInputConfigPage? controllerInputConfig, + TrainerGearIndexConfigPage? trainerGearIndexConfig, + WifiConfigurationPage? wifiConfig, + DateTimePage? dateTimeConfig, + }) { + final $result = create(); + if (devInfo != null) { + $result.devInfo = devInfo; + } + if (trainerSimParam != null) { + $result.trainerSimParam = trainerSimParam; + } + if (trainerOptions != null) { + $result.trainerOptions = trainerOptions; + } + if (deviceTiltConfig != null) { + $result.deviceTiltConfig = deviceTiltConfig; + } + if (controllerInputConfig != null) { + $result.controllerInputConfig = controllerInputConfig; + } + if (trainerGearIndexConfig != null) { + $result.trainerGearIndexConfig = trainerGearIndexConfig; + } + if (wifiConfig != null) { + $result.wifiConfig = wifiConfig; + } + if (dateTimeConfig != null) { + $result.dateTimeConfig = dateTimeConfig; + } + return $result; + } + SetResponse_Response._() : super(); + factory SetResponse_Response.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SetResponse_Response.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, SetResponse_Response_Page> _SetResponse_Response_PageByTag = { + 1 : SetResponse_Response_Page.devInfo, + 2 : SetResponse_Response_Page.trainerSimParam, + 3 : SetResponse_Response_Page.trainerOptions, + 4 : SetResponse_Response_Page.deviceTiltConfig, + 5 : SetResponse_Response_Page.controllerInputConfig, + 6 : SetResponse_Response_Page.trainerGearIndexConfig, + 8 : SetResponse_Response_Page.wifiConfig, + 9 : SetResponse_Response_Page.dateTimeConfig, + 0 : SetResponse_Response_Page.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SetResponse.Response', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2, 3, 4, 5, 6, 8, 9]) + ..aOM(1, _omitFieldNames ? '' : 'devInfo', protoName: 'devInfo', subBuilder: DevInfoPage.create) + ..aOM(2, _omitFieldNames ? '' : 'trainerSimParam', protoName: 'trainerSimParam', subBuilder: TrainerSimulationParam.create) + ..aOM(3, _omitFieldNames ? '' : 'trainerOptions', protoName: 'trainerOptions', subBuilder: TrainerOptions.create) + ..aOM(4, _omitFieldNames ? '' : 'deviceTiltConfig', protoName: 'deviceTiltConfig', subBuilder: DeviceTiltConfigPage.create) + ..aOM(5, _omitFieldNames ? '' : 'controllerInputConfig', protoName: 'controllerInputConfig', subBuilder: ControllerInputConfigPage.create) + ..aOM(6, _omitFieldNames ? '' : 'trainerGearIndexConfig', protoName: 'trainerGearIndexConfig', subBuilder: TrainerGearIndexConfigPage.create) + ..aOM(8, _omitFieldNames ? '' : 'wifiConfig', protoName: 'wifiConfig', subBuilder: WifiConfigurationPage.create) + ..aOM(9, _omitFieldNames ? '' : 'dateTimeConfig', protoName: 'dateTimeConfig', subBuilder: DateTimePage.create) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SetResponse_Response clone() => SetResponse_Response()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SetResponse_Response copyWith(void Function(SetResponse_Response) updates) => super.copyWith((message) => updates(message as SetResponse_Response)) as SetResponse_Response; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SetResponse_Response create() => SetResponse_Response._(); + SetResponse_Response createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SetResponse_Response getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SetResponse_Response? _defaultInstance; + + SetResponse_Response_Page whichPage() => _SetResponse_Response_PageByTag[$_whichOneof(0)]!; + void clearPage() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + DevInfoPage get devInfo => $_getN(0); + @$pb.TagNumber(1) + set devInfo(DevInfoPage v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDevInfo() => $_has(0); + @$pb.TagNumber(1) + void clearDevInfo() => clearField(1); + @$pb.TagNumber(1) + DevInfoPage ensureDevInfo() => $_ensure(0); + + @$pb.TagNumber(2) + TrainerSimulationParam get trainerSimParam => $_getN(1); + @$pb.TagNumber(2) + set trainerSimParam(TrainerSimulationParam v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasTrainerSimParam() => $_has(1); + @$pb.TagNumber(2) + void clearTrainerSimParam() => clearField(2); + @$pb.TagNumber(2) + TrainerSimulationParam ensureTrainerSimParam() => $_ensure(1); + + @$pb.TagNumber(3) + TrainerOptions get trainerOptions => $_getN(2); + @$pb.TagNumber(3) + set trainerOptions(TrainerOptions v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasTrainerOptions() => $_has(2); + @$pb.TagNumber(3) + void clearTrainerOptions() => clearField(3); + @$pb.TagNumber(3) + TrainerOptions ensureTrainerOptions() => $_ensure(2); + + @$pb.TagNumber(4) + DeviceTiltConfigPage get deviceTiltConfig => $_getN(3); + @$pb.TagNumber(4) + set deviceTiltConfig(DeviceTiltConfigPage v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasDeviceTiltConfig() => $_has(3); + @$pb.TagNumber(4) + void clearDeviceTiltConfig() => clearField(4); + @$pb.TagNumber(4) + DeviceTiltConfigPage ensureDeviceTiltConfig() => $_ensure(3); + + @$pb.TagNumber(5) + ControllerInputConfigPage get controllerInputConfig => $_getN(4); + @$pb.TagNumber(5) + set controllerInputConfig(ControllerInputConfigPage v) { setField(5, v); } + @$pb.TagNumber(5) + $core.bool hasControllerInputConfig() => $_has(4); + @$pb.TagNumber(5) + void clearControllerInputConfig() => clearField(5); + @$pb.TagNumber(5) + ControllerInputConfigPage ensureControllerInputConfig() => $_ensure(4); + + @$pb.TagNumber(6) + TrainerGearIndexConfigPage get trainerGearIndexConfig => $_getN(5); + @$pb.TagNumber(6) + set trainerGearIndexConfig(TrainerGearIndexConfigPage v) { setField(6, v); } + @$pb.TagNumber(6) + $core.bool hasTrainerGearIndexConfig() => $_has(5); + @$pb.TagNumber(6) + void clearTrainerGearIndexConfig() => clearField(6); + @$pb.TagNumber(6) + TrainerGearIndexConfigPage ensureTrainerGearIndexConfig() => $_ensure(5); + + @$pb.TagNumber(8) + WifiConfigurationPage get wifiConfig => $_getN(6); + @$pb.TagNumber(8) + set wifiConfig(WifiConfigurationPage v) { setField(8, v); } + @$pb.TagNumber(8) + $core.bool hasWifiConfig() => $_has(6); + @$pb.TagNumber(8) + void clearWifiConfig() => clearField(8); + @$pb.TagNumber(8) + WifiConfigurationPage ensureWifiConfig() => $_ensure(6); + + @$pb.TagNumber(9) + DateTimePage get dateTimeConfig => $_getN(7); + @$pb.TagNumber(9) + set dateTimeConfig(DateTimePage v) { setField(9, v); } + @$pb.TagNumber(9) + $core.bool hasDateTimeConfig() => $_has(7); + @$pb.TagNumber(9) + void clearDateTimeConfig() => clearField(9); + @$pb.TagNumber(9) + DateTimePage ensureDateTimeConfig() => $_ensure(7); +} + +/// OpCode 0x40 +class SetResponse extends $pb.GeneratedMessage { + factory SetResponse({ + $core.int? status, + $core.int? msgId, + SetResponse_Response? response, + }) { + final $result = create(); + if (status != null) { + $result.status = status; + } + if (msgId != null) { + $result.msgId = msgId; + } + if (response != null) { + $result.response = response; + } + return $result; + } + SetResponse._() : super(); + factory SetResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SetResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SetResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'status', $pb.PbFieldType.OU3) + ..a<$core.int>(2, _omitFieldNames ? '' : 'msgId', $pb.PbFieldType.OU3, protoName: 'msgId') + ..aOM(3, _omitFieldNames ? '' : 'response', subBuilder: SetResponse_Response.create) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SetResponse clone() => SetResponse()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SetResponse copyWith(void Function(SetResponse) updates) => super.copyWith((message) => updates(message as SetResponse)) as SetResponse; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SetResponse create() => SetResponse._(); + SetResponse createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SetResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SetResponse? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get status => $_getIZ(0); + @$pb.TagNumber(1) + set status($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasStatus() => $_has(0); + @$pb.TagNumber(1) + void clearStatus() => clearField(1); + + @$pb.TagNumber(2) + $core.int get msgId => $_getIZ(1); + @$pb.TagNumber(2) + set msgId($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasMsgId() => $_has(1); + @$pb.TagNumber(2) + void clearMsgId() => clearField(2); + + @$pb.TagNumber(3) + SetResponse_Response get response => $_getN(2); + @$pb.TagNumber(3) + set response(SetResponse_Response v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasResponse() => $_has(2); + @$pb.TagNumber(3) + void clearResponse() => clearField(3); + @$pb.TagNumber(3) + SetResponse_Response ensureResponse() => $_ensure(2); +} + +/// OpCode 0x41 +class LogLevelSet extends $pb.GeneratedMessage { + factory LogLevelSet({ + LogLevel? logLevel, + }) { + final $result = create(); + if (logLevel != null) { + $result.logLevel = logLevel; + } + return $result; + } + LogLevelSet._() : super(); + factory LogLevelSet.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory LogLevelSet.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'LogLevelSet', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'logLevel', $pb.PbFieldType.OE, protoName: 'logLevel', defaultOrMaker: LogLevel.LOGLEVEL_OFF, valueOf: LogLevel.valueOf, enumValues: LogLevel.values) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + LogLevelSet clone() => LogLevelSet()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + LogLevelSet copyWith(void Function(LogLevelSet) updates) => super.copyWith((message) => updates(message as LogLevelSet)) as LogLevelSet; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static LogLevelSet create() => LogLevelSet._(); + LogLevelSet createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static LogLevelSet getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static LogLevelSet? _defaultInstance; + + @$pb.TagNumber(1) + LogLevel get logLevel => $_getN(0); + @$pb.TagNumber(1) + set logLevel(LogLevel v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasLogLevel() => $_has(0); + @$pb.TagNumber(1) + void clearLogLevel() => clearField(1); +} + +enum DataChangeNotification_Notification { + devInfo, + trainerSimParam, + trainerOptions, + deviceTiltConfig, + controllerInputConfig, + trainerGearIndexConfig, + wifiConfig, + deviceUpdatePage, + dateTimePage, + bleSecurityPage, + notSet +} + +/// Opcode 0x42 +class DataChangeNotification extends $pb.GeneratedMessage { + factory DataChangeNotification({ + DevInfoPage? devInfo, + TrainerSimulationParam? trainerSimParam, + TrainerOptions? trainerOptions, + DeviceTiltConfigPage? deviceTiltConfig, + ControllerInputConfigPage? controllerInputConfig, + TrainerGearIndexConfigPage? trainerGearIndexConfig, + WifiConfigurationPage? wifiConfig, + DeviceUpdatePage? deviceUpdatePage, + DateTimePage? dateTimePage, + BleSecurityPage? bleSecurityPage, + }) { + final $result = create(); + if (devInfo != null) { + $result.devInfo = devInfo; + } + if (trainerSimParam != null) { + $result.trainerSimParam = trainerSimParam; + } + if (trainerOptions != null) { + $result.trainerOptions = trainerOptions; + } + if (deviceTiltConfig != null) { + $result.deviceTiltConfig = deviceTiltConfig; + } + if (controllerInputConfig != null) { + $result.controllerInputConfig = controllerInputConfig; + } + if (trainerGearIndexConfig != null) { + $result.trainerGearIndexConfig = trainerGearIndexConfig; + } + if (wifiConfig != null) { + $result.wifiConfig = wifiConfig; + } + if (deviceUpdatePage != null) { + $result.deviceUpdatePage = deviceUpdatePage; + } + if (dateTimePage != null) { + $result.dateTimePage = dateTimePage; + } + if (bleSecurityPage != null) { + $result.bleSecurityPage = bleSecurityPage; + } + return $result; + } + DataChangeNotification._() : super(); + factory DataChangeNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DataChangeNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, DataChangeNotification_Notification> _DataChangeNotification_NotificationByTag = { + 1 : DataChangeNotification_Notification.devInfo, + 2 : DataChangeNotification_Notification.trainerSimParam, + 3 : DataChangeNotification_Notification.trainerOptions, + 4 : DataChangeNotification_Notification.deviceTiltConfig, + 5 : DataChangeNotification_Notification.controllerInputConfig, + 6 : DataChangeNotification_Notification.trainerGearIndexConfig, + 8 : DataChangeNotification_Notification.wifiConfig, + 9 : DataChangeNotification_Notification.deviceUpdatePage, + 10 : DataChangeNotification_Notification.dateTimePage, + 11 : DataChangeNotification_Notification.bleSecurityPage, + 0 : DataChangeNotification_Notification.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DataChangeNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2, 3, 4, 5, 6, 8, 9, 10, 11]) + ..aOM(1, _omitFieldNames ? '' : 'devInfo', protoName: 'devInfo', subBuilder: DevInfoPage.create) + ..aOM(2, _omitFieldNames ? '' : 'trainerSimParam', protoName: 'trainerSimParam', subBuilder: TrainerSimulationParam.create) + ..aOM(3, _omitFieldNames ? '' : 'trainerOptions', protoName: 'trainerOptions', subBuilder: TrainerOptions.create) + ..aOM(4, _omitFieldNames ? '' : 'deviceTiltConfig', protoName: 'deviceTiltConfig', subBuilder: DeviceTiltConfigPage.create) + ..aOM(5, _omitFieldNames ? '' : 'controllerInputConfig', protoName: 'controllerInputConfig', subBuilder: ControllerInputConfigPage.create) + ..aOM(6, _omitFieldNames ? '' : 'trainerGearIndexConfig', protoName: 'trainerGearIndexConfig', subBuilder: TrainerGearIndexConfigPage.create) + ..aOM(8, _omitFieldNames ? '' : 'wifiConfig', protoName: 'wifiConfig', subBuilder: WifiConfigurationPage.create) + ..aOM(9, _omitFieldNames ? '' : 'deviceUpdatePage', protoName: 'deviceUpdatePage', subBuilder: DeviceUpdatePage.create) + ..aOM(10, _omitFieldNames ? '' : 'dateTimePage', protoName: 'dateTimePage', subBuilder: DateTimePage.create) + ..aOM(11, _omitFieldNames ? '' : 'bleSecurityPage', protoName: 'bleSecurityPage', subBuilder: BleSecurityPage.create) + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DataChangeNotification clone() => DataChangeNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DataChangeNotification copyWith(void Function(DataChangeNotification) updates) => super.copyWith((message) => updates(message as DataChangeNotification)) as DataChangeNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DataChangeNotification create() => DataChangeNotification._(); + DataChangeNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DataChangeNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DataChangeNotification? _defaultInstance; + + DataChangeNotification_Notification whichNotification() => _DataChangeNotification_NotificationByTag[$_whichOneof(0)]!; + void clearNotification() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + DevInfoPage get devInfo => $_getN(0); + @$pb.TagNumber(1) + set devInfo(DevInfoPage v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDevInfo() => $_has(0); + @$pb.TagNumber(1) + void clearDevInfo() => clearField(1); + @$pb.TagNumber(1) + DevInfoPage ensureDevInfo() => $_ensure(0); + + @$pb.TagNumber(2) + TrainerSimulationParam get trainerSimParam => $_getN(1); + @$pb.TagNumber(2) + set trainerSimParam(TrainerSimulationParam v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasTrainerSimParam() => $_has(1); + @$pb.TagNumber(2) + void clearTrainerSimParam() => clearField(2); + @$pb.TagNumber(2) + TrainerSimulationParam ensureTrainerSimParam() => $_ensure(1); + + @$pb.TagNumber(3) + TrainerOptions get trainerOptions => $_getN(2); + @$pb.TagNumber(3) + set trainerOptions(TrainerOptions v) { setField(3, v); } + @$pb.TagNumber(3) + $core.bool hasTrainerOptions() => $_has(2); + @$pb.TagNumber(3) + void clearTrainerOptions() => clearField(3); + @$pb.TagNumber(3) + TrainerOptions ensureTrainerOptions() => $_ensure(2); + + @$pb.TagNumber(4) + DeviceTiltConfigPage get deviceTiltConfig => $_getN(3); + @$pb.TagNumber(4) + set deviceTiltConfig(DeviceTiltConfigPage v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasDeviceTiltConfig() => $_has(3); + @$pb.TagNumber(4) + void clearDeviceTiltConfig() => clearField(4); + @$pb.TagNumber(4) + DeviceTiltConfigPage ensureDeviceTiltConfig() => $_ensure(3); + + @$pb.TagNumber(5) + ControllerInputConfigPage get controllerInputConfig => $_getN(4); + @$pb.TagNumber(5) + set controllerInputConfig(ControllerInputConfigPage v) { setField(5, v); } + @$pb.TagNumber(5) + $core.bool hasControllerInputConfig() => $_has(4); + @$pb.TagNumber(5) + void clearControllerInputConfig() => clearField(5); + @$pb.TagNumber(5) + ControllerInputConfigPage ensureControllerInputConfig() => $_ensure(4); + + @$pb.TagNumber(6) + TrainerGearIndexConfigPage get trainerGearIndexConfig => $_getN(5); + @$pb.TagNumber(6) + set trainerGearIndexConfig(TrainerGearIndexConfigPage v) { setField(6, v); } + @$pb.TagNumber(6) + $core.bool hasTrainerGearIndexConfig() => $_has(5); + @$pb.TagNumber(6) + void clearTrainerGearIndexConfig() => clearField(6); + @$pb.TagNumber(6) + TrainerGearIndexConfigPage ensureTrainerGearIndexConfig() => $_ensure(5); + + @$pb.TagNumber(8) + WifiConfigurationPage get wifiConfig => $_getN(6); + @$pb.TagNumber(8) + set wifiConfig(WifiConfigurationPage v) { setField(8, v); } + @$pb.TagNumber(8) + $core.bool hasWifiConfig() => $_has(6); + @$pb.TagNumber(8) + void clearWifiConfig() => clearField(8); + @$pb.TagNumber(8) + WifiConfigurationPage ensureWifiConfig() => $_ensure(6); + + @$pb.TagNumber(9) + DeviceUpdatePage get deviceUpdatePage => $_getN(7); + @$pb.TagNumber(9) + set deviceUpdatePage(DeviceUpdatePage v) { setField(9, v); } + @$pb.TagNumber(9) + $core.bool hasDeviceUpdatePage() => $_has(7); + @$pb.TagNumber(9) + void clearDeviceUpdatePage() => clearField(9); + @$pb.TagNumber(9) + DeviceUpdatePage ensureDeviceUpdatePage() => $_ensure(7); + + @$pb.TagNumber(10) + DateTimePage get dateTimePage => $_getN(8); + @$pb.TagNumber(10) + set dateTimePage(DateTimePage v) { setField(10, v); } + @$pb.TagNumber(10) + $core.bool hasDateTimePage() => $_has(8); + @$pb.TagNumber(10) + void clearDateTimePage() => clearField(10); + @$pb.TagNumber(10) + DateTimePage ensureDateTimePage() => $_ensure(8); + + @$pb.TagNumber(11) + BleSecurityPage get bleSecurityPage => $_getN(9); + @$pb.TagNumber(11) + set bleSecurityPage(BleSecurityPage v) { setField(11, v); } + @$pb.TagNumber(11) + $core.bool hasBleSecurityPage() => $_has(9); + @$pb.TagNumber(11) + void clearBleSecurityPage() => clearField(11); + @$pb.TagNumber(11) + BleSecurityPage ensureBleSecurityPage() => $_ensure(9); +} + +class GameStateNotification_GameSpeed extends $pb.GeneratedMessage { + factory GameStateNotification_GameSpeed({ + $core.int? normalisedSpeed, + $core.int? speed, + }) { + final $result = create(); + if (normalisedSpeed != null) { + $result.normalisedSpeed = normalisedSpeed; + } + if (speed != null) { + $result.speed = speed; + } + return $result; + } + GameStateNotification_GameSpeed._() : super(); + factory GameStateNotification_GameSpeed.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory GameStateNotification_GameSpeed.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GameStateNotification.GameSpeed', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'normalisedSpeed', $pb.PbFieldType.OS3, protoName: 'normalisedSpeed') + ..a<$core.int>(2, _omitFieldNames ? '' : 'speed', $pb.PbFieldType.OS3) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + GameStateNotification_GameSpeed clone() => GameStateNotification_GameSpeed()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + GameStateNotification_GameSpeed copyWith(void Function(GameStateNotification_GameSpeed) updates) => super.copyWith((message) => updates(message as GameStateNotification_GameSpeed)) as GameStateNotification_GameSpeed; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GameStateNotification_GameSpeed create() => GameStateNotification_GameSpeed._(); + GameStateNotification_GameSpeed createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GameStateNotification_GameSpeed getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static GameStateNotification_GameSpeed? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get normalisedSpeed => $_getIZ(0); + @$pb.TagNumber(1) + set normalisedSpeed($core.int v) { $_setSignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasNormalisedSpeed() => $_has(0); + @$pb.TagNumber(1) + void clearNormalisedSpeed() => clearField(1); + + @$pb.TagNumber(2) + $core.int get speed => $_getIZ(1); + @$pb.TagNumber(2) + set speed($core.int v) { $_setSignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasSpeed() => $_has(1); + @$pb.TagNumber(2) + void clearSpeed() => clearField(2); +} + +class GameStateNotification_RoadSurface extends $pb.GeneratedMessage { + factory GameStateNotification_RoadSurface({ + RoadSurfaceType? roadSurfaceType, + $core.int? roughness, + }) { + final $result = create(); + if (roadSurfaceType != null) { + $result.roadSurfaceType = roadSurfaceType; + } + if (roughness != null) { + $result.roughness = roughness; + } + return $result; + } + GameStateNotification_RoadSurface._() : super(); + factory GameStateNotification_RoadSurface.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory GameStateNotification_RoadSurface.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GameStateNotification.RoadSurface', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'roadSurfaceType', $pb.PbFieldType.OE, protoName: 'roadSurfaceType', defaultOrMaker: RoadSurfaceType.ROAD_SURFACE_SMOOTH_TARMAC, valueOf: RoadSurfaceType.valueOf, enumValues: RoadSurfaceType.values) + ..a<$core.int>(2, _omitFieldNames ? '' : 'roughness', $pb.PbFieldType.OU3) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + GameStateNotification_RoadSurface clone() => GameStateNotification_RoadSurface()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + GameStateNotification_RoadSurface copyWith(void Function(GameStateNotification_RoadSurface) updates) => super.copyWith((message) => updates(message as GameStateNotification_RoadSurface)) as GameStateNotification_RoadSurface; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GameStateNotification_RoadSurface create() => GameStateNotification_RoadSurface._(); + GameStateNotification_RoadSurface createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GameStateNotification_RoadSurface getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static GameStateNotification_RoadSurface? _defaultInstance; + + @$pb.TagNumber(1) + RoadSurfaceType get roadSurfaceType => $_getN(0); + @$pb.TagNumber(1) + set roadSurfaceType(RoadSurfaceType v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasRoadSurfaceType() => $_has(0); + @$pb.TagNumber(1) + void clearRoadSurfaceType() => clearField(1); + + @$pb.TagNumber(2) + $core.int get roughness => $_getIZ(1); + @$pb.TagNumber(2) + set roughness($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasRoughness() => $_has(1); + @$pb.TagNumber(2) + void clearRoughness() => clearField(2); +} + +enum GameStateNotification_Notification { + gameSpeed, + roadSurface, + notSet +} + +/// Opcode 0x43 GAME_STATE_NOTIFICATION +class GameStateNotification extends $pb.GeneratedMessage { + factory GameStateNotification({ + GameStateNotification_GameSpeed? gameSpeed, + GameStateNotification_RoadSurface? roadSurface, + }) { + final $result = create(); + if (gameSpeed != null) { + $result.gameSpeed = gameSpeed; + } + if (roadSurface != null) { + $result.roadSurface = roadSurface; + } + return $result; + } + GameStateNotification._() : super(); + factory GameStateNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory GameStateNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, GameStateNotification_Notification> _GameStateNotification_NotificationByTag = { + 1 : GameStateNotification_Notification.gameSpeed, + 2 : GameStateNotification_Notification.roadSurface, + 0 : GameStateNotification_Notification.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'GameStateNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'gameSpeed', protoName: 'gameSpeed', subBuilder: GameStateNotification_GameSpeed.create) + ..aOM(2, _omitFieldNames ? '' : 'roadSurface', protoName: 'roadSurface', subBuilder: GameStateNotification_RoadSurface.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') + GameStateNotification clone() => GameStateNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + GameStateNotification copyWith(void Function(GameStateNotification) updates) => super.copyWith((message) => updates(message as GameStateNotification)) as GameStateNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GameStateNotification create() => GameStateNotification._(); + GameStateNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GameStateNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static GameStateNotification? _defaultInstance; + + GameStateNotification_Notification whichNotification() => _GameStateNotification_NotificationByTag[$_whichOneof(0)]!; + void clearNotification() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + GameStateNotification_GameSpeed get gameSpeed => $_getN(0); + @$pb.TagNumber(1) + set gameSpeed(GameStateNotification_GameSpeed v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasGameSpeed() => $_has(0); + @$pb.TagNumber(1) + void clearGameSpeed() => clearField(1); + @$pb.TagNumber(1) + GameStateNotification_GameSpeed ensureGameSpeed() => $_ensure(0); + + @$pb.TagNumber(2) + GameStateNotification_RoadSurface get roadSurface => $_getN(1); + @$pb.TagNumber(2) + set roadSurface(GameStateNotification_RoadSurface v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasRoadSurface() => $_has(1); + @$pb.TagNumber(2) + void clearRoadSurface() => clearField(2); + @$pb.TagNumber(2) + GameStateNotification_RoadSurface ensureRoadSurface() => $_ensure(1); +} + +enum SensorRelayConfig_Procedure { + search, + connect, + disconnect, + clearAll, + disconnectAll, + notSet +} + +/// Opcode 0x44 (SENSOR_RELAY_CONFIG) +class SensorRelayConfig extends $pb.GeneratedMessage { + factory SensorRelayConfig({ + $core.bool? search, + $core.int? connect, + $core.int? disconnect, + $core.bool? clearAll, + $core.bool? disconnectAll, + }) { + final $result = create(); + if (search != null) { + $result.search = search; + } + if (connect != null) { + $result.connect = connect; + } + if (disconnect != null) { + $result.disconnect = disconnect; + } + if (clearAll != null) { + $result.clearAll = clearAll; + } + if (disconnectAll != null) { + $result.disconnectAll = disconnectAll; + } + return $result; + } + SensorRelayConfig._() : super(); + factory SensorRelayConfig.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SensorRelayConfig.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, SensorRelayConfig_Procedure> _SensorRelayConfig_ProcedureByTag = { + 1 : SensorRelayConfig_Procedure.search, + 2 : SensorRelayConfig_Procedure.connect, + 3 : SensorRelayConfig_Procedure.disconnect, + 4 : SensorRelayConfig_Procedure.clearAll, + 5 : SensorRelayConfig_Procedure.disconnectAll, + 0 : SensorRelayConfig_Procedure.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SensorRelayConfig', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2, 3, 4, 5]) + ..aOB(1, _omitFieldNames ? '' : 'search') + ..a<$core.int>(2, _omitFieldNames ? '' : 'connect', $pb.PbFieldType.OU3) + ..a<$core.int>(3, _omitFieldNames ? '' : 'disconnect', $pb.PbFieldType.OU3) + ..aOB(4, _omitFieldNames ? '' : 'clearAll', protoName: 'clearAll') + ..aOB(5, _omitFieldNames ? '' : 'disconnectAll', protoName: 'disconnectAll') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SensorRelayConfig clone() => SensorRelayConfig()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SensorRelayConfig copyWith(void Function(SensorRelayConfig) updates) => super.copyWith((message) => updates(message as SensorRelayConfig)) as SensorRelayConfig; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SensorRelayConfig create() => SensorRelayConfig._(); + SensorRelayConfig createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SensorRelayConfig getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SensorRelayConfig? _defaultInstance; + + SensorRelayConfig_Procedure whichProcedure() => _SensorRelayConfig_ProcedureByTag[$_whichOneof(0)]!; + void clearProcedure() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $core.bool get search => $_getBF(0); + @$pb.TagNumber(1) + set search($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasSearch() => $_has(0); + @$pb.TagNumber(1) + void clearSearch() => clearField(1); + + @$pb.TagNumber(2) + $core.int get connect => $_getIZ(1); + @$pb.TagNumber(2) + set connect($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasConnect() => $_has(1); + @$pb.TagNumber(2) + void clearConnect() => clearField(2); + + @$pb.TagNumber(3) + $core.int get disconnect => $_getIZ(2); + @$pb.TagNumber(3) + set disconnect($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasDisconnect() => $_has(2); + @$pb.TagNumber(3) + void clearDisconnect() => clearField(3); + + @$pb.TagNumber(4) + $core.bool get clearAll => $_getBF(3); + @$pb.TagNumber(4) + set clearAll($core.bool v) { $_setBool(3, v); } + @$pb.TagNumber(4) + $core.bool hasClearAll() => $_has(3); + @$pb.TagNumber(4) + void clearClearAll() => clearField(4); + + @$pb.TagNumber(5) + $core.bool get disconnectAll => $_getBF(4); + @$pb.TagNumber(5) + set disconnectAll($core.bool v) { $_setBool(4, v); } + @$pb.TagNumber(5) + $core.bool hasDisconnectAll() => $_has(4); + @$pb.TagNumber(5) + void clearDisconnectAll() => clearField(5); +} + +enum SensorRelayGet_Request { + pairedSensorInfo, + notSet +} + +/// Opcode 0x45 (SENSOR_RELAY_GET) +class SensorRelayGet extends $pb.GeneratedMessage { + factory SensorRelayGet({ + $core.int? pairedSensorInfo, + }) { + final $result = create(); + if (pairedSensorInfo != null) { + $result.pairedSensorInfo = pairedSensorInfo; + } + return $result; + } + SensorRelayGet._() : super(); + factory SensorRelayGet.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SensorRelayGet.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, SensorRelayGet_Request> _SensorRelayGet_RequestByTag = { + 1 : SensorRelayGet_Request.pairedSensorInfo, + 0 : SensorRelayGet_Request.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SensorRelayGet', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1]) + ..a<$core.int>(1, _omitFieldNames ? '' : 'pairedSensorInfo', $pb.PbFieldType.OU3, protoName: 'pairedSensorInfo') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + SensorRelayGet clone() => SensorRelayGet()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SensorRelayGet copyWith(void Function(SensorRelayGet) updates) => super.copyWith((message) => updates(message as SensorRelayGet)) as SensorRelayGet; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SensorRelayGet create() => SensorRelayGet._(); + SensorRelayGet createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SensorRelayGet getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SensorRelayGet? _defaultInstance; + + SensorRelayGet_Request whichRequest() => _SensorRelayGet_RequestByTag[$_whichOneof(0)]!; + void clearRequest() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $core.int get pairedSensorInfo => $_getIZ(0); + @$pb.TagNumber(1) + set pairedSensorInfo($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasPairedSensorInfo() => $_has(0); + @$pb.TagNumber(1) + void clearPairedSensorInfo() => clearField(1); +} + +enum SensorRelayResponse_Response { + pairedSensorInfoList, + notSet +} + +/// Opcode 0x46 (SENSOR_RELAY_RESPONSE) +class SensorRelayResponse extends $pb.GeneratedMessage { + factory SensorRelayResponse({ + SensorInfoList? pairedSensorInfoList, + }) { + final $result = create(); + if (pairedSensorInfoList != null) { + $result.pairedSensorInfoList = pairedSensorInfoList; + } + return $result; + } + SensorRelayResponse._() : super(); + factory SensorRelayResponse.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SensorRelayResponse.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, SensorRelayResponse_Response> _SensorRelayResponse_ResponseByTag = { + 1 : SensorRelayResponse_Response.pairedSensorInfoList, + 0 : SensorRelayResponse_Response.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SensorRelayResponse', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1]) + ..aOM(1, _omitFieldNames ? '' : 'pairedSensorInfoList', protoName: 'pairedSensorInfoList', subBuilder: SensorInfoList.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') + SensorRelayResponse clone() => SensorRelayResponse()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SensorRelayResponse copyWith(void Function(SensorRelayResponse) updates) => super.copyWith((message) => updates(message as SensorRelayResponse)) as SensorRelayResponse; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SensorRelayResponse create() => SensorRelayResponse._(); + SensorRelayResponse createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SensorRelayResponse getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SensorRelayResponse? _defaultInstance; + + SensorRelayResponse_Response whichResponse() => _SensorRelayResponse_ResponseByTag[$_whichOneof(0)]!; + void clearResponse() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + SensorInfoList get pairedSensorInfoList => $_getN(0); + @$pb.TagNumber(1) + set pairedSensorInfoList(SensorInfoList v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasPairedSensorInfoList() => $_has(0); + @$pb.TagNumber(1) + void clearPairedSensorInfoList() => clearField(1); + @$pb.TagNumber(1) + SensorInfoList ensurePairedSensorInfoList() => $_ensure(0); +} + +enum SensorRelayNotification_Notification { + newSensor, + sensorStatus, + notSet +} + +/// Opcode 0x47 (SENSOR_RELAY_NOTIFIATION) +class SensorRelayNotification extends $pb.GeneratedMessage { + factory SensorRelayNotification({ + SensorInfo? newSensor, + SensorInfo? sensorStatus, + }) { + final $result = create(); + if (newSensor != null) { + $result.newSensor = newSensor; + } + if (sensorStatus != null) { + $result.sensorStatus = sensorStatus; + } + return $result; + } + SensorRelayNotification._() : super(); + factory SensorRelayNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory SensorRelayNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, SensorRelayNotification_Notification> _SensorRelayNotification_NotificationByTag = { + 1 : SensorRelayNotification_Notification.newSensor, + 2 : SensorRelayNotification_Notification.sensorStatus, + 0 : SensorRelayNotification_Notification.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'SensorRelayNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'newSensor', protoName: 'newSensor', subBuilder: SensorInfo.create) + ..aOM(2, _omitFieldNames ? '' : 'sensorStatus', protoName: 'sensorStatus', subBuilder: SensorInfo.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') + SensorRelayNotification clone() => SensorRelayNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + SensorRelayNotification copyWith(void Function(SensorRelayNotification) updates) => super.copyWith((message) => updates(message as SensorRelayNotification)) as SensorRelayNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static SensorRelayNotification create() => SensorRelayNotification._(); + SensorRelayNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static SensorRelayNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static SensorRelayNotification? _defaultInstance; + + SensorRelayNotification_Notification whichNotification() => _SensorRelayNotification_NotificationByTag[$_whichOneof(0)]!; + void clearNotification() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + SensorInfo get newSensor => $_getN(0); + @$pb.TagNumber(1) + set newSensor(SensorInfo v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasNewSensor() => $_has(0); + @$pb.TagNumber(1) + void clearNewSensor() => clearField(1); + @$pb.TagNumber(1) + SensorInfo ensureNewSensor() => $_ensure(0); + + @$pb.TagNumber(2) + SensorInfo get sensorStatus => $_getN(1); + @$pb.TagNumber(2) + set sensorStatus(SensorInfo v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasSensorStatus() => $_has(1); + @$pb.TagNumber(2) + void clearSensorStatus() => clearField(2); + @$pb.TagNumber(2) + SensorInfo ensureSensorStatus() => $_ensure(1); +} + +/// Opcode 0x48 (HRM_DATA_NOTIFICATION) +class HrmDataNotification extends $pb.GeneratedMessage { + factory HrmDataNotification({ + $core.int? sensorId, + $core.int? hrm, + $core.int? energyExpended, + $core.Iterable<$core.int>? rrInterval, + }) { + final $result = create(); + if (sensorId != null) { + $result.sensorId = sensorId; + } + if (hrm != null) { + $result.hrm = hrm; + } + if (energyExpended != null) { + $result.energyExpended = energyExpended; + } + if (rrInterval != null) { + $result.rrInterval.addAll(rrInterval); + } + return $result; + } + HrmDataNotification._() : super(); + factory HrmDataNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory HrmDataNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'HrmDataNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'sensorId', $pb.PbFieldType.OU3, protoName: 'sensorId') + ..a<$core.int>(2, _omitFieldNames ? '' : 'hrm', $pb.PbFieldType.OU3) + ..a<$core.int>(3, _omitFieldNames ? '' : 'energyExpended', $pb.PbFieldType.OU3, protoName: 'energyExpended') + ..p<$core.int>(4, _omitFieldNames ? '' : 'rrInterval', $pb.PbFieldType.PU3, protoName: 'rrInterval') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + HrmDataNotification clone() => HrmDataNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + HrmDataNotification copyWith(void Function(HrmDataNotification) updates) => super.copyWith((message) => updates(message as HrmDataNotification)) as HrmDataNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static HrmDataNotification create() => HrmDataNotification._(); + HrmDataNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static HrmDataNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static HrmDataNotification? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get sensorId => $_getIZ(0); + @$pb.TagNumber(1) + set sensorId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSensorId() => $_has(0); + @$pb.TagNumber(1) + void clearSensorId() => clearField(1); + + @$pb.TagNumber(2) + $core.int get hrm => $_getIZ(1); + @$pb.TagNumber(2) + set hrm($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasHrm() => $_has(1); + @$pb.TagNumber(2) + void clearHrm() => clearField(2); + + @$pb.TagNumber(3) + $core.int get energyExpended => $_getIZ(2); + @$pb.TagNumber(3) + set energyExpended($core.int v) { $_setUnsignedInt32(2, v); } + @$pb.TagNumber(3) + $core.bool hasEnergyExpended() => $_has(2); + @$pb.TagNumber(3) + void clearEnergyExpended() => clearField(3); + + @$pb.TagNumber(4) + $core.List<$core.int> get rrInterval => $_getList(3); +} + +enum WifiConfigurationRequest_Request { + startScan, + connect, + forget, + setRegionCode, + notSet +} + +/// OpCode 0x49 +class WifiConfigurationRequest extends $pb.GeneratedMessage { + factory WifiConfigurationRequest({ + $core.bool? startScan, + WifiNetwork? connect, + $core.bool? forget, + WifiRegionCode? setRegionCode, + }) { + final $result = create(); + if (startScan != null) { + $result.startScan = startScan; + } + if (connect != null) { + $result.connect = connect; + } + if (forget != null) { + $result.forget = forget; + } + if (setRegionCode != null) { + $result.setRegionCode = setRegionCode; + } + return $result; + } + WifiConfigurationRequest._() : super(); + factory WifiConfigurationRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory WifiConfigurationRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, WifiConfigurationRequest_Request> _WifiConfigurationRequest_RequestByTag = { + 1 : WifiConfigurationRequest_Request.startScan, + 2 : WifiConfigurationRequest_Request.connect, + 3 : WifiConfigurationRequest_Request.forget, + 4 : WifiConfigurationRequest_Request.setRegionCode, + 0 : WifiConfigurationRequest_Request.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'WifiConfigurationRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2, 3, 4]) + ..aOB(1, _omitFieldNames ? '' : 'startScan', protoName: 'startScan') + ..aOM(2, _omitFieldNames ? '' : 'connect', subBuilder: WifiNetwork.create) + ..aOB(3, _omitFieldNames ? '' : 'forget') + ..aOM(4, _omitFieldNames ? '' : 'setRegionCode', protoName: 'setRegionCode', subBuilder: WifiRegionCode.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') + WifiConfigurationRequest clone() => WifiConfigurationRequest()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + WifiConfigurationRequest copyWith(void Function(WifiConfigurationRequest) updates) => super.copyWith((message) => updates(message as WifiConfigurationRequest)) as WifiConfigurationRequest; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WifiConfigurationRequest create() => WifiConfigurationRequest._(); + WifiConfigurationRequest createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static WifiConfigurationRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static WifiConfigurationRequest? _defaultInstance; + + WifiConfigurationRequest_Request whichRequest() => _WifiConfigurationRequest_RequestByTag[$_whichOneof(0)]!; + void clearRequest() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $core.bool get startScan => $_getBF(0); + @$pb.TagNumber(1) + set startScan($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasStartScan() => $_has(0); + @$pb.TagNumber(1) + void clearStartScan() => clearField(1); + + @$pb.TagNumber(2) + WifiNetwork get connect => $_getN(1); + @$pb.TagNumber(2) + set connect(WifiNetwork v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasConnect() => $_has(1); + @$pb.TagNumber(2) + void clearConnect() => clearField(2); + @$pb.TagNumber(2) + WifiNetwork ensureConnect() => $_ensure(1); + + @$pb.TagNumber(3) + $core.bool get forget => $_getBF(2); + @$pb.TagNumber(3) + set forget($core.bool v) { $_setBool(2, v); } + @$pb.TagNumber(3) + $core.bool hasForget() => $_has(2); + @$pb.TagNumber(3) + void clearForget() => clearField(3); + + @$pb.TagNumber(4) + WifiRegionCode get setRegionCode => $_getN(3); + @$pb.TagNumber(4) + set setRegionCode(WifiRegionCode v) { setField(4, v); } + @$pb.TagNumber(4) + $core.bool hasSetRegionCode() => $_has(3); + @$pb.TagNumber(4) + void clearSetRegionCode() => clearField(4); + @$pb.TagNumber(4) + WifiRegionCode ensureSetRegionCode() => $_ensure(3); +} + +class WifiNotification_WifiStatus extends $pb.GeneratedMessage { + factory WifiNotification_WifiStatus({ + WifiStatusCode? wifiStatusCode, + WifiErrorCode? wifiErrorCode, + }) { + final $result = create(); + if (wifiStatusCode != null) { + $result.wifiStatusCode = wifiStatusCode; + } + if (wifiErrorCode != null) { + $result.wifiErrorCode = wifiErrorCode; + } + return $result; + } + WifiNotification_WifiStatus._() : super(); + factory WifiNotification_WifiStatus.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory WifiNotification_WifiStatus.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'WifiNotification.WifiStatus', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..e(1, _omitFieldNames ? '' : 'wifiStatusCode', $pb.PbFieldType.OE, protoName: 'wifiStatusCode', defaultOrMaker: WifiStatusCode.WIFI_STATUS_DISABLED, valueOf: WifiStatusCode.valueOf, enumValues: WifiStatusCode.values) + ..e(2, _omitFieldNames ? '' : 'wifiErrorCode', $pb.PbFieldType.OE, protoName: 'wifiErrorCode', defaultOrMaker: WifiErrorCode.WIFI_ERROR_UNKNOWN, valueOf: WifiErrorCode.valueOf, enumValues: WifiErrorCode.values) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + WifiNotification_WifiStatus clone() => WifiNotification_WifiStatus()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + WifiNotification_WifiStatus copyWith(void Function(WifiNotification_WifiStatus) updates) => super.copyWith((message) => updates(message as WifiNotification_WifiStatus)) as WifiNotification_WifiStatus; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WifiNotification_WifiStatus create() => WifiNotification_WifiStatus._(); + WifiNotification_WifiStatus createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static WifiNotification_WifiStatus getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static WifiNotification_WifiStatus? _defaultInstance; + + @$pb.TagNumber(1) + WifiStatusCode get wifiStatusCode => $_getN(0); + @$pb.TagNumber(1) + set wifiStatusCode(WifiStatusCode v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasWifiStatusCode() => $_has(0); + @$pb.TagNumber(1) + void clearWifiStatusCode() => clearField(1); + + @$pb.TagNumber(2) + WifiErrorCode get wifiErrorCode => $_getN(1); + @$pb.TagNumber(2) + set wifiErrorCode(WifiErrorCode v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasWifiErrorCode() => $_has(1); + @$pb.TagNumber(2) + void clearWifiErrorCode() => clearField(2); +} + +enum WifiNotification_Notification { + discoveredNetwork, + wifiStatus, + notSet +} + +/// OpCode 0x4A +class WifiNotification extends $pb.GeneratedMessage { + factory WifiNotification({ + WifiNetworkDetails? discoveredNetwork, + WifiNotification_WifiStatus? wifiStatus, + }) { + final $result = create(); + if (discoveredNetwork != null) { + $result.discoveredNetwork = discoveredNetwork; + } + if (wifiStatus != null) { + $result.wifiStatus = wifiStatus; + } + return $result; + } + WifiNotification._() : super(); + factory WifiNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory WifiNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, WifiNotification_Notification> _WifiNotification_NotificationByTag = { + 1 : WifiNotification_Notification.discoveredNetwork, + 2 : WifiNotification_Notification.wifiStatus, + 0 : WifiNotification_Notification.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'WifiNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOM(1, _omitFieldNames ? '' : 'discoveredNetwork', protoName: 'discoveredNetwork', subBuilder: WifiNetworkDetails.create) + ..aOM(2, _omitFieldNames ? '' : 'wifiStatus', protoName: 'wifiStatus', subBuilder: WifiNotification_WifiStatus.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') + WifiNotification clone() => WifiNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + WifiNotification copyWith(void Function(WifiNotification) updates) => super.copyWith((message) => updates(message as WifiNotification)) as WifiNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WifiNotification create() => WifiNotification._(); + WifiNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static WifiNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static WifiNotification? _defaultInstance; + + WifiNotification_Notification whichNotification() => _WifiNotification_NotificationByTag[$_whichOneof(0)]!; + void clearNotification() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + WifiNetworkDetails get discoveredNetwork => $_getN(0); + @$pb.TagNumber(1) + set discoveredNetwork(WifiNetworkDetails v) { setField(1, v); } + @$pb.TagNumber(1) + $core.bool hasDiscoveredNetwork() => $_has(0); + @$pb.TagNumber(1) + void clearDiscoveredNetwork() => clearField(1); + @$pb.TagNumber(1) + WifiNetworkDetails ensureDiscoveredNetwork() => $_ensure(0); + + @$pb.TagNumber(2) + WifiNotification_WifiStatus get wifiStatus => $_getN(1); + @$pb.TagNumber(2) + set wifiStatus(WifiNotification_WifiStatus v) { setField(2, v); } + @$pb.TagNumber(2) + $core.bool hasWifiStatus() => $_has(1); + @$pb.TagNumber(2) + void clearWifiStatus() => clearField(2); + @$pb.TagNumber(2) + WifiNotification_WifiStatus ensureWifiStatus() => $_ensure(1); +} + +/// OpCode 0x4B +class PowerDataNotification extends $pb.GeneratedMessage { + factory PowerDataNotification({ + $core.int? sensorId, + $core.int? power, + }) { + final $result = create(); + if (sensorId != null) { + $result.sensorId = sensorId; + } + if (power != null) { + $result.power = power; + } + return $result; + } + PowerDataNotification._() : super(); + factory PowerDataNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory PowerDataNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'PowerDataNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'sensorId', $pb.PbFieldType.OU3, protoName: 'sensorId') + ..a<$core.int>(2, _omitFieldNames ? '' : 'power', $pb.PbFieldType.OU3) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + PowerDataNotification clone() => PowerDataNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + PowerDataNotification copyWith(void Function(PowerDataNotification) updates) => super.copyWith((message) => updates(message as PowerDataNotification)) as PowerDataNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static PowerDataNotification create() => PowerDataNotification._(); + PowerDataNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static PowerDataNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static PowerDataNotification? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get sensorId => $_getIZ(0); + @$pb.TagNumber(1) + set sensorId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSensorId() => $_has(0); + @$pb.TagNumber(1) + void clearSensorId() => clearField(1); + + @$pb.TagNumber(2) + $core.int get power => $_getIZ(1); + @$pb.TagNumber(2) + set power($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasPower() => $_has(1); + @$pb.TagNumber(2) + void clearPower() => clearField(2); +} + +/// OpCode 0x4C +class CadenceDataNotification extends $pb.GeneratedMessage { + factory CadenceDataNotification({ + $core.int? sensorId, + $core.int? cadence, + }) { + final $result = create(); + if (sensorId != null) { + $result.sensorId = sensorId; + } + if (cadence != null) { + $result.cadence = cadence; + } + return $result; + } + CadenceDataNotification._() : super(); + factory CadenceDataNotification.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory CadenceDataNotification.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'CadenceDataNotification', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'sensorId', $pb.PbFieldType.OU3, protoName: 'sensorId') + ..a<$core.int>(2, _omitFieldNames ? '' : 'cadence', $pb.PbFieldType.OU3) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + CadenceDataNotification clone() => CadenceDataNotification()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + CadenceDataNotification copyWith(void Function(CadenceDataNotification) updates) => super.copyWith((message) => updates(message as CadenceDataNotification)) as CadenceDataNotification; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static CadenceDataNotification create() => CadenceDataNotification._(); + CadenceDataNotification createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static CadenceDataNotification getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static CadenceDataNotification? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get sensorId => $_getIZ(0); + @$pb.TagNumber(1) + set sensorId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasSensorId() => $_has(0); + @$pb.TagNumber(1) + void clearSensorId() => clearField(1); + + @$pb.TagNumber(2) + $core.int get cadence => $_getIZ(1); + @$pb.TagNumber(2) + set cadence($core.int v) { $_setUnsignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasCadence() => $_has(1); + @$pb.TagNumber(2) + void clearCadence() => clearField(2); +} + +enum DeviceUpdateRequest_Procedure { + checkForUpdates, + activateUpdates, + notSet +} + +/// OpCode 0x4D +class DeviceUpdateRequest extends $pb.GeneratedMessage { + factory DeviceUpdateRequest({ + $core.bool? checkForUpdates, + $core.bool? activateUpdates, + }) { + final $result = create(); + if (checkForUpdates != null) { + $result.checkForUpdates = checkForUpdates; + } + if (activateUpdates != null) { + $result.activateUpdates = activateUpdates; + } + return $result; + } + DeviceUpdateRequest._() : super(); + factory DeviceUpdateRequest.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory DeviceUpdateRequest.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static const $core.Map<$core.int, DeviceUpdateRequest_Procedure> _DeviceUpdateRequest_ProcedureByTag = { + 1 : DeviceUpdateRequest_Procedure.checkForUpdates, + 2 : DeviceUpdateRequest_Procedure.activateUpdates, + 0 : DeviceUpdateRequest_Procedure.notSet + }; + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'DeviceUpdateRequest', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..oo(0, [1, 2]) + ..aOB(1, _omitFieldNames ? '' : 'checkForUpdates', protoName: 'checkForUpdates') + ..aOB(2, _omitFieldNames ? '' : 'activateUpdates', protoName: 'activateUpdates') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + DeviceUpdateRequest clone() => DeviceUpdateRequest()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + DeviceUpdateRequest copyWith(void Function(DeviceUpdateRequest) updates) => super.copyWith((message) => updates(message as DeviceUpdateRequest)) as DeviceUpdateRequest; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static DeviceUpdateRequest create() => DeviceUpdateRequest._(); + DeviceUpdateRequest createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static DeviceUpdateRequest getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static DeviceUpdateRequest? _defaultInstance; + + DeviceUpdateRequest_Procedure whichProcedure() => _DeviceUpdateRequest_ProcedureByTag[$_whichOneof(0)]!; + void clearProcedure() => clearField($_whichOneof(0)); + + @$pb.TagNumber(1) + $core.bool get checkForUpdates => $_getBF(0); + @$pb.TagNumber(1) + set checkForUpdates($core.bool v) { $_setBool(0, v); } + @$pb.TagNumber(1) + $core.bool hasCheckForUpdates() => $_has(0); + @$pb.TagNumber(1) + void clearCheckForUpdates() => clearField(1); + + @$pb.TagNumber(2) + $core.bool get activateUpdates => $_getBF(1); + @$pb.TagNumber(2) + set activateUpdates($core.bool v) { $_setBool(1, v); } + @$pb.TagNumber(2) + $core.bool hasActivateUpdates() => $_has(1); + @$pb.TagNumber(2) + void clearActivateUpdates() => clearField(2); +} + +/// Opcode 0x4E +class RelayZpMessage extends $pb.GeneratedMessage { + factory RelayZpMessage({ + $core.int? relayAssignedId, + $core.List<$core.int>? payload, + }) { + final $result = create(); + if (relayAssignedId != null) { + $result.relayAssignedId = relayAssignedId; + } + if (payload != null) { + $result.payload = payload; + } + return $result; + } + RelayZpMessage._() : super(); + factory RelayZpMessage.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory RelayZpMessage.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RelayZpMessage', package: const $pb.PackageName(_omitMessageNames ? '' : 'Zp'), createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'relayAssignedId', $pb.PbFieldType.OU3, protoName: 'relayAssignedId') + ..a<$core.List<$core.int>>(2, _omitFieldNames ? '' : 'payload', $pb.PbFieldType.OY) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + RelayZpMessage clone() => RelayZpMessage()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + RelayZpMessage copyWith(void Function(RelayZpMessage) updates) => super.copyWith((message) => updates(message as RelayZpMessage)) as RelayZpMessage; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static RelayZpMessage create() => RelayZpMessage._(); + RelayZpMessage createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static RelayZpMessage getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static RelayZpMessage? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get relayAssignedId => $_getIZ(0); + @$pb.TagNumber(1) + set relayAssignedId($core.int v) { $_setUnsignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasRelayAssignedId() => $_has(0); + @$pb.TagNumber(1) + void clearRelayAssignedId() => clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get payload => $_getN(1); + @$pb.TagNumber(2) + set payload($core.List<$core.int> v) { $_setBytes(1, v); } + @$pb.TagNumber(2) + $core.bool hasPayload() => $_has(1); + @$pb.TagNumber(2) + void clearPayload() => clearField(2); +} + + +const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); +const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names'); diff --git a/prop_public/lib/protocol/zp.pbenum.dart b/prop_public/lib/protocol/zp.pbenum.dart new file mode 100644 index 000000000..9350d9e9e --- /dev/null +++ b/prop_public/lib/protocol/zp.pbenum.dart @@ -0,0 +1,583 @@ +// +// Generated code. Do not modify. +// source: zp.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +/// /////////////////////////////////////////////////////////////// +/// Enumerations +/// /////////////////////////////////////////////////////////////// +class Opcode extends $pb.ProtobufEnum { + static const Opcode GET = Opcode._(0, _omitEnumNames ? '' : 'GET'); + static const Opcode DEV_INFO_STATUS = Opcode._(1, _omitEnumNames ? '' : 'DEV_INFO_STATUS'); + static const Opcode BLE_SECURITY_REQUEST = Opcode._(2, _omitEnumNames ? '' : 'BLE_SECURITY_REQUEST'); + static const Opcode TRAINER_NOTIF = Opcode._(3, _omitEnumNames ? '' : 'TRAINER_NOTIF'); + static const Opcode TRAINER_CONFIG_SET = Opcode._(4, _omitEnumNames ? '' : 'TRAINER_CONFIG_SET'); + static const Opcode TRAINER_CONFIG_STATUS = Opcode._(5, _omitEnumNames ? '' : 'TRAINER_CONFIG_STATUS'); + static const Opcode DEV_INFO_SET = Opcode._(12, _omitEnumNames ? '' : 'DEV_INFO_SET'); + static const Opcode POWER_OFF = Opcode._(15, _omitEnumNames ? '' : 'POWER_OFF'); + static const Opcode RESET = Opcode._(24, _omitEnumNames ? '' : 'RESET'); + static const Opcode BATTERY_NOTIF = Opcode._(25, _omitEnumNames ? '' : 'BATTERY_NOTIF'); + static const Opcode CONTROLLER_NOTIFICATION = Opcode._(35, _omitEnumNames ? '' : 'CONTROLLER_NOTIFICATION'); + static const Opcode LOG_DATA = Opcode._(42, _omitEnumNames ? '' : 'LOG_DATA'); + static const Opcode SPINDOWN_REQUEST = Opcode._(58, _omitEnumNames ? '' : 'SPINDOWN_REQUEST'); + static const Opcode SPINDOWN_NOTIFICATION = Opcode._(59, _omitEnumNames ? '' : 'SPINDOWN_NOTIFICATION'); + static const Opcode GET_RESPONSE = Opcode._(60, _omitEnumNames ? '' : 'GET_RESPONSE'); + static const Opcode STATUS_RESPONSE = Opcode._(62, _omitEnumNames ? '' : 'STATUS_RESPONSE'); + static const Opcode SET = Opcode._(63, _omitEnumNames ? '' : 'SET'); + static const Opcode SET_RESPONSE = Opcode._(64, _omitEnumNames ? '' : 'SET_RESPONSE'); + static const Opcode LOG_LEVEL_SET = Opcode._(65, _omitEnumNames ? '' : 'LOG_LEVEL_SET'); + static const Opcode DATA_CHANGE_NOTIFICATION = Opcode._(66, _omitEnumNames ? '' : 'DATA_CHANGE_NOTIFICATION'); + static const Opcode GAME_STATE_NOTIFICATION = Opcode._(67, _omitEnumNames ? '' : 'GAME_STATE_NOTIFICATION'); + static const Opcode SENSOR_RELAY_CONFIG = Opcode._(68, _omitEnumNames ? '' : 'SENSOR_RELAY_CONFIG'); + static const Opcode SENSOR_RELAY_GET = Opcode._(69, _omitEnumNames ? '' : 'SENSOR_RELAY_GET'); + static const Opcode SENSOR_RELAY_RESPONSE = Opcode._(70, _omitEnumNames ? '' : 'SENSOR_RELAY_RESPONSE'); + static const Opcode SENSOR_RELAY_NOTIFICATION = Opcode._(71, _omitEnumNames ? '' : 'SENSOR_RELAY_NOTIFICATION'); + static const Opcode HRM_DATA_NOTIFICATION = Opcode._(72, _omitEnumNames ? '' : 'HRM_DATA_NOTIFICATION'); + static const Opcode WIFI_CONFIG_REQUEST = Opcode._(73, _omitEnumNames ? '' : 'WIFI_CONFIG_REQUEST'); + static const Opcode WIFI_NOTIFICATION = Opcode._(74, _omitEnumNames ? '' : 'WIFI_NOTIFICATION'); + static const Opcode POWER_METER_NOTIFICATION = Opcode._(75, _omitEnumNames ? '' : 'POWER_METER_NOTIFICATION'); + static const Opcode CADENCE_SENSOR_NOTIFICATION = Opcode._(76, _omitEnumNames ? '' : 'CADENCE_SENSOR_NOTIFICATION'); + static const Opcode DEVICE_UPDATE_REQUEST = Opcode._(77, _omitEnumNames ? '' : 'DEVICE_UPDATE_REQUEST'); + static const Opcode RELAY_ZP_MESSAGE = Opcode._(78, _omitEnumNames ? '' : 'RELAY_ZP_MESSAGE'); + static const Opcode RIDE_ON = Opcode._(82, _omitEnumNames ? '' : 'RIDE_ON'); + static const Opcode RESERVED = Opcode._(253, _omitEnumNames ? '' : 'RESERVED'); + static const Opcode LOST_CONTROL = Opcode._(254, _omitEnumNames ? '' : 'LOST_CONTROL'); + static const Opcode VENDOR_MESSAGE = Opcode._(255, _omitEnumNames ? '' : 'VENDOR_MESSAGE'); + + static const $core.List values = [ + GET, + DEV_INFO_STATUS, + BLE_SECURITY_REQUEST, + TRAINER_NOTIF, + TRAINER_CONFIG_SET, + TRAINER_CONFIG_STATUS, + DEV_INFO_SET, + POWER_OFF, + RESET, + BATTERY_NOTIF, + CONTROLLER_NOTIFICATION, + LOG_DATA, + SPINDOWN_REQUEST, + SPINDOWN_NOTIFICATION, + GET_RESPONSE, + STATUS_RESPONSE, + SET, + SET_RESPONSE, + LOG_LEVEL_SET, + DATA_CHANGE_NOTIFICATION, + GAME_STATE_NOTIFICATION, + SENSOR_RELAY_CONFIG, + SENSOR_RELAY_GET, + SENSOR_RELAY_RESPONSE, + SENSOR_RELAY_NOTIFICATION, + HRM_DATA_NOTIFICATION, + WIFI_CONFIG_REQUEST, + WIFI_NOTIFICATION, + POWER_METER_NOTIFICATION, + CADENCE_SENSOR_NOTIFICATION, + DEVICE_UPDATE_REQUEST, + RELAY_ZP_MESSAGE, + RIDE_ON, + RESERVED, + LOST_CONTROL, + VENDOR_MESSAGE, + ]; + + static final $core.Map<$core.int, Opcode> _byValue = $pb.ProtobufEnum.initByValue(values); + static Opcode? valueOf($core.int value) => _byValue[value]; + + const Opcode._($core.int v, $core.String n) : super(v, n); +} + +/// Data Objects +class DO extends $pb.ProtobufEnum { + static const DO PAGE_DEV_INFO = DO._(0, _omitEnumNames ? '' : 'PAGE_DEV_INFO'); + static const DO PROTOCOL_VERSION = DO._(1, _omitEnumNames ? '' : 'PROTOCOL_VERSION'); + static const DO SYSTEM_FW_VERSION = DO._(2, _omitEnumNames ? '' : 'SYSTEM_FW_VERSION'); + static const DO DEVICE_NAME = DO._(3, _omitEnumNames ? '' : 'DEVICE_NAME'); + static const DO SERIAL_NUMBER = DO._(5, _omitEnumNames ? '' : 'SERIAL_NUMBER'); + static const DO SYSTEM_HW_REVISION = DO._(6, _omitEnumNames ? '' : 'SYSTEM_HW_REVISION'); + static const DO DEVICE_CAPABILITIES = DO._(7, _omitEnumNames ? '' : 'DEVICE_CAPABILITIES'); + static const DO MANUFACTURER_ID = DO._(8, _omitEnumNames ? '' : 'MANUFACTURER_ID'); + static const DO PRODUCT_ID = DO._(9, _omitEnumNames ? '' : 'PRODUCT_ID'); + static const DO DEVICE_UID = DO._(10, _omitEnumNames ? '' : 'DEVICE_UID'); + static const DO PAGE_CLIENT_SERVER_CONFIGURATION = DO._(16, _omitEnumNames ? '' : 'PAGE_CLIENT_SERVER_CONFIGURATION'); + static const DO CLIENT_SERVER_NOTIFICATIONS = DO._(17, _omitEnumNames ? '' : 'CLIENT_SERVER_NOTIFICATIONS'); + static const DO PAGE_DEVICE_UPDATE_INFO = DO._(32, _omitEnumNames ? '' : 'PAGE_DEVICE_UPDATE_INFO'); + static const DO DEVICE_UPDATE_STATUS = DO._(33, _omitEnumNames ? '' : 'DEVICE_UPDATE_STATUS'); + static const DO DEVICE_UPDATE_NEW_VERSION = DO._(34, _omitEnumNames ? '' : 'DEVICE_UPDATE_NEW_VERSION'); + static const DO PAGE_DATE_TIME = DO._(48, _omitEnumNames ? '' : 'PAGE_DATE_TIME'); + static const DO UTC_DATE_TIME = DO._(49, _omitEnumNames ? '' : 'UTC_DATE_TIME'); + static const DO PAGE_BLE_SECURITY = DO._(64, _omitEnumNames ? '' : 'PAGE_BLE_SECURITY'); + static const DO BLE_SECURE_CONNECTION_STATUS = DO._(65, _omitEnumNames ? '' : 'BLE_SECURE_CONNECTION_STATUS'); + static const DO BLE_SECURE_CONNECTION_WINDOW_STATUS = DO._(66, _omitEnumNames ? '' : 'BLE_SECURE_CONNECTION_WINDOW_STATUS'); + static const DO PAGE_TRAINER_CONFIG = DO._(512, _omitEnumNames ? '' : 'PAGE_TRAINER_CONFIG'); + static const DO TRAINER_MODE = DO._(513, _omitEnumNames ? '' : 'TRAINER_MODE'); + static const DO CFG_RESISTANCE = DO._(514, _omitEnumNames ? '' : 'CFG_RESISTANCE'); + static const DO ERG_POWER = DO._(515, _omitEnumNames ? '' : 'ERG_POWER'); + static const DO AVERAGING_WINDOW = DO._(516, _omitEnumNames ? '' : 'AVERAGING_WINDOW'); + static const DO SIM_WIND = DO._(517, _omitEnumNames ? '' : 'SIM_WIND'); + static const DO SIM_GRADE = DO._(518, _omitEnumNames ? '' : 'SIM_GRADE'); + static const DO SIM_REAL_GEAR_RATIO = DO._(519, _omitEnumNames ? '' : 'SIM_REAL_GEAR_RATIO'); + static const DO SIM_VIRT_GEAR_RATIO = DO._(520, _omitEnumNames ? '' : 'SIM_VIRT_GEAR_RATIO'); + static const DO SIM_CW = DO._(521, _omitEnumNames ? '' : 'SIM_CW'); + static const DO SIM_WHEEL_DIAMETER = DO._(522, _omitEnumNames ? '' : 'SIM_WHEEL_DIAMETER'); + static const DO SIM_BIKE_MASS = DO._(523, _omitEnumNames ? '' : 'SIM_BIKE_MASS'); + static const DO SIM_RIDER_MASS = DO._(524, _omitEnumNames ? '' : 'SIM_RIDER_MASS'); + static const DO SIM_CRR = DO._(525, _omitEnumNames ? '' : 'SIM_CRR'); + static const DO SIM_RESERVED_FRONTAL_AREA = DO._(526, _omitEnumNames ? '' : 'SIM_RESERVED_FRONTAL_AREA'); + static const DO SIM_EBRAKE = DO._(527, _omitEnumNames ? '' : 'SIM_EBRAKE'); + static const DO PAGE_TRAINER_GEAR_INDEX_CONFIG = DO._(528, _omitEnumNames ? '' : 'PAGE_TRAINER_GEAR_INDEX_CONFIG'); + static const DO FRONT_GEAR_INDEX = DO._(529, _omitEnumNames ? '' : 'FRONT_GEAR_INDEX'); + static const DO FRONT_GEAR_INDEX_MAX = DO._(530, _omitEnumNames ? '' : 'FRONT_GEAR_INDEX_MAX'); + static const DO FRONT_GEAR_INDEX_MIN = DO._(531, _omitEnumNames ? '' : 'FRONT_GEAR_INDEX_MIN'); + static const DO REAR_GEAR_INDEX = DO._(532, _omitEnumNames ? '' : 'REAR_GEAR_INDEX'); + static const DO REAR_GEAR_INDEX_MAX = DO._(533, _omitEnumNames ? '' : 'REAR_GEAR_INDEX_MAX'); + static const DO REAR_GEAR_INDEX_MIN = DO._(534, _omitEnumNames ? '' : 'REAR_GEAR_INDEX_MIN'); + static const DO PAGE_TRAINER_CONFIG2 = DO._(544, _omitEnumNames ? '' : 'PAGE_TRAINER_CONFIG2'); + static const DO HIGH_SPEED_DATA = DO._(545, _omitEnumNames ? '' : 'HIGH_SPEED_DATA'); + static const DO ERG_POWER_SMOOTHING = DO._(546, _omitEnumNames ? '' : 'ERG_POWER_SMOOTHING'); + static const DO VIRTUAL_SHIFTING_MODE = DO._(547, _omitEnumNames ? '' : 'VIRTUAL_SHIFTING_MODE'); + static const DO PAGE_DEVICE_TILT_CONFIG = DO._(560, _omitEnumNames ? '' : 'PAGE_DEVICE_TILT_CONFIG'); + static const DO DEVICE_TILT_ENABLED = DO._(561, _omitEnumNames ? '' : 'DEVICE_TILT_ENABLED'); + static const DO DEVICE_TILT_GRADIENT_MIN = DO._(562, _omitEnumNames ? '' : 'DEVICE_TILT_GRADIENT_MIN'); + static const DO DEVICE_TILT_GRADIENT_MAX = DO._(563, _omitEnumNames ? '' : 'DEVICE_TILT_GRADIENT_MAX'); + static const DO DEVICE_TILT_GRADIENT = DO._(564, _omitEnumNames ? '' : 'DEVICE_TILT_GRADIENT'); + static const DO BATTERY_STATE = DO._(771, _omitEnumNames ? '' : 'BATTERY_STATE'); + static const DO PAGE_CONTROLLER_INPUT_CONFIG = DO._(1024, _omitEnumNames ? '' : 'PAGE_CONTROLLER_INPUT_CONFIG'); + static const DO INPUT_SUPPORTED_DIGITAL_INPUTS = DO._(1025, _omitEnumNames ? '' : 'INPUT_SUPPORTED_DIGITAL_INPUTS'); + static const DO INPUT_SUPPORTED_ANALOG_INPUTS = DO._(1026, _omitEnumNames ? '' : 'INPUT_SUPPORTED_ANALOG_INPUTS'); + static const DO INPUT_ANALOG_INPUT_RANGE = DO._(1027, _omitEnumNames ? '' : 'INPUT_ANALOG_INPUT_RANGE'); + static const DO INPUT_ANALOG_INPUT_DEADZONE = DO._(1028, _omitEnumNames ? '' : 'INPUT_ANALOG_INPUT_DEADZONE'); + static const DO PAGE_WIFI_CONFIGURATION = DO._(1056, _omitEnumNames ? '' : 'PAGE_WIFI_CONFIGURATION'); + static const DO WIFI_ENABLED = DO._(1057, _omitEnumNames ? '' : 'WIFI_ENABLED'); + static const DO WIFI_STATUS = DO._(1058, _omitEnumNames ? '' : 'WIFI_STATUS'); + static const DO WIFI_SSID = DO._(1059, _omitEnumNames ? '' : 'WIFI_SSID'); + static const DO WIFI_BAND = DO._(1060, _omitEnumNames ? '' : 'WIFI_BAND'); + static const DO WIFI_RSSI = DO._(1061, _omitEnumNames ? '' : 'WIFI_RSSI'); + static const DO WIFI_REGION_CODE = DO._(1062, _omitEnumNames ? '' : 'WIFI_REGION_CODE'); + static const DO SENSOR_RELAY_DATA_PAGE = DO._(1280, _omitEnumNames ? '' : 'SENSOR_RELAY_DATA_PAGE'); + static const DO SENSOR_RELAY_SUPPORTED_SENSORS = DO._(1281, _omitEnumNames ? '' : 'SENSOR_RELAY_SUPPORTED_SENSORS'); + static const DO SENSOR_RELAY_PAIRED_SENSORS = DO._(1282, _omitEnumNames ? '' : 'SENSOR_RELAY_PAIRED_SENSORS'); + + static const $core.List values = [ + PAGE_DEV_INFO, + PROTOCOL_VERSION, + SYSTEM_FW_VERSION, + DEVICE_NAME, + SERIAL_NUMBER, + SYSTEM_HW_REVISION, + DEVICE_CAPABILITIES, + MANUFACTURER_ID, + PRODUCT_ID, + DEVICE_UID, + PAGE_CLIENT_SERVER_CONFIGURATION, + CLIENT_SERVER_NOTIFICATIONS, + PAGE_DEVICE_UPDATE_INFO, + DEVICE_UPDATE_STATUS, + DEVICE_UPDATE_NEW_VERSION, + PAGE_DATE_TIME, + UTC_DATE_TIME, + PAGE_BLE_SECURITY, + BLE_SECURE_CONNECTION_STATUS, + BLE_SECURE_CONNECTION_WINDOW_STATUS, + PAGE_TRAINER_CONFIG, + TRAINER_MODE, + CFG_RESISTANCE, + ERG_POWER, + AVERAGING_WINDOW, + SIM_WIND, + SIM_GRADE, + SIM_REAL_GEAR_RATIO, + SIM_VIRT_GEAR_RATIO, + SIM_CW, + SIM_WHEEL_DIAMETER, + SIM_BIKE_MASS, + SIM_RIDER_MASS, + SIM_CRR, + SIM_RESERVED_FRONTAL_AREA, + SIM_EBRAKE, + PAGE_TRAINER_GEAR_INDEX_CONFIG, + FRONT_GEAR_INDEX, + FRONT_GEAR_INDEX_MAX, + FRONT_GEAR_INDEX_MIN, + REAR_GEAR_INDEX, + REAR_GEAR_INDEX_MAX, + REAR_GEAR_INDEX_MIN, + PAGE_TRAINER_CONFIG2, + HIGH_SPEED_DATA, + ERG_POWER_SMOOTHING, + VIRTUAL_SHIFTING_MODE, + PAGE_DEVICE_TILT_CONFIG, + DEVICE_TILT_ENABLED, + DEVICE_TILT_GRADIENT_MIN, + DEVICE_TILT_GRADIENT_MAX, + DEVICE_TILT_GRADIENT, + BATTERY_STATE, + PAGE_CONTROLLER_INPUT_CONFIG, + INPUT_SUPPORTED_DIGITAL_INPUTS, + INPUT_SUPPORTED_ANALOG_INPUTS, + INPUT_ANALOG_INPUT_RANGE, + INPUT_ANALOG_INPUT_DEADZONE, + PAGE_WIFI_CONFIGURATION, + WIFI_ENABLED, + WIFI_STATUS, + WIFI_SSID, + WIFI_BAND, + WIFI_RSSI, + WIFI_REGION_CODE, + SENSOR_RELAY_DATA_PAGE, + SENSOR_RELAY_SUPPORTED_SENSORS, + SENSOR_RELAY_PAIRED_SENSORS, + ]; + + static final $core.Map<$core.int, DO> _byValue = $pb.ProtobufEnum.initByValue(values); + static DO? valueOf($core.int value) => _byValue[value]; + + const DO._($core.int v, $core.String n) : super(v, n); +} + +class Status extends $pb.ProtobufEnum { + static const Status SUCCESS = Status._(0, _omitEnumNames ? '' : 'SUCCESS'); + static const Status FAILURE = Status._(1, _omitEnumNames ? '' : 'FAILURE'); + static const Status BUSY = Status._(2, _omitEnumNames ? '' : 'BUSY'); + static const Status INVALID_PARAM = Status._(3, _omitEnumNames ? '' : 'INVALID_PARAM'); + static const Status NOT_PERMITTED = Status._(4, _omitEnumNames ? '' : 'NOT_PERMITTED'); + static const Status NOT_SUPPORTED = Status._(5, _omitEnumNames ? '' : 'NOT_SUPPORTED'); + static const Status INVALID_MODE = Status._(6, _omitEnumNames ? '' : 'INVALID_MODE'); + + static const $core.List values = [ + SUCCESS, + FAILURE, + BUSY, + INVALID_PARAM, + NOT_PERMITTED, + NOT_SUPPORTED, + INVALID_MODE, + ]; + + static final $core.Map<$core.int, Status> _byValue = $pb.ProtobufEnum.initByValue(values); + static Status? valueOf($core.int value) => _byValue[value]; + + const Status._($core.int v, $core.String n) : super(v, n); +} + +class DeviceType extends $pb.ProtobufEnum { + static const DeviceType UNDEFINED = DeviceType._(0, _omitEnumNames ? '' : 'UNDEFINED'); + static const DeviceType CYCLING_TURBO_TRAINER = DeviceType._(1, _omitEnumNames ? '' : 'CYCLING_TURBO_TRAINER'); + static const DeviceType USER_INPUT_DEVICE = DeviceType._(2, _omitEnumNames ? '' : 'USER_INPUT_DEVICE'); + static const DeviceType TREADMILL = DeviceType._(3, _omitEnumNames ? '' : 'TREADMILL'); + static const DeviceType SENSOR_RELAY = DeviceType._(4, _omitEnumNames ? '' : 'SENSOR_RELAY'); + static const DeviceType HEART_RATE_MONITOR = DeviceType._(5, _omitEnumNames ? '' : 'HEART_RATE_MONITOR'); + static const DeviceType POWER_METER = DeviceType._(6, _omitEnumNames ? '' : 'POWER_METER'); + static const DeviceType CADENCE_SENSOR = DeviceType._(7, _omitEnumNames ? '' : 'CADENCE_SENSOR'); + static const DeviceType WIFI = DeviceType._(8, _omitEnumNames ? '' : 'WIFI'); + + static const $core.List values = [ + UNDEFINED, + CYCLING_TURBO_TRAINER, + USER_INPUT_DEVICE, + TREADMILL, + SENSOR_RELAY, + HEART_RATE_MONITOR, + POWER_METER, + CADENCE_SENSOR, + WIFI, + ]; + + static final $core.Map<$core.int, DeviceType> _byValue = $pb.ProtobufEnum.initByValue(values); + static DeviceType? valueOf($core.int value) => _byValue[value]; + + const DeviceType._($core.int v, $core.String n) : super(v, n); +} + +class TrainerMode extends $pb.ProtobufEnum { + static const TrainerMode MODE_UNKNOWN = TrainerMode._(0, _omitEnumNames ? '' : 'MODE_UNKNOWN'); + static const TrainerMode MODE_ERG = TrainerMode._(1, _omitEnumNames ? '' : 'MODE_ERG'); + static const TrainerMode MODE_RESISTANCE = TrainerMode._(2, _omitEnumNames ? '' : 'MODE_RESISTANCE'); + static const TrainerMode MODE_SIM = TrainerMode._(3, _omitEnumNames ? '' : 'MODE_SIM'); + + static const $core.List values = [ + MODE_UNKNOWN, + MODE_ERG, + MODE_RESISTANCE, + MODE_SIM, + ]; + + static final $core.Map<$core.int, TrainerMode> _byValue = $pb.ProtobufEnum.initByValue(values); + static TrainerMode? valueOf($core.int value) => _byValue[value]; + + const TrainerMode._($core.int v, $core.String n) : super(v, n); +} + +class ChargingState extends $pb.ProtobufEnum { + static const ChargingState CHARGING_IDLE = ChargingState._(0, _omitEnumNames ? '' : 'CHARGING_IDLE'); + static const ChargingState CHARGING_PROGRESS = ChargingState._(1, _omitEnumNames ? '' : 'CHARGING_PROGRESS'); + static const ChargingState CHARGING_DONE = ChargingState._(2, _omitEnumNames ? '' : 'CHARGING_DONE'); + + static const $core.List values = [ + CHARGING_IDLE, + CHARGING_PROGRESS, + CHARGING_DONE, + ]; + + static final $core.Map<$core.int, ChargingState> _byValue = $pb.ProtobufEnum.initByValue(values); + static ChargingState? valueOf($core.int value) => _byValue[value]; + + const ChargingState._($core.int v, $core.String n) : super(v, n); +} + +class SpindownStatus extends $pb.ProtobufEnum { + static const SpindownStatus SPINDOWN_IDLE = SpindownStatus._(0, _omitEnumNames ? '' : 'SPINDOWN_IDLE'); + static const SpindownStatus SPINDOWN_REQUESTED = SpindownStatus._(1, _omitEnumNames ? '' : 'SPINDOWN_REQUESTED'); + static const SpindownStatus SPINDOWN_SUCCESS = SpindownStatus._(2, _omitEnumNames ? '' : 'SPINDOWN_SUCCESS'); + static const SpindownStatus SPINDOWN_ERROR = SpindownStatus._(3, _omitEnumNames ? '' : 'SPINDOWN_ERROR'); + static const SpindownStatus SPINDOWN_STOP_PEDALLING = SpindownStatus._(4, _omitEnumNames ? '' : 'SPINDOWN_STOP_PEDALLING'); + static const SpindownStatus SPINDOWN_ERROR_TIMEOUT = SpindownStatus._(5, _omitEnumNames ? '' : 'SPINDOWN_ERROR_TIMEOUT'); + static const SpindownStatus SPINDOWN_ERROR_TOSHORT = SpindownStatus._(6, _omitEnumNames ? '' : 'SPINDOWN_ERROR_TOSHORT'); + static const SpindownStatus SPINDOWN_ERROR_TOSLOW = SpindownStatus._(7, _omitEnumNames ? '' : 'SPINDOWN_ERROR_TOSLOW'); + static const SpindownStatus SPINDOWN_ERROR_TOFAST = SpindownStatus._(8, _omitEnumNames ? '' : 'SPINDOWN_ERROR_TOFAST'); + static const SpindownStatus SPINDOWN_ERROR_SAMPLEERROR = SpindownStatus._(9, _omitEnumNames ? '' : 'SPINDOWN_ERROR_SAMPLEERROR'); + static const SpindownStatus SPINDOWN_ERROR_ABORT = SpindownStatus._(10, _omitEnumNames ? '' : 'SPINDOWN_ERROR_ABORT'); + + static const $core.List values = [ + SPINDOWN_IDLE, + SPINDOWN_REQUESTED, + SPINDOWN_SUCCESS, + SPINDOWN_ERROR, + SPINDOWN_STOP_PEDALLING, + SPINDOWN_ERROR_TIMEOUT, + SPINDOWN_ERROR_TOSHORT, + SPINDOWN_ERROR_TOSLOW, + SPINDOWN_ERROR_TOFAST, + SPINDOWN_ERROR_SAMPLEERROR, + SPINDOWN_ERROR_ABORT, + ]; + + static final $core.Map<$core.int, SpindownStatus> _byValue = $pb.ProtobufEnum.initByValue(values); + static SpindownStatus? valueOf($core.int value) => _byValue[value]; + + const SpindownStatus._($core.int v, $core.String n) : super(v, n); +} + +class LogLevel extends $pb.ProtobufEnum { + static const LogLevel LOGLEVEL_OFF = LogLevel._(0, _omitEnumNames ? '' : 'LOGLEVEL_OFF'); + static const LogLevel LOGLEVEL_ERROR = LogLevel._(1, _omitEnumNames ? '' : 'LOGLEVEL_ERROR'); + static const LogLevel LOGLEVEL_WARNING = LogLevel._(2, _omitEnumNames ? '' : 'LOGLEVEL_WARNING'); + static const LogLevel LOGLEVEL_INFO = LogLevel._(3, _omitEnumNames ? '' : 'LOGLEVEL_INFO'); + static const LogLevel LOGLEVEL_DEBUG = LogLevel._(4, _omitEnumNames ? '' : 'LOGLEVEL_DEBUG'); + static const LogLevel LOGLEVEL_TRACE = LogLevel._(5, _omitEnumNames ? '' : 'LOGLEVEL_TRACE'); + + static const $core.List values = [ + LOGLEVEL_OFF, + LOGLEVEL_ERROR, + LOGLEVEL_WARNING, + LOGLEVEL_INFO, + LOGLEVEL_DEBUG, + LOGLEVEL_TRACE, + ]; + + static final $core.Map<$core.int, LogLevel> _byValue = $pb.ProtobufEnum.initByValue(values); + static LogLevel? valueOf($core.int value) => _byValue[value]; + + const LogLevel._($core.int v, $core.String n) : super(v, n); +} + +class RoadSurfaceType extends $pb.ProtobufEnum { + static const RoadSurfaceType ROAD_SURFACE_SMOOTH_TARMAC = RoadSurfaceType._(0, _omitEnumNames ? '' : 'ROAD_SURFACE_SMOOTH_TARMAC'); + static const RoadSurfaceType ROAD_SURFACE_BRICK_ROAD = RoadSurfaceType._(1, _omitEnumNames ? '' : 'ROAD_SURFACE_BRICK_ROAD'); + static const RoadSurfaceType ROAD_SURFACE_HARD_COBBLES = RoadSurfaceType._(2, _omitEnumNames ? '' : 'ROAD_SURFACE_HARD_COBBLES'); + static const RoadSurfaceType ROAD_SURFACE_SOFT_COBBLES = RoadSurfaceType._(3, _omitEnumNames ? '' : 'ROAD_SURFACE_SOFT_COBBLES'); + static const RoadSurfaceType ROAD_SURFACE_NARROW_WOODEN_PLANKS = RoadSurfaceType._(4, _omitEnumNames ? '' : 'ROAD_SURFACE_NARROW_WOODEN_PLANKS'); + static const RoadSurfaceType ROAD_SURFACE_WIDE_WOODEN_PLANKS = RoadSurfaceType._(5, _omitEnumNames ? '' : 'ROAD_SURFACE_WIDE_WOODEN_PLANKS'); + static const RoadSurfaceType ROAD_SURFACE_DIRT = RoadSurfaceType._(6, _omitEnumNames ? '' : 'ROAD_SURFACE_DIRT'); + static const RoadSurfaceType ROAD_SURFACE_GRAVEL = RoadSurfaceType._(7, _omitEnumNames ? '' : 'ROAD_SURFACE_GRAVEL'); + static const RoadSurfaceType ROAD_SURFACE_CATTLE_GRID = RoadSurfaceType._(8, _omitEnumNames ? '' : 'ROAD_SURFACE_CATTLE_GRID'); + static const RoadSurfaceType ROAD_SURFACE_CONCRETE_FLAG_STONES = RoadSurfaceType._(9, _omitEnumNames ? '' : 'ROAD_SURFACE_CONCRETE_FLAG_STONES'); + static const RoadSurfaceType ROAD_SURFACE_ICE = RoadSurfaceType._(10, _omitEnumNames ? '' : 'ROAD_SURFACE_ICE'); + + static const $core.List values = [ + ROAD_SURFACE_SMOOTH_TARMAC, + ROAD_SURFACE_BRICK_ROAD, + ROAD_SURFACE_HARD_COBBLES, + ROAD_SURFACE_SOFT_COBBLES, + ROAD_SURFACE_NARROW_WOODEN_PLANKS, + ROAD_SURFACE_WIDE_WOODEN_PLANKS, + ROAD_SURFACE_DIRT, + ROAD_SURFACE_GRAVEL, + ROAD_SURFACE_CATTLE_GRID, + ROAD_SURFACE_CONCRETE_FLAG_STONES, + ROAD_SURFACE_ICE, + ]; + + static final $core.Map<$core.int, RoadSurfaceType> _byValue = $pb.ProtobufEnum.initByValue(values); + static RoadSurfaceType? valueOf($core.int value) => _byValue[value]; + + const RoadSurfaceType._($core.int v, $core.String n) : super(v, n); +} + +class WifiStatusCode extends $pb.ProtobufEnum { + static const WifiStatusCode WIFI_STATUS_DISABLED = WifiStatusCode._(0, _omitEnumNames ? '' : 'WIFI_STATUS_DISABLED'); + static const WifiStatusCode WIFI_STATUS_NOT_PROVISIONED = WifiStatusCode._(1, _omitEnumNames ? '' : 'WIFI_STATUS_NOT_PROVISIONED'); + static const WifiStatusCode WIFI_STATUS_SCANNING = WifiStatusCode._(2, _omitEnumNames ? '' : 'WIFI_STATUS_SCANNING'); + static const WifiStatusCode WIFI_STATUS_DISCONNECTED = WifiStatusCode._(3, _omitEnumNames ? '' : 'WIFI_STATUS_DISCONNECTED'); + static const WifiStatusCode WIFI_STATUS_CONNECTING = WifiStatusCode._(4, _omitEnumNames ? '' : 'WIFI_STATUS_CONNECTING'); + static const WifiStatusCode WIFI_STATUS_CONNECTED = WifiStatusCode._(5, _omitEnumNames ? '' : 'WIFI_STATUS_CONNECTED'); + static const WifiStatusCode WIFI_STATUS_ERROR = WifiStatusCode._(6, _omitEnumNames ? '' : 'WIFI_STATUS_ERROR'); + + static const $core.List values = [ + WIFI_STATUS_DISABLED, + WIFI_STATUS_NOT_PROVISIONED, + WIFI_STATUS_SCANNING, + WIFI_STATUS_DISCONNECTED, + WIFI_STATUS_CONNECTING, + WIFI_STATUS_CONNECTED, + WIFI_STATUS_ERROR, + ]; + + static final $core.Map<$core.int, WifiStatusCode> _byValue = $pb.ProtobufEnum.initByValue(values); + static WifiStatusCode? valueOf($core.int value) => _byValue[value]; + + const WifiStatusCode._($core.int v, $core.String n) : super(v, n); +} + +class WifiErrorCode extends $pb.ProtobufEnum { + static const WifiErrorCode WIFI_ERROR_UNKNOWN = WifiErrorCode._(0, _omitEnumNames ? '' : 'WIFI_ERROR_UNKNOWN'); + static const WifiErrorCode WIFI_ERROR_NO_MEMORY = WifiErrorCode._(1, _omitEnumNames ? '' : 'WIFI_ERROR_NO_MEMORY'); + static const WifiErrorCode WIFI_ERROR_INVALID_PARAMETERS = WifiErrorCode._(2, _omitEnumNames ? '' : 'WIFI_ERROR_INVALID_PARAMETERS'); + static const WifiErrorCode WIFI_ERROR_INVALID_STATE = WifiErrorCode._(3, _omitEnumNames ? '' : 'WIFI_ERROR_INVALID_STATE'); + static const WifiErrorCode WIFI_ERROR_NOT_FOUND = WifiErrorCode._(4, _omitEnumNames ? '' : 'WIFI_ERROR_NOT_FOUND'); + static const WifiErrorCode WIFI_ERROR_NOT_SUPPORTED = WifiErrorCode._(5, _omitEnumNames ? '' : 'WIFI_ERROR_NOT_SUPPORTED'); + static const WifiErrorCode WIFI_ERROR_NOT_ALLOWED = WifiErrorCode._(6, _omitEnumNames ? '' : 'WIFI_ERROR_NOT_ALLOWED'); + static const WifiErrorCode WIFI_ERROR_NOT_INITIALISED = WifiErrorCode._(7, _omitEnumNames ? '' : 'WIFI_ERROR_NOT_INITIALISED'); + static const WifiErrorCode WIFI_ERROR_NOT_STARTED = WifiErrorCode._(8, _omitEnumNames ? '' : 'WIFI_ERROR_NOT_STARTED'); + static const WifiErrorCode WIFI_ERROR_TIMEOUT = WifiErrorCode._(9, _omitEnumNames ? '' : 'WIFI_ERROR_TIMEOUT'); + static const WifiErrorCode WIFI_ERROR_MODE = WifiErrorCode._(10, _omitEnumNames ? '' : 'WIFI_ERROR_MODE'); + static const WifiErrorCode WIFI_ERROR_SSID_INVALID = WifiErrorCode._(11, _omitEnumNames ? '' : 'WIFI_ERROR_SSID_INVALID'); + + static const $core.List values = [ + WIFI_ERROR_UNKNOWN, + WIFI_ERROR_NO_MEMORY, + WIFI_ERROR_INVALID_PARAMETERS, + WIFI_ERROR_INVALID_STATE, + WIFI_ERROR_NOT_FOUND, + WIFI_ERROR_NOT_SUPPORTED, + WIFI_ERROR_NOT_ALLOWED, + WIFI_ERROR_NOT_INITIALISED, + WIFI_ERROR_NOT_STARTED, + WIFI_ERROR_TIMEOUT, + WIFI_ERROR_MODE, + WIFI_ERROR_SSID_INVALID, + ]; + + static final $core.Map<$core.int, WifiErrorCode> _byValue = $pb.ProtobufEnum.initByValue(values); + static WifiErrorCode? valueOf($core.int value) => _byValue[value]; + + const WifiErrorCode._($core.int v, $core.String n) : super(v, n); +} + +class InterfaceType extends $pb.ProtobufEnum { + static const InterfaceType INTERFACE_BLE = InterfaceType._(1, _omitEnumNames ? '' : 'INTERFACE_BLE'); + static const InterfaceType INTERFACE_ANT = InterfaceType._(2, _omitEnumNames ? '' : 'INTERFACE_ANT'); + static const InterfaceType INTERFACE_USB = InterfaceType._(3, _omitEnumNames ? '' : 'INTERFACE_USB'); + static const InterfaceType INTERFACE_ETH = InterfaceType._(4, _omitEnumNames ? '' : 'INTERFACE_ETH'); + static const InterfaceType INTERFACE_WIFI = InterfaceType._(5, _omitEnumNames ? '' : 'INTERFACE_WIFI'); + + static const $core.List values = [ + INTERFACE_BLE, + INTERFACE_ANT, + INTERFACE_USB, + INTERFACE_ETH, + INTERFACE_WIFI, + ]; + + static final $core.Map<$core.int, InterfaceType> _byValue = $pb.ProtobufEnum.initByValue(values); + static InterfaceType? valueOf($core.int value) => _byValue[value]; + + const InterfaceType._($core.int v, $core.String n) : super(v, n); +} + +class SensorConnectionStatus extends $pb.ProtobufEnum { + static const SensorConnectionStatus SENSOR_STATUS_DISCOVERED = SensorConnectionStatus._(1, _omitEnumNames ? '' : 'SENSOR_STATUS_DISCOVERED'); + static const SensorConnectionStatus SENSOR_STATUS_DISCONNECTED = SensorConnectionStatus._(2, _omitEnumNames ? '' : 'SENSOR_STATUS_DISCONNECTED'); + static const SensorConnectionStatus SENSOR_STATUS_PAIRING = SensorConnectionStatus._(3, _omitEnumNames ? '' : 'SENSOR_STATUS_PAIRING'); + static const SensorConnectionStatus SENSOR_STATUS_CONNECTED = SensorConnectionStatus._(4, _omitEnumNames ? '' : 'SENSOR_STATUS_CONNECTED'); + + static const $core.List values = [ + SENSOR_STATUS_DISCOVERED, + SENSOR_STATUS_DISCONNECTED, + SENSOR_STATUS_PAIRING, + SENSOR_STATUS_CONNECTED, + ]; + + static final $core.Map<$core.int, SensorConnectionStatus> _byValue = $pb.ProtobufEnum.initByValue(values); + static SensorConnectionStatus? valueOf($core.int value) => _byValue[value]; + + const SensorConnectionStatus._($core.int v, $core.String n) : super(v, n); +} + +class BleSecureConnectionStatus extends $pb.ProtobufEnum { + static const BleSecureConnectionStatus BLE_CONNECTION_SECURITY_STATUS_NONE = BleSecureConnectionStatus._(0, _omitEnumNames ? '' : 'BLE_CONNECTION_SECURITY_STATUS_NONE'); + static const BleSecureConnectionStatus BLE_CONNECTION_SECURITY_STATUS_INPROGRESS = BleSecureConnectionStatus._(1, _omitEnumNames ? '' : 'BLE_CONNECTION_SECURITY_STATUS_INPROGRESS'); + static const BleSecureConnectionStatus BLE_CONNECTION_SECURITY_STATUS_ACTIVE = BleSecureConnectionStatus._(2, _omitEnumNames ? '' : 'BLE_CONNECTION_SECURITY_STATUS_ACTIVE'); + static const BleSecureConnectionStatus BLE_CONNECTION_SECURITY_STATUS_REJECTED = BleSecureConnectionStatus._(3, _omitEnumNames ? '' : 'BLE_CONNECTION_SECURITY_STATUS_REJECTED'); + + static const $core.List values = [ + BLE_CONNECTION_SECURITY_STATUS_NONE, + BLE_CONNECTION_SECURITY_STATUS_INPROGRESS, + BLE_CONNECTION_SECURITY_STATUS_ACTIVE, + BLE_CONNECTION_SECURITY_STATUS_REJECTED, + ]; + + static final $core.Map<$core.int, BleSecureConnectionStatus> _byValue = $pb.ProtobufEnum.initByValue(values); + static BleSecureConnectionStatus? valueOf($core.int value) => _byValue[value]; + + const BleSecureConnectionStatus._($core.int v, $core.String n) : super(v, n); +} + +class BleSecureConnectionWindowStatus extends $pb.ProtobufEnum { + static const BleSecureConnectionWindowStatus BLE_SECURE_CONNECTION_WINDOW_STATUS_CLOSED = BleSecureConnectionWindowStatus._(0, _omitEnumNames ? '' : 'BLE_SECURE_CONNECTION_WINDOW_STATUS_CLOSED'); + static const BleSecureConnectionWindowStatus BLE_SECURE_CONNECTION_WINDOW_STATUS_OPEN = BleSecureConnectionWindowStatus._(1, _omitEnumNames ? '' : 'BLE_SECURE_CONNECTION_WINDOW_STATUS_OPEN'); + + static const $core.List values = [ + BLE_SECURE_CONNECTION_WINDOW_STATUS_CLOSED, + BLE_SECURE_CONNECTION_WINDOW_STATUS_OPEN, + ]; + + static final $core.Map<$core.int, BleSecureConnectionWindowStatus> _byValue = $pb.ProtobufEnum.initByValue(values); + static BleSecureConnectionWindowStatus? valueOf($core.int value) => _byValue[value]; + + const BleSecureConnectionWindowStatus._($core.int v, $core.String n) : super(v, n); +} + +class WifiRegionCode_RegionCodeType extends $pb.ProtobufEnum { + static const WifiRegionCode_RegionCodeType ALPHA_2 = WifiRegionCode_RegionCodeType._(0, _omitEnumNames ? '' : 'ALPHA_2'); + static const WifiRegionCode_RegionCodeType ALPHA_3 = WifiRegionCode_RegionCodeType._(1, _omitEnumNames ? '' : 'ALPHA_3'); + static const WifiRegionCode_RegionCodeType NUMERIC = WifiRegionCode_RegionCodeType._(2, _omitEnumNames ? '' : 'NUMERIC'); + static const WifiRegionCode_RegionCodeType UNKNOWN = WifiRegionCode_RegionCodeType._(3, _omitEnumNames ? '' : 'UNKNOWN'); + + static const $core.List values = [ + ALPHA_2, + ALPHA_3, + NUMERIC, + UNKNOWN, + ]; + + static final $core.Map<$core.int, WifiRegionCode_RegionCodeType> _byValue = $pb.ProtobufEnum.initByValue(values); + static WifiRegionCode_RegionCodeType? valueOf($core.int value) => _byValue[value]; + + const WifiRegionCode_RegionCodeType._($core.int v, $core.String n) : super(v, n); +} + + +const _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names'); diff --git a/lib/protocol/zwift.pb.dart b/prop_public/lib/protocol/zwift.pb.dart similarity index 92% rename from lib/protocol/zwift.pb.dart rename to prop_public/lib/protocol/zwift.pb.dart index db9986b08..cb04bea6c 100644 --- a/lib/protocol/zwift.pb.dart +++ b/prop_public/lib/protocol/zwift.pb.dart @@ -25,8 +25,8 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage { PlayButtonStatus? buttonZLeft, PlayButtonStatus? buttonARight, PlayButtonStatus? buttonBDown, - PlayButtonStatus? buttonOn, PlayButtonStatus? buttonShift, + PlayButtonStatus? buttonOn, $core.int? analogLR, $core.int? analogUD, }) { @@ -46,12 +46,12 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage { if (buttonBDown != null) { $result.buttonBDown = buttonBDown; } - if (buttonOn != null) { - $result.buttonOn = buttonOn; - } if (buttonShift != null) { $result.buttonShift = buttonShift; } + if (buttonOn != null) { + $result.buttonOn = buttonOn; + } if (analogLR != null) { $result.analogLR = analogLR; } @@ -70,8 +70,8 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage { ..e(3, _omitFieldNames ? '' : 'ButtonZLeft', $pb.PbFieldType.OE, protoName: 'Button_Z_Left', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values) ..e(4, _omitFieldNames ? '' : 'ButtonARight', $pb.PbFieldType.OE, protoName: 'Button_A_Right', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values) ..e(5, _omitFieldNames ? '' : 'ButtonBDown', $pb.PbFieldType.OE, protoName: 'Button_B_Down', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values) - ..e(6, _omitFieldNames ? '' : 'ButtonOn', $pb.PbFieldType.OE, protoName: 'Button_On', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values) - ..e(7, _omitFieldNames ? '' : 'ButtonShift', $pb.PbFieldType.OE, protoName: 'Button_Shift', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values) + ..e(6, _omitFieldNames ? '' : 'ButtonShift', $pb.PbFieldType.OE, protoName: 'Button_Shift', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values) + ..e(7, _omitFieldNames ? '' : 'ButtonOn', $pb.PbFieldType.OE, protoName: 'Button_On', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values) ..a<$core.int>(8, _omitFieldNames ? '' : 'AnalogLR', $pb.PbFieldType.OS3, protoName: 'Analog_LR') ..a<$core.int>(9, _omitFieldNames ? '' : 'AnalogUD', $pb.PbFieldType.OS3, protoName: 'Analog_UD') ..hasRequiredFields = false @@ -144,22 +144,22 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage { void clearButtonBDown() => clearField(5); @$pb.TagNumber(6) - PlayButtonStatus get buttonOn => $_getN(5); + PlayButtonStatus get buttonShift => $_getN(5); @$pb.TagNumber(6) - set buttonOn(PlayButtonStatus v) { setField(6, v); } + set buttonShift(PlayButtonStatus v) { setField(6, v); } @$pb.TagNumber(6) - $core.bool hasButtonOn() => $_has(5); + $core.bool hasButtonShift() => $_has(5); @$pb.TagNumber(6) - void clearButtonOn() => clearField(6); + void clearButtonShift() => clearField(6); @$pb.TagNumber(7) - PlayButtonStatus get buttonShift => $_getN(6); + PlayButtonStatus get buttonOn => $_getN(6); @$pb.TagNumber(7) - set buttonShift(PlayButtonStatus v) { setField(7, v); } + set buttonOn(PlayButtonStatus v) { setField(7, v); } @$pb.TagNumber(7) - $core.bool hasButtonShift() => $_has(6); + $core.bool hasButtonOn() => $_has(6); @$pb.TagNumber(7) - void clearButtonShift() => clearField(7); + void clearButtonOn() => clearField(7); @$pb.TagNumber(8) $core.int get analogLR => $_getIZ(7); @@ -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/protocol/zwift.pbenum.dart b/prop_public/lib/protocol/zwift.pbenum.dart similarity index 100% rename from lib/protocol/zwift.pbenum.dart rename to prop_public/lib/protocol/zwift.pbenum.dart diff --git a/prop_public/pubspec.yaml b/prop_public/pubspec.yaml new file mode 100644 index 000000000..fa29deea1 --- /dev/null +++ b/prop_public/pubspec.yaml @@ -0,0 +1,26 @@ +name: prop +description: "Prop" +version: 0.0.1 +publish_to: none +homepage: https://bikecontrol.app + +environment: + sdk: ^3.10.7 + flutter: ">=1.17.0" + +dependencies: + + protobuf: ^4.2.0 + dartx: any + shared_preferences: ^2.5.3 + universal_ble: + git: + url: https://github.com/jonasbark/universal_ble.git + + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 diff --git a/pubspec.lock b/pubspec.lock old mode 100644 new mode 100755 index 15761c086..fe5ecb47d --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" accessibility: dependency: "direct main" description: @@ -8,14 +16,94 @@ packages: relative: true source: path version: "0.0.1" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + android_intent_plus: + dependency: "direct main" + description: + name: android_intent_plus + sha256: "14a9f94c5825a528e8c38ee89a33dbeba947efbbf76f066c174f4f3ae4f48feb" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + animated_vector: + dependency: transitive + description: + name: animated_vector + sha256: f1beb10e6fcfd8bd15abb788e20345def786d1c7391d7c1426bb2a1f2adf2132 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + animated_vector_annotations: + dependency: transitive + description: + name: animated_vector_annotations + sha256: "07c1ea603a2096f7eb6f1c2b8f16c3c330c680843ea78b7782a3217c3c53f979" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + animation_kit: + dependency: transitive + description: + name: animation_kit + sha256: d9b0944b3ee02fae3fedbc6cb04d9a9ea26ad1d29f3261e0b55443b1e0bfba63 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + app_links: + dependency: transitive + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: name: archive - sha256: "7dcbd0f87fe5f61cb28da39a1a8b70dbc106e2fe0516f7836eb7bb2948481a12" + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" url: "https://pub.dev" source: hosted - version: "4.0.5" + version: "4.0.7" args: dependency: transitive description: @@ -24,14 +112,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + assorted_layout_widgets: + dependency: transitive + description: + name: assorted_layout_widgets + sha256: "86c6942f569f7f70bfb03b9cb0ada9bf5aee72264aaefdb0e2be0fbee70cfb06" + url: "https://pub.dev" + source: hosted + version: "11.0.0" async: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + bluetooth_low_energy: + dependency: "direct main" + description: + name: bluetooth_low_energy + sha256: ac70d6f95c54df943b40d58b0be6cce1ed49ae8b76219d03386cb6c56e06ba5a + url: "https://pub.dev" + source: hosted + version: "6.2.1" + bluetooth_low_energy_android: + dependency: transitive + description: + name: bluetooth_low_energy_android + sha256: "1a1ad8aa3016b6ae843eeaa86c0f4eab7eda6669c06066a6da2494ffe6350853" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + bluetooth_low_energy_darwin: + dependency: transitive + description: + name: bluetooth_low_energy_darwin + sha256: "63243add2f57ad201f09d42878a31bad6e5b93291c6ae3c36de24b5213178a0d" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + bluetooth_low_energy_linux: + dependency: transitive + description: + name: bluetooth_low_energy_linux + sha256: "95ae6d4f598595af61b73a32e1dc7a49d95081526c0c6b17205e852332845a5a" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + bluetooth_low_energy_platform_interface: + dependency: transitive + description: + name: bluetooth_low_energy_platform_interface + sha256: "430a137fe0a662e2dd632390aa6e020a1c91335a5821ae07dacc9f319f9e590a" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + bluetooth_low_energy_windows: + dependency: transitive + description: + name: bluetooth_low_energy_windows + sha256: a235b4e368f8e45ad1712c8c1c9a138b4edb2ff98a2f1fbf3208fffeb9a27e3a + url: "https://pub.dev" + source: hosted + version: "6.2.1" bluez: dependency: transitive description: @@ -48,22 +200,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - characters: + build: dependency: transitive description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + name: build + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" url: "https://pub.dev" source: hosted - version: "1.4.0" - checked_yaml: + version: "4.0.4" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: dependency: transitive description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "1.4.1" cli_util: dependency: transitive description: @@ -80,6 +240,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -88,6 +256,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + console: + dependency: transitive + description: + name: console + sha256: e04e7824384c5b39389acdd6dc7d33f3efe6b232f6f16d7626f194f6a01ad69a + url: "https://pub.dev" + source: hosted + version: "4.1.0" convert: dependency: transitive description: @@ -96,14 +272,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - crypto: + country_flags: + dependency: transitive + description: + name: country_flags + sha256: "457c545369eaddcaa47dc351ea97f00dbe2ee74ee7b487a805b86c10d1dfd880" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cross_file: dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" + crypto: + dependency: "direct main" description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + d4rt: + dependency: "direct main" + description: + name: d4rt + sha256: "4c178f741141592d60e4037c3205f9c081a3b32edc1061029a43667dff409a36" url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "0.2.2" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: "0de65691c1d736e9459f22f654ddd6fd8368a271d4e41aa07e53e6301eff5075" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" dartx: dependency: "direct main" description: @@ -112,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + data_widget: + dependency: transitive + description: + name: data_widget + sha256: "4947aae3c50635496d56f94ad18de98e19015c5ebf01abee0f39a2c098c7021a" + url: "https://pub.dev" + source: hosted + version: "0.0.3" dbus: dependency: transitive description: @@ -120,131 +344,161 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" - fake_async: + device_auto_rotate_checker: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "2cd4bb52733f664875cec2780a8091d4698226cb" + url: "https://github.com/mpcreza/device_auto_rotate_checker.git" + source: git + version: "0.0.2" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: dependency: transitive description: - name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f url: "https://pub.dev" source: hosted - version: "1.3.2" - ffi: + version: "7.0.3" + ed25519_edwards: dependency: transitive description: - name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" url: "https://pub.dev" source: hosted - version: "2.1.4" - fixnum: + version: "0.3.1" + email_validator: dependency: transitive description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + name: email_validator + sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb url: "https://pub.dev" source: hosted - version: "1.1.1" - flex_color_scheme: - dependency: "direct main" + version: "3.0.0" + equatable: + dependency: transitive description: - name: flex_color_scheme - sha256: "3344f8f6536c6ce0473b98e9f084ef80ca89024ad3b454f9c32cf840206f4387" + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "8.2.0" - flex_seed_scheme: + version: "2.0.8" + expressions: dependency: transitive description: - name: flex_seed_scheme - sha256: b06d8b367b84cbf7ca5c5603c858fa5edae88486c4e4da79ac1044d73b6c62ec + name: expressions + sha256: f3b0e99563a9a1bde1138e728eb722f292cc7d2aec55d28136c49b1a370306c5 url: "https://pub.dev" source: hosted - version: "3.5.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_blue_plus: - dependency: "direct main" + version: "0.2.5+3" + fake_async: + dependency: transitive description: - name: flutter_blue_plus - sha256: "2d926dbef0fd6c58d4be8fca9eaaf1ba747c0ccb8373ddd5386665317e26eb61" + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.35.3" - flutter_blue_plus_android: + version: "1.3.3" + ffi: dependency: transitive description: - name: flutter_blue_plus_android - sha256: c1d83f84b514e46345a8a58599c428f20b11e78379521e0d3b0611c7b7cbf2c1 + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c url: "https://pub.dev" source: hosted - version: "3.0.0" - flutter_blue_plus_darwin: + version: "2.1.5" + file: dependency: transitive description: - name: flutter_blue_plus_darwin - sha256: "8d0a0f11f83b13dda173396b7e4028b4e8656bc8dbbc82c26a7e49aafc62644b" + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "3.0.0" - flutter_blue_plus_linux: + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" + file_selector_linux: dependency: transitive description: - name: flutter_blue_plus_linux - sha256: "1d367ed378b2bd6c3b9685fda7044e1d2f169884802b7dec7badb31a99a72660" + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" url: "https://pub.dev" source: hosted - version: "3.0.0" - flutter_blue_plus_platform_interface: + version: "0.9.4" + file_selector_macos: dependency: transitive description: - name: flutter_blue_plus_platform_interface - sha256: "114f8e85a03a28a48d707a4df6cc9218e1f2005cf260c5e815e5585a00da5778" + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" url: "https://pub.dev" source: hosted - version: "3.0.0" - flutter_blue_plus_web: - dependency: "direct overridden" + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive description: - path: "packages/flutter_blue_plus_web" - ref: HEAD - resolved-ref: "5fc128ed6ff6b1bc038ec9f91a6d9789c2cebc53" - url: "https://github.com/chipweinberger/flutter_blue_plus.git" - source: git - version: "3.0.0" - flutter_blue_plus_windows: - dependency: "direct main" + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive description: - path: flutter_blue_plus_windows - relative: true - source: path - version: "1.26.1" - flutter_launcher_icons: - dependency: "direct dev" + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + fixnum: + dependency: transitive description: - name: flutter_launcher_icons - sha256: bfa04787c85d80ecb3f8777bde5fc10c3de809240c48fa061a2c2bf15ea5211c + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be url: "https://pub.dev" source: hosted - version: "0.14.3" + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications - sha256: d59eeafd6df92174b1d5f68fc9d66634c97ce2e7cfe2293476236547bb19bbbd + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" url: "https://pub.dev" source: hosted - version: "19.0.0" + version: "19.5.0" flutter_local_notifications_linux: dependency: transitive description: @@ -257,265 +511,1294 @@ packages: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "2569b973fc9d1f63a37410a9f7c1c552081226c597190cb359ef5d5762d1631c" + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "9.1.0" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: f8fc0652a601f83419d623c85723a3e82ad81f92b33eaa9bcc21ea1b94773e6e + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" url: "https://pub.dev" source: hosted - version: "1.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive + version: "1.0.3" + flutter_localizations: + dependency: "direct main" description: flutter source: sdk version: "0.0.0" - http: - dependency: transitive + flutter_md: + dependency: "direct main" description: - name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + name: flutter_md + sha256: "3d726f06573397c66543a8ee229c86a263e8330768485a317f3ccd99c7794b6b" url: "https://pub.dev" source: hosted - version: "1.3.0" - http_parser: + version: "0.0.8" + flutter_plugin_android_lifecycle: dependency: transitive description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "4.1.2" - image: + version: "2.0.33" + flutter_rust_bridge: dependency: transitive description: - name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" url: "https://pub.dev" source: hosted - version: "4.5.4" - json_annotation: - dependency: transitive + version: "2.11.1" + flutter_screen_capture: + dependency: "direct main" description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + name: flutter_screen_capture + sha256: cfa9133ef61bc6f2f6af62eeb6916d208277e851dbb38d7f7bb9587ad2921882 url: "https://pub.dev" source: hosted - version: "4.9.0" - keypress_simulator: + version: "2.0.1" + flutter_secure_storage: dependency: "direct main" description: - name: keypress_simulator - sha256: d5aa5ed472b6b396f41fd6dcee99f4afb2c7ac6202af622b4cec7955de6ed7f6 + name: flutter_secure_storage + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "0.2.0" - keypress_simulator_macos: + version: "10.0.0" + flutter_secure_storage_darwin: dependency: transitive description: - name: keypress_simulator_macos - sha256: babb698b1331cff0301de839c7bc6b051d84f98ddb137cf9a6dda6c6caeb78ac + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" url: "https://pub.dev" source: hosted version: "0.2.0" - keypress_simulator_platform_interface: + flutter_secure_storage_linux: dependency: transitive description: - name: keypress_simulator_platform_interface - sha256: "38c35fee6b107ff10cfb6bdb61e32eb0db17545ed64399a357794431212ca4b4" + name: flutter_secure_storage_linux + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "0.2.0" - keypress_simulator_windows: + version: "3.0.0" + flutter_secure_storage_platform_interface: dependency: transitive description: - name: keypress_simulator_windows - sha256: b4ff055131a2e5ea920eb3b6a185e1889fe00749b027df3b83aa726ed590a9b5 + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "0.2.0" - leak_tracker: + version: "2.0.1" + flutter_secure_storage_web: dependency: transitive description: - name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + name: flutter_secure_storage_web + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "10.0.8" - leak_tracker_flutter_testing: + version: "2.1.0" + flutter_secure_storage_windows: dependency: transitive description: - name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "3.0.9" - leak_tracker_testing: - dependency: transitive + version: "4.1.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_volume_controller: + dependency: "direct main" description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + name: flutter_volume_controller + sha256: "22edb0993ad03ecbc8d1164daeb5b39d798d409625db692675a86889403b1532" url: "https://pub.dev" source: hosted - version: "3.0.1" - lints: + version: "1.3.4" + flutter_web_bluetooth: dependency: transitive description: - name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + name: flutter_web_bluetooth + sha256: ad26a1b3fef95b86ea5f63793b9a0cdc1a33490f35d754e4e711046cae3ebbf8 url: "https://pub.dev" source: hosted - version: "5.1.1" - matcher: + version: "1.1.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + font_awesome_flutter: dependency: transitive description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + name: font_awesome_flutter + sha256: b9011df3a1fa02993630b8fb83526368cf2206a711259830325bab2f1d2a4eb0 url: "https://pub.dev" source: hosted - version: "0.12.17" - material_color_utilities: + version: "10.12.0" + fuchsia_remote_debug_protocol: dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + gamepads: + dependency: "direct main" + description: + name: gamepads + sha256: "3a8a35502f0b3d28ea55e2c6c42320583389c0514a2bdd92ba0440d0cadd628e" + url: "https://pub.dev" + source: hosted + version: "0.1.9" + gamepads_android: + dependency: transitive + description: + name: gamepads_android + sha256: "7913cd171ff06d5b588cb3e1dae64390c6e1352dd2999ff19a96d822eb7441fd" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + gamepads_darwin: + dependency: transitive + description: + name: gamepads_darwin + sha256: "91250975ee196703816c55117502ec53ce4a1b881ca50dec1d0fbcb9fbb75eff" + url: "https://pub.dev" + source: hosted + version: "0.1.2+2" + gamepads_ios: + dependency: transitive + description: + name: gamepads_ios + sha256: "085459e2f677c18c4b15aee5dacc66e0b05491d4ed32bd3041c8328394e00d3a" + url: "https://pub.dev" + source: hosted + version: "0.1.3+1" + gamepads_linux: + dependency: transitive + description: + name: gamepads_linux + sha256: f4c17915a84400d7f624aadb6371424ada596eedff2a25663121453e65917e0d + url: "https://pub.dev" + source: hosted + version: "0.1.1+3" + gamepads_platform_interface: + dependency: transitive + description: + name: gamepads_platform_interface + sha256: ddab8677a4137d92e381b04cc97a8081ae4b75673dc0f24c846618d2b5226c4f + url: "https://pub.dev" + source: hosted + version: "0.1.2+1" + gamepads_web: + dependency: transitive + description: + name: gamepads_web + sha256: "4885a792f16de023c4975854ffe17ffccacb46beb1e78ac1ecde76fd10edcb22" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + gamepads_windows: + dependency: "direct overridden" + description: + path: "packages/gamepads_windows" + ref: windows-api-rework + resolved-ref: a4b8ded91b14d799ef0998a738b1f2031fef0bd2 + url: "https://github.com/lea108/gamepads.git" + source: git + version: "0.1.4+1" + gap: + dependency: transitive + description: + name: gap + sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d + url: "https://pub.dev" + source: hosted + version: "3.0.1" + get_it: + dependency: transitive + description: + name: get_it + sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 + url: "https://pub.dev" + source: hosted + version: "8.3.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + golden_screenshot: + dependency: "direct dev" + description: + name: golden_screenshot + sha256: "048ae833c8a1c5b52d96484fd94a75ae28b185b93ba32cf3fd4cdedf69131018" + url: "https://pub.dev" + source: hosted + version: "8.2.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: "5ec98ab35387c68c0050495bb211bd88375873723a80fae7c2e9266ea0bdd8bb" + url: "https://pub.dev" + source: hosted + version: "7.2.7" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2 + url: "https://pub.dev" + source: hosted + version: "2.18.0" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5d309c86e7ce34cd8e37aa71cb30cb652d3829b900ab145e4d9da564b31d59f7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + in_app_purchase_android: + dependency: transitive + description: + name: in_app_purchase_android + sha256: abb254ae159a5a9d4f867795ecb076864faeba59ce015ab81d4cca380f23df45 + url: "https://pub.dev" + source: hosted + version: "0.4.0+8" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: f7cbbd7fb47ab5a4fb736fc3f20ae81a4f6def0af9297b3c525ca727761e2589 + url: "https://pub.dev" + source: hosted + version: "0.4.7" + in_app_review: + dependency: "direct main" + description: + name: in_app_review + sha256: ab26ac54dbd802896af78c670b265eaeab7ecddd6af4d0751e9604b60574817f + url: "https://pub.dev" + source: hosted + version: "2.0.11" + in_app_review_platform_interface: + dependency: transitive + description: + name: in_app_review_platform_interface + sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10 + url: "https://pub.dev" + source: hosted + version: "2.0.5" + in_app_update: + dependency: "direct main" + description: + name: in_app_update + sha256: "9924a3efe592e1c0ec89dda3683b3cfec3d4cd02d908e6de00c24b759038ddb1" + url: "https://pub.dev" + source: hosted + version: "4.2.5" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + intl_utils: + dependency: "direct dev" + description: + name: intl_utils + sha256: fd84aea0e75b41ce36d40aa8dfc94a59fd63cb6582018f3c78c86163157a309f + url: "https://pub.dev" + source: hosted + version: "2.8.13" + ios_receipt: + dependency: "direct main" + description: + path: ios_receipt + relative: true + source: path + version: "1.1.0" + jovial_misc: + dependency: transitive + description: + name: jovial_misc + sha256: "4301011027d87b8b919cb862db84071a34448eadbb32cc8d40fe505424dfe69a" + url: "https://pub.dev" + source: hosted + version: "0.9.2" + jovial_svg: + dependency: transitive + description: + name: jovial_svg + sha256: "08dd24b800d48796c9c0227acb96eb00c6cacccb1d7de58d79fc924090049868" + url: "https://pub.dev" + source: hosted + version: "1.1.28" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + js_interop: + dependency: transitive + description: + name: js_interop + sha256: "7ec859c296958ccea34dc770504bd3ff4ae52fdd9e7eeb2bacc7081ad476a1f5" + url: "https://pub.dev" + source: hosted + version: "0.0.1" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + just_audio: + dependency: transitive + description: + name: just_audio + sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908" + url: "https://pub.dev" + source: hosted + version: "0.10.5" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" + url: "https://pub.dev" + source: hosted + version: "0.4.16" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" + keypress_simulator: + dependency: "direct main" + description: + path: "keypress_simulator/packages/keypress_simulator" + relative: true + source: path + version: "0.2.0" + keypress_simulator_macos: + dependency: transitive + description: + path: "keypress_simulator/packages/keypress_simulator_macos" + relative: true + source: path + version: "0.2.0" + keypress_simulator_platform_interface: + dependency: transitive + description: + path: "keypress_simulator/packages/keypress_simulator_platform_interface" + relative: true + source: path + version: "0.2.0" + keypress_simulator_windows: + dependency: transitive + description: + path: "keypress_simulator/packages/keypress_simulator_windows" + relative: true + source: path + version: "0.2.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + matrix4_transform: + dependency: transitive + description: + name: matrix4_transform + sha256: "1346e53517e3081d3e8362377be97e285e2bd348855c177eae2a18aa965cafa0" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + media_key_detector: + dependency: "direct main" + description: + path: "media_key_detector/media_key_detector" + relative: true + source: path + version: "0.0.2" + media_key_detector_ios: + dependency: transitive + description: + path: "media_key_detector/media_key_detector_ios" + relative: true + source: path + version: "0.0.2" + media_key_detector_linux: + dependency: transitive + description: + path: "media_key_detector/media_key_detector_linux" + relative: true + source: path + version: "0.0.2" + media_key_detector_macos: + dependency: transitive + description: + path: "media_key_detector/media_key_detector_macos" + relative: true + source: path + version: "0.0.2" + media_key_detector_platform_interface: + dependency: transitive + description: + path: "media_key_detector/media_key_detector_platform_interface" + relative: true + source: path + version: "0.0.2" + media_key_detector_windows: + dependency: transitive + description: + path: "media_key_detector/media_key_detector_windows" + relative: true + source: path + version: "0.0.2" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + msix: + dependency: "direct dev" + description: + name: msix + sha256: f88033fcb9e0dd8de5b18897cbebbd28ea30596810f4a7c86b12b0c03ace87e5 + url: "https://pub.dev" + source: hosted + version: "3.16.12" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + nsd: + dependency: "direct main" + description: + name: nsd + sha256: "1611a5c9f61d56ff2973e1488ae04112103e5203b4a7a1fb594b48cfb366fc14" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + nsd_android: + dependency: transitive + description: + name: nsd_android + sha256: "96d2d451c5db0319c37b1b2a38f2d55eb56ae54c0b0d3144c03c19c97436de4a" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + nsd_ios: + dependency: transitive + description: + name: nsd_ios + sha256: "562fffe753543a65190344d3acd0ed80d96c571ac1a05bf10780c5584b5055ac" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + nsd_macos: + dependency: transitive + description: + name: nsd_macos + sha256: "47cd355d84009befe02710c72bf1c1999b24d5f3fb3a3d914f8b77bcaca42542" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + nsd_platform_interface: + dependency: transitive + description: + name: nsd_platform_interface + sha256: b1a5ace6f01ea2ce37f373e52c3b7af4fd7c11de2582ddcc89f4fc00615d9dff + url: "https://pub.dev" + source: hosted + version: "2.2.0" + nsd_windows: + dependency: transitive + description: + name: nsd_windows + sha256: "68b4a256b0be258dbbad0ae789f2e8838d0935a353dac86b17c14c1a05df4ecd" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "9922a1ad59ac5afb154cc948aa6ded01987a75003651d0a2866afc23f4da624e" + url: "https://pub.dev" + source: hosted + version: "9.2.3" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + phonecodes: + dependency: transitive + description: + name: phonecodes + sha256: d963c19d35914cd83620e64125689a0c09047e25046639f2a124142ccf5868bb + url: "https://pub.dev" + source: hosted + version: "0.0.4" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + platform_linux: + dependency: transitive + description: + name: platform_linux + sha256: "907b7c6da6ee6eea61cd1266b7bd72e2d5bbf7e85160221e8ac5583a44e7a1c7" + url: "https://pub.dev" + source: hosted + version: "0.1.2+1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + prop: + dependency: "direct main" + description: + path: prop + relative: true + source: path + version: "0.0.1" + protobuf: + dependency: "direct main" + description: + name: protobuf + sha256: de9c9eb2c33f8e933a42932fe1dc504800ca45ebc3d673e6ed7f39754ee4053e + url: "https://pub.dev" + source: hosted + version: "4.2.0" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubghost: + dependency: "direct dev" + description: + name: pubghost + sha256: feb4c76c873b9c8cbbf53a45085fc5ff1705dd88e63554bb0de10ecb96ec1ba9 + url: "https://pub.dev" + source: hosted + version: "1.0.7" + purchases_flutter: + dependency: "direct main" + description: + name: purchases_flutter + sha256: "1a7132bac772d90e9b959364369beddfc7fca8a3d2909d6e4d818cef7fd64a8f" + url: "https://pub.dev" + source: hosted + version: "9.10.6" + purchases_ui_flutter: + dependency: "direct main" + description: + name: purchases_ui_flutter + sha256: "350550b6fe8cf91a8de9e613ed38829fcdd5e098f0ae1ae25d5b2d45c680c842" + url: "https://pub.dev" + source: hosted + version: "9.10.6" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + restart_app: + dependency: "direct main" description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + path: "." + ref: "feature/resolve-all-open-issues" + resolved-ref: de208e0d393812f1a6edb0a20cef7fb49cec957c + url: "https://github.com/maple135790/restart_app.git" + source: git + version: "1.4.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" url: "https://pub.dev" source: hosted - version: "0.11.1" - meta: + version: "3.1.2" + rxdart: dependency: transitive description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" url: "https://pub.dev" source: hosted - version: "1.16.0" - path: + version: "0.28.0" + screen_retriever: dependency: transitive description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + name: screen_retriever + sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" url: "https://pub.dev" source: hosted - version: "1.9.1" - path_provider: + version: "0.2.0" + screen_retriever_linux: dependency: transitive description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + name: screen_retriever_linux + sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 url: "https://pub.dev" source: hosted - version: "2.1.5" - path_provider_android: + version: "0.2.0" + screen_retriever_macos: dependency: transitive description: - name: path_provider_android - sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + name: screen_retriever_macos + sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" url: "https://pub.dev" source: hosted - version: "2.2.16" - path_provider_foundation: + version: "0.2.0" + screen_retriever_platform_interface: dependency: transitive description: - name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + name: screen_retriever_platform_interface + sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 url: "https://pub.dev" source: hosted - version: "2.4.1" - path_provider_linux: + version: "0.2.0" + screen_retriever_windows: dependency: transitive description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + name: screen_retriever_windows + sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" url: "https://pub.dev" source: hosted - version: "2.2.1" - path_provider_platform_interface: + version: "0.2.0" + sensors_plus: + dependency: "direct main" + description: + name: sensors_plus + sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sensors_plus_platform_interface: dependency: transitive description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + name: sensors_plus_platform_interface + sha256: "58815d2f5e46c0c41c40fb39375d3f127306f7742efe3b891c0b1c87e2b5cd5d" url: "https://pub.dev" source: hosted - version: "2.1.2" - path_provider_windows: + version: "2.0.1" + shadcn_flutter: + dependency: "direct main" + description: + name: shadcn_flutter + sha256: e677bf66246fa97df82f32eb80c5b683af14a5b1799fd313016b26ed823a1d9e + url: "https://pub.dev" + source: hosted + version: "0.0.51" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: dependency: transitive description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" url: "https://pub.dev" source: hosted - version: "2.3.0" - petitparser: + version: "2.4.18" + shared_preferences_foundation: dependency: transitive description: - name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "6.1.0" - platform: + version: "2.5.6" + shared_preferences_linux: dependency: transitive description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "3.1.6" - plugin_platform_interface: + version: "2.4.1" + shared_preferences_platform_interface: dependency: transitive description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" url: "https://pub.dev" source: hosted - version: "2.1.8" - pointycastle: - dependency: "direct main" + version: "2.4.1" + shared_preferences_web: + dependency: transitive description: - name: pointycastle - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 url: "https://pub.dev" source: hosted - version: "4.0.0" - posix: + version: "2.4.3" + shared_preferences_windows: dependency: transitive description: - name: posix - sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "6.0.1" - protobuf: + version: "2.4.1" + shorebird_code_push: dependency: "direct main" description: - name: protobuf - sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + name: shorebird_code_push + sha256: "82203f39a66c78548da944dbe4079c2aa2a60fa5bc1105ed707b144c94f04349" url: "https://pub.dev" source: hosted - version: "3.1.0" - rxdart: + version: "2.0.5" + sign_in_button: + dependency: "direct main" + description: + name: sign_in_button + sha256: "7bcd5e3ca5f80578da6a92b8749badf4003cf4dc578b5cb737b9082871354ff8" + url: "https://pub.dev" + source: hosted + version: "4.0.1" + sign_in_with_apple: + dependency: "direct main" + description: + name: sign_in_with_apple + sha256: "8bd875c8e8748272749eb6d25b896f768e7e9d60988446d543fe85a37a2392b8" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + sign_in_with_apple_platform_interface: dependency: transitive description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + name: sign_in_with_apple_platform_interface + sha256: "981bca52cf3bb9c3ad7ef44aace2d543e5c468bb713fd8dda4275ff76dfa6659" url: "https://pub.dev" source: hosted - version: "0.28.0" + version: "2.0.0" + sign_in_with_apple_web: + dependency: transitive + description: + name: sign_in_with_apple_web + sha256: f316400827f52cafcf50d00e1a2e8a0abc534ca1264e856a81c5f06bd5b10fed + url: "https://pub.dev" + source: hosted + version: "3.0.0" + skeletonizer: + dependency: transitive + description: + name: skeletonizer + sha256: "83157d8e2e41f0252079cfec496281c16e4c63660052dab8d4cd72a206bb7109" + url: "https://pub.dev" + source: hosted + version: "2.1.2" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + smtc_windows: + dependency: "direct main" + description: + name: smtc_windows + sha256: dee279b0ddf663c4c729a88bca4e57fb4861aa1b3d01e230bdbf1277b8bfe664 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" source_span: dependency: transitive description: @@ -532,22 +1815,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" - stream_channel: + storage_client: dependency: transitive description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + name: storage_client + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" url: "https://pub.dev" source: hosted - version: "2.1.4" - stream_with_value: + version: "2.4.1" + stream_channel: dependency: transitive description: - name: stream_with_value - sha256: "483d79bf604fdea5274e31207956b2f624f5f03a506cacf081b65cdfcfa647a6" + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "2.1.4" string_scanner: dependency: transitive description: @@ -556,6 +1839,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911 + url: "https://pub.dev" + source: hosted + version: "2.10.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "92b2416ecb6a5c3ed34cf6e382b35ce6cc8921b64f2a9299d5d28968d42b09bb" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -568,26 +1883,26 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.10" time: dependency: transitive description: name: time - sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" + sha256: "46187cf30bffdab28c56be9a63861b36e4ab7347bf403297595d6a97e10c789f" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" timezone: dependency: transitive description: name: timezone - sha256: ffc9d5f4d1193534ef051f9254063fa53d588609418c84299956c3db9383587d + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 url: "https://pub.dev" source: hosted - version: "0.10.0" + version: "0.10.1" typed_data: dependency: transitive description: @@ -604,22 +1919,135 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + universal_ble: + dependency: "direct main" + description: + 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" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + 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: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + 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" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "1.2.1" web: dependency: transitive description: @@ -628,14 +2056,61 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - win_ble: + web_socket: dependency: transitive description: - name: win_ble - sha256: "2a867e13c4b355b101fc2c6e2ac85eeebf965db34eca46856f8b478e93b41e96" + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + window_manager: + dependency: "direct main" + description: + name: window_manager + sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + windows_iap: + dependency: "direct main" + description: + path: windows_iap + relative: true + source: path + version: "0.0.1" xdg_directories: dependency: transitive description: @@ -648,10 +2123,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -660,6 +2135,62 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + yaru: + dependency: transitive + description: + name: yaru + sha256: "67ac8c3dc52a5d69c049056d5fa40b909973e10b36df3cffeb666de867532d79" + url: "https://pub.dev" + source: hosted + version: "8.3.0" + yaru_window: + dependency: transitive + description: + name: yaru_window + sha256: "58539a9abe9901891dadce142c7a5d303920b780dd0f7bd21f076a80adeeb744" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + yaru_window_linux: + dependency: transitive + description: + name: yaru_window_linux + sha256: "885ad0ba5832d350c42862ce02da478599ef550280eb7f6b15285481fcff6f53" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + yaru_window_manager: + dependency: transitive + description: + name: yaru_window_manager + sha256: "6288fd6ccd8bb9d5be56073c6f277e2da08dd38e346507bf88bc9332b08dd180" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + yaru_window_platform_interface: + dependency: transitive + description: + name: yaru_window_platform_interface + sha256: d514387cc96750112ecf1933b6f12a1912beca199178d00052c0e87a94e232fa + url: "https://pub.dev" + source: hosted + version: "0.1.3" + yaru_window_web: + dependency: transitive + description: + name: yaru_window_web + sha256: cf4d79e0760fbdcb78d4bbca3b5563f99518629224b9e5611f0ebd592befe1d9 + url: "https://pub.dev" + source: hosted + version: "0.0.4" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: - dart: ">=3.7.0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml old mode 100644 new mode 100755 index 30923625f..591a718eb --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,39 +1,134 @@ -name: swift_control -description: "SwiftControl - Control your virtual riding" +name: bike_control +description: "BikeControl - Control your virtual riding" publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.0.0+1 +version: 5.0.0+109 environment: - sdk: ^3.7.0 + sdk: ^3.9.0 dependencies: flutter: sdk: flutter - flutter_local_notifications: ^19.0.0 - flutter_blue_plus: ^1.35.3 - protobuf: ^3.1.0 + url_launcher: ^6.3.1 + android_intent_plus: ^6.0.0 + flutter_local_notifications: ^19.5.0 + + # fork to allow iOS background BLE connections + universal_ble: + git: + url: https://github.com/jonasbark/universal_ble.git + gamepads: ^0.1.8+2 + + d4rt: ^0.2.2 + path_provider: ^2.1.5 + smtc_windows: ^1.1.0 + flutter_volume_controller: ^1.3.4 + 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 - pointycastle: any - keypress_simulator: ^0.2.0 - flex_color_scheme: ^8.2.0 - flutter_blue_plus_windows: - path: flutter_blue_plus_windows + + supabase_flutter: ^2.12.0 + crypto: ^3.0.6 + sign_in_button: ^4.0.1 + google_sign_in: ^7.2.0 + sign_in_with_apple: ^7.0.1 + + nsd: ^4.0.3 + image_picker: ^1.1.2 + file_picker: ^10.3.10 + flutter_screen_capture: ^2.0.1 + in_app_review: ^2.0.11 + ios_receipt: + path: ios_receipt + flutter_secure_storage: ^10.0.0 + in_app_purchase: ^3.2.1 + purchases_flutter: ^9.10.2 + purchases_ui_flutter: ^9.10.2 + windows_iap: + path: windows_iap + window_manager: ^0.5.1 + device_info_plus: ^12.1.0 + keypress_simulator: + path: keypress_simulator/packages/keypress_simulator + shared_preferences: ^2.5.3 + media_key_detector: + path: media_key_detector/media_key_detector accessibility: path: accessibility + sensors_plus: ^7.0.0 -dependency_overrides: - flutter_blue_plus_web: + device_auto_rotate_checker: + git: + url: https://github.com/mpcreza/device_auto_rotate_checker.git + + # app update related + shorebird_code_push: ^2.0.5 + restart_app: git: - url: https://github.com/chipweinberger/flutter_blue_plus.git - path: packages/flutter_blue_plus_web + url: https://github.com/maple135790/restart_app.git + ref: feature/resolve-all-open-issues + package_info_plus: ^9.0.0 + in_app_update: ^4.2.5 + http: ^1.3.0 + prop: + path: prop_public + shadcn_flutter: ^0.0.51 + + flutter_localizations: + sdk: flutter dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter + + pubghost: ^1.0.7 + intl_utils: ^2.8.12 + golden_screenshot: ^8.1.1 + flutter_lints: ^6.0.0 + msix: ^3.16.12 - flutter_launcher_icons: "^0.14.3" - flutter_lints: ^5.0.0 +dependency_overrides: + gamepads_windows: + git: + url: https://github.com/lea108/gamepads.git + ref: windows-api-rework + path: packages/gamepads_windows flutter: uses-material-design: true + assets: + - CHANGELOG.md + - TROUBLESHOOTING.md + - INSTRUCTIONS_MYWHOOSH_LINK.md + - INSTRUCTIONS_REMOTE_CONTROL.md + - INSTRUCTIONS_ROUVY.md + - INSTRUCTIONS_ZWIFT.md + - INSTRUCTIONS_LOCAL.md + - shorebird.yaml + - assets/silence.mp3 + - icon.png + +flutter_intl: + enabled: true + arb_dir: lib/i10n + class_name: AppLocalizations + output_dir: lib/gen + +msix_config: + display_name: BikeControl + publisher_display_name: Jonas Bark + identity_name: JonasBark.SwiftControl + publisher: CN=EDA6D86E-3E52-4054-9F3A-4277AFCCB2C4 + store: false + logo_path: web/icons/Icon-512.png + build_windows: false + protocol_activation: https, bikecontrol + capabilities: internetClient,bluetooth,inputInjectionBrokered diff --git a/pubspec_overrides_ci.yaml b/pubspec_overrides_ci.yaml new file mode 100644 index 000000000..d5a5acff4 --- /dev/null +++ b/pubspec_overrides_ci.yaml @@ -0,0 +1,9 @@ +dependency_overrides: + prop: + path: prop + + gamepads_windows: + git: + url: https://github.com/lea108/gamepads.git + ref: windows-api-rework + path: packages/gamepads_windows diff --git a/screenshots/companion-android-1320x2868.png b/screenshots/companion-android-1320x2868.png new file mode 100644 index 000000000..10b48b57b Binary files /dev/null and b/screenshots/companion-android-1320x2868.png differ diff --git a/screenshots/companion-androidTablet-3840x2400.png b/screenshots/companion-androidTablet-3840x2400.png new file mode 100644 index 000000000..31a42ae89 Binary files /dev/null and b/screenshots/companion-androidTablet-3840x2400.png differ diff --git a/screenshots/companion-desktop-2560x1600.png b/screenshots/companion-desktop-2560x1600.png new file mode 100644 index 000000000..8355c510d Binary files /dev/null and b/screenshots/companion-desktop-2560x1600.png differ diff --git a/screenshots/companion-iPad-2752x2064.png b/screenshots/companion-iPad-2752x2064.png new file mode 100644 index 000000000..2709e01f9 Binary files /dev/null and b/screenshots/companion-iPad-2752x2064.png differ diff --git a/screenshots/companion-iPhone-1242x2688.png b/screenshots/companion-iPhone-1242x2688.png new file mode 100644 index 000000000..5ca1f31e2 Binary files /dev/null and b/screenshots/companion-iPhone-1242x2688.png differ diff --git a/screenshots/companion-noFrame-1100x2390.png b/screenshots/companion-noFrame-1100x2390.png new file mode 100644 index 000000000..83293bf85 Binary files /dev/null and b/screenshots/companion-noFrame-1100x2390.png differ diff --git a/screenshots/customization-android-1320x2868.png b/screenshots/customization-android-1320x2868.png new file mode 100644 index 000000000..fe955b384 Binary files /dev/null and b/screenshots/customization-android-1320x2868.png differ diff --git a/screenshots/customization-androidTablet-3840x2400.png b/screenshots/customization-androidTablet-3840x2400.png new file mode 100644 index 000000000..5ef5c889b Binary files /dev/null and b/screenshots/customization-androidTablet-3840x2400.png differ diff --git a/screenshots/customization-desktop-2560x1600.png b/screenshots/customization-desktop-2560x1600.png new file mode 100644 index 000000000..0ec29579c Binary files /dev/null and b/screenshots/customization-desktop-2560x1600.png differ diff --git a/screenshots/customization-iPad-2752x2064.png b/screenshots/customization-iPad-2752x2064.png new file mode 100644 index 000000000..786661589 Binary files /dev/null and b/screenshots/customization-iPad-2752x2064.png differ diff --git a/screenshots/customization-iPhone-1242x2688.png b/screenshots/customization-iPhone-1242x2688.png new file mode 100644 index 000000000..ef7597939 Binary files /dev/null and b/screenshots/customization-iPhone-1242x2688.png differ diff --git a/screenshots/customization-noFrame-1100x2390.png b/screenshots/customization-noFrame-1100x2390.png new file mode 100644 index 000000000..7ce6916a6 Binary files /dev/null and b/screenshots/customization-noFrame-1100x2390.png differ diff --git a/screenshots/device-android-1320x2868.png b/screenshots/device-android-1320x2868.png new file mode 100644 index 000000000..511051cca Binary files /dev/null and b/screenshots/device-android-1320x2868.png differ diff --git a/screenshots/device-androidTablet-3840x2400.png b/screenshots/device-androidTablet-3840x2400.png new file mode 100644 index 000000000..395a9e4d4 Binary files /dev/null and b/screenshots/device-androidTablet-3840x2400.png differ diff --git a/screenshots/device-desktop-2560x1600.png b/screenshots/device-desktop-2560x1600.png new file mode 100644 index 000000000..fb840a300 Binary files /dev/null and b/screenshots/device-desktop-2560x1600.png differ diff --git a/screenshots/device-iPad-2752x2064.png b/screenshots/device-iPad-2752x2064.png new file mode 100644 index 000000000..d44390a80 Binary files /dev/null and b/screenshots/device-iPad-2752x2064.png differ diff --git a/screenshots/device-iPhone-1242x2688.png b/screenshots/device-iPhone-1242x2688.png new file mode 100644 index 000000000..ea84e025b Binary files /dev/null and b/screenshots/device-iPhone-1242x2688.png differ diff --git a/screenshots/device-noFrame-1100x2390.png b/screenshots/device-noFrame-1100x2390.png new file mode 100644 index 000000000..a20a6f74e Binary files /dev/null and b/screenshots/device-noFrame-1100x2390.png differ diff --git a/screenshots/trainer-android-1320x2868.png b/screenshots/trainer-android-1320x2868.png new file mode 100644 index 000000000..85028945d Binary files /dev/null and b/screenshots/trainer-android-1320x2868.png differ diff --git a/screenshots/trainer-androidTablet-3840x2400.png b/screenshots/trainer-androidTablet-3840x2400.png new file mode 100644 index 000000000..07df5bfc0 Binary files /dev/null and b/screenshots/trainer-androidTablet-3840x2400.png differ diff --git a/screenshots/trainer-desktop-2560x1600.png b/screenshots/trainer-desktop-2560x1600.png new file mode 100644 index 000000000..d26aa66f4 Binary files /dev/null and b/screenshots/trainer-desktop-2560x1600.png differ diff --git a/screenshots/trainer-iPad-2752x2064.png b/screenshots/trainer-iPad-2752x2064.png new file mode 100644 index 000000000..536befca7 Binary files /dev/null and b/screenshots/trainer-iPad-2752x2064.png differ diff --git a/screenshots/trainer-iPhone-1242x2688.png b/screenshots/trainer-iPhone-1242x2688.png new file mode 100644 index 000000000..aacbadcdb Binary files /dev/null and b/screenshots/trainer-iPhone-1242x2688.png differ diff --git a/screenshots/trainer-noFrame-1100x2390.png b/screenshots/trainer-noFrame-1100x2390.png new file mode 100644 index 000000000..c58e8f8aa Binary files /dev/null and b/screenshots/trainer-noFrame-1100x2390.png differ diff --git a/scripts/RELEASE_NOTES.md b/scripts/RELEASE_NOTES.md new file mode 100644 index 000000000..79444e1f7 --- /dev/null +++ b/scripts/RELEASE_NOTES.md @@ -0,0 +1,11 @@ +I recommend downloading from the official stores: + +GetItOnGooglePlay_Badge_Web_color_English + +App Store + +Mac App Store + +Microsoft Store + +(or direct download for Windows [here](https://bikecontrol.app/download/bikecontrol.windows.zip)) diff --git a/scripts/generate_release_body.sh b/scripts/generate_release_body.sh new file mode 100755 index 000000000..7fa53c1ce --- /dev/null +++ b/scripts/generate_release_body.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Script to generate GitHub release body with changelog and store links +# Usage: ./scripts/generate_release_body.sh + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +RELEASE_NOTES_FILE="$SCRIPT_DIR/RELEASE_NOTES.md" + +if [ ! -f "$RELEASE_NOTES_FILE" ]; then + echo "Error: RELEASE_NOTES.md not found at $RELEASE_NOTES_FILE" + exit 1 +fi + +# Extract the first changelog entry using get_latest_changelog.sh +# For GitHub releases, we want to include the version header +VERSION_HEADER=$(awk '/^### / {print; exit}' "$SCRIPT_DIR/../CHANGELOG.md") +LATEST_CHANGELOG=$("$SCRIPT_DIR/get_latest_changelog.sh") + +# Combine changelog with release notes +echo "$VERSION_HEADER" +echo "$LATEST_CHANGELOG" +echo "" +echo "---" +echo "" +cat "$RELEASE_NOTES_FILE" diff --git a/scripts/get_latest_changelog.sh b/scripts/get_latest_changelog.sh new file mode 100755 index 000000000..cf8d08a1c --- /dev/null +++ b/scripts/get_latest_changelog.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Script to extract the latest changelog entry for Play Store uploads +# Usage: ./scripts/get_latest_changelog.sh + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md" + +if [ ! -f "$CHANGELOG_FILE" ]; then + echo "Error: CHANGELOG.md not found at $CHANGELOG_FILE" + exit 1 +fi + +# Extract the first changelog entry (between first ### and second ###) +awk '/^### / {if (count++) exit} count' "$CHANGELOG_FILE" | tail -n +2 | sed 's/^- /• /' diff --git a/shorebird.yaml b/shorebird.yaml new file mode 100644 index 000000000..ad7de7a51 --- /dev/null +++ b/shorebird.yaml @@ -0,0 +1,2 @@ +app_id: 21d821f0-6795-4fca-aeb1-5a5dbe2d2b62 +auto_update: false diff --git a/test/base_device_long_press_behavior_test.dart b/test/base_device_long_press_behavior_test.dart new file mode 100644 index 000000000..8b2e1648e --- /dev/null +++ b/test/base_device_long_press_behavior_test.dart @@ -0,0 +1,128 @@ +import 'package:bike_control/bluetooth/devices/base_device.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/custom_app.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const testButton = ControllerButton('testButton'); + + CustomApp buildApp({ + required bool hasSingle, + required bool hasDouble, + required bool hasLong, + }) { + final app = CustomApp(profileName: 'Test'); + if (hasSingle) { + app.keymap.addKeyPair( + KeyPair( + buttons: [testButton], + physicalKey: null, + logicalKey: null, + trigger: ButtonTrigger.singleClick, + inGameAction: InGameAction.shiftUp, + ), + ); + } + if (hasDouble) { + app.keymap.addKeyPair( + KeyPair( + buttons: [testButton], + physicalKey: null, + logicalKey: null, + trigger: ButtonTrigger.doubleClick, + inGameAction: InGameAction.shiftDown, + ), + ); + } + if (hasLong) { + app.keymap.addKeyPair( + KeyPair( + buttons: [testButton], + physicalKey: null, + logicalKey: null, + trigger: ButtonTrigger.longPress, + inGameAction: InGameAction.steerLeft, + ), + ); + } + return app; + } + + setUp(() { + core.actionHandler = StubActions(); + }); + + test('fires long press immediately on button down when long press is the only mapped trigger', () async { + final stubActions = core.actionHandler as StubActions; + core.actionHandler.init( + buildApp( + hasSingle: false, + hasDouble: false, + hasLong: true, + ), + ); + final device = _TestDevice(button: testButton); + + await device.handleButtonsClicked([testButton]); + + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.single, + PerformedAction(testButton, isDown: true, isUp: false, trigger: ButtonTrigger.longPress), + ); + + await Future.delayed(const Duration(milliseconds: 600)); + expect(stubActions.performedActions.length, 1); + + await device.handleButtonsClicked([]); + expect(stubActions.performedActions.length, 2); + expect( + stubActions.performedActions.last, + PerformedAction(testButton, isDown: false, isUp: true, trigger: ButtonTrigger.longPress), + ); + }); + + test('keeps delayed long press behavior when single click action is also mapped', () async { + final stubActions = core.actionHandler as StubActions; + core.actionHandler.init( + buildApp( + hasSingle: true, + hasDouble: false, + hasLong: true, + ), + ); + final device = _TestDevice(button: testButton); + + await device.handleButtonsClicked([testButton]); + expect(stubActions.performedActions, isEmpty); + + await Future.delayed(const Duration(milliseconds: 200)); + expect(stubActions.performedActions, isEmpty); + + await Future.delayed(const Duration(milliseconds: 400)); + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.single, + PerformedAction(testButton, isDown: true, isUp: false, trigger: ButtonTrigger.longPress), + ); + }); +} + +class _TestDevice extends BaseDevice { + _TestDevice({required ControllerButton button}) + : super( + 'TestDevice', + uniqueId: 'test-device-id', + availableButtons: [button], + ); + + @override + Future connect() async {} + + @override + Widget showInformation(BuildContext context) => const SizedBox.shrink(); +} diff --git a/test/bluetooth_device_detection.dart b/test/bluetooth_device_detection.dart new file mode 100644 index 000000000..b1a742419 --- /dev/null +++ b/test/bluetooth_device_detection.dart @@ -0,0 +1,152 @@ +import 'dart:typed_data'; + +import 'package:bike_control/bluetooth/devices/bluetooth_device.dart'; +import 'package:bike_control/bluetooth/devices/cycplus/cycplus_bc2.dart'; +import 'package:bike_control/bluetooth/devices/elite/elite_square.dart'; +import 'package:bike_control/bluetooth/devices/elite/elite_sterzo.dart'; +import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart'; +import 'package:bike_control/bluetooth/devices/sram/sram_axs.dart'; +import 'package:bike_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart'; +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_click.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart'; +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/universal_ble.dart'; + +void main() { + core.actionHandler = StubActions(); + + group('Detect Zwift devices', () { + test('Detect Zwift Play', () { + final device = _createBleDevice( + name: 'Zwift Play', + manufacturerData: [ + ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RC1_RIGHT_SIDE])), + ], + services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase()], + ); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + + test('Detect Zwift Ride', () { + final device = _createBleDevice( + name: 'Zwift Ride', + manufacturerData: [ + ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RIDE_LEFT_SIDE])), + ], + services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase()], + ); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + test('Detect Zwift Ride old firmware', () { + final device = _createBleDevice( + name: 'Zwift Ride', + manufacturerData: [ + ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RIDE_LEFT_SIDE])), + ], + services: [ZwiftConstants.ZWIFT_RIDE_CUSTOM_SERVICE_UUID.toLowerCase()], + ); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + + test('Detect Zwift Ride oldest firmware', () { + final device = _createBleDevice( + name: 'Zwift Ride', + manufacturerData: [ + ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.RIDE_LEFT_SIDE])), + ], + services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_SHORT_UUID.toLowerCase()], + ); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + + test('Detect Zwift Click V1', () { + final device = _createBleDevice( + name: 'Zwift Click', + manufacturerData: [ + ManufacturerData(ZwiftConstants.ZWIFT_MANUFACTURER_ID, Uint8List.fromList([ZwiftConstants.BC1])), + ], + services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase()], + ); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + + test('Detect Zwift Click V2', () { + final device = _createBleDevice( + name: 'Zwift Click', + manufacturerData: [ + ManufacturerData( + ZwiftConstants.ZWIFT_MANUFACTURER_ID, + Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE]), + ), + ], + services: [ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID.toLowerCase()], + ); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + }); + + group('Detect Elite devices', () { + test('Elite Square', () { + final device = _createBleDevice(name: 'SQUARE 1337'); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + test('Elite Sterzo', () { + final device = _createBleDevice(name: 'STERZO 1337'); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + }); + + group('Detect Wahoo devices', () { + test('Kickr Bike Shift', () { + final device = _createBleDevice(name: '133 KICKR BIKE SHIFT 133'); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + }); + + group('Detect Cycplus devices', () { + test('Cycplus BC2', () { + final device = _createBleDevice(name: 'Cycplus BC2'); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + test('Other cycplus', () { + final device = _createBleDevice(name: 'Cycplus 1337'); + expect(BluetoothDevice.fromScanResult(device), isNull); + }); + }); + + group('Detect Shimano Di2', () { + test('Shimano Di2', () { + final device = _createBleDevice(name: 'RDR 1337', services: [ShimanoDi2Constants.SERVICE_UUID.toLowerCase()]); + expect(BluetoothDevice.fromScanResult(device), isInstanceOf()); + }); + }); + + group('Skip powermeters', () { + test('Skip Favero Assioma', () { + final device = _createBleDevice(name: 'Assioma 133', services: [SramAxsConstants.SERVICE_UUID]); + expect(BluetoothDevice.fromScanResult(device), isNull); + }); + test('Skip QUARQ', () { + final device = _createBleDevice(name: 'QUARQ 133', services: [SramAxsConstants.SERVICE_UUID]); + expect(BluetoothDevice.fromScanResult(device), isNull); + }); + }); +} + +BleDevice _createBleDevice({ + required String name, + List manufacturerData = const [], + List services = const [], +}) { + return BleDevice( + deviceId: '1337', + name: name, + manufacturerDataList: manufacturerData, + services: services, + ); +} diff --git a/test/button_simulator_hotkeys_test.dart b/test/button_simulator_hotkeys_test.dart new file mode 100644 index 000000000..fa134fa69 --- /dev/null +++ b/test/button_simulator_hotkeys_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; + +void main() { + group('Button Simulator Hotkey Tests', () { + setUp(() async { + // Initialize SharedPreferences with in-memory storage for testing + SharedPreferences.setMockInitialValues({}); + await core.settings.init(); + }); + + test('Should initialize with empty hotkeys', () { + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys.isEmpty, true); + }); + + test('Should save and retrieve hotkeys', () async { + final testHotkeys = { + InGameAction.shiftUp: '1', + InGameAction.shiftDown: '2', + InGameAction.uturn: '3', + }; + + await core.settings.setButtonSimulatorHotkeys(testHotkeys); + + final retrievedHotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(retrievedHotkeys[InGameAction.shiftUp], '1'); + expect(retrievedHotkeys[InGameAction.shiftDown], '2'); + expect(retrievedHotkeys[InGameAction.uturn], '3'); + expect(retrievedHotkeys.length, 3); + }); + + test('Should set individual hotkey', () async { + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, 'q'); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys[InGameAction.shiftUp], 'q'); + }); + + test('Should update existing hotkey', () async { + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, '1'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, 'q'); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys[InGameAction.shiftUp], 'q'); + }); + + test('Should remove hotkey', () async { + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftUp, '1'); + await core.settings.setButtonSimulatorHotkey(InGameAction.shiftDown, '2'); + + await core.settings.removeButtonSimulatorHotkey(InGameAction.shiftUp); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys.containsKey(InGameAction.shiftUp), false); + expect(hotkeys[InGameAction.shiftDown], '2'); + }); + + test('Should handle multiple actions with different hotkeys', () async { + final testHotkeys = { + InGameAction.shiftUp: '1', + InGameAction.shiftDown: '2', + InGameAction.uturn: '3', + InGameAction.steerLeft: 'q', + InGameAction.steerRight: 'w', + InGameAction.openActionBar: 'a', + InGameAction.usePowerUp: 's', + }; + + await core.settings.setButtonSimulatorHotkeys(testHotkeys); + + final retrievedHotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(retrievedHotkeys.length, 7); + expect(retrievedHotkeys[InGameAction.steerLeft], 'q'); + expect(retrievedHotkeys[InGameAction.usePowerUp], 's'); + }); + + test('Should clear all hotkeys', () async { + await core.settings.setButtonSimulatorHotkeys({InGameAction.shiftUp: '1', InGameAction.shiftDown: '2'}); + await core.settings.setButtonSimulatorHotkeys({}); + + final hotkeys = core.settings.getButtonSimulatorHotkeys(); + expect(hotkeys.isEmpty, true); + }); + }); +} diff --git a/test/custom_frame.dart b/test/custom_frame.dart new file mode 100644 index 000000000..f9d7cb2e8 --- /dev/null +++ b/test/custom_frame.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:golden_screenshot/golden_screenshot.dart'; +import 'package:bike_control/widgets/ui/colors.dart'; + +import 'screenshot_test.dart'; + +class CustomFrame extends StatelessWidget { + const CustomFrame({ + super.key, + required this.title, + required this.device, + this.frameColors, + required this.child, + required this.platform, + }); + + final DeviceType platform; + final String title; + final ScreenshotDevice device; + final ScreenshotFrameColors? frameColors; + final Widget child; + + @override + Widget build(BuildContext context) { + final borderRadiusValue = 26.0; + return platform == DeviceType.noFrame + ? Scaffold(body: child) + : Scaffold( + body: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [BKColor.main, BKColor.mainEnd], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 54, horizontal: 16), + child: Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 36, + fontWeight: FontWeight.bold, + ), + ), + ), + Positioned( + top: 170, + left: 8, + right: 8, + bottom: -30, + child: FittedBox( + child: Container( + width: device.resolution.width / device.pixelRatio, + height: device.resolution.height / device.pixelRatio, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadiusValue), + ), + foregroundDecoration: BoxDecoration( + border: Border.all(width: 8), + borderRadius: BorderRadius.circular(borderRadiusValue), + ), + clipBehavior: Clip.antiAlias, + child: switch (platform) { + DeviceType.android => ScreenshotFrame.androidPhone(device: device, child: child), + DeviceType.androidTablet => ScreenshotFrame.androidTablet(device: device, child: child), + DeviceType.iPhone => ScreenshotFrame.iphone(device: device, child: child), + DeviceType.iPad => ScreenshotFrame.ipad(device: device, child: child), + DeviceType.desktop => ScreenshotFrame.noFrame(device: device, child: child), + DeviceType.noFrame => throw UnimplementedError(), + }, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/custom_profile_test.dart b/test/custom_profile_test.dart new file mode 100644 index 000000000..47a9942bf --- /dev/null +++ b/test/custom_profile_test.dart @@ -0,0 +1,131 @@ +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/custom_app.dart'; +import 'package:bike_control/utils/settings/settings.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('Custom Profile Tests', () { + setUp(() async { + // Initialize SharedPreferences with in-memory storage for testing + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({}); + await core.settings.init(); + }); + + test('Should create custom app with default profile name', () { + final customApp = CustomApp(); + expect(customApp.profileName, 'Other'); + expect(customApp.name, 'Other'); + }); + + test('Should create custom app with custom profile name', () { + final customApp = CustomApp(profileName: 'Workout'); + expect(customApp.profileName, 'Workout'); + expect(customApp.name, 'Workout'); + }); + + test('Should save and retrieve custom profile', () async { + final customApp = CustomApp(profileName: 'Race'); + await core.settings.setKeyMap(customApp); + + final profiles = core.settings.getCustomAppProfiles(); + expect(profiles.contains('Race'), true); + }); + + test('Should list multiple custom profiles', () async { + final workout = CustomApp(profileName: 'Workout'); + final race = CustomApp(profileName: 'Race'); + final event = CustomApp(profileName: 'Event'); + + await core.settings.setKeyMap(workout); + await core.settings.setKeyMap(race); + await core.settings.setKeyMap(event); + + final profiles = core.settings.getCustomAppProfiles(); + expect(profiles.contains('Workout'), true); + expect(profiles.contains('Race'), true); + expect(profiles.contains('Event'), true); + expect(profiles.length, 3); + }); + + test('Should duplicate custom profile', () async { + await core.settings.reset(); + final original = CustomApp(profileName: 'Original'); + await core.settings.setKeyMap(original); + + await core.settings.duplicateCustomAppProfile('Original', 'Copy'); + + final profiles = core.settings.getCustomAppProfiles(); + expect(profiles.contains('Original'), true); + expect(profiles.contains('Copy'), true); + expect(profiles.length, 2); + }); + + test('Should delete custom profile', () async { + final customApp = CustomApp(profileName: 'ToDelete'); + await core.settings.setKeyMap(customApp); + + var profiles = core.settings.getCustomAppProfiles(); + expect(profiles.contains('ToDelete'), true); + + await core.settings.deleteCustomAppProfile('ToDelete'); + + profiles = core.settings.getCustomAppProfiles(); + expect(profiles.contains('ToDelete'), false); + }); + + test('Should not duplicate migration if already migrated', () async { + SharedPreferences.setMockInitialValues({ + 'customapp': ['old_data'], + 'customapp_Custom': ['new_data'], + 'app': 'Custom', + }); + + final newSettings = Settings(); + await newSettings.init(); + + // Old key should still exist because new key already existed + expect(newSettings.getCustomAppKeymap('customapp'), null); + final customKeymap = newSettings.getCustomAppKeymap('Custom'); + expect(customKeymap, isNotNull); + }); + + test('Should export custom profile as JSON', () async { + final customApp = CustomApp(profileName: 'TestProfile'); + await core.settings.setKeyMap(customApp); + + final jsonData = core.settings.exportCustomAppProfile('TestProfile'); + expect(jsonData, isNotNull); + expect(jsonData, contains('version')); + expect(jsonData, contains('profileName')); + expect(jsonData, contains('keymap')); + }); + + test('Should import custom profile from JSON', () async { + // First export a profile + final customApp = CustomApp(profileName: 'ExportTest'); + await core.settings.setKeyMap(customApp); + final jsonData = core.settings.exportCustomAppProfile('ExportTest'); + + // Import with a new name + final success = await core.settings.importCustomAppProfile(jsonData!, newProfileName: 'ImportTest'); + + expect(success, true); + final profiles = core.settings.getCustomAppProfiles(); + expect(profiles.contains('ImportTest'), true); + }); + + test('Should fail to import invalid JSON', () async { + final success = await core.settings.importCustomAppProfile('invalid json'); + expect(success, false); + }); + + test('Should fail to import JSON with missing fields', () async { + final invalidJson = '{"version": 1}'; + final success = await core.settings.importCustomAppProfile(invalidJson); + expect(success, false); + }); + }); +} diff --git a/test/cycplus_bc2_test.dart b/test/cycplus_bc2_test.dart new file mode 100644 index 000000000..88d430eac --- /dev/null +++ b/test/cycplus_bc2_test.dart @@ -0,0 +1,140 @@ +import 'dart:typed_data'; + +import 'package:bike_control/bluetooth/devices/cycplus/cycplus_bc2.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/universal_ble.dart'; + +void main() { + group('CYCPLUS BC2 Virtual Shifter Tests', () { + // TODO figure this out later + test(skip: true, 'Test state machine with full sequence', () { + core.actionHandler = StubActions(); + + final stubActions = core.actionHandler as StubActions; + + final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name')); + + // Packet 0: [6]=01 [7]=03 -> No trigger (lock state) + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206010397565E000155'), + ); + expect(stubActions.performedActions.isEmpty, true); + + // Packet 1: [6]=03 [7]=03 -> Trigger: shiftUp + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206030398565E000158'), + ); + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206010397565E000155'), + ); + expect(stubActions.performedActions.length, 1); + expect(stubActions.performedActions.first, PerformedAction(CycplusBc2Buttons.shiftUp, isDown: true, isUp: true)); + stubActions.performedActions.clear(); + + // Packet 2: [6]=03 [7]=01 -> Trigger: shiftDown + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206030198575E000157'), + ); + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206010397565E000155'), + ); + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.first, + PerformedAction(CycplusBc2Buttons.shiftDown, isDown: true, isUp: true), + ); + stubActions.performedActions.clear(); + + // Packet 3: [6]=03 [7]=03 -> No trigger (lock state) + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206030398585E00015A'), + ); + expect(stubActions.performedActions.isEmpty, true); + + // Packet 4: [6]=01 [7]=03 -> Trigger: shiftUp + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206010399585E000159'), + ); + expect(stubActions.performedActions.length, 1); + expect(stubActions.performedActions.first, PerformedAction(CycplusBc2Buttons.shiftUp, isDown: true, isUp: true)); + stubActions.performedActions.clear(); + }); + + test(skip: true, 'Test release and re-press behavior', () { + core.actionHandler = StubActions(); + final stubActions = core.actionHandler as StubActions; + final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name')); + + // Press: lock state + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206010300005E000100'), + ); + expect(stubActions.performedActions.isEmpty, true); + + // Release: reset state + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206000000005E000100'), + ); + expect(stubActions.performedActions.isEmpty, true); + + // Press again: lock state (no trigger since we reset) + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206020300005E000100'), + ); + expect(stubActions.performedActions.isEmpty, true); + + // Change to different pressed value: trigger + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206010300005E000100'), + ); + expect(stubActions.performedActions.length, 1); + expect(stubActions.performedActions.first, PerformedAction(CycplusBc2Buttons.shiftUp, isDown: true, isUp: true)); + }); + + test('Test both buttons can trigger simultaneously', () { + core.actionHandler = StubActions(); + final stubActions = core.actionHandler as StubActions; + final device = CycplusBc2(BleDevice(deviceId: 'deviceId', name: 'name')); + + // Lock both states + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206010100005E000100'), + ); + expect(stubActions.performedActions.isEmpty, true); + + // Change both: trigger both + device.processCharacteristic( + CycplusBc2Constants.TX_CHARACTERISTIC_UUID, + _hexToUint8List('FEEFFFEE0206020200005E000100'), + ); + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.contains(PerformedAction(CycplusBc2Buttons.shiftUp, isDown: true, isUp: true)), + true, + ); + }); + }); +} + +Uint8List _hexToUint8List(String seq) { + return Uint8List.fromList( + List.generate( + seq.length ~/ 2, + (i) => int.parse(seq.substring(i * 2, i * 2 + 2), radix: 16), + ), + ); +} diff --git a/test/elite_square_test.dart b/test/elite_square_test.dart new file mode 100644 index 000000000..732fc9c05 --- /dev/null +++ b/test/elite_square_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter_test/flutter_test.dart'; + +// Helper function matching the Elite Square implementation +// Extracts the 8-character button code from positions 6-14 of the hex string +String extractButtonCode(String hexValue) { + if (hexValue.length >= 14) { + return hexValue.substring(6, 14); + } + return hexValue.substring(6); +} + +String bytesToHex(List bytes) { + return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); +} + +void main() { + group('Elite Square Button Detection Tests', () { + test('Should extract correct button code from hex string', () { + // Test with actual dump data + expect(extractButtonCode('030153000000020318f40101'), equals('00000002')); + expect(extractButtonCode('030153000000010318f40101'), equals('00000001')); + expect(extractButtonCode('030153000004000318f40101'), equals('00000400')); + expect(extractButtonCode('030153000001000318f40101'), equals('00000100')); + expect(extractButtonCode('030153000008000318f40101'), equals('00000800')); + expect(extractButtonCode('030153000002000318f40101'), equals('00000200')); + expect(extractButtonCode('030153000000000318f40101'), equals('00000000')); + }); + + test('Should detect button changes correctly', () { + // Test that button code extraction is consistent for comparison + final idleState = '030153000000000318f40101'; + final buttonPressed = '030153000000020318f40101'; + + final idleCode = extractButtonCode(idleState); + final pressedCode = extractButtonCode(buttonPressed); + + expect(idleCode, equals('00000000')); + expect(pressedCode, equals('00000002')); + expect(idleCode != pressedCode, isTrue); + }); + + test('Should handle button release correctly', () { + // Simulate button press and release + final states = [ + '030153000000000318f40101', // idle + '030153000000020318f40101', // button pressed + '030153000000000318f40101', // button released (back to idle) + ]; + + final parts = states.map(extractButtonCode).toList(); + + expect(parts[0], equals('00000000')); // idle + expect(parts[1], equals('00000002')); // pressed + expect(parts[2], equals('00000000')); // released + + // Verify state transitions + expect(parts[0] != parts[1], isTrue); // idle -> pressed + expect(parts[1] != parts[2], isTrue); // pressed -> released + expect(parts[0] == parts[2], isTrue); // back to idle + }); + + test('Should handle all button codes from mapping', () { + // Test all button codes from the mapping + final buttonCodes = { + "00000200": "up", + "00000100": "left", + "00000800": "down", + "00000400": "right", + "00002000": "x", + "00001000": "square", + "00008000": "campagnoloLeft", + "00004000": "leftBrake", + "00000002": "leftShift1", + "00000001": "leftShift2", + "02000000": "y", + "01000000": "a", + "08000000": "b", + "04000000": "z", + "20000000": "circle", + "10000000": "triangle", + "80000000": "campagnoloRight", + "40000000": "rightBrake", + "00020000": "rightShift1", + "00010000": "rightShift2", + }; + + // Verify all button codes are 8 characters + for (final code in buttonCodes.keys) { + expect(code.length, equals(8), reason: 'Button code $code should be 8 characters'); + } + }); + + test('Should convert bytes to hex correctly', () { + // Test with sample data from the dump + // 030153000000020318f40101 = [0x03, 0x01, 0x53, 0x00, 0x00, 0x00, 0x02, 0x03, 0x18, 0xf4, 0x01, 0x01] + final bytes = [0x03, 0x01, 0x53, 0x00, 0x00, 0x00, 0x02, 0x03, 0x18, 0xf4, 0x01, 0x01]; + final hex = bytesToHex(bytes); + + expect(hex, equals('030153000000020318f40101')); + }); + + test('Should handle edge cases', () { + // Test with short strings + expect(extractButtonCode('0123456789'), equals('6789')); + expect(extractButtonCode('01234567'), equals('67')); + + // Test with exact length (14 chars extracts positions 6-14) + expect(extractButtonCode('01234567890123'), equals('67890123')); + + // Test strings shorter than 14 extract from position 6 to end + expect(extractButtonCode('0123456789ABC'), equals('6789ABC')); + }); + }); + + group('Elite Square Protocol Tests', () { + test('Should recognize button press pattern', () { + // According to the dump, the pattern is: + // Base: 030153000000000318f40101 + // Byte positions 6-13 (8 chars) change to indicate button + + final baseHex = '030153000000000318f40101'; + final buttonHex = '030153000000020318f40101'; + + // Extract the button part (positions 6-14) + final baseButton = baseHex.substring(6, 14); + final pressedButton = buttonHex.substring(6, 14); + + expect(baseButton, equals('00000000')); + expect(pressedButton, equals('00000002')); + }); + }); +} diff --git a/test/elite_sterzo_test.dart b/test/elite_sterzo_test.dart new file mode 100644 index 000000000..e785c747f --- /dev/null +++ b/test/elite_sterzo_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Elite Sterzo Smart Calibration Tests', () { + test('Should ignore NaN values during calibration', () { + // Test that NaN values are filtered out + final samples = [double.nan, 1.5, 2.0, 2.5, 3.0, double.nan, 3.5, 4.0, 4.5, 5.0, 5.5]; + final validSamples = samples.where((s) => !s.isNaN).take(10).toList(); + + expect(validSamples.length, equals(9)); + expect(validSamples.every((s) => !s.isNaN), isTrue); + }); + + test('Should compute correct calibration offset from samples', () { + // Test offset calculation + final samples = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]; + final offset = samples.reduce((a, b) => a + b) / samples.length; + + expect(offset, equals(5.5)); + }); + + test('Should round angles to whole degrees', () { + // Test rounding behavior + expect(4.3.round(), equals(4)); + expect(4.6.round(), equals(5)); + expect(4.7.round(), equals(5)); + expect((-4.3).round(), equals(-4)); + expect((-4.6).round(), equals(-5)); + }); + }); + + group('Elite Sterzo Smart PWM Keypress Tests', () { + test('Should calculate correct keypress levels', () { + // Test level calculation with LEVEL_DEGREE_STEP = 10 and MAX_LEVELS = 5 + const levelDegreeStep = 10.0; + const maxLevels = 5; + + int calculateLevels(int absAngle) { + final levels = (absAngle / levelDegreeStep).floor(); + return levels.clamp(1, maxLevels); + } + + expect(calculateLevels(5), equals(1)); // Below threshold but level 1 + expect(calculateLevels(10), equals(1)); // 10 / 10 = 1 + expect(calculateLevels(15), equals(1)); // 15 / 10 = 1.5 floor = 1 + expect(calculateLevels(20), equals(2)); // 20 / 10 = 2 + expect(calculateLevels(35), equals(3)); // 35 / 10 = 3.5 floor = 3 + expect(calculateLevels(50), equals(5)); // 50 / 10 = 5 (max) + expect(calculateLevels(100), equals(5)); // 100 / 10 = 10 but clamped to 5 + }); + + test('Should determine correct steering direction', () { + // Test direction determination + expect(25 > 0, isTrue); // Positive = RIGHT + expect(-25 > 0, isFalse); // Negative = LEFT + }); + }); + + group('Elite Sterzo Smart Threshold Tests', () { + test('Should correctly apply steering threshold', () { + const steeringThreshold = 10.0; + + // Test threshold logic + expect(5.abs() > steeringThreshold, isFalse); // Below threshold + expect(10.abs() > steeringThreshold, isFalse); // At threshold + expect(11.abs() > steeringThreshold, isTrue); // Above threshold + expect((-11).abs() > steeringThreshold, isTrue); // Above threshold (negative) + }); + }); +} diff --git a/test/gyroscope_steering_test.dart b/test/gyroscope_steering_test.dart new file mode 100644 index 000000000..627d089aa --- /dev/null +++ b/test/gyroscope_steering_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Gyroscope Steering Calibration Tests', () { + test('Should compute correct calibration offset from samples', () { + // Test offset calculation + final samples = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]; + final offset = samples.reduce((a, b) => a + b) / samples.length; + + expect(offset, equals(5.5)); + }); + + test('Should round angles to whole degrees', () { + // Test rounding behavior + expect(4.3.round(), equals(4)); + expect(4.6.round(), equals(5)); + expect(4.7.round(), equals(5)); + expect((-4.3).round(), equals(-4)); + expect((-4.6).round(), equals(-5)); + }); + + test('Should apply low-pass filter correctly', () { + // Test low-pass filter with alpha = 0.9 + const alpha = 0.9; + double filteredValue = 0.0; + final newValue = 10.0; + + filteredValue = alpha * filteredValue + (1 - alpha) * newValue; + + expect(filteredValue, closeTo(1.0, 0.01)); // 0.9 * 0 + 0.1 * 10 + }); + + test('Should apply complementary filter correctly', () { + // Test complementary filter with alpha = 0.98 + const alpha = 0.98; + final gyroValue = 20.0; + final accelValue = 19.0; + + final filtered = alpha * gyroValue + (1 - alpha) * accelValue; + + expect(filtered, closeTo(19.98, 0.01)); // 0.98 * 20 + 0.02 * 19 + }); + }); + + group('Gyroscope Steering PWM Keypress Tests', () { + test('Should calculate correct keypress levels', () { + // Test level calculation with STEERING_THRESHOLD = 15 and LEVEL_DEGREE_STEP = 10 + const steeringThreshold = 15.0; + const levelDegreeStep = 10.0; + const maxLevels = 5; + + int calculateLevels(int absAngle) { + final levels = ((absAngle - steeringThreshold) / levelDegreeStep).floor() + 1; + return levels.clamp(1, maxLevels); + } + + expect(calculateLevels(15), equals(1)); // (15 - 15) / 10 = 0, floor + 1 = 1 + expect(calculateLevels(20), equals(1)); // (20 - 15) / 10 = 0.5, floor + 1 = 1 + expect(calculateLevels(25), equals(2)); // (25 - 15) / 10 = 1.0, floor + 1 = 2 + expect(calculateLevels(35), equals(3)); // (35 - 15) / 10 = 2.0, floor + 1 = 3 + expect(calculateLevels(45), equals(4)); // (45 - 15) / 10 = 3.0, floor + 1 = 4 + expect(calculateLevels(55), equals(5)); // (55 - 15) / 10 = 4.0, floor + 1 = 5 + expect(calculateLevels(100), equals(5)); // (100 - 15) / 10 = 8.5, floor + 1 = 9 but clamped to 5 + }); + + test('Should determine correct steering direction', () { + // Test direction determination + expect(25 > 0, isTrue); // Positive = RIGHT + expect(-25 > 0, isFalse); // Negative = LEFT + }); + }); + + group('Gyroscope Steering Threshold Tests', () { + test('Should correctly apply steering threshold', () { + const steeringThreshold = 15.0; + + // Test threshold logic + expect(10.abs() > steeringThreshold, isFalse); // Below threshold + expect(15.abs() > steeringThreshold, isFalse); // At threshold + expect(16.abs() > steeringThreshold, isTrue); // Above threshold + expect((-16).abs() > steeringThreshold, isTrue); // Above threshold (negative) + }); + }); + + group('Gyroscope Steering Integration Tests', () { + test('Should integrate gyroscope readings correctly', () { + // Test gyroscope integration for angle calculation + const angularVelocity = 10.0; // degrees per second + const timeInterval = 0.1; // seconds + + final angleDelta = angularVelocity * timeInterval; + + expect(angleDelta, equals(1.0)); // 10 * 0.1 = 1 degree + }); + + test('Should handle time deltas within reasonable bounds', () { + // Test that time deltas are validated + const minDt = 0.0; + const maxDt = 1.0; + + expect(0.01 > minDt && 0.01 < maxDt, isTrue); + expect(0.5 > minDt && 0.5 < maxDt, isTrue); + expect(1.5 > minDt && 1.5 < maxDt, isFalse); // Too large + }); + }); +} diff --git a/test/long_press_test.dart b/test/long_press_test.dart new file mode 100644 index 000000000..d59b5c7ac --- /dev/null +++ b/test/long_press_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; + +void main() { + group('Long Press KeyPair Tests', () { + test('KeyPair should encode and decode isLongPress property', () { + // Create a KeyPair with long press enabled + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + isLongPress: true, + ); + + // Encode the KeyPair + final encoded = keyPair.encode(); + + // Decode the KeyPair + final decoded = KeyPair.decode(encoded); + + // Verify the decoded KeyPair has the correct properties + expect(decoded, isNotNull); + expect(decoded!.isLongPress, true); + expect(decoded.trigger, ButtonTrigger.longPress); + expect(decoded.buttons, equals([ZwiftButtons.a])); + expect(decoded.physicalKey, equals(PhysicalKeyboardKey.keyA)); + expect(decoded.logicalKey, equals(LogicalKeyboardKey.keyA)); + }); + + test('KeyPair should default isLongPress to false when not specified in decode', () { + // Create a legacy encoded KeyPair without isLongPress property + const legacyEncoded = ''' + { + "actions": ["a"], + "logicalKey": "97", + "physicalKey": "458752", + "touchPosition": {"x": 0.0, "y": 0.0} + } + '''; + + // Decode the legacy KeyPair + final decoded = KeyPair.decode(legacyEncoded); + + // Verify the decoded KeyPair defaults isLongPress to false + expect(decoded, isNotNull); + expect(decoded!.isLongPress, false); + expect(decoded.trigger, ButtonTrigger.singleClick); + }); + + test('KeyPair constructor should default isLongPress to false', () { + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + ); + + expect(keyPair.isLongPress, false); + expect(keyPair.trigger, ButtonTrigger.singleClick); + }); + + test('KeyPair should correctly encode isLongPress false', () { + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + isLongPress: false, + ); + + final encoded = keyPair.encode(); + final decoded = KeyPair.decode(encoded); + + expect(decoded, isNotNull); + expect(decoded!.isLongPress, false); + expect(decoded.trigger, ButtonTrigger.singleClick); + }); + + test('KeyPair should encode and decode screenshotPath', () { + const screenshotPath = '/Users/test/Desktop/bikecontrol-screenshot.png'; + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: null, + logicalKey: null, + screenshotPath: screenshotPath, + ); + + final encoded = keyPair.encode(); + final decoded = KeyPair.decode(encoded); + + expect(decoded, isNotNull); + expect(decoded!.screenshotPath, screenshotPath); + expect(decoded.command, isNull); + }); + }); +} diff --git a/test/modifier_keys_test.dart b/test/modifier_keys_test.dart new file mode 100644 index 000000000..965991913 --- /dev/null +++ b/test/modifier_keys_test.dart @@ -0,0 +1,159 @@ +import 'package:bike_control/bluetooth/devices/zwift/constants.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/my_whoosh.dart'; +import 'package:bike_control/utils/keymap/keymap.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future main() async { + FlutterSecureStorage.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({}); + screenshotMode = true; + + await core.settings.init(); + await core.settings.reset(); + + core.settings.setTrainerApp(MyWhoosh()); + core.settings.setKeyMap(MyWhoosh()); + core.settings.setLastTarget(Target.thisDevice); + + group('Modifier Keys KeyPair Tests', () { + test('KeyPair should encode and decode modifiers property', () { + // Create a KeyPair with modifiers + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyR, + logicalKey: LogicalKeyboardKey.keyR, + modifiers: [ModifierKey.controlModifier, ModifierKey.altModifier], + ); + + // Encode the KeyPair + final encoded = keyPair.encode(); + + // Decode the KeyPair + final decoded = KeyPair.decode(encoded); + + // Verify the decoded KeyPair has the correct properties + expect(decoded, isNotNull); + expect(decoded!.modifiers.length, 2); + expect(decoded.modifiers, contains(ModifierKey.controlModifier)); + expect(decoded.modifiers, contains(ModifierKey.altModifier)); + expect(decoded.buttons, equals([ZwiftButtons.a])); + expect(decoded.physicalKey, equals(PhysicalKeyboardKey.keyR)); + expect(decoded.logicalKey, equals(LogicalKeyboardKey.keyR)); + }); + + test('KeyPair should default modifiers to empty list when not specified in decode', () { + // Create a legacy encoded KeyPair without modifiers property + const legacyEncoded = ''' + { + "actions": ["a"], + "logicalKey": "97", + "physicalKey": "458752", + "touchPosition": {"x": 0.0, "y": 0.0}, + "isLongPress": false + } + '''; + + // Decode the legacy KeyPair + final decoded = KeyPair.decode(legacyEncoded); + + // Verify the decoded KeyPair defaults modifiers to empty + expect(decoded, isNotNull); + expect(decoded!.modifiers, isEmpty); + }); + + test('KeyPair constructor should default modifiers to empty list', () { + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + ); + + expect(keyPair.modifiers, isEmpty); + }); + + test('KeyPair should correctly encode empty modifiers', () { + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + modifiers: [], + ); + + final encoded = keyPair.encode(); + final decoded = KeyPair.decode(encoded); + + expect(decoded, isNotNull); + expect(decoded!.modifiers, isEmpty); + }); + + test('KeyPair toString should format modifiers correctly', () { + final keyPairWithCtrlAlt = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyR, + logicalKey: LogicalKeyboardKey.keyR, + modifiers: [ModifierKey.controlModifier, ModifierKey.altModifier], + ); + + final result = keyPairWithCtrlAlt.toString(); + expect(result, contains('Ctrl')); + expect(result, contains('Alt')); + expect(result, contains('R')); + }); + + test('KeyPair toString should handle single modifier', () { + final keyPairWithShift = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + modifiers: [ModifierKey.shiftModifier], + ); + + final result = keyPairWithShift.toString(); + expect(result, contains('Shift')); + expect(result, contains('A')); + }); + + test('KeyPair toString should handle no modifiers', () { + final keyPairNoModifier = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: LogicalKeyboardKey.keyA, + modifiers: [], + ); + + final result = keyPairNoModifier.toString(); + expect(result, equals('A')); + expect(result, isNot(contains('+'))); + }); + + test('KeyPair should encode and decode all modifier types', () { + final keyPair = KeyPair( + buttons: [ZwiftButtons.a], + physicalKey: PhysicalKeyboardKey.keyF, + logicalKey: LogicalKeyboardKey.keyF, + modifiers: [ + ModifierKey.shiftModifier, + ModifierKey.controlModifier, + ModifierKey.altModifier, + ModifierKey.metaModifier, + ], + ); + + final encoded = keyPair.encode(); + final decoded = KeyPair.decode(encoded); + + expect(decoded, isNotNull); + expect(decoded!.modifiers.length, 4); + expect(decoded.modifiers, contains(ModifierKey.shiftModifier)); + expect(decoded.modifiers, contains(ModifierKey.controlModifier)); + expect(decoded.modifiers, contains(ModifierKey.altModifier)); + expect(decoded.modifiers, contains(ModifierKey.metaModifier)); + }); + }); +} diff --git a/test/screenshot_test.dart b/test/screenshot_test.dart new file mode 100644 index 000000000..00ca27c33 --- /dev/null +++ b/test/screenshot_test.dart @@ -0,0 +1,269 @@ +import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart'; +import 'package:bike_control/main.dart'; +import 'package:bike_control/pages/button_simulator.dart'; +import 'package:bike_control/pages/navigation.dart'; +import 'package:bike_control/utils/core.dart' show core; +import 'package:bike_control/utils/iap/iap_manager.dart'; +import 'package:bike_control/utils/keymap/apps/my_whoosh.dart'; +import 'package:bike_control/utils/requirements/multi.dart'; +import 'package:flutter/material.dart' as ma; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:golden_screenshot/golden_screenshot.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:universal_ble/universal_ble.dart'; + +import 'custom_frame.dart'; + +enum DeviceType { + android, + androidTablet, + iPhone, + iPad, + desktop, + noFrame, +} + +Future main() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + PackageInfo.setMockInitialValues( + appName: 'BikeControl', + packageName: 'de.jonasbark.swiftcontrol', + version: '5.0.0', + buildNumber: '1', + buildSignature: '', + ); + FlutterSecureStorage.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({}); + IAPManager.instance.isPurchased.value = true; + + screenshotMode = true; + + await core.settings.init(); + await core.settings.reset(); + + core.settings.setTrainerApp(MyWhoosh()); + core.settings.setKeyMap(MyWhoosh()); + core.settings.setLastTarget(Target.thisDevice); + + core.connection.addDevices([ + ZwiftRide( + BleDevice( + name: 'Controller', + deviceId: '00:11:22:33:44:55', + ), + ) + ..firmwareVersion = '1.2.0' + ..rssi = -51 + ..batteryLevel = 81, + ]); + + final List<({DeviceType type, TargetPlatform platform, Size size})> sizes = [ + (type: DeviceType.android, platform: TargetPlatform.android, size: Size(1320, 2868)), + (type: DeviceType.androidTablet, platform: TargetPlatform.android, size: Size(3840, 2400)), + (type: DeviceType.iPhone, platform: TargetPlatform.iOS, size: Size(1242, 2688)), + (type: DeviceType.iPad, platform: TargetPlatform.iOS, size: Size(2752, 2064)), + (type: DeviceType.desktop, platform: TargetPlatform.windows, size: Size(2560, 1600)), + (type: DeviceType.noFrame, platform: TargetPlatform.windows, size: Size(1320, 2868) / 1.2), + /*('iPhone', Size(1242, 2688)), + ('macOS', Size(1280, 800)), + ('GitHub', Size(600, 900)),*/ + ]; + + debugDisableShadows = true; + + testGoldens('Init', (WidgetTester tester) async { + screenshotMode = true; + IAPManager.instance.isPurchased.value = true; + await tester.loadAssets(); + for (final size in sizes) { + await tester.pumpWidget( + ScreenshotApp( + device: ScreenshotDevice( + platform: size.platform, + resolution: size.size, + pixelRatio: 3, + goldenSubFolder: 'iphoneScreenshots/', + frameBuilder: + ({ + required ScreenshotDevice device, + required ScreenshotFrameColors? frameColors, + required Widget child, + }) => CustomFrame( + platform: size.type, + title: 'BikeControl connects to your favorite controller', + device: device, + child: child, + ), + ), + home: BikeControlApp( + page: BCPage.devices, + ), + ), + ); + + await tester.pump(); + } + }); + testGoldens('Device', (WidgetTester tester) async { + IAPManager.instance.isPurchased.value = true; + for (final size in sizes) { + await tester.pumpWidget( + ScreenshotApp( + device: ScreenshotDevice( + platform: size.platform, + resolution: size.size, + pixelRatio: 3, + goldenSubFolder: 'iphoneScreenshots/', + frameBuilder: + ({ + required ScreenshotDevice device, + required ScreenshotFrameColors? frameColors, + required Widget child, + }) => CustomFrame( + platform: size.type, + title: 'Control your trainer app using ANY controller', + device: device, + child: child, + ), + ), + home: BikeControlApp( + page: BCPage.devices, + ), + ), + ); + + await tester.pump(); + await expectLater( + find.byType(ma.Scaffold), + matchesGoldenFile( + '../screenshots/device-${size.type.name}-${size.size.width.toInt()}x${size.size.height.toInt()}.png', + ), + ); + } + }); + + testGoldens('Trainer', (WidgetTester tester) async { + IAPManager.instance.isPurchased.value = true; + screenshotMode = true; + for (final size in sizes) { + await tester.pumpWidget( + ScreenshotApp( + device: ScreenshotDevice( + platform: size.platform, + resolution: size.size, + pixelRatio: 3, + goldenSubFolder: 'iphoneScreenshots/', + frameBuilder: + ({ + required ScreenshotDevice device, + required ScreenshotFrameColors? frameColors, + required Widget child, + }) => CustomFrame( + platform: size.type, + title: 'Connect BikeControl to your trainer', + device: device, + child: child, + ), + ), + home: BikeControlApp( + page: BCPage.trainer, + ), + ), + ); + + await tester.pump(); + await expectLater( + find.byType(ma.Scaffold), + matchesGoldenFile( + '../screenshots/trainer-${size.type.name}-${size.size.width.toInt()}x${size.size.height.toInt()}.png', + ), + ); + } + }); + + testGoldens('Customization', (WidgetTester tester) async { + IAPManager.instance.isPurchased.value = true; + screenshotMode = true; + + for (final size in sizes) { + await tester.pumpWidget( + ScreenshotApp( + device: ScreenshotDevice( + platform: size.platform, + resolution: size.size, + pixelRatio: 3, + goldenSubFolder: 'iphoneScreenshots/', + frameBuilder: + ({ + required ScreenshotDevice device, + required ScreenshotFrameColors? frameColors, + required Widget child, + }) => CustomFrame( + platform: size.type, + title: 'Customize every controller button', + device: device, + child: child, + ), + ), + home: BikeControlApp( + page: BCPage.customization, + ), + ), + ); + + await tester.pump(); + await expectLater( + find.byType(ma.Scaffold), + matchesGoldenFile( + '../screenshots/customization-${size.type.name}-${size.size.width.toInt()}x${size.size.height.toInt()}.png', + ), + ); + } + }); + + testGoldens('Trainer Controls', (WidgetTester tester) async { + IAPManager.instance.isPurchased.value = true; + screenshotMode = true; + + core.settings.setMyWhooshLinkEnabled(true); + core.whooshLink.isConnected.value = true; + for (final size in sizes) { + await tester.pumpWidget( + ScreenshotApp( + device: ScreenshotDevice( + platform: size.platform, + resolution: size.size, + pixelRatio: 3, + goldenSubFolder: 'iphoneScreenshots/', + frameBuilder: + ({ + required ScreenshotDevice device, + required ScreenshotFrameColors? frameColors, + required Widget child, + }) => CustomFrame( + platform: size.type, + title: 'Companion App mode with custom hotkeys', + device: device, + child: child, + ), + ), + home: BikeControlApp( + customChild: ButtonSimulator(), + ), + ), + ); + + await tester.pump(); + await expectLater( + find.byType(ma.Scaffold), + matchesGoldenFile( + '../screenshots/companion-${size.type.name}-${size.size.width.toInt()}x${size.size.height.toInt()}.png', + ), + ); + } + }); +} diff --git a/test/shimano_di2_test.dart b/test/shimano_di2_test.dart new file mode 100644 index 000000000..60fb768d8 --- /dev/null +++ b/test/shimano_di2_test.dart @@ -0,0 +1,85 @@ +import 'dart:typed_data'; + +import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:bike_control/utils/keymap/apps/openbikecontrol.dart'; +import 'package:bike_control/utils/keymap/buttons.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:universal_ble/universal_ble.dart'; + +Future main() async { + final stubActions = StubActions(); + stubActions.supportedApp = OpenBikeControl(); + + SharedPreferences.setMockInitialValues({}); + await core.settings.init(); + core.actionHandler = stubActions; + + group('Shimano DI2 Tests', () { + test('Should parse Di2 values correctly', () async { + stubActions.cleanup(); + final instance = ShimanoDi2(BleDevice(name: 'Di2', deviceId: '')); + await instance.processCharacteristic( + ShimanoDi2Constants.D_FLY_CHANNEL_UUID, + Uint8List.fromList([0x21, 0x13, 0xF0, 0xF0]), + ); + + expect(stubActions.performedActions.isEmpty, true); + + await instance.processCharacteristic( + ShimanoDi2Constants.D_FLY_CHANNEL_UUID, + Uint8List.fromList([0x21, 0x13, 0xF0, 0xF0]), + ); + expect(stubActions.performedActions.isEmpty, true); + + await instance.processCharacteristic( + ShimanoDi2Constants.D_FLY_CHANNEL_UUID, + Uint8List.fromList([0x21, 0x14, 0xF0, 0xF0]), + ); + expect(stubActions.performedActions.isEmpty, false); + }); + + test('should transmit all events', () async { + stubActions.cleanup(); + final instance = ShimanoDi2(BleDevice(name: 'Di2', deviceId: '')); + await instance.processCharacteristic( + ShimanoDi2Constants.D_FLY_CHANNEL_UUID, + Uint8List.fromList([0x21, 0x13, 0xF0, 0xF0]), + ); + + expect(stubActions.performedActions.isEmpty, true); + + await instance.processCharacteristic( + ShimanoDi2Constants.D_FLY_CHANNEL_UUID, + Uint8List.fromList([0x21, 0x13, 0xF0, 0xF0]), + ); + expect(stubActions.performedActions.isEmpty, true); + + await instance.processCharacteristic( + ShimanoDi2Constants.D_FLY_CHANNEL_UUID, + Uint8List.fromList([0x21, 0x14, 0xF0, 0xF0]), + ); + final button = ControllerButton('D-Fly Channel 1'); + expect( + stubActions.performedActions, + equals([ + PerformedAction(button, isDown: true, isUp: true), + ]), + ); + + await instance.processCharacteristic( + ShimanoDi2Constants.D_FLY_CHANNEL_UUID, + Uint8List.fromList([0x21, 0x15, 0xF0, 0xF0]), + ); + expect( + stubActions.performedActions, + equals([ + PerformedAction(button, isDown: true, isUp: true), + PerformedAction(button, isDown: true, isUp: true), + ]), + ); + }); + }); +} diff --git a/test/steering_estimator_test.dart b/test/steering_estimator_test.dart new file mode 100644 index 000000000..2df2b54f2 --- /dev/null +++ b/test/steering_estimator_test.dart @@ -0,0 +1,173 @@ +import 'package:bike_control/bluetooth/devices/gyroscope/steering_estimator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('learns gyro bias while still and prevents drift', () { + final est = SteeringEstimator( + biasLearningRate: 0.05, + gyroStillThresholdRadPerSec: 0.2, + accelStillThresholdMS2: 2.0, + minStillTimeForBiasSec: 0.0, + minStillTimeForRecenterSec: 999.0, + lowPassAlpha: 0.0, // make assertions easier + maxAngleAbsDeg: 180, + ); + + // Provide accel close to gravity so estimator considers us still. + est.updateAccel(x: 0, y: 0, z: 9.80665); + + const bias = 0.02; // rad/s + const dt = 0.01; + + // 10 seconds of data with pure bias. + for (var i = 0; i < 1000; i++) { + est.updateGyro(wz: bias, dt: dt); + } + + // Bias should converge close to the true bias. + expect(est.biasZRadPerSec, closeTo(bias, 0.002)); + + // Angle should remain near 0 because correctedWz ~= 0. + expect(est.angleDeg.abs(), lessThan(1.0)); + }); + + test('recenters slowly after being still for long enough', () { + final est = SteeringEstimator( + biasLearningRate: 0.0, + gyroStillThresholdRadPerSec: 1.0, + accelStillThresholdMS2: 2.0, + minStillTimeForBiasSec: 999.0, + minStillTimeForRecenterSec: 0.2, + recenterHalfLifeSec: 0.2, + recenterDeadbandDeg: 2.0, + lowPassAlpha: 0.0, + maxAngleAbsDeg: 180, + ); + + est.updateAccel(x: 0, y: 0, z: 9.80665); + + // Create a small non-zero yaw within the deadband (so auto-recenter is allowed). + // 1.0 rad/s for 0.02s => ~1.15 deg. + for (var i = 0; i < 2; i++) { + est.updateGyro(wz: 1.0, dt: 0.01); + } + + final initial = est.angleDeg.abs(); + expect(initial, greaterThan(0.1)); + expect(initial, lessThan(2.0)); + + // Hold still long enough to pass the recenter delay + apply decay. + for (var i = 0; i < 150; i++) { + est.updateGyro(wz: 0.0, dt: 0.01); + } + + // It should have decayed noticeably. + expect(est.angleDeg.abs(), lessThan(initial * 0.9)); + }); + + test("doesn't recenter while user holds a constant steering angle", () { + final est = SteeringEstimator( + biasLearningRate: 0.0, + gyroStillThresholdRadPerSec: 1.0, + accelStillThresholdMS2: 2.0, + minStillTimeForBiasSec: 999.0, + // Even if recenter were enabled, it must not recenter away a held angle. + minStillTimeForRecenterSec: 0.2, + recenterHalfLifeSec: 0.1, + recenterDeadbandDeg: 2.0, + lowPassAlpha: 0.0, + maxAngleAbsDeg: 180, + ); + + est.updateAccel(x: 0, y: 0, z: 9.80665); + + // Create a held steering angle (~20 deg). + // 0.6 rad/s for 0.6s => ~20.6 deg + for (var i = 0; i < 60; i++) { + est.updateGyro(wz: 0.6, dt: 0.01); + } + + final held = est.angleDeg; + expect(held.abs(), greaterThan(10.0)); + + // Now "hold" that angle: no rotation (wz ~ 0), but device is still. + // Previous implementation would recenter here; we must not. + for (var i = 0; i < 400; i++) { + est.updateGyro(wz: 0.0, dt: 0.01); + } + + expect(est.angleDeg, closeTo(held, 0.5)); + }); + + test("doesn't learn bias while held at a non-zero angle", () { + final est = SteeringEstimator( + biasLearningRate: 0.2, + gyroStillThresholdRadPerSec: 0.2, + accelStillThresholdMS2: 2.0, + minStillTimeForBiasSec: 0.0, + biasLearningDeadbandDeg: 3.0, + minStillTimeForRecenterSec: double.infinity, + lowPassAlpha: 0.0, + maxAngleAbsDeg: 180, + ); + + est.updateAccel(x: 0, y: 0, z: 9.80665); + + // Simulate a true gyro bias. + const trueBias = 0.02; // rad/s + const dt = 0.01; + + // First, let it learn bias near center. + for (var i = 0; i < 300; i++) { + est.updateGyro(wz: trueBias, dt: dt); + } + expect(est.biasZRadPerSec, closeTo(trueBias, 0.004)); + + // Now user turns to a steady held angle (~20 deg). + for (var i = 0; i < 60; i++) { + est.updateGyro(wz: 0.6 + trueBias, dt: dt); + } + final heldAngle = est.angleDeg; + expect(heldAngle.abs(), greaterThan(10.0)); + + // User holds that angle still for several seconds. + // Gyro reads only bias during the hold. + final biasBeforeHold = est.biasZRadPerSec; + for (var i = 0; i < 600; i++) { + est.updateGyro(wz: trueBias, dt: dt); + } + + // Bias should not have drifted significantly. + expect(est.biasZRadPerSec, closeTo(biasBeforeHold, 0.003)); + }); + + test('responds quickly to a fast steering change with default filtering', () { + final est = SteeringEstimator( + // Keep defaults for filtering/responsiveness. + // Ensure stillness detector is satisfied when we later go still. + gyroStillThresholdRadPerSec: 1.0, + accelStillThresholdMS2: 2.0, + maxAngleAbsDeg: 180, + ); + + est.updateAccel(x: 0, y: 0, z: 9.80665); + + const dt = 0.01; + const wz = 1.8; // rad/s (~103 deg/s) + + // Integrate for 0.2s => ~20.6 deg raw. + for (var i = 0; i < 20; i++) { + est.updateGyro(wz: wz, dt: dt); + } + + // With the adaptive low-pass, the filtered output should have caught up + // substantially by now (the old fixed alpha=0.9 could feel ~1s laggy). + expect(est.angleDeg.abs(), greaterThan(14.0)); + + // After another 0.2s it should be very close. + for (var i = 0; i < 20; i++) { + est.updateGyro(wz: wz, dt: dt); + } + expect(est.angleDeg.abs(), greaterThan(35.0)); + }); +} diff --git a/test/thinkrider_vs200_test.dart b/test/thinkrider_vs200_test.dart new file mode 100644 index 000000000..80740c8bd --- /dev/null +++ b/test/thinkrider_vs200_test.dart @@ -0,0 +1,138 @@ +import 'dart:typed_data'; + +import 'package:bike_control/bluetooth/devices/thinkrider/thinkrider_vs200.dart'; +import 'package:bike_control/utils/actions/base_actions.dart'; +import 'package:bike_control/utils/core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:universal_ble/universal_ble.dart'; + +void main() { + group('ThinkRider VS200 Virtual Shifter Tests', () { + test('Test shift up button press with correct pattern', () async { + core.actionHandler = StubActions(); + + final stubActions = core.actionHandler as StubActions; + + final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285')); + + // Send shift up pattern: F3-05-03-01-FC + await device.processCharacteristic( + ThinkRiderVs200Constants.CHARACTERISTIC_UUID, + _hexToUint8List('F3050301FC'), + ); + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.first, + PerformedAction(ThinkRiderVs200Buttons.shiftUp, isDown: true, isUp: true), + ); + }); + + test('Test shift down button press with correct pattern', () async { + core.actionHandler = StubActions(); + final stubActions = core.actionHandler as StubActions; + final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285')); + + // Send shift down pattern: F3-05-03-00-FB + await device.processCharacteristic( + ThinkRiderVs200Constants.CHARACTERISTIC_UUID, + _hexToUint8List('F3050300FB'), + ); + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.first, + PerformedAction(ThinkRiderVs200Buttons.shiftDown, isDown: true, isUp: true), + ); + }); + + test('Test multiple button presses', () async { + core.actionHandler = StubActions(); + final stubActions = core.actionHandler as StubActions; + final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285')); + + // Shift up + await device.processCharacteristic( + ThinkRiderVs200Constants.CHARACTERISTIC_UUID, + _hexToUint8List('F3050301FC'), + ); + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.first, + PerformedAction(ThinkRiderVs200Buttons.shiftUp, isDown: true, isUp: true), + ); + stubActions.performedActions.clear(); + + // Shift down + await device.processCharacteristic( + ThinkRiderVs200Constants.CHARACTERISTIC_UUID, + _hexToUint8List('F3050300FB'), + ); + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.first, + PerformedAction(ThinkRiderVs200Buttons.shiftDown, isDown: true, isUp: true), + ); + }); + + test('Test incorrect pattern does not trigger action', () async { + core.actionHandler = StubActions(); + final stubActions = core.actionHandler as StubActions; + final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285')); + + // Send random pattern + await device.processCharacteristic( + ThinkRiderVs200Constants.CHARACTERISTIC_UUID, + _hexToUint8List('0000000000'), + ); + expect(stubActions.performedActions.isEmpty, true); + }); + + test('Test shift up performs single click action (not double)', () async { + core.actionHandler = StubActions(); + final stubActions = core.actionHandler as StubActions; + final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285')); + + // Send shift up pattern: F3-05-03-01-FC + await device.processCharacteristic( + ThinkRiderVs200Constants.CHARACTERISTIC_UUID, + _hexToUint8List('F3050301FC'), + ); + + // Should have exactly 1 action (single click with isKeyDown: true, isKeyUp: true) + // NOT 2 actions (down then up) + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.first, + equals(PerformedAction(ThinkRiderVs200Buttons.shiftUp, isDown: true, isUp: true)), + ); + }); + + test('Test shift down performs single click action (not double)', () async { + core.actionHandler = StubActions(); + final stubActions = core.actionHandler as StubActions; + final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285')); + + // Send shift down pattern: F3-05-03-00-FB + await device.processCharacteristic( + ThinkRiderVs200Constants.CHARACTERISTIC_UUID, + _hexToUint8List('F3050300FB'), + ); + + // Should have exactly 1 action (single click with isKeyDown: true, isKeyUp: true) + // NOT 2 actions (down then up) + expect(stubActions.performedActions.length, 1); + expect( + stubActions.performedActions.first, + equals(PerformedAction(ThinkRiderVs200Buttons.shiftDown, isDown: true, isUp: true)), + ); + }); + }); +} + +Uint8List _hexToUint8List(String seq) { + return Uint8List.fromList( + List.generate( + seq.length ~/ 2, + (i) => int.parse(seq.substring(i * 2, i * 2 + 2), radix: 16), + ), + ); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index b0504cb74..000000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:swift_control/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const SwiftPlayApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/web/favicon.ico b/web/favicon.ico new file mode 100644 index 000000000..6a06f5210 Binary files /dev/null and b/web/favicon.ico differ diff --git a/web/favicon.png b/web/favicon.png deleted file mode 100644 index 39918661e..000000000 Binary files a/web/favicon.png and /dev/null differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index 1e24bd1a6..1622aff01 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 0e1598407..4063cc353 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index 1e24bd1a6..b0da3594b 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index 0e1598407..a9d54d027 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html index 3fe0aaaab..b3102de3f 100644 --- a/web/index.html +++ b/web/index.html @@ -1,24 +1,11 @@ - - + @@ -27,9 +14,9 @@ - + - swift_play + BikeControl diff --git a/web/manifest.json b/web/manifest.json index 7e2f0f65f..50bb91ac3 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "SwiftControl", - "short_name": "SwiftControl", + "name": "BikeControl", + "short_name": "BikeControl", "start_url": ".", "display": "standalone", - "background_color": "#hexcode", - "theme_color": "#hexcode", - "description": "SwiftControl - Control your virtual riding", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "description": "BikeControl - Control your virtual riding", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 5c3f5f7be..2d98a7ea9 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,10 +1,10 @@ # Project-level configuration. cmake_minimum_required(VERSION 3.14) -project(swift_play LANGUAGES CXX) +project(bike_control LANGUAGES CXX) # The name of the executable created for the application. Change this to change # the on-disk name of your application. -set(BINARY_NAME "swift_play") +set(BINARY_NAME "bike_control") # Explicitly opt in to modern CMake behaviors to avoid warnings with recent # versions of CMake. diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 110b575aa..cd9000a34 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,54 @@ #include "generated_plugin_registrant.h" +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + BluetoothLowEnergyWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BluetoothLowEnergyWindowsPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterScreenCapturePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterScreenCapturePluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterVolumeControllerPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterVolumeControllerPluginCApi")); + GamepadsWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GamepadsWindowsPluginCApi")); KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("KeypressSimulatorWindowsPluginCApi")); + MediaKeyDetectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("MediaKeyDetectorWindows")); + NsdWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("NsdWindowsPluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); + UniversalBlePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UniversalBlePluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); + WindowsIapPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowsIapPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index cc18f110b..7d6e1274e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,11 +3,27 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links + bluetooth_low_energy_windows + file_selector_windows + flutter_screen_capture + flutter_secure_storage_windows + flutter_volume_controller + gamepads_windows keypress_simulator_windows + media_key_detector_windows + nsd_windows + permission_handler_windows + screen_retriever_windows + universal_ble + url_launcher_windows + window_manager + windows_iap ) list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows + smtc_windows ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 4d99b65d2..7c5ea94ad 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "de.jonasbark" "\0" - VALUE "FileDescription", "swift_play" "\0" + VALUE "FileDescription", "BikeControl" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "swift_play" "\0" + VALUE "InternalName", "BikeControl" "\0" VALUE "LegalCopyright", "Copyright (C) 2025 de.jonasbark. All rights reserved." "\0" - VALUE "OriginalFilename", "swift_play.exe" "\0" - VALUE "ProductName", "swift_play" "\0" + VALUE "OriginalFilename", "bike_control.exe" "\0" + VALUE "ProductName", "BikeControl" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 6da0652f0..a51df9084 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -15,6 +15,8 @@ class FlutterWindow : public Win32Window { explicit FlutterWindow(const flutter::DartProject& project); virtual ~FlutterWindow(); + flutter::FlutterViewController* GetController() const { return flutter_controller_.get(); } + protected: // Win32Window: bool OnCreate() override; diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index c5885a6bd..13faa7e8d 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -1,15 +1,102 @@ #include #include #include - +#include #include "flutter_window.h" #include "utils.h" +#include +#include +#include "app_links/app_links_plugin_c_api.h" + +namespace +{ + + bool IsPackagedApp() + { + UINT32 length = 0; + // GetCurrentPackageFullName returns APPMODEL_ERROR_NO_PACKAGE when unpackaged. + const LONG rc = GetCurrentPackageFullName(&length, nullptr); + return rc != APPMODEL_ERROR_NO_PACKAGE; + } + + void RegisterStoreEnvironmentChannel(flutter::FlutterViewController *controller) + { + auto channel = std::make_unique>( + controller->engine()->messenger(), "bike_control/store_env", + &flutter::StandardMethodCodec::GetInstance()); + + channel->SetMethodCallHandler( + [](const flutter::MethodCall &call, + std::unique_ptr> result) + { + if (call.method_name() == "isPackaged") + { + result->Success(flutter::EncodableValue(IsPackagedApp())); + return; + } + result->NotImplemented(); + }); + + // Channel must outlive this function. + static std::unique_ptr> s_channel; + s_channel = std::move(channel); + } + +} // namespace + +bool SendAppLinkToInstance(const std::wstring &title) +{ + // Find our exact window + HWND hwnd = ::FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW", title.c_str()); + + if (hwnd) + { + // Dispatch new link to current window + SendAppLink(hwnd); + + // (Optional) Restore our window to front in same state + WINDOWPLACEMENT place = {sizeof(WINDOWPLACEMENT)}; + GetWindowPlacement(hwnd, &place); + + switch (place.showCmd) + { + case SW_SHOWMAXIMIZED: + ShowWindow(hwnd, SW_SHOWMAXIMIZED); + break; + case SW_SHOWMINIMIZED: + ShowWindow(hwnd, SW_RESTORE); + break; + default: + ShowWindow(hwnd, SW_NORMAL); + break; + } + + SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); + SetForegroundWindow(hwnd); + // END (Optional) Restore + + // Window has been found, don't create another one. + return true; + } + + return false; +} + int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { + _In_ wchar_t *command_line, _In_ int show_command) +{ + // Replace "example" with the generated title found as parameter of `window.Create` in this file. + // You may ignore the result if you need to create another window. + if (SendAppLinkToInstance(L"BikeControl")) + { + return EXIT_SUCCESS; + } + // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) + { CreateAndAttachConsole(); } @@ -27,13 +114,20 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, FlutterWindow window(project); Win32Window::Point origin(10, 10); Win32Window::Size size(1280, 720); - if (!window.Create(L"swift_play", origin, size)) { + if (!window.Create(L"BikeControl", origin, size)) + { return EXIT_FAILURE; } + + // Register our small environment channel after engine/window creation. + // FlutterWindow exposes the controller via GetController(). + RegisterStoreEnvironmentChannel(window.GetController()); + window.SetQuitOnClose(true); ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { + while (::GetMessage(&msg, nullptr, 0, 0)) + { ::TranslateMessage(&msg); ::DispatchMessage(&msg); } diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index 975d4c9b4..150a9d38c 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ diff --git a/windows_iap/.gitignore b/windows_iap/.gitignore new file mode 100644 index 000000000..96486fd93 --- /dev/null +++ b/windows_iap/.gitignore @@ -0,0 +1,30 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/windows_iap/.metadata b/windows_iap/.metadata new file mode 100644 index 000000000..ea33959a5 --- /dev/null +++ b/windows_iap/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + channel: stable + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + - platform: windows + create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/windows_iap/CHANGELOG.md b/windows_iap/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/windows_iap/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/windows_iap/LICENSE b/windows_iap/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/windows_iap/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/windows_iap/README.md b/windows_iap/README.md new file mode 100644 index 000000000..910303dfd --- /dev/null +++ b/windows_iap/README.md @@ -0,0 +1,15 @@ +# windows_iap + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/developing-packages/), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/windows_iap/analysis_options.yaml b/windows_iap/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/windows_iap/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/windows_iap/lib/models/product.dart b/windows_iap/lib/models/product.dart new file mode 100644 index 000000000..c4ba5d8fe --- /dev/null +++ b/windows_iap/lib/models/product.dart @@ -0,0 +1,41 @@ +class Product { + final String? title; + final String? description; + final String? price; + final bool? inCollection; + final String? productKind; + final String? storeId; + + const Product({ + required this.title, + required this.description, + required this.price, + required this.inCollection, + required this.productKind, + required this.storeId, + }); + + String? get formattedTitle { + return "$title ($productKind) $price, InUserCollection: $inCollection"; + } + + factory Product.fromJson(Map json) { + return Product( + title: json['title'] as String?, + description: json['description'] as String?, + price: json['price'] as String?, + inCollection: json['inCollection'] as bool?, + productKind: json['productKind'] as String?, + storeId: json['storeId'] as String?); + } + + Map toJson() { + return { + 'title': title, + 'price': price, + 'inCellection': inCollection, + 'productKind': productKind, + 'storeId': storeId, + }; + } +} diff --git a/windows_iap/lib/models/store_license.dart b/windows_iap/lib/models/store_license.dart new file mode 100644 index 000000000..35d4e264a --- /dev/null +++ b/windows_iap/lib/models/store_license.dart @@ -0,0 +1,48 @@ +class StoreLicense { + final bool? isActive; + final String? skuStoreId; + final String? inAppOfferToken; + final num? expirationDate; + + const StoreLicense({ + this.isActive, + this.skuStoreId, + this.inAppOfferToken, + this.expirationDate, + }); + + /// get Expiration date in DateTime format. + DateTime? getExpirationDate() { + if (expirationDate == null) { + return null; + } + + var magic = num.tryParse(expirationDate.toString().substring(0, 14)); + if (magic == null) { + return null; + } + // 11644499200000 is the count of millis from DateTime(1601,1,1) -> DateTime(1970,1,1) + // more docs: https://docs.microsoft.com/en-us/uwp/cpp-ref-for-winrt/clock + // https://docs.microsoft.com/en-us/uwp/api/windows.foundation.datetime?view=winrt-22621#remarks + // https://docs.microsoft.com/en-us/uwp/api/windows.services.store.storelicense.expirationdate?view=winrt-22621 + magic = magic - 11644499200000; + return DateTime.fromMillisecondsSinceEpoch(magic.toInt()); + } + + factory StoreLicense.fromJson(Map json) { + return StoreLicense( + isActive: json['isActive'], + skuStoreId: json['skuStoreId'], + inAppOfferToken: json['inAppOfferToken'], + expirationDate: json['expirationDate']); + } + + Map toJson() { + return { + 'isActive': isActive, + 'skuStoreId': skuStoreId, + 'inAppOfferToken': inAppOfferToken, + 'expirationDate': expirationDate, + }; + } +} diff --git a/windows_iap/lib/models/trial.dart b/windows_iap/lib/models/trial.dart new file mode 100644 index 000000000..32c107a5c --- /dev/null +++ b/windows_iap/lib/models/trial.dart @@ -0,0 +1,18 @@ +class Trial { + final bool isTrial; + final String remainingDays; + bool isActive; + final bool isTrialOwnedByThisUser; + + Trial({ + required this.isTrial, + required this.remainingDays, + required this.isActive, + required this.isTrialOwnedByThisUser, + }); + + @override + String toString() { + return 'Trial{isTrial: $isTrial, remainingDays: $remainingDays, isActive: $isActive, isTrialOwnedByThisUser: $isTrialOwnedByThisUser}'; + } +} diff --git a/windows_iap/lib/utils.dart b/windows_iap/lib/utils.dart new file mode 100644 index 000000000..813ce084b --- /dev/null +++ b/windows_iap/lib/utils.dart @@ -0,0 +1,6 @@ +List parseListNotNull({ + required List json, + required T Function(Map json) fromJson, +}) { + return (json).map((e) => fromJson(e as Map)).toList(); +} diff --git a/windows_iap/lib/windows_iap.dart b/windows_iap/lib/windows_iap.dart new file mode 100644 index 000000000..4e52a2bdd --- /dev/null +++ b/windows_iap/lib/windows_iap.dart @@ -0,0 +1,85 @@ +library windows_iap; + +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:windows_iap/models/product.dart'; +import 'package:windows_iap/models/trial.dart'; + +import 'models/store_license.dart'; +import 'windows_iap_platform_interface.dart'; + +enum StorePurchaseStatus { + succeeded, + alreadyPurchased, + notPurchased, + networkError, + serverError, +} + +class WindowsIap { + Future makePurchase(String storeId) { + return WindowsIapPlatform.instance.makePurchase(storeId); + } + + /// throw PlatformException if error + Future> getProducts() { + if (Platform.isMacOS) { + return Future.delayed(const Duration(seconds: 2), () { + throw PlatformException(code: '123123123', message: 'Products can not loaded now.'); + }); + } + return WindowsIapPlatform.instance.getProducts(); + } + + /// Check when user has current valid purchase + /// + /// - Add-On type: Subscription, Durable + /// + /// - Always return false if AppLicense has IsActive status = false. + /// + /// - if storeId is Not Empty: + /// + /// -- it will return true if Product(storeId) has IsActive status = true. + /// + /// -- return false if not. + /// + /// - if storeId is Empty: + /// + /// -- it will return true if any Add-On have IsActive status = true. + /// + /// -- return false if all Add-On have IsActive status = false. + Future checkPurchase({String storeId = ''}) { + if (Platform.isMacOS) { + return Future.value(false); + } + return WindowsIapPlatform.instance.checkPurchase(storeId: storeId); + } + + /// return the map of StoreLicense + /// + /// A map of key and value pairs, where each key is the Store ID of an add-on SKU from the + /// Microsoft Store catalog and each value is a StoreLicense object that contains license + /// info for the add-on. + Future> getAddonLicenses() { + return WindowsIapPlatform.instance.getAddonLicenses(); + } + + Future getTrialStatusAndRemainingDays() { + return WindowsIapPlatform.instance.getTrialStatusAndRemainingDays(); + } + + Future getCustomerPurchaseIdKey({ + required String serviceTicket, + required String publisherUserId, + }) { + return WindowsIapPlatform.instance.getCustomerPurchaseIdKey( + serviceTicket: serviceTicket, + publisherUserId: publisherUserId, + ); + } + + Future getStoreId() { + return WindowsIapPlatform.instance.getStoreId(); + } +} diff --git a/windows_iap/lib/windows_iap_method_channel.dart b/windows_iap/lib/windows_iap_method_channel.dart new file mode 100644 index 000000000..86653fd6b --- /dev/null +++ b/windows_iap/lib/windows_iap_method_channel.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:windows_iap/models/trial.dart'; + +import 'models/product.dart'; +import 'models/store_license.dart'; +import 'utils.dart'; +import 'windows_iap.dart'; +import 'windows_iap_platform_interface.dart'; + +/// A [Map] between whitespace characters and their escape sequences. +const _escapeMap = { + '\n': '', + '\r': '', + '\f': '', + '\b': '', + '\t': '', + '\v': '', + '\x7F': '', // delete +}; + +/// A [RegExp] that matches whitespace characters that should be escaped. +var _escapeRegExp = RegExp('[\\x00-\\x07\\x0E-\\x1F${_escapeMap.keys.map(_getHexLiteral).join()}]'); + +/// Returns [str] with all whitespace characters represented as their escape +/// sequences. +/// +/// Backslash characters are escaped as `\\` +String escape(String str) { + str = str.replaceAll('\\', r'\\'); + return str.replaceAllMapped(_escapeRegExp, (match) { + var mapped = _escapeMap[match[0]]; + if (mapped != null) return mapped; + return _getHexLiteral(match[0]!); + }); +} + +/// Given single-character string, return the hex-escaped equivalent. +String _getHexLiteral(String input) { + var rune = input.runes.single; + return r'\x' + rune.toRadixString(16).toUpperCase().padLeft(2, '0'); +} + +/// An implementation of [WindowsIapPlatform] that uses method channels. +class MethodChannelWindowsIap extends WindowsIapPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('windows_iap'); + + @override + Future makePurchase(String storeId) async { + final result = await methodChannel.invokeMethod('makePurchase', {'storeId': storeId}); + if (result == null) { + return null; + } + switch (result) { + case 0: + return StorePurchaseStatus.succeeded; + case 1: + return StorePurchaseStatus.alreadyPurchased; + case 2: + return StorePurchaseStatus.notPurchased; + case 3: + return StorePurchaseStatus.networkError; + case 4: + return StorePurchaseStatus.serverError; + } + return null; + } + + Stream> productsStream() { + return const EventChannel('windows_iap_event_products').receiveBroadcastStream().map((event) { + if (event is String) { + return parseListNotNull(json: jsonDecode(escape(event)), fromJson: Product.fromJson); + } else { + return []; + } + }); + } + + @override + Future> getProducts() async { + final result = await methodChannel.invokeMethod('getProducts'); + if (result == null) { + return []; + } + return parseListNotNull(json: jsonDecode(escape(result)), fromJson: Product.fromJson); + } + + @override + Future checkPurchase({required String storeId}) async { + final result = await methodChannel.invokeMethod('checkPurchase', {'storeId': storeId}); + return result ?? false; + } + + @override + Future getTrialStatusAndRemainingDays() async { + final result = await methodChannel.invokeMethod('getTrialStatusAndRemainingDays'); + return Trial( + isTrial: result?['isTrial'] as bool? ?? false, + isActive: result?['isActive'] as bool? ?? false, + isTrialOwnedByThisUser: result?['isTrialOwnedByThisUser'] as bool? ?? false, + remainingDays: result?['remainingDays'] as String? ?? DateTime.now().add(Duration(days: 7)).toString(), + ); + } + + @override + Future> getAddonLicenses() async { + final result = await methodChannel.invokeMethod('getAddonLicenses'); + if (result == null) { + return {}; + } + return result.map((key, value) => MapEntry(key.toString(), StoreLicense.fromJson(jsonDecode(value)))); + } + + @override + Future getCustomerPurchaseIdKey({ + required String serviceTicket, + required String publisherUserId, + }) async { + final result = await methodChannel.invokeMethod( + 'getCustomerPurchaseIdKey', + { + 'serviceTicket': serviceTicket, + 'publisherUserId': publisherUserId, + }, + ); + return result ?? ''; + } + + @override + Future getStoreId() async { + final result = await methodChannel.invokeMethod('getStoreId'); + return result; + } +} diff --git a/windows_iap/lib/windows_iap_platform_interface.dart b/windows_iap/lib/windows_iap_platform_interface.dart new file mode 100644 index 000000000..e5b25f2c2 --- /dev/null +++ b/windows_iap/lib/windows_iap_platform_interface.dart @@ -0,0 +1,60 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:windows_iap/models/product.dart'; +import 'package:windows_iap/models/store_license.dart'; +import 'package:windows_iap/models/trial.dart'; + +import 'windows_iap.dart'; +import 'windows_iap_method_channel.dart'; + +abstract class WindowsIapPlatform extends PlatformInterface { + /// Constructs a WindowsIapPlatform. + WindowsIapPlatform() : super(token: _token); + + static final Object _token = Object(); + + static WindowsIapPlatform _instance = MethodChannelWindowsIap(); + + /// The default instance of [WindowsIapPlatform] to use. + /// + /// Defaults to [MethodChannelWindowsIap]. + static WindowsIapPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [WindowsIapPlatform] when + /// they register themselves. + static set instance(WindowsIapPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future makePurchase(String storeId) { + throw UnimplementedError('makePurchase() has not been implemented.'); + } + + Future> getProducts() { + throw UnimplementedError('getProducts() has not been implemented.'); + } + + Future checkPurchase({required String storeId}) { + throw UnimplementedError('checkPurchase() has not been implemented.'); + } + + Future getTrialStatusAndRemainingDays() { + throw UnimplementedError('checkPurchase() has not been implemented.'); + } + + Future> getAddonLicenses() { + throw UnimplementedError('getAddonLicenses() has not been implemented.'); + } + + Future getCustomerPurchaseIdKey({ + required String serviceTicket, + required String publisherUserId, + }) { + throw UnimplementedError('getCustomerPurchaseIdKey() has not been implemented.'); + } + + Future getStoreId() { + throw UnimplementedError('getStoreId() has not been implemented.'); + } +} diff --git a/windows_iap/pubspec.yaml b/windows_iap/pubspec.yaml new file mode 100644 index 000000000..158193a55 --- /dev/null +++ b/windows_iap/pubspec.yaml @@ -0,0 +1,24 @@ +name: windows_iap +description: A new Flutter project. +version: 0.0.1 +homepage: https://github.com/opmgit/windows_iap + +environment: + sdk: ">=2.19.0 <3.0.0" + flutter: ">=3.7.0" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.1.4 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + + plugin: + platforms: + windows: + pluginClass: WindowsIapPluginCApi diff --git a/windows_iap/test/windows_iap_method_channel_test.dart b/windows_iap/test/windows_iap_method_channel_test.dart new file mode 100644 index 000000000..04f493f1f --- /dev/null +++ b/windows_iap/test/windows_iap_method_channel_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:windows_iap/windows_iap_method_channel.dart'; + +void main() { + MethodChannelWindowsIap platform = MethodChannelWindowsIap(); + const MethodChannel channel = MethodChannel('windows_iap'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + channel.setMockMethodCallHandler((MethodCall methodCall) async { + return '42'; + }); + }); + + tearDown(() { + channel.setMockMethodCallHandler(null); + }); + + test('getPlatformVersion', () async { + // expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/windows_iap/test/windows_iap_test.dart b/windows_iap/test/windows_iap_test.dart new file mode 100644 index 000000000..0d34f4b3a --- /dev/null +++ b/windows_iap/test/windows_iap_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('1=1', () async { + expect(1, 1); + }); +} diff --git a/windows_iap/watch.bat b/windows_iap/watch.bat new file mode 100644 index 000000000..ecdb2ec84 --- /dev/null +++ b/windows_iap/watch.bat @@ -0,0 +1 @@ +flutter pub run build_runner watch --delete-conflicting-outputs --use-polling-watcher \ No newline at end of file diff --git a/windows_iap/windows/.gitignore b/windows_iap/windows/.gitignore new file mode 100644 index 000000000..b3eb2be16 --- /dev/null +++ b/windows_iap/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows_iap/windows/CMakeLists.txt b/windows_iap/windows/CMakeLists.txt new file mode 100644 index 000000000..3d388b4a0 --- /dev/null +++ b/windows_iap/windows/CMakeLists.txt @@ -0,0 +1,89 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "windows_iap") +project(${PROJECT_NAME} LANGUAGES CXX) + +include(FetchContent) +set(CPPWINRT_VERSION "2.0.221121.5") + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "windows_iap_plugin") + +################ NuGet intall begin ################ +FetchContent_Declare(nuget + URL "https://dist.nuget.org/win-x86-commandline/v6.4.0/nuget.exe" + URL_HASH SHA256=26730829b240581a3e6a4e276b9ace088930032df0c680d5591beccf6452374e + DOWNLOAD_NO_EXTRACT true +) + +find_program(NUGET nuget) +if (NOT NUGET) + message("Nuget.exe not found, trying to download or use cached version.") + FetchContent_MakeAvailable(nuget) + set(NUGET ${nuget_SOURCE_DIR}/nuget.exe) +endif() + +execute_process(COMMAND + ${NUGET} install "Microsoft.Windows.CppWinRT" -Version ${CPPWINRT_VERSION} -OutputDirectory packages + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + RESULT_VARIABLE ret) +if (NOT ret EQUAL 0) + message(FATAL_ERROR "Failed to install nuget package Microsoft.Windows.CppWinRT.${CPPWINRT_VERSION}") +endif() +################ NuGet install end ################ + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "windows_iap_plugin.cpp" + "windows_iap_plugin.h" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + "include/windows_iap/windows_iap_plugin_c_api.h" + "windows_iap_plugin_c_api.cpp" + ${PLUGIN_SOURCES} +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +################ NuGet import begin ################ +set_target_properties(${PLUGIN_NAME} PROPERTIES VS_PROJECT_IMPORT + ${CMAKE_BINARY_DIR}/packages/Microsoft.Windows.CppWinRT.${CPPWINRT_VERSION}/build/native/Microsoft.Windows.CppWinRT.props +) + +target_link_libraries(${PLUGIN_NAME} PRIVATE + ${CMAKE_BINARY_DIR}/packages/Microsoft.Windows.CppWinRT.${CPPWINRT_VERSION}/build/native/Microsoft.Windows.CppWinRT.targets +) +################ NuGet import end ################ + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(windows_iap_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/windows_iap/windows/include/windows_iap/windows_iap_plugin_c_api.h b/windows_iap/windows/include/windows_iap/windows_iap_plugin_c_api.h new file mode 100644 index 000000000..af5b6f4f7 --- /dev/null +++ b/windows_iap/windows/include/windows_iap/windows_iap_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_WINDOWS_IAP_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_WINDOWS_IAP_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void WindowsIapPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_WINDOWS_IAP_PLUGIN_C_API_H_ diff --git a/windows_iap/windows/windows_iap_plugin.cpp b/windows_iap/windows/windows_iap_plugin.cpp new file mode 100644 index 000000000..00a4a7a28 --- /dev/null +++ b/windows_iap/windows/windows_iap_plugin.cpp @@ -0,0 +1,451 @@ +#include "windows_iap_plugin.h" + +// This must be included before many other Windows headers. +#include + +#include +#include +#include + +#include +#include +#include + +#pragma once +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace winrt; +using namespace Windows::Services::Store; +using namespace Windows::Foundation::Collections; +namespace foundation = Windows::Foundation; + +namespace windows_iap +{ + + //////////////////////////////////////////////////////////////////////// BEGIN OF MY CODE ////////////////////////////////////////////////////////////// + flutter::PluginRegistrarWindows *_registrar; + + HWND GetRootWindow(flutter::FlutterView *view) + { + return ::GetAncestor(view->GetNativeWindow(), GA_ROOT); + } + + StoreContext getStore() + { + StoreContext store = StoreContext::GetDefault(); + auto initWindow = store.try_as(); + if (initWindow != nullptr) + { + initWindow->Initialize(GetRootWindow(_registrar->GetView())); + } + return store; + } + + std::wstring s2ws(const std::string &s) + { + int len; + int slength = (int)s.length() + 1; + len = MultiByteToWideChar(CP_ACP, 0, s.c_str(), slength, 0, 0); + wchar_t *buf = new wchar_t[len]; + MultiByteToWideChar(CP_ACP, 0, s.c_str(), slength, buf, len); + std::wstring r(buf); + delete[] buf; + return r; + } + + std::string debugString(std::vector vt) + { + + std::stringstream ss; + ss << "( "; + for (auto t : vt) + { + ss << t << ", "; + } + ss << " )\n"; + return ss.str(); + } + + std::string getExtendedErrorString(winrt::hresult error) + { + const HRESULT IAP_E_UNEXPECTED = 0x803f6107L; + std::string message; + if (error.value == IAP_E_UNEXPECTED) + { + message = "This Product has not been properly configured."; + } + else + { + message = "ExtendedError: " + std::to_string(error.value); + } + return message; + } + + foundation::IAsyncAction makePurchase(hstring storeId, std::unique_ptr> resultCallback) + { + StorePurchaseResult result = co_await getStore().RequestPurchaseAsync(storeId); + + if (result.ExtendedError().value != S_OK) + { + resultCallback->Error(std::to_string(result.ExtendedError().value), getExtendedErrorString(result.ExtendedError().value)); + co_return; + } + int32_t returnCode; + switch (result.Status()) + { + case StorePurchaseStatus::AlreadyPurchased: + returnCode = 1; + break; + + case StorePurchaseStatus::Succeeded: + returnCode = 0; + break; + + case StorePurchaseStatus::NotPurchased: + returnCode = 2; + break; + + case StorePurchaseStatus::NetworkError: + returnCode = 3; + break; + + case StorePurchaseStatus::ServerError: + returnCode = 4; + break; + + default: + auto status = reinterpret_cast(result.Status()); + resultCallback->Error(std::to_string(*status), "Product was not purchased due to an unknown error."); + co_return; + break; + } + + resultCallback->Success(flutter::EncodableValue(returnCode)); + } + + std::string productsToString(std::vector products) + { + std::stringstream ss; + ss << "["; + for (int i = 0; i < products.size(); i++) + { + auto product = products.at(i); + ss << "{"; + ss << "\"title\":\"" << to_string(product.Title()) << "\","; + ss << "\"description\":\"" << to_string(product.Description()) << "\","; + ss << "\"price\":\"" << to_string(product.Price().FormattedPrice()) << "\","; + ss << "\"inCollection\":" << (product.IsInUserCollection() ? "true" : "false") << ","; + ss << "\"productKind\":\"" << to_string(product.ProductKind()) << "\","; + ss << "\"storeId\":\"" << to_string(product.StoreId()) << "\""; + ss << "}"; + if (i != products.size() - 1) + { + ss << ","; + } + } + ss << "]"; + + return ss.str(); + } + + foundation::IAsyncAction getProducts(std::unique_ptr> resultCallback) + { + auto result = co_await getStore().GetAssociatedStoreProductsAsync({L"Consumable", L"Durable", L"UnmanagedConsumable"}); + if (result.ExtendedError().value != S_OK) + { + resultCallback->Error(std::to_string(result.ExtendedError().value), getExtendedErrorString(result.ExtendedError())); + } + else if (result.Products().Size() == 0) + { + resultCallback->Success(flutter::EncodableValue("[]")); + } + else + { + std::vector products; + for (IKeyValuePair addOn : result.Products()) + { + StoreProduct product = addOn.Value(); + products.push_back(product); + } + std::string productsString = productsToString(products); + resultCallback->Success(flutter::EncodableValue(productsString)); + } + } + + std::string getStoreLicenseString(StoreLicense license) + { + std::stringstream ss; + ss << "{"; + ss << "\"isActive\":" << (license.IsActive() ? "true" : "false") << ","; + ss << "\"skuStoreId\":\"" << to_string(license.SkuStoreId()) << "\","; + ss << "\"inAppOfferToken\":\"" << to_string(license.InAppOfferToken()) << "\","; + ss << "\"expirationDate\":" << license.ExpirationDate().time_since_epoch().count() << ""; + ss << "}"; + + return ss.str(); + } + + foundation::IAsyncAction getAddonLicenses(std::unique_ptr> resultCallback) + { + auto result = co_await getStore().GetAppLicenseAsync(); + auto addonLicenses = result.AddOnLicenses(); + + std::map mapLicenses; + + for (IKeyValuePair addonLicense : addonLicenses) + { + mapLicenses[flutter::EncodableValue(to_string(addonLicense.Key()))] = flutter::EncodableValue(getStoreLicenseString(addonLicense.Value())); + } + + resultCallback->Success(flutter::EncodableValue(mapLicenses)); + } + + /// + /// need to test in real app on store + /// + foundation::IAsyncAction checkPurchase(std::string storeId, std::unique_ptr> resultCallback) + { + auto result = co_await getStore().GetAppLicenseAsync(); + + if (result.IsActive()) + { + + auto addonLicenses = result.AddOnLicenses(); + + for (IKeyValuePair addonLicense : addonLicenses) + { + StoreLicense license = addonLicense.Value(); + + if (storeId.compare("") == 0) + { + // Truong hop storeId empty => bat ky Add-on nao co IsActive = true deu return true + if (license.IsActive()) + { + resultCallback->Success(flutter::EncodableValue(true)); + co_return; + } + } + else + { + // Truong hop storeId not empty => check key = storeId + auto key = to_string(addonLicense.Key()); + if (key.compare(storeId) == 0) + { + resultCallback->Success(flutter::EncodableValue(license.IsActive())); + co_return; + } + } + } + // truong hop duyet het add-on license nhung vang khong tim thay IsActive = true thi return false + resultCallback->Success(flutter::EncodableValue(false)); + } + else + { + resultCallback->Success(flutter::EncodableValue(false)); + } + } + + /// + /// need to test in real app on store + /// + /// + /// need to test in real app on store + /// + foundation::IAsyncAction getTrialStatusAndRemainingDays( + std::unique_ptr> resultCallback) + { + auto store = getStore(); + auto license = co_await store.GetAppLicenseAsync(); + + flutter::EncodableMap result; + result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(true); + result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(""); + result[flutter::EncodableValue("isActive")] = flutter::EncodableValue(license.IsActive()); + result[flutter::EncodableValue("isTrialOwnedByThisUser")] = flutter::EncodableValue(license.IsTrialOwnedByThisUser()); + + + if (!license.IsActive()) + { + resultCallback->Success(flutter::EncodableValue(result)); + co_return; + } + + if (license.IsTrial()) + { + result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(true); + + auto expirationDate = license.ExpirationDate(); + + // dt is your winrt::Windows::Foundation::DateTime + std::time_t t = winrt::clock::to_time_t(expirationDate); // Convert to time_t (UTC seconds since 1970) + std::tm tm_buf; + localtime_s(&tm_buf, &t); // Safe version + + std::wstringstream wss; + wss << std::put_time(&tm_buf, L"%Y-%m-%d %H:%M:%S"); // Custom format + + winrt::hstring readable = winrt::hstring{ wss.str() }; + std::string utf8 = winrt::to_string(readable); // Converts hstring to UTF-8 std::string + + result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(utf8); + } + else { + result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(false); + + } + + resultCallback->Success(flutter::EncodableValue(result)); + } + + foundation::IAsyncAction getCustomerPurchaseIdKey( + std::string serviceTicket, + std::string publisherUserId, + std::unique_ptr> resultCallback) + { + if (serviceTicket.empty()) + { + resultCallback->Error("invalid-args", "serviceTicket is required."); + co_return; + } + if (publisherUserId.empty()) + { + resultCallback->Error("invalid-args", "publisherUserId is required."); + co_return; + } + + try + { + auto purchaseId = co_await getStore().GetCustomerPurchaseIdAsync( + to_hstring(serviceTicket), + to_hstring(publisherUserId)); + + resultCallback->Success(flutter::EncodableValue(to_string(purchaseId))); + } + catch (const winrt::hresult_error &error) + { + resultCallback->Error( + std::to_string(error.code().value), + to_string(error.message())); + } + } + + foundation::IAsyncAction getStoreId( + std::unique_ptr> resultCallback) + { + try + { + auto store = getStore(); + auto license = co_await store.GetAppLicenseAsync(); + auto skuStoreId = to_string(license.SkuStoreId()); + resultCallback->Success(flutter::EncodableValue(skuStoreId)); + } + catch (const winrt::hresult_error &error) + { + resultCallback->Error( + std::to_string(error.code().value), + to_string(error.message())); + } + } + + //////////////////////////////////////////////////////////////////////// END OF MY CODE ////////////////////////////////////////////////////////////// + + // static + void WindowsIapPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) + { + _registrar = registrar; + + auto channel = + std::make_unique>( + registrar->messenger(), "windows_iap", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) + { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + registrar->AddPlugin(std::move(plugin)); + } + + WindowsIapPlugin::WindowsIapPlugin() {} + + WindowsIapPlugin::~WindowsIapPlugin() {} + + void WindowsIapPlugin::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) + { + if (method_call.method_name().compare("makePurchase") == 0) + { + auto args = std::get(*method_call.arguments()); + auto storeId = std::get(args[flutter::EncodableValue("storeId")]); + makePurchase(to_hstring(storeId), std::move(result)); + } + else if (method_call.method_name().compare("getProducts") == 0) + { + getProducts(std::move(result)); + } + else if (method_call.method_name().compare("checkPurchase") == 0) + { + auto args = std::get(*method_call.arguments()); + auto storeId = std::get(args[flutter::EncodableValue("storeId")]); + checkPurchase(storeId, std::move(result)); + } + else if (method_call.method_name().compare("getAddonLicenses") == 0) + { + getAddonLicenses(std::move(result)); + } + else if (method_call.method_name().compare("getTrialStatusAndRemainingDays") == 0) + { + getTrialStatusAndRemainingDays(std::move(result)); + } + else if (method_call.method_name().compare("getCustomerPurchaseIdKey") == 0) + { + if (method_call.arguments() == nullptr) + { + result->Error("invalid-args", "Arguments are required."); + return; + } + auto args = std::get(*method_call.arguments()); + auto serviceTicketIt = args.find(flutter::EncodableValue("serviceTicket")); + auto publisherUserIdIt = args.find(flutter::EncodableValue("publisherUserId")); + if (serviceTicketIt == args.end() || !std::holds_alternative(serviceTicketIt->second)) + { + result->Error("invalid-args", "serviceTicket must be a string."); + return; + } + if (publisherUserIdIt == args.end() || !std::holds_alternative(publisherUserIdIt->second)) + { + result->Error("invalid-args", "publisherUserId must be a string."); + return; + } + auto serviceTicket = std::get(serviceTicketIt->second); + auto publisherUserId = std::get(publisherUserIdIt->second); + getCustomerPurchaseIdKey(serviceTicket, publisherUserId, std::move(result)); + } + else if (method_call.method_name().compare("getStoreId") == 0) + { + getStoreId(std::move(result)); + } + else + { + result->NotImplemented(); + } + } + +} // namespace windows_iap diff --git a/windows_iap/windows/windows_iap_plugin.h b/windows_iap/windows/windows_iap_plugin.h new file mode 100644 index 000000000..018910d83 --- /dev/null +++ b/windows_iap/windows/windows_iap_plugin.h @@ -0,0 +1,32 @@ +#ifndef FLUTTER_PLUGIN_WINDOWS_IAP_PLUGIN_H_ +#define FLUTTER_PLUGIN_WINDOWS_IAP_PLUGIN_H_ + +#include +#include + +#include + +namespace windows_iap { + +class WindowsIapPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + WindowsIapPlugin(); + + virtual ~WindowsIapPlugin(); + + // Disallow copy and assign. + WindowsIapPlugin(const WindowsIapPlugin&) = delete; + WindowsIapPlugin& operator=(const WindowsIapPlugin&) = delete; + + private: + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); +}; + +} // namespace windows_iap + +#endif // FLUTTER_PLUGIN_WINDOWS_IAP_PLUGIN_H_ diff --git a/windows_iap/windows/windows_iap_plugin_c_api.cpp b/windows_iap/windows/windows_iap_plugin_c_api.cpp new file mode 100644 index 000000000..29a2d471e --- /dev/null +++ b/windows_iap/windows/windows_iap_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/windows_iap/windows_iap_plugin_c_api.h" + +#include + +#include "windows_iap_plugin.h" + +void WindowsIapPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + windows_iap::WindowsIapPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +}