Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
febfbc3cc8 | ||
|
|
5ea848b62e | ||
|
|
96118a98b1 | ||
|
|
d25f3a2d4e | ||
|
|
c0600746b6 | ||
|
|
24cb34408b | ||
|
|
f90ae87017 | ||
|
|
273a71e759 | ||
|
|
d5c6a8f7f1 | ||
|
|
b6bb2c37a1 | ||
|
|
3ea1184bab | ||
|
|
a45e5c4874 | ||
|
|
d5926f1d5c | ||
|
|
c08ac5468a | ||
|
|
32ad152079 | ||
|
|
94372918ac | ||
|
|
3ce364a5be | ||
|
|
e4105ea248 | ||
|
|
604a8b6bd6 | ||
|
|
fc82a62af3 | ||
|
|
67aeb3e257 | ||
|
|
d371ec6d6e | ||
|
|
01509eaae9 | ||
|
|
b0df25241a | ||
|
|
56447743b2 | ||
|
|
301dc39648 | ||
|
|
3195568399 | ||
|
|
200b13c97f | ||
|
|
47173f6dbd | ||
|
|
83bf1fe047 | ||
|
|
aa8310905d | ||
|
|
a67a82d638 | ||
|
|
65b0807903 | ||
|
|
ca4702a684 |
185
.github/workflows/build.yml
vendored
@@ -1,16 +1,28 @@
|
||||
name: "Build"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- ios
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- 'lib/**'
|
||||
- 'accessibility/**'
|
||||
- 'keypress_simulator/**'
|
||||
- 'pubspec.yaml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_mac:
|
||||
description: 'Build for macOS'
|
||||
required: false
|
||||
default: 'true'
|
||||
build_windows:
|
||||
description: 'Build for Windows'
|
||||
required: false
|
||||
default: 'true'
|
||||
build_android:
|
||||
description: 'Build for Android'
|
||||
required: false
|
||||
default: 'true'
|
||||
build_ios:
|
||||
description: 'Build for iOS'
|
||||
required: false
|
||||
default: 'true'
|
||||
build_web:
|
||||
description: 'Build for Web'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
@@ -32,6 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install certificates
|
||||
if: github.event.inputs.build_mac == 'true' || github.event.inputs.build_ios == 'true'
|
||||
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 }}
|
||||
@@ -79,23 +92,20 @@ jobs:
|
||||
cache: true
|
||||
|
||||
- name: 🚀 Shorebird Release macOS
|
||||
if: github.event.inputs.build_mac == 'true'
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: macos
|
||||
|
||||
- name: Code Signing
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v
|
||||
working-directory: build/macos/Build/Products/Release
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
|
||||
- name: Decode Keystore
|
||||
if: github.event.inputs.build_android == 'true'
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
|
||||
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
|
||||
|
||||
- name: 🚀 Shorebird Release Android
|
||||
if: github.event.inputs.build_android == 'true'
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -103,80 +113,25 @@ jobs:
|
||||
args: "--artifact=apk"
|
||||
|
||||
- name: Set Up Flutter
|
||||
if: github.event.inputs.build_web == 'true'
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Build Web
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: github.event.inputs.build_web == 'true'
|
||||
run: flutter build web --release --base-href "/swiftcontrol/"
|
||||
|
||||
- name: Handle archives
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
cd build/macos/Build/Products/Release/
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/
|
||||
|
||||
#9 Upload Artifacts
|
||||
- name: Upload Artifacts
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Releases
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: extract_version
|
||||
run: |
|
||||
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
|
||||
echo "VERSION=$version" >> $GITHUB_ENV
|
||||
|
||||
#11 Check if Tag Exists
|
||||
- name: Check if Tag Exists
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: check_tag
|
||||
run: |
|
||||
if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then
|
||||
echo "TAG_EXISTS=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "TAG_EXISTS=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
#12 Modify Tag if it Exists
|
||||
- name: Modify Tag
|
||||
if: env.TAG_EXISTS == 'true' && github.ref == 'refs/heads/main'
|
||||
id: modify_tag
|
||||
run: |
|
||||
new_version="${{ env.VERSION }}-build-${{ github.run_number }}"
|
||||
echo "VERSION=$new_version" >> $GITHUB_ENV
|
||||
|
||||
#13 Create Release
|
||||
- name: Create Release
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
allowUpdates: true
|
||||
prerelease: ${{ endsWith(env.VERSION, '1337') }}
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Upload static files as artifact
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: github.event.inputs.build_web == 'true'
|
||||
id: deployment
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: build/web
|
||||
|
||||
- name: Web Deploy
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: github.event.inputs.build_web == 'true'
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
- name: Extract latest changelog
|
||||
@@ -187,6 +142,7 @@ jobs:
|
||||
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
|
||||
|
||||
- name: 🚀 Shorebird Release iOS
|
||||
if: github.event.inputs.build_ios == 'true'
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
@@ -194,6 +150,7 @@ jobs:
|
||||
args: "--export-options-plist ios/ExportOptions.plist"
|
||||
|
||||
- name: Prepare App Store authentication key
|
||||
if: github.event.inputs.build_ios == 'true' || github.event.inputs.build_mac == 'true'
|
||||
env:
|
||||
API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }}
|
||||
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
|
||||
@@ -202,8 +159,7 @@ jobs:
|
||||
printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8";
|
||||
|
||||
- name: Upload to Play Store
|
||||
# only upload when env.VERSION does not end with 1337, which is our indicator for beta releases
|
||||
if: "!endsWith(env.VERSION, '1337')"
|
||||
if: github.event.inputs.build_android == 'true'
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
@@ -213,6 +169,7 @@ jobs:
|
||||
whatsNewDirectory: whatsnew
|
||||
|
||||
- name: Upload to macOS App Store
|
||||
if: github.event.inputs.build_mac == 'true'
|
||||
env:
|
||||
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
@@ -221,16 +178,86 @@ jobs:
|
||||
xcrun altool --upload-app -f SwiftControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
|
||||
- name: Upload to iOS App Store
|
||||
if: github.event.inputs.build_ios == 'true'
|
||||
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";
|
||||
|
||||
|
||||
- name: Handle Android archives
|
||||
if: github.event.inputs.build_android == 'true'
|
||||
run: |
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
|
||||
- name: Code Signing of macOS app
|
||||
if: github.event.inputs.build_mac == 'true'
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v
|
||||
working-directory: build/macos/Build/Products/Release
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
|
||||
- name: Handle macOS archives
|
||||
if: github.event.inputs.build_mac == 'true'
|
||||
run: |
|
||||
cd build/macos/Build/Products/Release/
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/
|
||||
|
||||
- name: Upload Android Artifacts
|
||||
if: github.event.inputs.build_android == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Releases
|
||||
path: |
|
||||
build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
|
||||
- name: Upload macOS Artifacts
|
||||
if: github.event.inputs.build_mac == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
id: extract_version
|
||||
run: |
|
||||
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
|
||||
echo "VERSION=$version" >> $GITHUB_ENV
|
||||
|
||||
#11 Check if Tag Exists
|
||||
- name: Check if Tag Exists
|
||||
id: check_tag
|
||||
run: |
|
||||
if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then
|
||||
echo "TAG_EXISTS=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "TAG_EXISTS=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
#12 Modify Tag if it Exists
|
||||
- name: Modify Tag
|
||||
if: env.TAG_EXISTS == 'true'
|
||||
id: modify_tag
|
||||
run: |
|
||||
new_version="${{ env.VERSION }}-build-${{ github.run_number }}"
|
||||
echo "VERSION=$new_version" >> $GITHUB_ENV
|
||||
|
||||
#13 Create Release
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
allowUpdates: true
|
||||
prerelease: ${{ endsWith(env.VERSION, '1337') }}
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
windows:
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: github.event.inputs.build_windows == 'true'
|
||||
name: Build & Release on Windows
|
||||
runs-on: windows-latest
|
||||
|
||||
|
||||
161
.github/workflows/patch.yml
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
name: "Patch"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.35.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
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- 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
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: macos
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs'
|
||||
|
||||
- name: 🚀 Shorebird Patch Android
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: android
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs'
|
||||
|
||||
- name: 🚀 Shorebird Patch iOS
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: ios
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs'
|
||||
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
# shorebird struggles with the app from GitHub
|
||||
- name: Build macOS
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
flutter build macos --release;
|
||||
cd build/macos/Build/Products/Release/;
|
||||
zip -r SwiftControl.macos.zip SwiftControl.app/;
|
||||
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r');
|
||||
echo "VERSION=$version" >> $GITHUB_ENV;
|
||||
/usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v;
|
||||
|
||||
#9 Upload Artifacts
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
overwrite: true
|
||||
name: Releases
|
||||
path: |
|
||||
build/macos/Build/Products/Release/SwiftControl.macos.zip
|
||||
|
||||
# add artifact to release
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
windows:
|
||||
name: Patch Windows
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
#1 Checkout Repository
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
#2 Setup Java
|
||||
- name: Set Up Java
|
||||
uses: actions/setup-java@v3.12.0
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
cache: true
|
||||
|
||||
- name: 🚀 Shorebird Patch Windows
|
||||
uses: shorebirdtech/shorebird-patch@v1
|
||||
with:
|
||||
platform: windows
|
||||
release-version: latest
|
||||
args: '--allow-asset-diffs'
|
||||
6
.github/workflows/web.yml
vendored
@@ -4,8 +4,10 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- web
|
||||
- wahoo_kickr_bike_shift
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- '.github/workflows/web.yml'
|
||||
- 'lib/**'
|
||||
- 'accessibility/**'
|
||||
- 'keypress_simulator/**'
|
||||
@@ -13,7 +15,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Release
|
||||
name: Build Web
|
||||
runs-on: macos-latest
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
### 3.0.4 (not released yet)
|
||||
- adjusted MyWhoosh keyboard navigation mapping (thanks @bin101)
|
||||
- initial support for Wahook Kickr Bike Shift (thanks @MattW2)
|
||||
- initial support for Elite Square Smart Frame
|
||||
|
||||
### 3.0.3 (2025-10-12)
|
||||
- SwiftControl now supports iOS!
|
||||
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations but...:
|
||||
|
||||
43
README.md
@@ -18,6 +18,8 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
|
||||
## Downloads
|
||||
Check the compatibility matrix below!
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
|
||||
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a>
|
||||
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a>
|
||||
@@ -27,43 +29,48 @@ Get the latest version for Windows here: https://github.com/jonasbark/swiftcontr
|
||||
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- indieVelo / Training Peaks
|
||||
- Biketerra.com
|
||||
- any other! Customize touch points or keyboard shortcuts to your liking
|
||||
- TrainingPeaks Virtual / indieVelo
|
||||
- Biketerra.com (they do offer native integration already - check it out)
|
||||
- Rouvy (most Zwift devices are already supported by Rouvy)
|
||||
- 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
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Wahoo Kickr Bike Shift (beta)
|
||||
|
||||
## Supported Platforms
|
||||
- Android
|
||||
- App is losing connection over time? Read about how to [keep the app alive](https://dontkillmyapp.com/).
|
||||
- macOS
|
||||
- Windows
|
||||
- Windows may flag the app as virus. I think it does so because the app does control the mouse and keyboard.
|
||||
- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).
|
||||
- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70).
|
||||
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
|
||||
- iOS (iPhone, iPad)
|
||||
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you can use it to remotely control MyWhoosh and similar on e.g. an iPad.
|
||||
|
||||
| Platform you want to run your Trainer app, e.g. MyWhoosh on | Possible | Link | Information |
|
||||
|-------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Android | ✅ | <a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a> | |
|
||||
| iPad (and possibly Apple TV) | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically you would use an iPhone or an Android phone for that. |
|
||||
| Windows | ✅ | [Get it here](https://github.com/jonasbark/swiftcontrol/releases) | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
|
||||
| macOS | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a> | |
|
||||
| iPhone | ❌ | | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you can use it to remotely control MyWhoosh and similar on e.g. an iPad. |
|
||||
|
||||
|
||||
For testing purposes you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/) but this is just a tech demo - you won't be able to control other apps.
|
||||
|
||||
## Troubleshooting
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
|
||||
## How does it work?
|
||||
The app connects to your Zwift device automatically. It does not connect to your trainer itself.
|
||||
The app connects to your Zwift devices automatically. It does not connect to your trainer itself.
|
||||
|
||||
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Zwift devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
|
||||
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
|
||||
- you can also create your own Keymaps for any other app
|
||||
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
|
||||
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Zwift devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
</details>
|
||||
|
||||
## Alternatives
|
||||
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app). This can be useful if your trainer app does not support virtual shifting.
|
||||
|
||||
@@ -20,3 +20,9 @@ If you don't do that SwiftControl will need to reconnect every minute.
|
||||
- Try restarting the pairing process in SwiftControl
|
||||
- try restarting Bluetooth on your phone and on the device you want to control
|
||||
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
|
||||
- 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 SwiftControl / restart SwiftControl.
|
||||
|
||||
## 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 SwiftControl iOS) > Button 1
|
||||
switch the setting to None, then back to Single-Tap and it should work again
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class BleUuid {
|
||||
static final DEVICE_INFORMATION_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION =
|
||||
"00002a26-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION = "00002a26-0000-1000-8000-00805f9b34fb"
|
||||
.toLowerCase();
|
||||
|
||||
static final DEVICE_BATTERY_SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
|
||||
@@ -43,8 +43,9 @@ class Connection {
|
||||
_addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
final data =
|
||||
manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
|
||||
final data = manufacturerData
|
||||
.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)
|
||||
?.payload;
|
||||
_actionStreams.add(LogNotification('Found unknown device with identifier: ${data?.firstOrNull}'));
|
||||
}
|
||||
}
|
||||
@@ -69,7 +70,7 @@ class Connection {
|
||||
// does not work on web, may not work on Windows
|
||||
if (!kIsWeb && !Platform.isWindows) {
|
||||
UniversalBle.getSystemDevices(
|
||||
withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID],
|
||||
withServices: BaseDevice.servicesToScan,
|
||||
).then((devices) async {
|
||||
final baseDevices = devices.mapNotNull(BaseDevice.fromScanResult).toList();
|
||||
if (baseDevices.isNotEmpty) {
|
||||
@@ -79,8 +80,8 @@ class Connection {
|
||||
}
|
||||
|
||||
await UniversalBle.startScan(
|
||||
scanFilter: ScanFilter(withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID]),
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID])),
|
||||
scanFilter: ScanFilter(withServices: BaseDevice.servicesToScan),
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BaseDevice.servicesToScan)),
|
||||
);
|
||||
Future.delayed(Duration(seconds: 30)).then((_) {
|
||||
if (isScanning.value) {
|
||||
|
||||
@@ -3,63 +3,71 @@ import 'dart:async';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/crypto/local_key_provider.dart';
|
||||
import 'package:swift_control/utils/crypto/zap_crypto.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../utils/crypto/encryption_utils.dart';
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
final List<ZwiftButton> availableButtons;
|
||||
final bool isBeta;
|
||||
final List<ControllerButton> availableButtons;
|
||||
|
||||
BaseDevice(this.scanResult, {required this.availableButtons});
|
||||
|
||||
final zapEncryption = ZapCrypto(LocalKeyProvider());
|
||||
BaseDevice(this.scanResult, {required this.availableButtons, this.isBeta = false});
|
||||
|
||||
bool isConnected = false;
|
||||
int? batteryLevel;
|
||||
String? firmwareVersion;
|
||||
|
||||
bool supportsEncryption = false;
|
||||
|
||||
BleCharacteristic? syncRxCharacteristic;
|
||||
Timer? _longPressTimer;
|
||||
Set<ZwiftButton> _previouslyPressedButtons = <ZwiftButton>{};
|
||||
Set<ControllerButton> _previouslyPressedButtons = <ControllerButton>{};
|
||||
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
|
||||
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
static List<String> servicesToScan = [
|
||||
BleUuid.ZWIFT_CUSTOM_SERVICE_UUID,
|
||||
BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
SquareConstants.SERVICE_UUID,
|
||||
WahooKickrBikeShiftConstants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static BaseDevice? fromScanResult(BleDevice scanResult) {
|
||||
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
|
||||
final device =
|
||||
kIsWeb
|
||||
? switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
_ => null,
|
||||
}
|
||||
: switch (scanResult.name) {
|
||||
//'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
|
||||
_ => null,
|
||||
};
|
||||
BaseDevice? device;
|
||||
if (kIsWeb) {
|
||||
device = switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'Zwift Play' => ZwiftPlay(scanResult),
|
||||
'Zwift Click' => ZwiftClickV2(scanResult),
|
||||
'SQUARE' => EliteSquare(scanResult),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
|
||||
device = WahooKickrBikeShift(scanResult);
|
||||
}
|
||||
} else {
|
||||
device = switch (scanResult.name) {
|
||||
//'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
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
if (device != null) {
|
||||
return device;
|
||||
} else {
|
||||
} else if (scanResult.services.containsAny([
|
||||
BleUuid.ZWIFT_CUSTOM_SERVICE_UUID,
|
||||
BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID,
|
||||
])) {
|
||||
// otherwise use the manufacturer data to identify the device
|
||||
final manufacturerData = scanResult.manufacturerDataList;
|
||||
final data = manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
|
||||
@@ -79,6 +87,19 @@ abstract class BaseDevice {
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
} else if (scanResult.services.contains(SquareConstants.SERVICE_UUID)) {
|
||||
return EliteSquare(scanResult);
|
||||
} else if (scanResult.services.contains(WahooKickrBikeShiftConstants.SERVICE_UUID)) {
|
||||
if (scanResult.name != null && !scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT')) {
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
} else if (kIsWeb && scanResult.name == null) {
|
||||
// some devices don't broadcast the name, so we must rely on the service UUID
|
||||
return WahooKickrBikeShift(scanResult);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,165 +133,13 @@ abstract class BaseDevice {
|
||||
}
|
||||
|
||||
final services = await UniversalBle.discoverServices(device.deviceId);
|
||||
await _handleServices(services);
|
||||
await handleServices(services);
|
||||
}
|
||||
|
||||
Future<void> _handleServices(List<BleService> services) async {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
|
||||
Future<void> handleServices(List<BleService> services);
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes);
|
||||
|
||||
if (customService == null) {
|
||||
throw Exception(
|
||||
'Custom service $customServiceId 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 deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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');
|
||||
}
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await setupHandshake();
|
||||
}
|
||||
|
||||
Future<void> setupHandshake() async {
|
||||
if (supportsEncryption) {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Uint8List.fromList([
|
||||
...Constants.RIDE_ON,
|
||||
...Constants.REQUEST_START,
|
||||
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
|
||||
]),
|
||||
withoutResponse: true,
|
||||
);
|
||||
} else {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Constants.RIDE_ON,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode && false) {
|
||||
print(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
);
|
||||
}
|
||||
if (bytes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (bytes.startsWith(startCommand)) {
|
||||
_processDevicePublicKeyResponse(bytes);
|
||||
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
|
||||
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(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
|
||||
if (kDebugMode) {
|
||||
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
|
||||
}
|
||||
zapEncryption.initialise(devicePublicKeyBytes);
|
||||
}
|
||||
|
||||
Future<void> processData(Uint8List bytes) async {
|
||||
int type;
|
||||
Uint8List message;
|
||||
|
||||
if (supportsEncryption) {
|
||||
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
|
||||
final payload = bytes.sublist(4);
|
||||
|
||||
if (zapEncryption.encryptionKeyBytes == null) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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:
|
||||
if (batteryLevel != message[1]) {
|
||||
batteryLevel = message[1];
|
||||
connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE:
|
||||
processClickNotification(message)
|
||||
.then((buttonsClicked) async {
|
||||
return handleButtonsClicked(buttonsClicked);
|
||||
})
|
||||
.catchError((e) {
|
||||
actionStreamInternal.add(LogNotification(e.toString()));
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message);
|
||||
|
||||
Future<void> handleButtonsClicked(List<ZwiftButton>? buttonsClicked) async {
|
||||
Future<void> handleButtonsClicked(List<ControllerButton>? buttonsClicked) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
@@ -283,7 +152,7 @@ abstract class BaseDevice {
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && isLongPress) {
|
||||
await _performRelease(buttonsReleased);
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
_previouslyPressedButtons.clear();
|
||||
} else {
|
||||
@@ -293,7 +162,7 @@ abstract class BaseDevice {
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && wasLongPress) {
|
||||
await _performRelease(buttonsReleased);
|
||||
await performRelease(buttonsReleased);
|
||||
}
|
||||
|
||||
final isLongPress =
|
||||
@@ -301,30 +170,26 @@ abstract class BaseDevice {
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
|
||||
if (!isLongPress &&
|
||||
!(buttonsClicked.singleOrNull == ZwiftButton.onOffLeft ||
|
||||
buttonsClicked.singleOrNull == ZwiftButton.onOffRight)) {
|
||||
!(buttonsClicked.singleOrNull == ControllerButton.onOffLeft ||
|
||||
buttonsClicked.singleOrNull == ControllerButton.onOffRight)) {
|
||||
// we don't want to trigger the long press timer for the on/off buttons, also not when it's a long press key
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = Timer.periodic(const Duration(milliseconds: 350), (timer) async {
|
||||
_performClick(buttonsClicked);
|
||||
performClick(buttonsClicked);
|
||||
});
|
||||
}
|
||||
// Update currently pressed buttons
|
||||
_previouslyPressedButtons = buttonsClicked.toSet();
|
||||
|
||||
if (isLongPress) {
|
||||
return _performDown(buttonsClicked);
|
||||
return performDown(buttonsClicked);
|
||||
} else {
|
||||
return _performClick(buttonsClicked);
|
||||
return performClick(buttonsClicked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performDown(List<ZwiftButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
actionStreamInternal.add(
|
||||
@@ -333,11 +198,7 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performClick(List<ZwiftButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
for (final action in buttonsClicked) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true)),
|
||||
@@ -345,7 +206,7 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performRelease(List<ZwiftButton> buttonsReleased) async {
|
||||
Future<void> performRelease(List<ControllerButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true)),
|
||||
@@ -353,17 +214,6 @@ abstract class BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _vibrate() async {
|
||||
final vibrateCommand = Uint8List.fromList([...Constants.VIBRATE_PATTERN, 0x20]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
supportsEncryption ? zapEncryption.encrypt(vibrateCommand) : vibrateCommand,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_longPressTimer?.cancel();
|
||||
// Release any held keys in long press mode
|
||||
|
||||
108
lib/bluetooth/devices/elite/elite_square.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../messages/notification.dart';
|
||||
|
||||
class EliteSquare extends BaseDevice {
|
||||
EliteSquare(super.scanResult)
|
||||
: super(
|
||||
availableButtons: SquareConstants.BUTTON_MAPPING.values.toList(),
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
String? _lastValue;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> 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<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (characteristic == SquareConstants.CHARACTERISTIC_UUID) {
|
||||
final fullValue = _bytesToHex(bytes);
|
||||
final currentValue = _extractButtonCode(fullValue);
|
||||
|
||||
if (_lastValue != null) {
|
||||
final currentRelevantPart = fullValue.length >= 19
|
||||
? fullValue.substring(6, fullValue.length - 13)
|
||||
: fullValue.substring(6);
|
||||
final lastRelevantPart = _lastValue!.length >= 19
|
||||
? _lastValue!.substring(6, _lastValue!.length - 13)
|
||||
: _lastValue!.substring(6);
|
||||
|
||||
if (currentRelevantPart != lastRelevantPart) {
|
||||
final buttonClicked = SquareConstants.BUTTON_MAPPING[currentValue];
|
||||
if (buttonClicked != null) {
|
||||
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<int> 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<String, ControllerButton> BUTTON_MAPPING = {
|
||||
"00000200": ControllerButton.navigationUp, //"Up",
|
||||
"00000100": ControllerButton.navigationLeft, //"Left",
|
||||
"00000800": ControllerButton.navigationDown, // "Down",
|
||||
"00000400": ControllerButton.navigationRight, //"Right",
|
||||
"00002000": ControllerButton.powerUpLeft, //"X",
|
||||
"00001000": ControllerButton.sideButtonLeft, // "Square",
|
||||
"00008000": ControllerButton.campagnoloLeft, // "Left Campagnolo",
|
||||
"00004000": ControllerButton.onOffLeft, //"Left brake",
|
||||
"00000002": ControllerButton.shiftDownLeft, //"Left shift 1",
|
||||
"00000001": ControllerButton.paddleLeft, // "Left shift 2",
|
||||
"02000000": ControllerButton.y, // "Y",
|
||||
"01000000": ControllerButton.a, //"A",
|
||||
"08000000": ControllerButton.b, // "B",
|
||||
"04000000": ControllerButton.z, // "Z",
|
||||
"20000000": ControllerButton.powerUpRight, // "Circle",
|
||||
"10000000": ControllerButton.sideButtonRight, //"Triangle",
|
||||
"80000000": ControllerButton.campagnoloRight, // "Right Campagnolo",
|
||||
"40000000": ControllerButton.onOffRight, //"Right brake",
|
||||
"00020000": ControllerButton.sideButtonRight, //"Right shift 1",
|
||||
"00010000": ControllerButton.paddleRight, //"Right shift 2",
|
||||
};
|
||||
}
|
||||
128
lib/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class WahooKickrBikeShift extends BaseDevice {
|
||||
WahooKickrBikeShift(super.scanResult)
|
||||
: super(
|
||||
availableButtons: WahooKickrBikeShiftConstants.prefixToButton.values.toList(),
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> 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<void> 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<String, int> lastSeqByPrefix = HashMap<String, int>();
|
||||
|
||||
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<String, ControllerButton> prefixToButton = {
|
||||
'0001': ControllerButton.powerUpRight, //'Right Up',
|
||||
'8000': ControllerButton.sideButtonRight, //'Right Down',
|
||||
'0008': ControllerButton.navigationRight, //'Right Steer',
|
||||
'0200': ControllerButton.powerUpLeft, // 'Left Up',
|
||||
'0400': ControllerButton.sideButtonLeft, //'Left Down',
|
||||
'2000': ControllerButton.navigationLeft, //'Left Steer',
|
||||
'0004': ControllerButton.shiftUpRight, // 'Right Shift Up',
|
||||
'0002': ControllerButton.shiftDownRight, // 'Right Shift Down',
|
||||
'1000': ControllerButton.shiftUpLeft, //'Left Shift Up',
|
||||
'0800': ControllerButton.shiftDownLeft, //'Left Shift Down',
|
||||
'4000': ControllerButton.paddleRight, //'Right Brake',
|
||||
'0100': ControllerButton.paddleLeft, //'Left Brake',
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../messages/click_notification.dart';
|
||||
import '../../messages/click_notification.dart';
|
||||
|
||||
class ZwiftClick extends BaseDevice {
|
||||
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButton.shiftUpRight, ZwiftButton.shiftDownLeft]);
|
||||
class ZwiftClick extends ZwiftDevice {
|
||||
ZwiftClick(super.scanResult)
|
||||
: super(availableButtons: [ControllerButton.shiftUpRight, ControllerButton.shiftDownLeft]);
|
||||
|
||||
ClickNotification? _lastClickNotification;
|
||||
|
||||
@override
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
Future<List<ControllerButton>?> processClickNotification(Uint8List message) async {
|
||||
final ClickNotification clickNotification = ClickNotification(message);
|
||||
if (_lastClickNotification == null || _lastClickNotification != clickNotification) {
|
||||
_lastClickNotification = clickNotification;
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
|
||||
import '../ble.dart';
|
||||
import '../protocol/zp.pbenum.dart';
|
||||
import '../../ble.dart';
|
||||
import '../../protocol/zp.pbenum.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult);
|
||||
ZwiftClickV2(super.scanResult) : super(isBeta: true);
|
||||
|
||||
@override
|
||||
bool get supportsEncryption => false;
|
||||
214
lib/bluetooth/devices/zwift/zwift_device.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/crypto/local_key_provider.dart';
|
||||
import 'package:swift_control/utils/crypto/zap_crypto.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../../utils/crypto/encryption_utils.dart';
|
||||
|
||||
abstract class ZwiftDevice extends BaseDevice {
|
||||
final zapEncryption = ZapCrypto(LocalKeyProvider());
|
||||
|
||||
ZwiftDevice(super.scanResult, {required super.availableButtons, super.isBeta});
|
||||
|
||||
bool supportsEncryption = false;
|
||||
|
||||
BleCharacteristic? syncRxCharacteristic;
|
||||
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
|
||||
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
|
||||
|
||||
if (customService == null) {
|
||||
throw Exception(
|
||||
'Custom service $customServiceId 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 deviceInformationService = services.firstOrNullWhere(
|
||||
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID,
|
||||
);
|
||||
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
|
||||
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION,
|
||||
);
|
||||
if (firmwareCharacteristic != null) {
|
||||
final firmwareData = await UniversalBle.read(
|
||||
device.deviceId,
|
||||
deviceInformationService!.uuid,
|
||||
firmwareCharacteristic.uuid,
|
||||
);
|
||||
firmwareVersion = String.fromCharCodes(firmwareData);
|
||||
connection.signalChange(this);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
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');
|
||||
}
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await setupHandshake();
|
||||
}
|
||||
|
||||
Future<void> setupHandshake() async {
|
||||
if (supportsEncryption) {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Uint8List.fromList([
|
||||
...Constants.RIDE_ON,
|
||||
...Constants.REQUEST_START,
|
||||
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
|
||||
]),
|
||||
withoutResponse: true,
|
||||
);
|
||||
} else {
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Constants.RIDE_ON,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode && false) {
|
||||
print(
|
||||
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
);
|
||||
}
|
||||
if (bytes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (bytes.startsWith(startCommand)) {
|
||||
_processDevicePublicKeyResponse(bytes);
|
||||
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
|
||||
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(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
|
||||
if (kDebugMode) {
|
||||
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
|
||||
}
|
||||
zapEncryption.initialise(devicePublicKeyBytes);
|
||||
}
|
||||
|
||||
Future<void> processData(Uint8List bytes) async {
|
||||
int type;
|
||||
Uint8List message;
|
||||
|
||||
if (supportsEncryption) {
|
||||
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
|
||||
final payload = bytes.sublist(4);
|
||||
|
||||
if (zapEncryption.encryptionKeyBytes == null) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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:
|
||||
if (batteryLevel != message[1]) {
|
||||
batteryLevel = message[1];
|
||||
connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE:
|
||||
processClickNotification(message)
|
||||
.then((buttonsClicked) async {
|
||||
return handleButtonsClicked(buttonsClicked);
|
||||
})
|
||||
.catchError((e) {
|
||||
actionStreamInternal.add(LogNotification(e.toString()));
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ControllerButton>?> processClickNotification(Uint8List message);
|
||||
|
||||
@override
|
||||
Future<void> performDown(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performDown(buttonsClicked);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> performClick(List<ControllerButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
return super.performClick(buttonsClicked);
|
||||
}
|
||||
|
||||
Future<void> _vibrate() async {
|
||||
final vibrateCommand = Uint8List.fromList([...Constants.VIBRATE_PATTERN, 0x20]);
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
supportsEncryption ? zapEncryption.encrypt(vibrateCommand) : vibrateCommand,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/play_notification.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../ble.dart';
|
||||
import '../../ble.dart';
|
||||
|
||||
class ZwiftPlay extends BaseDevice {
|
||||
class ZwiftPlay extends ZwiftDevice {
|
||||
ZwiftPlay(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButton.y,
|
||||
ZwiftButton.z,
|
||||
ZwiftButton.a,
|
||||
ZwiftButton.b,
|
||||
ZwiftButton.onOffRight,
|
||||
ZwiftButton.sideButtonRight,
|
||||
ZwiftButton.paddleRight,
|
||||
ZwiftButton.navigationUp,
|
||||
ZwiftButton.navigationLeft,
|
||||
ZwiftButton.navigationRight,
|
||||
ZwiftButton.navigationDown,
|
||||
ZwiftButton.onOffLeft,
|
||||
ZwiftButton.sideButtonLeft,
|
||||
ZwiftButton.paddleLeft,
|
||||
ControllerButton.y,
|
||||
ControllerButton.z,
|
||||
ControllerButton.a,
|
||||
ControllerButton.b,
|
||||
ControllerButton.onOffRight,
|
||||
ControllerButton.sideButtonRight,
|
||||
ControllerButton.paddleRight,
|
||||
ControllerButton.navigationUp,
|
||||
ControllerButton.navigationLeft,
|
||||
ControllerButton.navigationRight,
|
||||
ControllerButton.navigationDown,
|
||||
ControllerButton.onOffLeft,
|
||||
ControllerButton.sideButtonLeft,
|
||||
ControllerButton.paddleLeft,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ class ZwiftPlay extends BaseDevice {
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY;
|
||||
|
||||
@override
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
Future<List<ControllerButton>?> processClickNotification(Uint8List message) async {
|
||||
final PlayNotification clickNotification = PlayNotification(message);
|
||||
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
|
||||
_lastControllerNotification = clickNotification;
|
||||
@@ -1,45 +1,45 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:protobuf/protobuf.dart' as $pb;
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zp_vendor.pb.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../ble.dart';
|
||||
import '../messages/notification.dart';
|
||||
import '../protocol/zp.pb.dart';
|
||||
import '../../../main.dart';
|
||||
import '../../ble.dart';
|
||||
import '../../messages/notification.dart';
|
||||
import '../../protocol/zp.pb.dart';
|
||||
|
||||
class ZwiftRide extends BaseDevice {
|
||||
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)
|
||||
ZwiftRide(super.scanResult, {super.isBeta})
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButton.navigationLeft,
|
||||
ZwiftButton.navigationRight,
|
||||
ZwiftButton.navigationUp,
|
||||
ZwiftButton.navigationDown,
|
||||
ZwiftButton.a,
|
||||
ZwiftButton.b,
|
||||
ZwiftButton.y,
|
||||
ZwiftButton.z,
|
||||
ZwiftButton.shiftUpLeft,
|
||||
ZwiftButton.shiftDownLeft,
|
||||
ZwiftButton.shiftUpRight,
|
||||
ZwiftButton.shiftDownRight,
|
||||
ZwiftButton.powerUpLeft,
|
||||
ZwiftButton.powerUpRight,
|
||||
ZwiftButton.onOffLeft,
|
||||
ZwiftButton.onOffRight,
|
||||
ZwiftButton.paddleLeft,
|
||||
ZwiftButton.paddleRight,
|
||||
ControllerButton.navigationLeft,
|
||||
ControllerButton.navigationRight,
|
||||
ControllerButton.navigationUp,
|
||||
ControllerButton.navigationDown,
|
||||
ControllerButton.a,
|
||||
ControllerButton.b,
|
||||
ControllerButton.y,
|
||||
ControllerButton.z,
|
||||
ControllerButton.shiftUpLeft,
|
||||
ControllerButton.shiftDownLeft,
|
||||
ControllerButton.shiftUpRight,
|
||||
ControllerButton.shiftDownRight,
|
||||
ControllerButton.powerUpLeft,
|
||||
ControllerButton.powerUpRight,
|
||||
ControllerButton.onOffLeft,
|
||||
ControllerButton.onOffRight,
|
||||
ControllerButton.paddleLeft,
|
||||
ControllerButton.paddleRight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -202,7 +202,7 @@ class ZwiftRide extends BaseDevice {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
Future<List<ControllerButton>?> processClickNotification(Uint8List message) async {
|
||||
final RideNotification clickNotification = RideNotification(
|
||||
message,
|
||||
analogPaddleThreshold: analogPaddleThreshold,
|
||||
@@ -8,13 +8,13 @@ import '../protocol/zwift.pb.dart';
|
||||
import 'notification.dart';
|
||||
|
||||
class ClickNotification extends BaseNotification {
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
late List<ControllerButton> buttonsClicked;
|
||||
|
||||
ClickNotification(Uint8List message) {
|
||||
final status = ClickKeyPadStatus.fromBuffer(message);
|
||||
buttonsClicked = [
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButton.shiftDownLeft,
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ControllerButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ControllerButton.shiftDownLeft,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,29 +7,29 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
|
||||
class PlayNotification extends BaseNotification {
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
late List<ControllerButton> buttonsClicked;
|
||||
|
||||
PlayNotification(Uint8List message) {
|
||||
final status = PlayKeyPadStatus.fromBuffer(message);
|
||||
|
||||
buttonsClicked = [
|
||||
if (status.rightPad == PlayButtonStatus.ON) ...[
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButton.y,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButton.z,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButton.a,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButton.b,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButton.onOffRight,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButton.sideButtonRight,
|
||||
if (status.analogLR.abs() == 100) ZwiftButton.paddleRight,
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ControllerButton.y,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ControllerButton.z,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ControllerButton.a,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ControllerButton.b,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ControllerButton.onOffRight,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ControllerButton.sideButtonRight,
|
||||
if (status.analogLR.abs() == 100) ControllerButton.paddleRight,
|
||||
],
|
||||
if (status.rightPad == PlayButtonStatus.OFF) ...[
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButton.navigationUp,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButton.navigationLeft,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButton.navigationRight,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButton.navigationDown,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButton.onOffLeft,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButton.sideButtonLeft,
|
||||
if (status.analogLR.abs() == 100) ZwiftButton.paddleLeft,
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ControllerButton.navigationUp,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ControllerButton.navigationLeft,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ControllerButton.navigationRight,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ControllerButton.navigationDown,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ControllerButton.onOffLeft,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ControllerButton.sideButtonLeft,
|
||||
if (status.analogLR.abs() == 100) ControllerButton.paddleLeft,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
@@ -34,8 +32,8 @@ enum _RideButtonMask {
|
||||
}
|
||||
|
||||
class RideNotification extends BaseNotification {
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
late List<ZwiftButton> analogButtons;
|
||||
late List<ControllerButton> buttonsClicked;
|
||||
late List<ControllerButton> analogButtons;
|
||||
|
||||
RideNotification(Uint8List message, {required int analogPaddleThreshold}) {
|
||||
final status = RideKeyPadStatus.fromBuffer(message);
|
||||
@@ -44,23 +42,31 @@ class RideNotification extends BaseNotification {
|
||||
|
||||
// Process DIGITAL buttons separately
|
||||
buttonsClicked = [
|
||||
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationLeft,
|
||||
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationRight,
|
||||
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationUp,
|
||||
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationDown,
|
||||
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.a,
|
||||
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.b,
|
||||
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.y,
|
||||
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.z,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftDownLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationLeft,
|
||||
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationRight,
|
||||
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.navigationUp,
|
||||
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.navigationDown,
|
||||
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.a,
|
||||
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.b,
|
||||
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.y,
|
||||
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.z,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftDownLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.shiftUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ZwiftButton.shiftDownRight,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.powerUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.powerUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffLeft,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffRight,
|
||||
ControllerButton.shiftDownRight,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.powerUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ControllerButton.powerUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.onOffLeft,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ControllerButton.onOffRight,
|
||||
];
|
||||
|
||||
// Process ANALOG inputs separately - now properly separated from digital
|
||||
@@ -71,9 +77,9 @@ class RideNotification extends BaseNotification {
|
||||
if (paddle.hasLocation() && paddle.hasAnalogValue()) {
|
||||
if (paddle.analogValue.abs() >= analogPaddleThreshold) {
|
||||
final button = switch (paddle.location.value) {
|
||||
0 => ZwiftButton.paddleLeft, // L0 = left paddle
|
||||
1 => ZwiftButton.paddleRight, // L1 = right paddle
|
||||
_ => null, // L2, L3 unused
|
||||
0 => ControllerButton.paddleLeft, // L0 = left paddle
|
||||
1 => ControllerButton.paddleRight, // L1 = right paddle
|
||||
_ => null, // L2, L3 unused
|
||||
};
|
||||
|
||||
if (button != null) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
@@ -162,6 +162,25 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
device.device.name?.screenshot ?? device.runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (device.isBeta)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'BETA',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (device.batteryLevel != null) ...[
|
||||
Icon(switch (device.batteryLevel!) {
|
||||
>= 80 => Icons.battery_full,
|
||||
|
||||
@@ -35,7 +35,7 @@ class TouchAreaSetupPage extends StatefulWidget {
|
||||
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
File? _backgroundImage;
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
ZwiftButton? _pressedButton;
|
||||
ControllerButton? _pressedButton;
|
||||
final TransformationController _transformationController = TransformationController();
|
||||
|
||||
late Rect _imageRect;
|
||||
@@ -164,6 +164,9 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
? (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}');
|
||||
@@ -358,9 +361,19 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
child: icon,
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDragStarted: () {
|
||||
// Capture the starting position to calculate drag distance later
|
||||
dragStartPosition = position;
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
// otherwise simulated touch will move it
|
||||
if (details.velocity.pixelsPerSecond.distance > 0) {
|
||||
// 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;
|
||||
|
||||
@@ -25,7 +25,7 @@ class AndroidActions extends BaseActions {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<String> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ("Could not perform ${button.name.splitByUpperCase()}: No keymap set");
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class AndroidActions extends BaseActions {
|
||||
return "Key pressed: ${keyPair.toString()}";
|
||||
}
|
||||
}
|
||||
final point = resolveTouchPosition(action: button, windowInfo: windowInfo);
|
||||
final point = await resolveTouchPosition(action: button, windowInfo: windowInfo);
|
||||
if (point != Offset.zero) {
|
||||
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
|
||||
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../keymap/apps/supported_app.dart';
|
||||
@@ -17,35 +23,66 @@ abstract class BaseActions {
|
||||
this.supportedApp = supportedApp;
|
||||
}
|
||||
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
Future<Offset> resolveTouchPosition({required ControllerButton action, required WindowEvent? windowInfo}) async {
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
|
||||
// convert relative position to absolute position based on window info
|
||||
if (windowInfo != null && windowInfo.right != 0) {
|
||||
|
||||
if (windowInfo != null && windowInfo.top > 0) {
|
||||
final x = windowInfo.left + (keyPair.touchPosition.dx / 100) * (windowInfo.right - windowInfo.left);
|
||||
final y = windowInfo.top + (keyPair.touchPosition.dy / 100) * (windowInfo.bottom - windowInfo.top);
|
||||
|
||||
if (kDebugMode) {
|
||||
print("Window info: ${windowInfo.encode()} => Touch at: $x, $y");
|
||||
}
|
||||
return Offset(x, y);
|
||||
} else {
|
||||
// TODO support multiple screens
|
||||
final screenSize =
|
||||
WidgetsBinding.instance.platformDispatcher.views.first.display.size /
|
||||
WidgetsBinding.instance.platformDispatcher.views.first.devicePixelRatio;
|
||||
final x = (keyPair.touchPosition.dx / 100) * screenSize.width;
|
||||
final y = (keyPair.touchPosition.dy / 100) * screenSize.height;
|
||||
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) {
|
||||
// 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 => Touch at: $x, $y");
|
||||
}
|
||||
return Offset(x, y);
|
||||
}
|
||||
}
|
||||
return Offset.zero;
|
||||
}
|
||||
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
}
|
||||
|
||||
class StubActions extends BaseActions {
|
||||
StubActions({super.supportedModes = const []});
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
return Future.value(action.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class DesktopActions extends BaseActions {
|
||||
// Track keys that are currently held down in long press mode
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ('Supported app is not set');
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class DesktopActions extends BaseActions {
|
||||
return 'Key released: $keyPair';
|
||||
}
|
||||
} else {
|
||||
final point = resolveTouchPosition(action: action, windowInfo: null);
|
||||
final point = await resolveTouchPosition(action: action, windowInfo: null);
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
@@ -49,7 +49,7 @@ class DesktopActions extends BaseActions {
|
||||
}
|
||||
|
||||
// Release all held keys (useful for cleanup)
|
||||
Future<void> releaseAllHeldKeys(List<ZwiftButton> list) async {
|
||||
Future<void> releaseAllHeldKeys(List<ControllerButton> list) async {
|
||||
for (final action in list) {
|
||||
final keyPair = supportedApp?.keymap.getKeyPair(action);
|
||||
if (keyPair?.physicalKey != null) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'dart:ui';
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
@@ -16,7 +16,7 @@ class RemoteActions extends BaseActions {
|
||||
RemoteActions({super.supportedModes = const [SupportedMode.touch]});
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return 'Supported app is not set';
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class RemoteActions extends BaseActions {
|
||||
if (keyPair.physicalKey != null && keyPair.touchPosition == Offset.zero) {
|
||||
return ('Physical key actions are not supported, yet');
|
||||
} else {
|
||||
final point = resolveTouchPosition(action: action, windowInfo: null);
|
||||
final point = await resolveTouchPosition(action: action, windowInfo: null);
|
||||
final point2 = point; //Offset(100, 99.0);
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
|
||||
@@ -43,7 +43,7 @@ class RemoteActions extends BaseActions {
|
||||
}
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
Future<Offset> resolveTouchPosition({required ControllerButton action, required WindowEvent? windowInfo}) async {
|
||||
// for remote actions we use the relative position only
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
|
||||
@@ -68,6 +68,9 @@ class RemoteActions extends BaseActions {
|
||||
}
|
||||
|
||||
await peripheralManager.notifyCharacteristic(connectedCentral!, connectedCharacteristic!, value: bytes);
|
||||
|
||||
// we don't want to overwhelm the target device
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
|
||||
Central? connectedCentral;
|
||||
|
||||
@@ -13,27 +13,27 @@ class Biketerra extends SupportedApp {
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyS,
|
||||
logicalKey: LogicalKeyboardKey.keyS,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyW,
|
||||
logicalKey: LogicalKeyboardKey.keyW,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyU,
|
||||
logicalKey: LogicalKeyboardKey.keyU,
|
||||
),
|
||||
|
||||
@@ -35,7 +35,7 @@ class CustomApp extends SupportedApp {
|
||||
}
|
||||
|
||||
void setKey(
|
||||
ZwiftButton zwiftButton, {
|
||||
ControllerButton zwiftButton, {
|
||||
required PhysicalKeyboardKey? physicalKey,
|
||||
required LogicalKeyboardKey? logicalKey,
|
||||
bool isLongPress = false,
|
||||
|
||||
@@ -13,33 +13,33 @@ class MyWhoosh extends SupportedApp {
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyI,
|
||||
logicalKey: LogicalKeyboardKey.keyI,
|
||||
touchPosition: Offset(80, 94),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyK,
|
||||
logicalKey: LogicalKeyboardKey.keyK,
|
||||
touchPosition: Offset(98, 94),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyD,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
touchPosition: Offset(98, 80),
|
||||
isLongPress: true,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
touchPosition: Offset(32, 80),
|
||||
isLongPress: true,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyH,
|
||||
logicalKey: LogicalKeyboardKey.keyH,
|
||||
),
|
||||
|
||||
@@ -8,45 +8,45 @@ import '../keymap.dart';
|
||||
class TrainingPeaks extends SupportedApp {
|
||||
TrainingPeaks()
|
||||
: super(
|
||||
name: 'IndieVelo / TrainingPeaks',
|
||||
name: 'TrainingPeaks Virtual / IndieVelo',
|
||||
packageName: "com.indieVelo.client",
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
// https://help.trainingpeaks.com/hc/en-us/articles/31340399556877-TrainingPeaks-Virtual-Controls-and-Keyboard-Shortcuts
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
touchPosition: Offset(50 * 1.32, 74),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.numpadAdd,
|
||||
logicalKey: LogicalKeyboardKey.numpadAdd,
|
||||
touchPosition: Offset(50 * 1.15, 74),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyH,
|
||||
logicalKey: LogicalKeyboardKey.keyH,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.increaseResistance).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.increaseResistance).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.pageUp,
|
||||
logicalKey: LogicalKeyboardKey.pageUp,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.decreaseResistance).toList(),
|
||||
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.decreaseResistance).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.pageDown,
|
||||
logicalKey: LogicalKeyboardKey.pageDown,
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ enum InGameAction {
|
||||
}
|
||||
}
|
||||
|
||||
enum ZwiftButton {
|
||||
enum ControllerButton {
|
||||
// left controller
|
||||
navigationUp._(InGameAction.increaseResistance, icon: Icons.keyboard_arrow_up, color: Colors.black),
|
||||
navigationDown._(InGameAction.decreaseResistance, icon: Icons.keyboard_arrow_down, color: Colors.black),
|
||||
@@ -26,8 +26,8 @@ enum ZwiftButton {
|
||||
paddleLeft._(InGameAction.shiftDown),
|
||||
|
||||
// zwift ride only
|
||||
shiftUpLeft._(InGameAction.shiftDown, icon: Icons.minimize_rounded, color: Colors.black),
|
||||
shiftDownLeft._(InGameAction.shiftDown, icon: Icons.minimize_rounded, color: Colors.black),
|
||||
shiftUpLeft._(InGameAction.shiftDown, icon: Icons.remove, color: Colors.black),
|
||||
shiftDownLeft._(InGameAction.shiftDown, icon: Icons.remove, color: Colors.black),
|
||||
powerUpLeft._(InGameAction.shiftDown),
|
||||
|
||||
// right controller
|
||||
@@ -42,12 +42,16 @@ enum ZwiftButton {
|
||||
// zwift ride only
|
||||
shiftUpRight._(InGameAction.shiftUp, icon: Icons.add, color: Colors.black),
|
||||
shiftDownRight._(InGameAction.shiftUp),
|
||||
powerUpRight._(InGameAction.shiftUp);
|
||||
powerUpRight._(InGameAction.shiftUp),
|
||||
|
||||
// elite square only
|
||||
campagnoloLeft._(InGameAction.shiftDown),
|
||||
campagnoloRight._(InGameAction.shiftUp);
|
||||
|
||||
final InGameAction? action;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
const ZwiftButton._(this.action, {this.color, this.icon});
|
||||
const ControllerButton._(this.action, {this.color, this.icon});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -24,12 +24,12 @@ class Keymap {
|
||||
);
|
||||
}
|
||||
|
||||
PhysicalKeyboardKey? getPhysicalKey(ZwiftButton action) {
|
||||
PhysicalKeyboardKey? getPhysicalKey(ControllerButton action) {
|
||||
// get the key pair by in game action
|
||||
return keyPairs.firstOrNullWhere((element) => element.buttons.contains(action))?.physicalKey;
|
||||
}
|
||||
|
||||
KeyPair? getKeyPair(ZwiftButton action) {
|
||||
KeyPair? getKeyPair(ControllerButton action) {
|
||||
// get the key pair by in game action
|
||||
return keyPairs.firstOrNullWhere((element) => element.buttons.contains(action));
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class Keymap {
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
final List<ZwiftButton> buttons;
|
||||
final List<ControllerButton> buttons;
|
||||
PhysicalKeyboardKey? physicalKey;
|
||||
LogicalKeyboardKey? logicalKey;
|
||||
Offset touchPosition;
|
||||
@@ -117,7 +117,7 @@ class KeyPair {
|
||||
|
||||
return KeyPair(
|
||||
buttons: decoded['actions']
|
||||
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
|
||||
.map<ControllerButton>((e) => ControllerButton.values.firstWhere((element) => element.name == e))
|
||||
.toList(),
|
||||
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
|
||||
? LogicalKeyboardKey(int.parse(decoded['logicalKey']))
|
||||
|
||||
@@ -14,6 +14,13 @@ class KeyboardRequirement extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Enable keyboard access in the following screen for SwiftControl. If you don\'t see SwiftControl, please add it manually.',
|
||||
),
|
||||
),
|
||||
);
|
||||
await keyPressSimulator.requestAccess(onlyOpenPrefPane: Platform.isMacOS);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
KeyDownEvent? _pressedKey;
|
||||
ZwiftButton? _pressedButton;
|
||||
ControllerButton? _pressedButton;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -84,24 +84,23 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
content:
|
||||
_pressedButton == null
|
||||
? Text('Press a button on your Click device')
|
||||
: KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 20,
|
||||
children: [
|
||||
Text("Press a key on your keyboard to assign to ${_pressedButton.toString()}"),
|
||||
Text(_formatKey(_pressedKey)),
|
||||
],
|
||||
),
|
||||
content: _pressedButton == null
|
||||
? Text('Press a button on your Click device')
|
||||
: KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 20,
|
||||
children: [
|
||||
Text("Press a key on your keyboard to assign to ${_pressedButton.toString()}"),
|
||||
Text(_formatKey(_pressedKey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(_pressedKey), child: Text("OK"))],
|
||||
);
|
||||
|
||||
@@ -153,7 +153,7 @@ class KeyWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
class ButtonWidget extends StatelessWidget {
|
||||
final ZwiftButton button;
|
||||
final ControllerButton button;
|
||||
final bool big;
|
||||
const ButtonWidget({super.key, required this.button, this.big = false});
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class MenuButton extends StatelessWidget {
|
||||
child: PopupMenuButton(
|
||||
child: Text("Simulate buttons"),
|
||||
itemBuilder: (_) {
|
||||
return ZwiftButton.values
|
||||
return ControllerButton.values
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
child: Text(e.name),
|
||||
@@ -77,6 +77,7 @@ class MenuButton extends StatelessWidget {
|
||||
connection.signalNotification(
|
||||
RideNotification(Uint8List(0), analogPaddleThreshold: 25)..buttonsClicked = [e],
|
||||
);
|
||||
connection.devices.firstOrNull?.handleButtonsClicked([e]);
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -14,7 +14,6 @@ import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
String? _latestVersionUrlValue;
|
||||
PackageInfo? _packageInfoValue;
|
||||
bool? isFromPlayStore;
|
||||
|
||||
@@ -29,7 +28,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
final updater = ShorebirdUpdater();
|
||||
Patch? _shorebirdPatch;
|
||||
|
||||
Future<String?> _getLatestVersionUrlIfNewer() async {
|
||||
Future<Pair<Version, String>?> _getLatestVersionUrlIfNewer() async {
|
||||
final response = await http.get(Uri.parse('https://api.github.com/repos/jonasbark/swiftcontrol/releases/latest'));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
@@ -43,17 +42,17 @@ class _AppTitleState extends State<AppTitle> {
|
||||
final assets = data['assets'] as List;
|
||||
if (Platform.isAndroid) {
|
||||
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
|
||||
return apkUrl;
|
||||
return Pair(latestVersion, apkUrl);
|
||||
} else if (Platform.isMacOS) {
|
||||
final dmgUrl = assets.firstOrNullWhere(
|
||||
(asset) => asset['name'].endsWith('.macos.zip'),
|
||||
)['browser_download_url'];
|
||||
return dmgUrl;
|
||||
return Pair(latestVersion, dmgUrl);
|
||||
} else if (Platform.isWindows) {
|
||||
final appImageUrl = assets.firstOrNullWhere(
|
||||
(asset) => asset['name'].endsWith('.windows.zip'),
|
||||
)['browser_download_url'];
|
||||
return appImageUrl;
|
||||
return Pair(latestVersion, appImageUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,8 +78,6 @@ class _AppTitleState extends State<AppTitle> {
|
||||
});
|
||||
_checkForUpdate();
|
||||
});
|
||||
} else {
|
||||
_checkForUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +92,19 @@ class _AppTitleState extends State<AppTitle> {
|
||||
action: SnackBarAction(
|
||||
label: 'Update',
|
||||
onPressed: () {
|
||||
updater.update().then((value) {
|
||||
_showShorebirdRestartSnackbar();
|
||||
});
|
||||
updater
|
||||
.update()
|
||||
.then((value) {
|
||||
_showShorebirdRestartSnackbar();
|
||||
})
|
||||
.catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to update: $e'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -105,9 +112,11 @@ class _AppTitleState extends State<AppTitle> {
|
||||
} else if (updateStatus == UpdateStatus.restartRequired) {
|
||||
_showShorebirdRestartSnackbar();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
|
||||
if (kIsWeb) {
|
||||
// no-op
|
||||
} else if (Platform.isAndroid) {
|
||||
try {
|
||||
final appUpdateInfo = await InAppUpdate.checkForUpdate();
|
||||
if (context.mounted && appUpdateInfo.updateAvailability == UpdateAvailability.updateAvailable) {
|
||||
@@ -131,22 +140,38 @@ class _AppTitleState extends State<AppTitle> {
|
||||
print('Failed to check for update: $e');
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
if (_latestVersionUrlValue == null && !kIsWeb && !Platform.isIOS) {
|
||||
final url = await _getLatestVersionUrlIfNewer();
|
||||
if (url != null && mounted && !kDebugMode) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('New version available: ${url.split("/").takeLast(2).first.split('%').first}'),
|
||||
duration: Duration(seconds: 1337),
|
||||
action: SnackBarAction(
|
||||
label: 'Download',
|
||||
onPressed: () {
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} 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'whats-new__latest__version">Version ([0-9]{1,2}\.[0-9]{1,2}.[0-9]{1,2})</p>',
|
||||
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 updatePair = await _getLatestVersionUrlIfNewer();
|
||||
if (updatePair != null && mounted && !kDebugMode) {
|
||||
_showUpdateSnackbar(updatePair.first, updatePair.second);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,4 +213,31 @@ class _AppTitleState extends State<AppTitle> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _compareVersion(String versionString) {
|
||||
final parsed = Version.parse(versionString);
|
||||
final current = Version.parse(_packageInfoValue!.version);
|
||||
if (parsed > current && mounted && !kDebugMode) {
|
||||
if (Platform.isAndroid) {
|
||||
_showUpdateSnackbar(parsed, 'https://play.google.com/store/apps/details?id=org.jonasbark.swiftcontrol');
|
||||
} else if (Platform.isIOS || Platform.isMacOS) {
|
||||
_showUpdateSnackbar(parsed, 'https://apps.apple.com/app/id6753721284');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showUpdateSnackbar(Version newVersion, String url) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('New version available: ${newVersion.toString()}'),
|
||||
duration: Duration(seconds: 1337),
|
||||
action: SnackBarAction(
|
||||
label: 'Download',
|
||||
onPressed: () {
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB |
BIN
playstoreassets/mac_screenshot_1.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 104 KiB |
BIN
playstoreassets/mac_screenshot_2.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 259 KiB |
40
pubspec.lock
@@ -136,6 +136,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:
|
||||
@@ -359,6 +367,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
get_it:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: get_it
|
||||
sha256: a4292e7cf67193f8e7c1258203104eb2a51ec8b3a04baa14695f4064c144297b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -579,6 +595,22 @@ packages:
|
||||
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"
|
||||
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:
|
||||
@@ -723,6 +755,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
restart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
12
pubspec.yaml
@@ -51,6 +51,7 @@ dev_dependencies:
|
||||
|
||||
flutter_launcher_icons: "^0.14.3"
|
||||
flutter_lints: ^6.0.0
|
||||
msix: ^3.16.12
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -58,3 +59,14 @@ flutter:
|
||||
- CHANGELOG.md
|
||||
- TROUBLESHOOTING.md
|
||||
- shorebird.yaml
|
||||
|
||||
msix_config:
|
||||
display_name: SwiftControl
|
||||
publisher_display_name: Jonas Bark
|
||||
identity_name: JonasBark.SwiftControl
|
||||
publisher: CN=EDA6D86E-3E52-4054-9F3A-4277AFCCB2C4
|
||||
store: true
|
||||
msix_version: 1.0.0.0
|
||||
logo_path: logo.jpg
|
||||
build_windows: false
|
||||
capabilities: internetClient,bluetooth,inputInjectionBrokered
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
void main() {
|
||||
group('Long Press KeyPair Tests', () {
|
||||
test('KeyPair should encode and decode isLongPress property', () {
|
||||
// Create a KeyPair with long press enabled
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
buttons: [ControllerButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
isLongPress: true,
|
||||
@@ -16,14 +16,14 @@ void main() {
|
||||
|
||||
// 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.buttons, equals([ZwiftButton.a]));
|
||||
expect(decoded.buttons, equals([ControllerButton.a]));
|
||||
expect(decoded.physicalKey, equals(PhysicalKeyboardKey.keyA));
|
||||
expect(decoded.logicalKey, equals(LogicalKeyboardKey.keyA));
|
||||
});
|
||||
@@ -41,7 +41,7 @@ void main() {
|
||||
|
||||
// Decode the legacy KeyPair
|
||||
final decoded = KeyPair.decode(legacyEncoded);
|
||||
|
||||
|
||||
// Verify the decoded KeyPair defaults isLongPress to false
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, false);
|
||||
@@ -49,7 +49,7 @@ void main() {
|
||||
|
||||
test('KeyPair constructor should default isLongPress to false', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
buttons: [ControllerButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
);
|
||||
@@ -59,7 +59,7 @@ void main() {
|
||||
|
||||
test('KeyPair should correctly encode isLongPress false', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
buttons: [ControllerButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
isLongPress: false,
|
||||
@@ -67,9 +67,9 @@ void main() {
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ void main() {
|
||||
group('Percentage-based Keymap Tests', () {
|
||||
test('Should encode touch position as percentage using fallback screen size', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
buttons: [ControllerButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
|
||||
@@ -22,7 +22,7 @@ void main() {
|
||||
|
||||
test('Should encode touch position as percentages with fallback when screen size not available', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
buttons: [ControllerButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
|
||||
@@ -57,7 +57,7 @@ void main() {
|
||||
|
||||
test('Should handle zero touch position correctly', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
buttons: [ControllerButton.paddleRight],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
touchPosition: Offset.zero,
|
||||
@@ -72,7 +72,7 @@ void main() {
|
||||
|
||||
test('Should encode and decode with fallback screen size', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
buttons: [ControllerButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(480, 270), // 25% of 1920x1080
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift/zwift_ride.dart';
|
||||
|
||||
void main() {
|
||||
group('Zwift Ride Analog Paddle - ZigZag Encoding Tests', () {
|
||||
|
||||