Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a89ffc7ffd | ||
|
|
4e75270e49 | ||
|
|
e08a1dc183 | ||
|
|
8fa31968c0 | ||
|
|
27e25978f2 | ||
|
|
5a0761ef1a | ||
|
|
52c40e6f5c | ||
|
|
be7a18384c | ||
|
|
b4693229d2 | ||
|
|
dc28be0657 | ||
|
|
ce6f33522f | ||
|
|
200ac9d81e | ||
|
|
078398daba | ||
|
|
9ac73ec6fc | ||
|
|
a469134d2f | ||
|
|
57690808dd | ||
|
|
4edc8ef10c | ||
|
|
576e66c60c | ||
|
|
0e53f225d0 | ||
|
|
5d656913a8 | ||
|
|
49cea5f45d | ||
|
|
255435e419 | ||
|
|
1657338640 | ||
|
|
eb66731784 | ||
|
|
07c9abc87b | ||
|
|
f5e8bad1ae | ||
|
|
38e9533bfa | ||
|
|
2cd0273382 | ||
|
|
d62d572387 | ||
|
|
b65fe57c68 | ||
|
|
0e5f6ef2dd | ||
|
|
45112ccfcf | ||
|
|
d26e937066 | ||
|
|
bb1bb42214 | ||
|
|
07c16dcbe2 | ||
|
|
1b4f5613ac | ||
|
|
3315bcd73e | ||
|
|
87f33b9a15 | ||
|
|
c06d364344 | ||
|
|
cbab56c17b | ||
|
|
585c78c232 | ||
|
|
e569b20b9f | ||
|
|
590e18ee43 | ||
|
|
a8edd09eae | ||
|
|
f3dae6fb48 | ||
|
|
b4672c7f39 | ||
|
|
e60a7b61a8 | ||
|
|
e443e5ab0d | ||
|
|
29f773d212 | ||
|
|
86d09450b0 | ||
|
|
c081da9545 | ||
|
|
4d0f447b25 | ||
|
|
9cc7c1b123 | ||
|
|
354742a545 | ||
|
|
b64fbfb6e4 | ||
|
|
3a2ff5c8d2 | ||
|
|
a5a4d9e0c2 | ||
|
|
cfeef1621a | ||
|
|
2e25b09bdf | ||
|
|
5ba70376e6 | ||
|
|
7c07d6ecf8 | ||
|
|
2788ecc32e | ||
|
|
26dc9e93b3 | ||
|
|
14bf6c9ac3 | ||
|
|
1db9669ed2 | ||
|
|
c466e6dfa3 | ||
|
|
1c00921ee1 | ||
|
|
df432542b5 | ||
|
|
fe989750e7 | ||
|
|
e008dea61e | ||
|
|
7a8c7c963b | ||
|
|
0ecf285a95 | ||
|
|
b14500351f | ||
|
|
97693e25b8 | ||
|
|
12d573bc55 | ||
|
|
68562aaec9 | ||
|
|
2c7e714856 | ||
|
|
a7183cc519 | ||
|
|
bfffb2856d | ||
|
|
d2be747fc1 | ||
|
|
7fb44d2782 | ||
|
|
d7b46205fa | ||
|
|
0e0835c2f7 | ||
|
|
e81d6cb86f | ||
|
|
8eef01437c | ||
|
|
0d446ee293 | ||
|
|
c0afe1792e | ||
|
|
11fdcad57d | ||
|
|
2ac94907e8 | ||
|
|
f7669b2bbc | ||
|
|
89d200243b | ||
|
|
013b078a44 | ||
|
|
06aefdedc2 | ||
|
|
4071a12c11 | ||
|
|
83cdb6efd7 | ||
|
|
040c0d3027 | ||
|
|
a44d4d62d0 | ||
|
|
f51d588510 | ||
|
|
54b2f73384 | ||
|
|
dc63f693f0 |
129
.github/workflows/build.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- ios
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- 'lib/**'
|
||||
@@ -11,6 +12,10 @@ on:
|
||||
- 'keypress_simulator/**'
|
||||
- 'pubspec.yaml'
|
||||
|
||||
env:
|
||||
SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
|
||||
FLUTTER_VERSION: 3.35.5
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Release
|
||||
@@ -29,15 +34,27 @@ jobs:
|
||||
- name: Install certificates
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64_MAC }}
|
||||
DEVELOPER_ID_INSTALLER_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_INSTALLER_P12_BASE64_MAC }}
|
||||
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPSTORE_PROFILE_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_IOS_BASE64 }}
|
||||
APPSTORE_PROFILE_MACOS_BASE64: ${{ secrets.APPSTORE_PROFILE_MACOS_BASE64 }}
|
||||
APPSTORE_PROFILE_DEV_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_DEV_IOS_BASE64 }}
|
||||
run: |
|
||||
# create variables
|
||||
DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_application_certificate.p12
|
||||
DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_installer_certificate.p12
|
||||
PP_PATH_IOS=$RUNNER_TEMP/build_pp_ios.mobileprovision
|
||||
PP_PATH_IOS_DEV=$RUNNER_TEMP/build_pp_ios_dev.mobileprovision
|
||||
PP_PATH_MACOS=$RUNNER_TEMP/build_pp_macos.provisionprofile
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/pg-signing.keychain-db
|
||||
|
||||
# import certificate and provisioning profile from secrets
|
||||
echo -n "$DEVELOPER_ID_APPLICATION_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH
|
||||
echo -n "$DEVELOPER_ID_INSTALLER_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH
|
||||
echo -n "$APPSTORE_PROFILE_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS
|
||||
echo -n "$APPSTORE_PROFILE_DEV_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS_DEV
|
||||
echo -n "$APPSTORE_PROFILE_MACOS_BASE64" | base64 --decode -o $PP_PATH_MACOS
|
||||
|
||||
# create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
@@ -47,32 +64,28 @@ jobs:
|
||||
|
||||
# import certificate to keychain
|
||||
security import $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security import $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH_IOS ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH_IOS_DEV ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
|
||||
#2 Setup Java
|
||||
- name: Set Up Java
|
||||
uses: actions/setup-java@v3.12.0
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
cache: true
|
||||
|
||||
#3 Setup Flutter
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
- name: 🚀 Shorebird Release macOS
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
channel: 'stable'
|
||||
|
||||
#4 Install Dependencies
|
||||
- name: Install Dependencies
|
||||
run: flutter pub get
|
||||
|
||||
#8 Build app ( macos Build )
|
||||
- name: Build App
|
||||
run: flutter build macos --release
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: macos
|
||||
|
||||
- name: Code Signing
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --options runtime SwiftControl.app -v
|
||||
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --entitlements ../../../../../macos/Runner/Release.entitlements --options runtime SwiftControl.app -v
|
||||
working-directory: build/macos/Build/Products/Release
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
@@ -82,16 +95,25 @@ jobs:
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
|
||||
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
|
||||
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
- name: 🚀 Shorebird Release Android
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: android
|
||||
args: "--artifact=apk"
|
||||
|
||||
- name: Build Bundle
|
||||
run: flutter build appbundle --release
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
|
||||
- name: Build Web
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: flutter build web --release --base-href "/swiftcontrol/"
|
||||
|
||||
- name: Handle archives
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
|
||||
cd build/macos/Build/Products/Release/
|
||||
@@ -99,6 +121,7 @@ jobs:
|
||||
|
||||
#9 Upload Artifacts
|
||||
- name: Upload Artifacts
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Releases
|
||||
@@ -108,6 +131,7 @@ jobs:
|
||||
|
||||
#10 Extract Version
|
||||
- name: Extract version from pubspec.yaml
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: extract_version
|
||||
run: |
|
||||
version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
|
||||
@@ -115,6 +139,7 @@ jobs:
|
||||
|
||||
#11 Check if Tag Exists
|
||||
- name: Check if Tag Exists
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: check_tag
|
||||
run: |
|
||||
if git rev-parse "v${{ env.VERSION }}" >/dev/null 2>&1; then
|
||||
@@ -125,7 +150,7 @@ jobs:
|
||||
|
||||
#12 Modify Tag if it Exists
|
||||
- name: Modify Tag
|
||||
if: env.TAG_EXISTS == 'true'
|
||||
if: env.TAG_EXISTS == 'true' && github.ref == 'refs/heads/main'
|
||||
id: modify_tag
|
||||
run: |
|
||||
new_version="${{ env.VERSION }}-build-${{ github.run_number }}"
|
||||
@@ -133,22 +158,25 @@ jobs:
|
||||
|
||||
#13 Create Release
|
||||
- name: Create Release
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
allowUpdates: true
|
||||
prerelease: ${{ endsWith(env.VERSION, '1337') }}
|
||||
body: "You can also download the Android version from the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Upload static files as artifact
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: deployment
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: build/web
|
||||
|
||||
- name: Web Deploy
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
- name: Extract latest changelog
|
||||
@@ -156,7 +184,22 @@ jobs:
|
||||
run: |
|
||||
chmod +x scripts/get_latest_changelog.sh
|
||||
mkdir -p whatsnew
|
||||
./scripts/get_latest_changelog.sh > whatsnew/whatsnew-en-US
|
||||
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
|
||||
|
||||
- name: 🚀 Shorebird Release iOS
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: ios
|
||||
args: "--export-options-plist ios/ExportOptions.plist"
|
||||
|
||||
- name: Prepare App Store authentication key
|
||||
env:
|
||||
API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }}
|
||||
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
|
||||
run: |
|
||||
mkdir -p ./private_keys;
|
||||
printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8";
|
||||
|
||||
- name: Upload to Play Store
|
||||
# only upload when env.VERSION does not end with 1337, which is our indicator for beta releases
|
||||
@@ -169,8 +212,25 @@ jobs:
|
||||
track: production
|
||||
whatsNewDirectory: whatsnew
|
||||
|
||||
- name: Upload to macOS App Store
|
||||
env:
|
||||
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
run: |
|
||||
productbuild --component "build/macos/Build/Products/Release/SwiftControl.app" /Applications "SwiftControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
|
||||
xcrun altool --upload-app -f SwiftControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
|
||||
- name: Upload to iOS App Store
|
||||
env:
|
||||
APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
run: |
|
||||
xcrun altool --upload-app -f build/ios/ipa/swift_play.ipa -t ios --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
|
||||
|
||||
|
||||
windows:
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
name: Build & Release on Windows
|
||||
runs-on: windows-latest
|
||||
|
||||
@@ -186,18 +246,16 @@ jobs:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
|
||||
#3 Setup Flutter
|
||||
- name: Set Up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
- name: 🐦 Setup Shorebird
|
||||
uses: shorebirdtech/setup-shorebird@v1
|
||||
with:
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
#4 Install Dependencies
|
||||
- name: Install Dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build App
|
||||
run: flutter build windows
|
||||
- name: 🚀 Shorebird Release Windows
|
||||
uses: shorebirdtech/shorebird-release@v1
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
platform: windows
|
||||
|
||||
- name: Zip directory (Windows)
|
||||
shell: pwsh
|
||||
@@ -248,5 +306,6 @@ jobs:
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip"
|
||||
bodyFile: scripts/RELEASE_NOTES.md
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
13
CHANGELOG.md
@@ -1,7 +1,18 @@
|
||||
### 2.6.2 (2025-10-01)
|
||||
### 3.0.3 (2025-10-12)
|
||||
- SwiftControl now supports iOS!
|
||||
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations but...:
|
||||
- You can now use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Click devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
- Ride: analog paddles are now supported thanks to contributor @jmoro
|
||||
- you can now zoom in and out in the Keymap customization screen
|
||||
|
||||
### 2.6.3 (2025-10-01)
|
||||
- fix a few issues with the new touch placement feature
|
||||
- add a workaround for Zwift Click V2 which resets the device when button events are no longer sent
|
||||
- fix issue on Android and Desktop where only a "touch down" was sent, but no "touch up"
|
||||
- improve UI when handling custom keymaps around the edges of the screen
|
||||
|
||||
### 2.6.0 (2025-09-30)
|
||||
- refactor touch placements: show touches on screen, fix misplaced coordinates - should fix #64
|
||||
|
||||
28
README.md
@@ -11,8 +11,6 @@ With SwiftControl you can **control your favorite trainer app** using your Zwift
|
||||
- control music on your device
|
||||
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
|
||||
|
||||
**Android AccessibilityService Usage**: On Android, SwiftControl uses the AccessibilityService API to simulate touch gestures on your screen, allowing your Zwift devices to control training apps. This service only monitors which app window is active and performs touch gestures at the locations you configure. No personal data is accessed or collected.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
@@ -21,20 +19,21 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
## Downloads
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
|
||||
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a>
|
||||
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a>
|
||||
|
||||
Get the latest version for free for Windows, macOS and Android here: https://github.com/jonasbark/swiftcontrol/releases
|
||||
|
||||
Get the latest version for Windows here: https://github.com/jonasbark/swiftcontrol/releases
|
||||
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- indieVelo / Training Peaks
|
||||
- Biketerra.com
|
||||
- any other:
|
||||
- Android: you can customize simulated touch points of all your buttons in the app
|
||||
- Desktop: you can customize keyboard shortcuts and mouse clicks in the app
|
||||
- any other! Customize touch points or keyboard shortcuts to your liking
|
||||
|
||||
## Supported Devices
|
||||
- Zwift Click
|
||||
- Zwift Click v2 (mostly, see #68)
|
||||
- Zwift Click v2 (mostly, see issue #68)
|
||||
- Zwift Ride
|
||||
- Zwift Play
|
||||
|
||||
@@ -47,22 +46,27 @@ Get the latest version for free for Windows, macOS and Android here: https://git
|
||||
- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).
|
||||
- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70).
|
||||
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
|
||||
- NOT SUPPORTED: iOS (iPhone, iPad) as Apple does not provide any way to simulate touches or keyboard events
|
||||
- iOS (iPhone, iPad)
|
||||
- Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you can use it to remotely control MyWhoosh and similar on e.g. an iPad.
|
||||
|
||||
## Troubleshooting
|
||||
- Your Zwift device is found but connection does not work properly? You may need to update the firmware in Zwift Companion app.
|
||||
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
|
||||
|
||||
## How does it work?
|
||||
The app connects to your Zwift device automatically. It does not connect to your trainer itself.
|
||||
|
||||
- When using Android: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- When using macOS or Windows a keyboard or mouse click is used to trigger the action.
|
||||
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
|
||||
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
|
||||
- you can also create your own Keymaps for any other app
|
||||
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
|
||||
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
|
||||
- your phone (Android/iOS) runs SwiftControl and connects to your Zwift devices
|
||||
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
|
||||
- after pairing SwiftControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
|
||||
|
||||
## Alternatives
|
||||
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app)
|
||||
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app). This can be useful if your trainer app does not support virtual shifting.
|
||||
|
||||
## Donate
|
||||
Please consider donating to support the development of this app :)
|
||||
|
||||
22
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## Click device cannot be found
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## Click device does not send any data
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## My Click v2 disconnects after a minute
|
||||
Check [this](https://github.com/jonasbark/swiftcontrol/issues/68) discussion.
|
||||
|
||||
To make your Click V2 work best you should connect it in the Zwift app once each day.
|
||||
If you don't do that SwiftControl will need to reconnect every minute.
|
||||
|
||||
1. Open Zwift app (not the Companion)
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Click V2
|
||||
4. Close the Zwift app again and connect again in SwiftControl
|
||||
|
||||
## Remote control is not working - nothing happens
|
||||
- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
|
||||
- Try restarting the pairing process in SwiftControl
|
||||
- try restarting Bluetooth on your phone and on the device you want to control
|
||||
- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
|
||||
@@ -56,7 +56,7 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
path.moveTo(x.toFloat(), y.toFloat())
|
||||
path.lineTo(x.toFloat()+1, y.toFloat())
|
||||
|
||||
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown)
|
||||
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown && !isKeyUp)
|
||||
gestureBuilder.addStroke(stroke)
|
||||
|
||||
dispatchGesture(gestureBuilder.build(), null, null)
|
||||
|
||||
@@ -23,8 +23,10 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
- require_trailing_commas
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
formatter:
|
||||
page_width: 120
|
||||
trailing_commas: preserve
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Allow Bluetooth -->
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
|
||||
|
||||
<!-- New Bluetooth permissions in Android 12
|
||||
https://developer.android.com/about/versions/12/features/bluetooth-permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
|
||||
@@ -16,7 +18,7 @@
|
||||
|
||||
|
||||
<!-- legacy for Android 9 or lower -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" tools:replace="android:maxSdkVersion" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- to check if you have the latest version -->
|
||||
|
||||
@@ -9,9 +9,9 @@ flutter_launcher_icons:
|
||||
# adaptive_icon_foreground: "assets/icon/foreground.png"
|
||||
# adaptive_icon_monochrome: "assets/icon/monochrome.png"
|
||||
|
||||
ios: false
|
||||
ios: true
|
||||
# image_path_ios: "assets/icon/icon.png"
|
||||
remove_alpha_channel_ios: true
|
||||
remove_alpha_ios: true
|
||||
# image_path_ios_dark_transparent: "assets/icon/icon_dark.png"
|
||||
# image_path_ios_tinted_grayscale: "assets/icon/icon_tinted.png"
|
||||
# desaturate_tinted_to_grayscale_ios: true
|
||||
|
||||
29
ios/ExportOptions.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
<key>generateAppStoreInformation</key>
|
||||
<false/>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<true/>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>de.jonasbark.swiftcontrol.darwin</key>
|
||||
<string>ios app store</string>
|
||||
</dict>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>teamID</key>
|
||||
<string>UZRHKPVWN9</string>
|
||||
<key>testFlightInternalTestingOnly</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,4 +1,7 @@
|
||||
PODS:
|
||||
- bluetooth_low_energy_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
@@ -10,6 +13,10 @@ PODS:
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- restart (1.0.0):
|
||||
- Flutter
|
||||
- restart_app (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -18,19 +25,27 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- bluetooth_low_energy_darwin (from `.symlinks/plugins/bluetooth_low_energy_darwin/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- restart (from `.symlinks/plugins/restart/ios`)
|
||||
- restart_app (from `.symlinks/plugins/restart_app/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
bluetooth_low_energy_darwin:
|
||||
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
@@ -43,23 +58,33 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
restart:
|
||||
:path: ".symlinks/plugins/restart/ios"
|
||||
restart_app:
|
||||
:path: ".symlinks/plugins/restart_app/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
universal_ble:
|
||||
:path: ".symlinks/plugins/universal_ble/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
bluetooth_low_energy_darwin: 764d8d1ae5abefbcdb839e812b4b25c0061fcf8b
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
restart: b5fe16e6e038f0024b2f3af43768e9d2a1557554
|
||||
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
8AA6D129479129F106E2298A /* Pods-RunnerTests.release.xcconfig */,
|
||||
EFDECED99A47773C293F8819 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -488,16 +487,19 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 7BL8RUV2K6;
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftPlay;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -558,7 +560,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -615,7 +617,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
@@ -671,16 +673,19 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 7BL8RUV2K6;
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftPlay;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -694,16 +699,19 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 7BL8RUV2K6;
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftPlay;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
@@ -1,122 +1 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 880 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 37 KiB |
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -24,6 +26,17 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>SwiftControl uses Bluetooth to connect to accessories.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-peripheral</string>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -41,11 +54,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>SwiftControl</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -186,6 +186,10 @@ class Connection {
|
||||
devices.clear();
|
||||
}
|
||||
|
||||
void signalNotification(BaseNotification notification) {
|
||||
_actionStreams.add(notification);
|
||||
}
|
||||
|
||||
void signalChange(BaseDevice baseDevice) {
|
||||
_connectionStreams.add(baseDevice);
|
||||
}
|
||||
|
||||
@@ -27,11 +27,10 @@ abstract class BaseDevice {
|
||||
final zapEncryption = ZapCrypto(LocalKeyProvider());
|
||||
|
||||
bool isConnected = false;
|
||||
bool _isInited = false;
|
||||
int? batteryLevel;
|
||||
String? firmwareVersion;
|
||||
|
||||
bool supportsEncryption = true;
|
||||
bool supportsEncryption = false;
|
||||
|
||||
BleCharacteristic? syncRxCharacteristic;
|
||||
Timer? _longPressTimer;
|
||||
@@ -280,14 +279,20 @@ abstract class BaseDevice {
|
||||
|
||||
// Handle release events for long press keys
|
||||
final buttonsReleased = _previouslyPressedButtons.toList();
|
||||
if (buttonsReleased.isNotEmpty) {
|
||||
final isLongPress =
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && isLongPress) {
|
||||
await _performRelease(buttonsReleased);
|
||||
}
|
||||
_previouslyPressedButtons.clear();
|
||||
} else {
|
||||
// Handle release events for buttons that are no longer pressed
|
||||
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
|
||||
if (buttonsReleased.isNotEmpty) {
|
||||
final wasLongPress =
|
||||
buttonsReleased.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsReleased.single)?.isLongPress == true;
|
||||
if (buttonsReleased.isNotEmpty && wasLongPress) {
|
||||
await _performRelease(buttonsReleased);
|
||||
}
|
||||
|
||||
@@ -301,27 +306,41 @@ abstract class BaseDevice {
|
||||
// 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 {
|
||||
_performActions(buttonsClicked, true);
|
||||
_performClick(buttonsClicked);
|
||||
});
|
||||
}
|
||||
// Update currently pressed buttons
|
||||
_previouslyPressedButtons = buttonsClicked.toSet();
|
||||
|
||||
return _performActions(buttonsClicked, false);
|
||||
if (isLongPress) {
|
||||
return _performDown(buttonsClicked);
|
||||
} else {
|
||||
return _performClick(buttonsClicked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performActions(List<ZwiftButton> buttonsClicked, bool repeated) async {
|
||||
if (!repeated &&
|
||||
buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
Future<void> _performDown(List<ZwiftButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
for (final action in buttonsClicked) {
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
final isKeyDown = !repeated;
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: isKeyDown, isKeyUp: false)),
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performClick(List<ZwiftButton> buttonsClicked) async {
|
||||
if (buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp)) &&
|
||||
settings.getVibrationEnabled()) {
|
||||
await _vibrate();
|
||||
}
|
||||
for (final action in buttonsClicked) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: true, isKeyUp: true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -346,7 +365,6 @@ abstract class BaseDevice {
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_isInited = false;
|
||||
_longPressTimer?.cancel();
|
||||
// Release any held keys in long press mode
|
||||
if (actionHandler is DesktopActions) {
|
||||
|
||||
@@ -14,6 +14,11 @@ import '../messages/notification.dart';
|
||||
import '../protocol/zp.pb.dart';
|
||||
|
||||
class ZwiftRide extends BaseDevice {
|
||||
/// Minimum absolute analog value (0-100) required to trigger paddle button press.
|
||||
/// Values below this threshold are ignored to prevent accidental triggers from
|
||||
/// analog drift or light touches.
|
||||
static const int analogPaddleThreshold = 25;
|
||||
|
||||
ZwiftRide(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
@@ -84,9 +89,7 @@ class ZwiftRide extends BaseDevice {
|
||||
'Your Zwift Click V2 no longer sends events. Connect it in the Zwift app once per day. Resetting the device now.',
|
||||
),
|
||||
);
|
||||
if (!kDebugMode) {
|
||||
sendCommand(Opcode.RESET, null);
|
||||
}
|
||||
sendCommand(Opcode.RESET, null);
|
||||
}
|
||||
|
||||
switch (opcode) {
|
||||
@@ -200,7 +203,11 @@ class ZwiftRide extends BaseDevice {
|
||||
|
||||
@override
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
final RideNotification clickNotification = RideNotification(message);
|
||||
final RideNotification clickNotification = RideNotification(
|
||||
message,
|
||||
analogPaddleThreshold: analogPaddleThreshold,
|
||||
);
|
||||
|
||||
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
|
||||
_lastControllerNotification = clickNotification;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
@@ -34,10 +35,14 @@ enum _RideButtonMask {
|
||||
|
||||
class RideNotification extends BaseNotification {
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
late List<ZwiftButton> analogButtons;
|
||||
|
||||
RideNotification(Uint8List message) {
|
||||
RideNotification(Uint8List message, {required int analogPaddleThreshold}) {
|
||||
final status = RideKeyPadStatus.fromBuffer(message);
|
||||
|
||||
// Debug: Log all button mask detections (moved to ZwiftRide.processClickNotification)
|
||||
|
||||
// Process DIGITAL buttons separately
|
||||
buttonsClicked = [
|
||||
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationLeft,
|
||||
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationRight,
|
||||
@@ -58,22 +63,37 @@ class RideNotification extends BaseNotification {
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffRight,
|
||||
];
|
||||
|
||||
for (final analogue in status.analogButtons.groupStatus) {
|
||||
if (analogue.analogValue.abs() == 100) {
|
||||
if (analogue.location == RideAnalogLocation.LEFT) {
|
||||
buttonsClicked.add(ZwiftButton.paddleLeft);
|
||||
} else if (analogue.location == RideAnalogLocation.RIGHT) {
|
||||
buttonsClicked.add(ZwiftButton.paddleRight);
|
||||
} else if (analogue.location == RideAnalogLocation.DOWN || analogue.location == RideAnalogLocation.UP) {
|
||||
// TODO what is this even?
|
||||
// Process ANALOG inputs separately - now properly separated from digital
|
||||
// All analog paddles (L0-L3) appear in field 3 as repeated RideAnalogKeyPress
|
||||
analogButtons = [];
|
||||
try {
|
||||
for (final paddle in status.analogPaddles) {
|
||||
if (paddle.hasLocation() && paddle.hasAnalogValue()) {
|
||||
if (paddle.analogValue.abs() >= analogPaddleThreshold) {
|
||||
final button = switch (paddle.location.value) {
|
||||
0 => ZwiftButton.paddleLeft, // L0 = left paddle
|
||||
1 => ZwiftButton.paddleRight, // L1 = right paddle
|
||||
_ => null, // L2, L3 unused
|
||||
};
|
||||
|
||||
if (button != null) {
|
||||
buttonsClicked.add(button);
|
||||
analogButtons.add(button);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error parsing analog paddle data: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name.splitByUpperCase())}';
|
||||
final digitalButtons = buttonsClicked.where((b) => !analogButtons.contains(b)).toList();
|
||||
return 'Digital: ${digitalButtons.joinToString(transform: (e) => e.name.splitByUpperCase())} | Analog: ${analogButtons.joinToString(transform: (e) => e.name.splitByUpperCase())}';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -480,62 +480,19 @@ class RideAnalogKeyPress extends $pb.GeneratedMessage {
|
||||
void clearAnalogValue() => clearField(2);
|
||||
}
|
||||
|
||||
class RideAnalogKeyGroup extends $pb.GeneratedMessage {
|
||||
factory RideAnalogKeyGroup({
|
||||
$core.Iterable<RideAnalogKeyPress>? groupStatus,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (groupStatus != null) {
|
||||
$result.groupStatus.addAll(groupStatus);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
RideAnalogKeyGroup._() : super();
|
||||
factory RideAnalogKeyGroup.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r);
|
||||
factory RideAnalogKeyGroup.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r);
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RideAnalogKeyGroup', package: const $pb.PackageName(_omitMessageNames ? '' : 'de.jonasbark'), createEmptyInstance: create)
|
||||
..pc<RideAnalogKeyPress>(1, _omitFieldNames ? '' : 'GroupStatus', $pb.PbFieldType.PM, protoName: 'GroupStatus', subBuilder: RideAnalogKeyPress.create)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.deepCopy] instead. '
|
||||
'Will be removed in next major version')
|
||||
RideAnalogKeyGroup clone() => RideAnalogKeyGroup()..mergeFromMessage(this);
|
||||
@$core.Deprecated(
|
||||
'Using this can add significant overhead to your binary. '
|
||||
'Use [GeneratedMessageGenericExtensions.rebuild] instead. '
|
||||
'Will be removed in next major version')
|
||||
RideAnalogKeyGroup copyWith(void Function(RideAnalogKeyGroup) updates) => super.copyWith((message) => updates(message as RideAnalogKeyGroup)) as RideAnalogKeyGroup;
|
||||
|
||||
$pb.BuilderInfo get info_ => _i;
|
||||
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static RideAnalogKeyGroup create() => RideAnalogKeyGroup._();
|
||||
RideAnalogKeyGroup createEmptyInstance() => create();
|
||||
static $pb.PbList<RideAnalogKeyGroup> createRepeated() => $pb.PbList<RideAnalogKeyGroup>();
|
||||
@$core.pragma('dart2js:noInline')
|
||||
static RideAnalogKeyGroup getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<RideAnalogKeyGroup>(create);
|
||||
static RideAnalogKeyGroup? _defaultInstance;
|
||||
|
||||
@$pb.TagNumber(1)
|
||||
$core.List<RideAnalogKeyPress> get groupStatus => $_getList(0);
|
||||
}
|
||||
|
||||
/// The command code prepending this message is 0x23
|
||||
/// All analog paddles (L0-L3) appear as repeated RideAnalogKeyPress in field 3
|
||||
class RideKeyPadStatus extends $pb.GeneratedMessage {
|
||||
factory RideKeyPadStatus({
|
||||
$core.int? buttonMap,
|
||||
RideAnalogKeyGroup? analogButtons,
|
||||
$core.Iterable<RideAnalogKeyPress>? analogPaddles,
|
||||
}) {
|
||||
final $result = create();
|
||||
if (buttonMap != null) {
|
||||
$result.buttonMap = buttonMap;
|
||||
}
|
||||
if (analogButtons != null) {
|
||||
$result.analogButtons = analogButtons;
|
||||
if (analogPaddles != null) {
|
||||
$result.analogPaddles.addAll(analogPaddles);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
@@ -545,7 +502,7 @@ class RideKeyPadStatus extends $pb.GeneratedMessage {
|
||||
|
||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'RideKeyPadStatus', package: const $pb.PackageName(_omitMessageNames ? '' : 'de.jonasbark'), createEmptyInstance: create)
|
||||
..a<$core.int>(1, _omitFieldNames ? '' : 'ButtonMap', $pb.PbFieldType.OU3, protoName: 'ButtonMap')
|
||||
..aOM<RideAnalogKeyGroup>(2, _omitFieldNames ? '' : 'AnalogButtons', protoName: 'AnalogButtons', subBuilder: RideAnalogKeyGroup.create)
|
||||
..pc<RideAnalogKeyPress>(3, _omitFieldNames ? '' : 'AnalogPaddles', $pb.PbFieldType.PM, protoName: 'AnalogPaddles', subBuilder: RideAnalogKeyPress.create)
|
||||
..hasRequiredFields = false
|
||||
;
|
||||
|
||||
@@ -579,16 +536,8 @@ class RideKeyPadStatus extends $pb.GeneratedMessage {
|
||||
@$pb.TagNumber(1)
|
||||
void clearButtonMap() => clearField(1);
|
||||
|
||||
@$pb.TagNumber(2)
|
||||
RideAnalogKeyGroup get analogButtons => $_getN(1);
|
||||
@$pb.TagNumber(2)
|
||||
set analogButtons(RideAnalogKeyGroup v) { setField(2, v); }
|
||||
@$pb.TagNumber(2)
|
||||
$core.bool hasAnalogButtons() => $_has(1);
|
||||
@$pb.TagNumber(2)
|
||||
void clearAnalogButtons() => clearField(2);
|
||||
@$pb.TagNumber(2)
|
||||
RideAnalogKeyGroup ensureAnalogButtons() => $_ensure(1);
|
||||
@$pb.TagNumber(3)
|
||||
$core.List<RideAnalogKeyPress> get analogPaddles => $_getList(1);
|
||||
}
|
||||
|
||||
/// ------------------ Zwift Click messages
|
||||
|
||||
@@ -170,33 +170,20 @@ final $typed_data.Uint8List rideAnalogKeyPressDescriptor = $convert.base64Decode
|
||||
'lkZUFuYWxvZ0xvY2F0aW9uUghMb2NhdGlvbhIgCgtBbmFsb2dWYWx1ZRgCIAEoEVILQW5hbG9n'
|
||||
'VmFsdWU=');
|
||||
|
||||
@$core.Deprecated('Use rideAnalogKeyGroupDescriptor instead')
|
||||
const RideAnalogKeyGroup$json = {
|
||||
'1': 'RideAnalogKeyGroup',
|
||||
'2': [
|
||||
{'1': 'GroupStatus', '3': 1, '4': 3, '5': 11, '6': '.de.jonasbark.RideAnalogKeyPress', '10': 'GroupStatus'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `RideAnalogKeyGroup`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List rideAnalogKeyGroupDescriptor = $convert.base64Decode(
|
||||
'ChJSaWRlQW5hbG9nS2V5R3JvdXASQgoLR3JvdXBTdGF0dXMYASADKAsyIC5kZS5qb25hc2Jhcm'
|
||||
'suUmlkZUFuYWxvZ0tleVByZXNzUgtHcm91cFN0YXR1cw==');
|
||||
|
||||
@$core.Deprecated('Use rideKeyPadStatusDescriptor instead')
|
||||
const RideKeyPadStatus$json = {
|
||||
'1': 'RideKeyPadStatus',
|
||||
'2': [
|
||||
{'1': 'ButtonMap', '3': 1, '4': 1, '5': 13, '10': 'ButtonMap'},
|
||||
{'1': 'AnalogButtons', '3': 2, '4': 1, '5': 11, '6': '.de.jonasbark.RideAnalogKeyGroup', '10': 'AnalogButtons'},
|
||||
{'1': 'AnalogPaddles', '3': 3, '4': 3, '5': 11, '6': '.de.jonasbark.RideAnalogKeyPress', '10': 'AnalogPaddles'},
|
||||
],
|
||||
};
|
||||
|
||||
/// Descriptor for `RideKeyPadStatus`. Decode as a `google.protobuf.DescriptorProto`.
|
||||
final $typed_data.Uint8List rideKeyPadStatusDescriptor = $convert.base64Decode(
|
||||
'ChBSaWRlS2V5UGFkU3RhdHVzEhwKCUJ1dHRvbk1hcBgBIAEoDVIJQnV0dG9uTWFwEkYKDUFuYW'
|
||||
'xvZ0J1dHRvbnMYAiABKAsyIC5kZS5qb25hc2JhcmsuUmlkZUFuYWxvZ0tleUdyb3VwUg1BbmFs'
|
||||
'b2dCdXR0b25z');
|
||||
'xvZ1BhZGRsZXMYAyADKAsyIC5kZS5qb25hc2JhcmsuUmlkZUFuYWxvZ0tleVByZXNzUg1BbmFs'
|
||||
'b2dQYWRkbGVz');
|
||||
|
||||
@$core.Deprecated('Use clickKeyPadStatusDescriptor instead')
|
||||
const ClickKeyPadStatus$json = {
|
||||
|
||||
@@ -79,14 +79,11 @@ message RideAnalogKeyPress {
|
||||
optional sint32 AnalogValue = 2;
|
||||
}
|
||||
|
||||
message RideAnalogKeyGroup {
|
||||
repeated RideAnalogKeyPress GroupStatus = 1;
|
||||
}
|
||||
|
||||
// The command code prepending this message is 0x23
|
||||
// All analog paddles (L0-L3) appear as repeated RideAnalogKeyPress in field 3
|
||||
message RideKeyPadStatus {
|
||||
optional uint32 ButtonMap = 1;
|
||||
optional RideAnalogKeyGroup AnalogButtons = 2;
|
||||
repeated RideAnalogKeyPress AnalogPaddles = 3; // Field 3 contains all paddles
|
||||
}
|
||||
|
||||
//------------------ Zwift Click messages
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:swift_control/pages/requirements.dart';
|
||||
import 'package:swift_control/theme.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/utils/settings/settings.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
@@ -15,26 +16,41 @@ import 'bluetooth/connection.dart';
|
||||
import 'utils/actions/base_actions.dart';
|
||||
|
||||
final connection = Connection();
|
||||
late final BaseActions actionHandler;
|
||||
late BaseActions actionHandler;
|
||||
final accessibilityHandler = Accessibility();
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
final settings = Settings();
|
||||
const screenshotMode = false;
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if (kIsWeb) {
|
||||
actionHandler = StubActions();
|
||||
} else if (Platform.isAndroid || Platform.isIOS) {
|
||||
actionHandler = AndroidActions();
|
||||
} else {
|
||||
actionHandler = DesktopActions();
|
||||
|
||||
initializeActions(true);
|
||||
if (actionHandler is DesktopActions) {
|
||||
// Must add this line.
|
||||
await windowManager.ensureInitialized();
|
||||
windowManager.setSize(Size(1280, 800));
|
||||
}
|
||||
|
||||
runApp(const SwiftPlayApp());
|
||||
}
|
||||
|
||||
Future<void> initializeActions(bool local) async {
|
||||
if (kIsWeb) {
|
||||
actionHandler = StubActions();
|
||||
} else if (Platform.isAndroid) {
|
||||
if (local) {
|
||||
actionHandler = AndroidActions();
|
||||
} else {
|
||||
actionHandler = RemoteActions();
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
actionHandler = RemoteActions();
|
||||
} else {
|
||||
actionHandler = DesktopActions();
|
||||
}
|
||||
}
|
||||
|
||||
class SwiftPlayApp extends StatelessWidget {
|
||||
const SwiftPlayApp({super.key});
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/changelog.dart';
|
||||
|
||||
class ChangelogPage extends StatefulWidget {
|
||||
const ChangelogPage({super.key});
|
||||
|
||||
@override
|
||||
State<ChangelogPage> createState() => _ChangelogPageState();
|
||||
}
|
||||
|
||||
class _ChangelogPageState extends State<ChangelogPage> {
|
||||
List<ChangelogEntry>? _entries;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadChangelog();
|
||||
}
|
||||
|
||||
Future<void> _loadChangelog() async {
|
||||
try {
|
||||
final entries = await ChangelogParser.parse();
|
||||
setState(() {
|
||||
_entries = entries;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Failed to load changelog: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Changelog'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: _error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _entries == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.all(16),
|
||||
itemCount: _entries!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _entries![index];
|
||||
return Card(
|
||||
margin: EdgeInsets.only(bottom: 16),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Version ${entry.version}',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
entry.date,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
...entry.changes.map(
|
||||
(change) => Padding(
|
||||
padding: EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('• ', style: TextStyle(fontSize: 16)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
change,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,26 @@ import 'dart:io';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zp.pb.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/pages/touch_area.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/loading_widget.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
import '../utils/actions/remote.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/apps/supported_app.dart';
|
||||
import '../utils/requirements/remote.dart';
|
||||
import '../widgets/menu.dart';
|
||||
|
||||
class DevicePage extends StatefulWidget {
|
||||
@@ -24,14 +33,50 @@ class DevicePage extends StatefulWidget {
|
||||
State<DevicePage> createState() => _DevicePageState();
|
||||
}
|
||||
|
||||
class _DevicePageState extends State<DevicePage> {
|
||||
class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
|
||||
late StreamSubscription<BaseDevice> _connectionStateSubscription;
|
||||
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
|
||||
|
||||
List<SupportedApp> _getAllApps() {
|
||||
final baseApps = SupportedApp.supportedApps.where((app) => app is! CustomApp).toList();
|
||||
final customProfiles = settings.getCustomAppProfiles();
|
||||
|
||||
final customApps = customProfiles.map((profile) {
|
||||
final customApp = CustomApp(profileName: profile);
|
||||
final savedKeymap = settings.getCustomAppKeymap(profile);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
return customApp;
|
||||
}).toList();
|
||||
|
||||
// If no custom profiles exist, add the default "Custom" one
|
||||
if (customApps.isEmpty) {
|
||||
customApps.add(CustomApp());
|
||||
}
|
||||
|
||||
return [...baseApps, ...customApps];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// keep screen on - this is required for iOS to keep the bluetooth connection alive
|
||||
WakelockPlus.enable();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// show snackbar to inform user that the app needs to stay in foreground
|
||||
_snackBarMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('To keep working properly the app needs to stay in the foreground.'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
_connectionStateSubscription = connection.connectionStream.listen((state) async {
|
||||
setState(() {});
|
||||
});
|
||||
@@ -39,15 +84,37 @@ class _DevicePageState extends State<DevicePage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
_connectionStateSubscription.cancel();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && actionHandler is RemoteActions && Platform.isIOS) {
|
||||
final requirement = RemoteRequirement();
|
||||
requirement.reconnect();
|
||||
_snackBarMessengerKey.currentState?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('To keep working properly the app needs to stay in the foreground.'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final canVibrate = connection.devices.any(
|
||||
(device) => (device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') && device.isConnected,
|
||||
);
|
||||
|
||||
final paddingMultiplicator = actionHandler is DesktopActions ? 2.5 : 1.0;
|
||||
|
||||
return ScaffoldMessenger(
|
||||
key: _snackBarMessengerKey,
|
||||
child: PopScope(
|
||||
@@ -63,156 +130,324 @@ class _DevicePageState extends State<DevicePage> {
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
left: 8.0 * paddingMultiplicator,
|
||||
right: 8 * paddingMultiplicator,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Text('Connected Devices:', style: Theme.of(context).textTheme.titleMedium),
|
||||
|
||||
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: actionHandler is RemoteActions ? 0 : 12,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that SwiftControl will need to reconnect every minute.
|
||||
child: Column(
|
||||
children: [
|
||||
if (connection.devices.isEmpty)
|
||||
Text('No devices connected. Go back and connect a device to get started.'),
|
||||
...connection.devices.map(
|
||||
(device) => Row(
|
||||
children: [
|
||||
Text(
|
||||
device.device.name?.screenshot ?? device.runtimeType.toString(),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (device.batteryLevel != null) ...[
|
||||
Icon(switch (device.batteryLevel!) {
|
||||
>= 80 => Icons.battery_full,
|
||||
>= 60 => Icons.battery_6_bar,
|
||||
>= 50 => Icons.battery_5_bar,
|
||||
>= 25 => Icons.battery_4_bar,
|
||||
>= 10 => Icons.battery_2_bar,
|
||||
_ => Icons.battery_alert,
|
||||
}),
|
||||
Text('${device.batteryLevel}%'),
|
||||
if (device.firmwareVersion != null) Text(' - Firmware: ${device.firmwareVersion}'),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (actionHandler is RemoteActions)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
|
||||
),
|
||||
LoadingWidget(
|
||||
futureCallback: () async {
|
||||
final requirement = RemoteRequirement();
|
||||
await requirement.reconnect();
|
||||
},
|
||||
renderChild: (isLoading, tap) => TextButton(
|
||||
onPressed: tap,
|
||||
child: isLoading ? SmallProgressIndicator() : Text('Reconnect'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
|
||||
Container(
|
||||
margin: EdgeInsets.only(bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'''To make your Zwift Click V2 work best you should connect it in the Zwift app once each day.\nIf you don't do that SwiftControl will need to reconnect every minute.
|
||||
|
||||
1. Open Zwift app
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Zwift Click V2
|
||||
4. Close the Zwift app again and connect again in SwiftControl''',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
connection.devices.whereType<ZwiftClickV2>().forEach(
|
||||
(device) => device.sendCommand(Opcode.RESET, null),
|
||||
);
|
||||
},
|
||||
child: Text('Reset now'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Troubleshooting'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
connection.devices.joinToString(
|
||||
separator: '\n',
|
||||
transform: (it) {
|
||||
return """${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}
|
||||
${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}
|
||||
${it.firmwareVersion != null ? ' - Firmware Version: ${it.firmwareVersion}' : ''}""".trim();
|
||||
},
|
||||
),
|
||||
),
|
||||
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
|
||||
if (!kIsWeb)
|
||||
Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flex(
|
||||
|
||||
if (!kIsWeb) ...[
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Customize', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: canVibrate ? 0 : 12,
|
||||
),
|
||||
child: Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
direction: MediaQuery.sizeOf(context).width > 600 ? Axis.horizontal : Axis.vertical,
|
||||
spacing: 8,
|
||||
children: [
|
||||
DropdownMenu<SupportedApp>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries:
|
||||
SupportedApp.supportedApps
|
||||
.map((app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name))
|
||||
.toList(),
|
||||
label: Text('Select Keymap / app'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
}
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setApp(app);
|
||||
setState(() {});
|
||||
if (app is! CustomApp && !kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Customize the keymap if you experience any issues (e.g. wrong keyboard output)',
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownMenu<SupportedApp?>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries: [
|
||||
..._getAllApps().map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: CustomApp(profileName: 'New'),
|
||||
label: 'Create new keymap',
|
||||
labelWidget: Text('Create new keymap'),
|
||||
leadingIcon: Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
label: Text('Select Keymap / app'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
} else if (app.name == 'New') {
|
||||
final profileName = await _showNewProfileDialog();
|
||||
if (profileName != null && profileName.isNotEmpty) {
|
||||
final customApp = CustomApp(profileName: profileName);
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
controller.text = profileName;
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
await settings.setApp(app);
|
||||
setState(() {});
|
||||
if (app is! CustomApp &&
|
||||
!kIsWeb &&
|
||||
(Platform.isMacOS || Platform.isWindows)) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Customize the keymap if you experience any issues (e.g. wrong keyboard output)',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
if (actionHandler.supportedApp != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
if (actionHandler.supportedApp is! CustomApp) {
|
||||
await _duplicate(actionHandler.supportedApp!.name);
|
||||
}
|
||||
final result = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
|
||||
|
||||
if (result == true && actionHandler.supportedApp is CustomApp) {
|
||||
await settings.setApp(actionHandler.supportedApp!);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
icon: Icon(Icons.edit),
|
||||
label: Text('Edit'),
|
||||
),
|
||||
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final currentProfile = actionHandler.supportedApp?.name;
|
||||
final action = await _showManageProfileDialog(currentProfile);
|
||||
if (action != null) {
|
||||
if (action == 'rename') {
|
||||
final newName = await _showRenameProfileDialog(currentProfile!);
|
||||
if (newName != null && newName.isNotEmpty && newName != currentProfile) {
|
||||
await settings.duplicateCustomAppProfile(currentProfile, newName);
|
||||
await settings.deleteCustomAppProfile(currentProfile);
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
final savedKeymap = settings.getCustomAppKeymap(newName);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
controller.text = newName;
|
||||
setState(() {});
|
||||
}
|
||||
} else if (action == 'duplicate') {
|
||||
_duplicate(currentProfile!);
|
||||
} else if (action == 'delete') {
|
||||
final confirmed = await _showDeleteConfirmDialog(currentProfile!);
|
||||
if (confirmed == true) {
|
||||
await settings.deleteCustomAppProfile(currentProfile);
|
||||
controller.text = '';
|
||||
setState(() {});
|
||||
}
|
||||
} else if (action == 'import') {
|
||||
final jsonData = await _showImportDialog();
|
||||
if (jsonData != null && jsonData.isNotEmpty) {
|
||||
final success = await settings.importCustomAppProfile(jsonData);
|
||||
if (mounted) {
|
||||
if (success) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Profile imported successfully'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
} else {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to import profile. Invalid format.'),
|
||||
duration: Duration(seconds: 5),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (action == 'export') {
|
||||
final currentProfile =
|
||||
(actionHandler.supportedApp as CustomApp).profileName;
|
||||
final jsonData = settings.exportCustomAppProfile(currentProfile);
|
||||
if (jsonData != null) {
|
||||
await Clipboard.setData(ClipboardData(text: jsonData));
|
||||
if (mounted) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Profile "$currentProfile" exported to clipboard'),
|
||||
duration: Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.more_vert),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (actionHandler.supportedApp != null)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (actionHandler.supportedApp! is! CustomApp) {
|
||||
final customApp = CustomApp();
|
||||
|
||||
final connectedDevice = connection.devices.firstOrNull;
|
||||
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
|
||||
pair.buttons
|
||||
.filter(
|
||||
(button) => connectedDevice?.availableButtons.contains(button) == true,
|
||||
)
|
||||
.forEachIndexed((button, indexB) {
|
||||
customApp.setKey(
|
||||
button,
|
||||
physicalKey: pair.physicalKey!,
|
||||
logicalKey: pair.logicalKey,
|
||||
isLongPress: pair.isLongPress,
|
||||
touchPosition:
|
||||
pair.touchPosition != Offset.zero
|
||||
? pair.touchPosition
|
||||
: Offset(((indexB + 1)) * 100, 200 + (index * 100)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
}
|
||||
final result = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
|
||||
|
||||
if (result == true && actionHandler.supportedApp is CustomApp) {
|
||||
await settings.setApp(actionHandler.supportedApp!);
|
||||
}
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
),
|
||||
if (canVibrate) ...[
|
||||
SwitchListTile(
|
||||
title: Text('Enable vibration feedback when shifting gears'),
|
||||
value: settings.getVibrationEnabled(),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await settings.setVibrationEnabled(value);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text('Customize Keymap'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (actionHandler.supportedApp != null)
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
),
|
||||
if (connection.devices.any(
|
||||
(device) =>
|
||||
(device.device.name == 'Zwift Ride' || device.device.name == 'Zwift Play') &&
|
||||
device.isConnected,
|
||||
))
|
||||
SwitchListTile(
|
||||
title: Text('Vibration on Shift'),
|
||||
subtitle: Text('Enable vibration feedback when shifting gears'),
|
||||
value: settings.getVibrationEnabled(),
|
||||
onChanged: (value) async {
|
||||
await settings.setVibrationEnabled(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (kDebugMode &&
|
||||
connection.devices.any((device) => (device is ZwiftClickV2) && device.isConnected))
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
(connection.devices.first as ZwiftClickV2).test();
|
||||
},
|
||||
child: Text('Test'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text('Logs', style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
LogViewer(),
|
||||
],
|
||||
),
|
||||
@@ -224,4 +459,208 @@ ${it.firmwareVersion != null ? ' - Firmware Version: ${it.firmwareVersion}' : ''
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _showNewProfileDialog() async {
|
||||
final controller = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('New Custom Profile'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'Profile Name', hintText: 'e.g., Workout, Race, Event'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Create')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _showManageProfileDialog(String? currentProfile) async {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Manage Profile: ${currentProfile ?? ''}'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (currentProfile != null && actionHandler.supportedApp is CustomApp)
|
||||
ListTile(
|
||||
leading: Icon(Icons.edit),
|
||||
title: Text('Rename'),
|
||||
onTap: () => Navigator.pop(context, 'rename'),
|
||||
),
|
||||
if (currentProfile != null)
|
||||
ListTile(
|
||||
leading: Icon(Icons.copy),
|
||||
title: Text('Duplicate'),
|
||||
onTap: () => Navigator.pop(context, 'duplicate'),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.file_upload),
|
||||
title: Text('Import'),
|
||||
onTap: () => Navigator.pop(context, 'import'),
|
||||
),
|
||||
if (currentProfile != null)
|
||||
ListTile(
|
||||
leading: Icon(Icons.share),
|
||||
title: Text('Export'),
|
||||
onTap: () => Navigator.pop(context, 'export'),
|
||||
),
|
||||
if (currentProfile != null)
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: Theme.of(context).colorScheme.error),
|
||||
title: Text('Delete', style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
onTap: () => Navigator.pop(context, 'delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel'))],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _showRenameProfileDialog(String currentName) async {
|
||||
final controller = TextEditingController(text: currentName);
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Rename Profile'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'Profile Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Rename')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _showDuplicateProfileDialog(String currentName) async {
|
||||
final controller = TextEditingController(text: '$currentName (Copy)');
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Duplicate Profile'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'New Profile Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Duplicate')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> _showDeleteConfirmDialog(String profileName) async {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Delete Profile'),
|
||||
content: Text('Are you sure you want to delete "$profileName"? This action cannot be undone.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('Delete'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _showImportDialog() async {
|
||||
final controller = TextEditingController();
|
||||
|
||||
// Try to get data from clipboard
|
||||
try {
|
||||
final clipboardData = await Clipboard.getData('text/plain');
|
||||
if (clipboardData?.text != null) {
|
||||
controller.text = clipboardData!.text!;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore clipboard errors
|
||||
}
|
||||
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Import Profile'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Paste the exported JSON data below:'),
|
||||
SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'JSON Data', border: OutlineInputBorder()),
|
||||
maxLines: 5,
|
||||
autofocus: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, controller.text), child: Text('Import')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _duplicate(String currentProfile) async {
|
||||
final newName = await _showDuplicateProfileDialog(currentProfile);
|
||||
if (newName != null && newName.isNotEmpty) {
|
||||
if (actionHandler.supportedApp is CustomApp) {
|
||||
await settings.duplicateCustomAppProfile(currentProfile, newName);
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
final savedKeymap = settings.getCustomAppKeymap(newName);
|
||||
if (savedKeymap != null) {
|
||||
customApp.decodeKeymap(savedKeymap);
|
||||
}
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
controller.text = newName;
|
||||
setState(() {});
|
||||
} else {
|
||||
final customApp = CustomApp(profileName: newName);
|
||||
|
||||
final connectedDevice = connection.devices.firstOrNull;
|
||||
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
|
||||
pair.buttons.filter((button) => connectedDevice?.availableButtons.contains(button) == true).forEachIndexed((
|
||||
button,
|
||||
indexB,
|
||||
) {
|
||||
customApp.setKey(
|
||||
button,
|
||||
physicalKey: pair.physicalKey,
|
||||
logicalKey: pair.logicalKey,
|
||||
isLongPress: pair.isLongPress,
|
||||
touchPosition: pair.touchPosition != Offset.zero
|
||||
? pair.touchPosition
|
||||
: Offset(((indexB + 1)) * 10, 20 + (index * 10)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
actionHandler.supportedApp = customApp;
|
||||
await settings.setApp(customApp);
|
||||
controller.text = newName;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Screenshot on String {
|
||||
String get screenshot => screenshotMode ? replaceAll('Zwift ', '') : this;
|
||||
}
|
||||
|
||||
84
lib/pages/markdown.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'package:flex_color_scheme/flex_color_scheme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_md/flutter_md.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class MarkdownPage extends StatefulWidget {
|
||||
final String assetPath;
|
||||
const MarkdownPage({super.key, required this.assetPath});
|
||||
|
||||
@override
|
||||
State<MarkdownPage> createState() => _ChangelogPageState();
|
||||
}
|
||||
|
||||
class _ChangelogPageState extends State<MarkdownPage> {
|
||||
Markdown? _markdown;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadChangelog();
|
||||
}
|
||||
|
||||
Future<void> _loadChangelog() async {
|
||||
try {
|
||||
final md = await rootBundle.loadString(widget.assetPath);
|
||||
setState(() {
|
||||
_markdown = Markdown.fromString(md);
|
||||
});
|
||||
|
||||
// load latest version
|
||||
final response = await http.get(
|
||||
Uri.parse('https://raw.githubusercontent.com/jonasbark/swiftcontrol/refs/heads/main/${widget.assetPath}'),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final latestMd = response.body;
|
||||
if (latestMd != md) {
|
||||
setState(() {
|
||||
_markdown = Markdown.fromString(md);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'Failed to load changelog: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.assetPath.replaceAll('.md', '').toLowerCase().capitalize),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body:
|
||||
_error != null
|
||||
? Center(child: Text(_error!))
|
||||
: _markdown == null
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MarkdownWidget(
|
||||
markdown: _markdown!,
|
||||
theme: MarkdownThemeData(
|
||||
textStyle: TextStyle(fontSize: 14.0, color: Colors.black87),
|
||||
onLinkTap: (title, url) {
|
||||
launchUrlString(url);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class RequirementsPage extends StatefulWidget {
|
||||
|
||||
class _RequirementsPageState extends State<RequirementsPage> with WidgetsBindingObserver {
|
||||
int _currentStep = 0;
|
||||
var _local = true;
|
||||
|
||||
List<PlatformRequirement> _requirements = [];
|
||||
|
||||
@@ -29,6 +30,8 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_local = kIsWeb || !Platform.isIOS;
|
||||
|
||||
// call after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
settings.init().then((_) {
|
||||
@@ -56,11 +59,11 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final currentVersion = packageInfo.version;
|
||||
final lastSeenVersion = settings.getLastSeenVersion();
|
||||
|
||||
|
||||
if (mounted) {
|
||||
await ChangelogDialog.showIfNeeded(context, currentVersion, lastSeenVersion);
|
||||
}
|
||||
|
||||
|
||||
// Update last seen version
|
||||
await settings.setLastSeenVersion(currentVersion);
|
||||
} catch (e) {
|
||||
@@ -89,38 +92,53 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: buildMenuButtons(),
|
||||
),
|
||||
body:
|
||||
_requirements.isEmpty
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0),
|
||||
child: Text(
|
||||
'Please complete the following requirements to make the app work correctly:',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
body: _requirements.isEmpty
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
SwitchListTile.adaptive(
|
||||
value: _local,
|
||||
title: Text('Trainer app is running on this device'),
|
||||
subtitle: Text('Turn off if you want to control another device, e.g. your tablet'),
|
||||
onChanged: (local) {
|
||||
if (kIsWeb || Platform.isIOS) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('This platform only supports controlling trainer apps on other devices'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
initializeActions(local);
|
||||
setState(() {
|
||||
_local = local;
|
||||
_reloadRequirements();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Stepper(
|
||||
currentStep: _currentStep,
|
||||
connectorColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onStepContinue:
|
||||
_currentStep < _requirements.length
|
||||
? () {
|
||||
setState(() {
|
||||
_currentStep += 1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onStepContinue: _currentStep < _requirements.length
|
||||
? () {
|
||||
setState(() {
|
||||
_currentStep += 1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onStepTapped: (step) {
|
||||
if (_requirements[step].status) {
|
||||
return;
|
||||
}
|
||||
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
|
||||
if (hasEarlierIncomplete) {
|
||||
if (hasEarlierIncomplete && !kDebugMode) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -128,44 +146,49 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
});
|
||||
},
|
||||
controlsBuilder: (context, details) => Container(),
|
||||
steps:
|
||||
_requirements
|
||||
.mapIndexed(
|
||||
(index, req) => Step(
|
||||
title: Text(req.name),
|
||||
content: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child:
|
||||
(index == _currentStep
|
||||
? req.build(context, () {
|
||||
steps: _requirements
|
||||
.mapIndexed(
|
||||
(index, req) => Step(
|
||||
title: Text(req.name, style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: req.description != null ? Text(req.description!) : null,
|
||||
content: Container(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child:
|
||||
(index == _currentStep
|
||||
? req.build(context, () {
|
||||
_reloadRequirements();
|
||||
})
|
||||
: null) ??
|
||||
ElevatedButton(
|
||||
onPressed: req.status
|
||||
? null
|
||||
: () => _callRequirement(req, context, () {
|
||||
_reloadRequirements();
|
||||
})
|
||||
: null) ??
|
||||
ElevatedButton(
|
||||
onPressed: req.status ? null : () => _callRequirement(req),
|
||||
child: Text(req.name),
|
||||
),
|
||||
),
|
||||
state: req.status ? StepState.complete : StepState.indexed,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
}),
|
||||
child: Text(req.name),
|
||||
),
|
||||
),
|
||||
state: req.status ? StepState.complete : StepState.indexed,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _callRequirement(PlatformRequirement req) {
|
||||
req.call().then((_) {
|
||||
void _callRequirement(PlatformRequirement req, BuildContext context, VoidCallback onUpdate) {
|
||||
req.call(context, onUpdate).then((_) {
|
||||
_reloadRequirements();
|
||||
});
|
||||
}
|
||||
|
||||
void _reloadRequirements() {
|
||||
getRequirements().then((req) {
|
||||
getRequirements(_local).then((req) {
|
||||
_requirements = req;
|
||||
_currentStep = req.indexWhere((req) => !req.status);
|
||||
if (mounted) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../widgets/logviewer.dart';
|
||||
|
||||
@@ -47,6 +47,8 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: 200),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: connection.isScanning,
|
||||
@@ -54,14 +56,17 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
if (isScanning) {
|
||||
return Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Scanning for devices... Make sure they are powered on and in range and not connected to another device.',
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-platforms',
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
);
|
||||
},
|
||||
child: const Text("Show Troubleshooting Guide"),
|
||||
@@ -83,7 +88,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
}
|
||||
},
|
||||
),
|
||||
if (kDebugMode) SizedBox(height: 500, child: LogViewer()),
|
||||
if (kDebugMode && false) SizedBox(height: 500, child: LogViewer()),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -9,6 +9,7 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/menu.dart';
|
||||
import 'package:swift_control/widgets/testbed.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
@@ -16,6 +17,7 @@ import '../bluetooth/messages/click_notification.dart';
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
import '../bluetooth/messages/play_notification.dart';
|
||||
import '../bluetooth/messages/ride_notification.dart';
|
||||
import '../utils/actions/base_actions.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/buttons.dart';
|
||||
import '../utils/keymap/keymap.dart';
|
||||
@@ -34,14 +36,37 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
File? _backgroundImage;
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
ZwiftButton? _pressedButton;
|
||||
final TransformationController _transformationController = TransformationController();
|
||||
|
||||
late Rect _imageRect;
|
||||
|
||||
Future<void> _pickScreenshot() async {
|
||||
final picker = ImagePicker();
|
||||
final result = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
_backgroundImage = File(result.path);
|
||||
});
|
||||
final image = File(result.path);
|
||||
|
||||
// need to decode image to get its size so we can have a percentage mapping
|
||||
final decodedImage = await decodeImageFromList(image.readAsBytesSync());
|
||||
// calculate image rectangle in the current screen, given it's boxfit contain
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final imageAspectRatio = decodedImage.width / decodedImage.height;
|
||||
final screenAspectRatio = screenSize.width / screenSize.height;
|
||||
if (imageAspectRatio > screenAspectRatio) {
|
||||
// image is wider than screen
|
||||
final width = screenSize.width;
|
||||
final height = width / imageAspectRatio;
|
||||
final top = (screenSize.height - height) / 2;
|
||||
_imageRect = Rect.fromLTWH(0, top, width, height);
|
||||
} else {
|
||||
// image is taller than screen
|
||||
final height = screenSize.height;
|
||||
final width = height * imageAspectRatio;
|
||||
final left = (screenSize.width - width) / 2;
|
||||
_imageRect = Rect.fromLTWH(left, 0, width, height);
|
||||
}
|
||||
_backgroundImage = image;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,13 +96,15 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// initialize _imageRect by using Flutter view size
|
||||
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
|
||||
final size = flutterView.physicalSize / flutterView.devicePixelRatio;
|
||||
_imageRect = Rect.fromLTWH(0, 0, size.width, size.height);
|
||||
|
||||
// Force landscape orientation during keymap editing
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []).then((_) {
|
||||
// this will make sure the buttons are placed correctly after the transition
|
||||
setState(() {});
|
||||
});
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []);
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
windowManager.setFullScreen(true);
|
||||
}
|
||||
@@ -100,9 +127,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
final KeyPair keyPair;
|
||||
actionHandler.supportedApp!.keymap.keyPairs.add(
|
||||
keyPair = KeyPair(
|
||||
touchPosition: context.size!
|
||||
.center(Offset.zero)
|
||||
.translate(actionHandler.supportedApp!.keymap.keyPairs.length * 40, 0),
|
||||
touchPosition: Offset((actionHandler.supportedApp!.keymap.keyPairs.length + 1) * 10, 10),
|
||||
buttons: [_pressedButton!],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
@@ -122,249 +147,352 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
});
|
||||
}
|
||||
|
||||
List<Widget> _buildDraggableArea({
|
||||
required Offset position,
|
||||
Widget _buildDraggableArea({
|
||||
required bool enableTouch,
|
||||
required void Function(Offset newPosition) onPositionChanged,
|
||||
required Color color,
|
||||
required KeyPair keyPair,
|
||||
}) {
|
||||
// map the percentage position to the image rect
|
||||
final relativeX = min(100.0, keyPair.touchPosition.dx) / 100.0;
|
||||
final relativeY = min(100.0, keyPair.touchPosition.dy) / 100.0;
|
||||
//print('Relative position: $relativeX, $relativeY');
|
||||
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
|
||||
|
||||
// figure out notch height for e.g. macOS. On Windows the display size is not available (0,0).
|
||||
final differenceInHeight =
|
||||
(flutterView.display.size.height > 0)
|
||||
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
|
||||
: 0.0;
|
||||
final differenceInHeight = (flutterView.display.size.height > 0 && !Platform.isIOS)
|
||||
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
|
||||
: 0.0;
|
||||
|
||||
if (kDebugMode) {
|
||||
if (kDebugMode && false) {
|
||||
print('Display Size: ${flutterView.display.size}');
|
||||
print('View size: ${flutterView.physicalSize}');
|
||||
print('Difference: $differenceInHeight');
|
||||
}
|
||||
return [
|
||||
Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy - differenceInHeight,
|
||||
child: PopupMenuButton<PhysicalKeyboardKey>(
|
||||
enabled: enableTouch,
|
||||
tooltip: 'Drag to reposition. Tap to edit.',
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.keyboard_alt_outlined),
|
||||
title: const Text('Simulate Keyboard shortcut'),
|
||||
),
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder:
|
||||
(c) => HotKeyListenerDialog(
|
||||
customApp: actionHandler.supportedApp! as CustomApp,
|
||||
keyPair: keyPair,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)),
|
||||
onTap: () {
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
onTap: () {
|
||||
keyPair.isLongPress = !keyPair.isLongPress;
|
||||
setState(() {});
|
||||
},
|
||||
child: CheckboxListTile(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
setState(() {});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
title: const Text('Long Press Mode (vs. repeating)'),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
child: PopupMenuButton<PhysicalKeyboardKey>(
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaPlayPause,
|
||||
child: const Text('Media: Play/Pause'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaStop,
|
||||
child: const Text('Media: Stop'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackPrevious,
|
||||
child: const Text('Media: Previous'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackNext,
|
||||
child: const Text('Media: Next'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeUp,
|
||||
child: const Text('Media: Volume Up'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeDown,
|
||||
child: const Text('Media: Volume Down'),
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
keyPair.physicalKey = key;
|
||||
keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.music_note_outlined),
|
||||
trailing: Icon(Icons.arrow_right),
|
||||
title: Text('Simulate Media key'),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(title: const Text('Delete Keymap'), leading: Icon(Icons.delete, color: Colors.red)),
|
||||
onTap: () {
|
||||
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
keyPair.physicalKey = key;
|
||||
//final isOnTheRightEdge = position.dx > (MediaQuery.sizeOf(context).width - 250);
|
||||
|
||||
final iconSize = 40.0;
|
||||
|
||||
final Offset position = Offset(
|
||||
_imageRect.left + relativeX * _imageRect.width - iconSize / 2,
|
||||
_imageRect.top + relativeY * _imageRect.height - differenceInHeight - iconSize / 2,
|
||||
);
|
||||
|
||||
final actions = [
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.keyboard_alt_outlined),
|
||||
title: const Text('Simulate Keyboard shortcut'),
|
||||
trailing: keyPair.physicalKey != null ? Checkbox(value: true, onChanged: null) : null,
|
||||
),
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder: (c) =>
|
||||
HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.touch))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
title: const Text('Simulate Touch'),
|
||||
leading: Icon(Icons.touch_app_outlined),
|
||||
trailing: keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero
|
||||
? Checkbox(value: true, onChanged: null)
|
||||
: null,
|
||||
),
|
||||
onTap: () {
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
},
|
||||
child: Draggable(
|
||||
feedback: Material(color: Colors.transparent, child: KeypairExplanation(withKey: true, keyPair: keyPair)),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDraggableCanceled: (_, offset) {
|
||||
final fixedPosition = offset + Offset(0, differenceInHeight);
|
||||
setState(() => onPositionChanged(fixedPosition));
|
||||
},
|
||||
child: KeypairExplanation(withKey: true, keyPair: keyPair),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!keyPair.isSpecialKey && keyPair.physicalKey == null && keyPair.touchPosition != Offset.zero)
|
||||
Positioned(
|
||||
left: position.dx - 10,
|
||||
top: position.dy - 10 - differenceInHeight,
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
size: 20,
|
||||
shadows: [
|
||||
Shadow(color: Colors.white, offset: Offset(1, 1)),
|
||||
Shadow(color: Colors.white, offset: Offset(-1, -1)),
|
||||
Shadow(color: Colors.white, offset: Offset(-1, 1)),
|
||||
Shadow(color: Colors.white, offset: Offset(-1, 1)),
|
||||
Shadow(color: Colors.white, offset: Offset(1, -1)),
|
||||
|
||||
if (actionHandler.supportedModes.contains(SupportedMode.media))
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
child: PopupMenuButton<PhysicalKeyboardKey>(
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaPlayPause,
|
||||
child: const Text('Media: Play/Pause'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaStop,
|
||||
child: const Text('Media: Stop'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackPrevious,
|
||||
child: const Text('Media: Previous'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackNext,
|
||||
child: const Text('Media: Next'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeUp,
|
||||
child: const Text('Media: Volume Up'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeDown,
|
||||
child: const Text('Media: Volume Down'),
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
keyPair.physicalKey = key;
|
||||
keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.music_note_outlined),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (keyPair.isSpecialKey) Checkbox(value: true, onChanged: null),
|
||||
Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
title: Text('Simulate Media key'),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = Platform.isWindows || Platform.isLinux || Platform.isMacOS;
|
||||
final devicePixelRatio = isDesktop ? 1.0 : MediaQuery.devicePixelRatioOf(context);
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
final icon = Container(
|
||||
constraints: BoxConstraints(minHeight: iconSize, minWidth: iconSize),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_backgroundImage != null)
|
||||
Positioned.fill(child: Opacity(opacity: 0.5, child: Image.file(_backgroundImage!, fit: BoxFit.contain)))
|
||||
else
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('''1. Create an in-game screenshot of your app (e.g. within MyWhoosh) in landscape orientation
|
||||
2. Load the screenshot with the button below
|
||||
3. The app is automatically set to landscape orientation for accurate mapping
|
||||
4. Press a button on your Zwift device to create a touch area
|
||||
5. Drag the touch areas to the desired position on the screenshot
|
||||
6. Save and close this screen'''),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_pickScreenshot();
|
||||
},
|
||||
child: Text('Load in-game screenshot for placement'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (keyPair.buttons.singleOrNull?.color == null)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.4),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
child: Icon(
|
||||
keyPair.icon,
|
||||
size: iconSize - 12,
|
||||
shadows: [
|
||||
Shadow(color: Colors.white, offset: Offset(1, 1)),
|
||||
Shadow(color: Colors.white, offset: Offset(-1, -1)),
|
||||
Shadow(color: Colors.white, offset: Offset(-1, 1)),
|
||||
Shadow(color: Colors.white, offset: Offset(-1, 1)),
|
||||
Shadow(color: Colors.white, offset: Offset(1, -1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Touch Areas
|
||||
...?actionHandler.supportedApp?.keymap.keyPairs
|
||||
.map(
|
||||
(keyPair) => _buildDraggableArea(
|
||||
enableTouch: true,
|
||||
position: Offset(
|
||||
keyPair.touchPosition.dx / devicePixelRatio,
|
||||
keyPair.touchPosition.dy / devicePixelRatio,
|
||||
),
|
||||
keyPair: keyPair,
|
||||
onPositionChanged: (newPos) {
|
||||
final converted = newPos * devicePixelRatio;
|
||||
keyPair.touchPosition = converted;
|
||||
PopupMenuButton<PhysicalKeyboardKey>(
|
||||
enabled: enableTouch,
|
||||
itemBuilder: (context) => [
|
||||
if (actions.length > 1) ...actions,
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
onTap: () {
|
||||
keyPair.isLongPress = !keyPair.isLongPress;
|
||||
setState(() {});
|
||||
},
|
||||
child: CheckboxListTile(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
setState(() {});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
color: Colors.red,
|
||||
title: const Text('Long Press Mode (vs. repeating)'),
|
||||
),
|
||||
)
|
||||
.flatten(),
|
||||
|
||||
Positioned.fill(child: Testbed()),
|
||||
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 20,
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(
|
||||
title: const Text('Delete Keymap'),
|
||||
leading: Icon(Icons.delete, color: Colors.red),
|
||||
),
|
||||
onTap: () {
|
||||
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
keyPair.physicalKey = key;
|
||||
keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
},
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(onPressed: _saveAndClose, icon: const Icon(Icons.save), label: const Text("Save")),
|
||||
PopupMenuButton(
|
||||
itemBuilder:
|
||||
(c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Reset'),
|
||||
onTap: () {
|
||||
actionHandler.supportedApp?.keymap.reset();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
icon: Icon(Icons.more_vert),
|
||||
),
|
||||
KeypairExplanation(withKey: true, keyPair: keyPair),
|
||||
Icon(Icons.more_vert),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: Tooltip(
|
||||
message: 'Drag to reposition',
|
||||
child: Draggable(
|
||||
dragAnchorStrategy: (widget, context, position) {
|
||||
final scale = _transformationController.value.getMaxScaleOnAxis();
|
||||
final RenderBox renderObject = context.findRenderObject() as RenderBox;
|
||||
return renderObject.globalToLocal(position).scale(scale, scale);
|
||||
},
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: icon,
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDragEnd: (details) {
|
||||
// otherwise simulated touch will move it
|
||||
if (details.velocity.pixelsPerSecond.distance > 0) {
|
||||
final matrix = Matrix4.inverted(_transformationController.value);
|
||||
final height = 0;
|
||||
final sceneY = details.offset.dy - height;
|
||||
final viewportPoint = MatrixUtils.transformPoint(
|
||||
matrix,
|
||||
Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2),
|
||||
);
|
||||
setState(() => onPositionChanged(viewportPoint));
|
||||
}
|
||||
},
|
||||
child: icon,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (_backgroundImage == null && constraints.biggest != _imageRect.size) {
|
||||
_imageRect = Rect.fromLTWH(0, 0, constraints.maxWidth, constraints.maxHeight);
|
||||
}
|
||||
return InteractiveViewer(
|
||||
transformationController: _transformationController,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_backgroundImage != null)
|
||||
Positioned.fill(
|
||||
child: Opacity(
|
||||
opacity: 0.5,
|
||||
child: Image.file(
|
||||
_backgroundImage!,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// draw _imageRect for debugging
|
||||
if (kDebugMode)
|
||||
Positioned(
|
||||
left: _imageRect.left,
|
||||
top: _imageRect.top,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.green, width: 2),
|
||||
),
|
||||
child: SizedBox.fromSize(size: _imageRect.size),
|
||||
),
|
||||
),
|
||||
|
||||
...?actionHandler.supportedApp?.keymap.keyPairs.map((keyPair) {
|
||||
return _buildDraggableArea(
|
||||
enableTouch: true,
|
||||
keyPair: keyPair,
|
||||
onPositionChanged: (newPos) {
|
||||
// convert to percentage
|
||||
final relativeX = ((newPos.dx - _imageRect.left) / _imageRect.width).clamp(0.0, 1.0);
|
||||
final relativeY = ((newPos.dy - _imageRect.top) / _imageRect.height).clamp(0.0, 1.0);
|
||||
keyPair.touchPosition = Offset(relativeX * 100.0, relativeY * 100.0);
|
||||
setState(() {});
|
||||
},
|
||||
color: Colors.red,
|
||||
);
|
||||
}),
|
||||
|
||||
Positioned.fill(child: Testbed()),
|
||||
|
||||
if (_backgroundImage == null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: Text(
|
||||
'''1. Create an in-game screenshot of your app (e.g. within MyWhoosh) in landscape orientation
|
||||
2. Load the screenshot with the button below
|
||||
3. The app is automatically set to landscape orientation for accurate mapping
|
||||
4. Press a button on your Click device to create a touch area
|
||||
5. Drag the touch areas to the desired position on the screenshot
|
||||
6. Save and close this screen''',
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_pickScreenshot();
|
||||
},
|
||||
child: Text('Load in-game screenshot for placement'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 20,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _saveAndClose,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text("Save"),
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (c) => [
|
||||
PopupMenuItem(
|
||||
child: Text('Reset'),
|
||||
onTap: () {
|
||||
_backgroundImage = null;
|
||||
actionHandler.supportedApp?.keymap.reset();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
icon: Icon(Icons.more_vert),
|
||||
),
|
||||
if (kDebugMode) MenuButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,35 +506,31 @@ class KeypairExplanation extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (withKey) KeyWidget(label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n')),
|
||||
if (keyPair.physicalKey != null) ...[
|
||||
Icon(switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause ||
|
||||
PhysicalKeyboardKey.mediaStop ||
|
||||
PhysicalKeyboardKey.mediaTrackPrevious ||
|
||||
PhysicalKeyboardKey.mediaTrackNext ||
|
||||
PhysicalKeyboardKey.audioVolumeUp ||
|
||||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
|
||||
_ => Icons.keyboard,
|
||||
}, size: 16),
|
||||
if (withKey)
|
||||
Row(
|
||||
children: keyPair.buttons.map((b) => ButtonWidget(button: b, big: true)).toList(),
|
||||
)
|
||||
else
|
||||
Icon(keyPair.icon),
|
||||
if (keyPair.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
|
||||
KeyWidget(
|
||||
label: switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Media: Play/Pause',
|
||||
PhysicalKeyboardKey.mediaStop => 'Media: Stop',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => 'Media: Previous',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Media: Next',
|
||||
PhysicalKeyboardKey.audioVolumeUp => 'Media: Volume Up',
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Media: Volume Down',
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaStop => 'Stop',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => 'Previous',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Next',
|
||||
PhysicalKeyboardKey.audioVolumeUp => 'Volume Up',
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
|
||||
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
|
||||
},
|
||||
),
|
||||
if (keyPair.isLongPress) Text('using long press'),
|
||||
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
|
||||
] else ...[
|
||||
Icon(Icons.touch_app, size: 16),
|
||||
KeyWidget(label: 'X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}'),
|
||||
|
||||
if (keyPair.isLongPress) Text('using long press'),
|
||||
if (!withKey)
|
||||
KeyWidget(label: 'X: ${keyPair.touchPosition.dx.toInt()}, Y: ${keyPair.touchPosition.dy.toInt()}'),
|
||||
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -7,6 +7,9 @@ abstract final class AppTheme {
|
||||
static ThemeData light = FlexThemeData.light(
|
||||
// Using FlexColorScheme built-in FlexScheme enum based colors
|
||||
scheme: FlexScheme.redM3,
|
||||
primary: Color(0xFF0E74B7),
|
||||
primaryContainer: Color(0x7C0E9297),
|
||||
onPrimaryContainer: Colors.black,
|
||||
// Component theme configurations for light mode.
|
||||
subThemesData: const FlexSubThemesData(
|
||||
interactionEffects: true,
|
||||
@@ -23,27 +26,28 @@ abstract final class AppTheme {
|
||||
);
|
||||
|
||||
// The FlexColorScheme defined dark mode ThemeData.
|
||||
static ThemeData dark = FlexThemeData.dark(
|
||||
// Using FlexColorScheme built-in FlexScheme enum based colors.
|
||||
scheme: FlexScheme.redM3,
|
||||
// Component theme configurations for dark mode.
|
||||
subThemesData: const FlexSubThemesData(
|
||||
interactionEffects: true,
|
||||
tintedDisabledControls: true,
|
||||
blendOnColors: true,
|
||||
useM2StyleDividerInM3: true,
|
||||
inputDecoratorIsFilled: true,
|
||||
inputDecoratorBorderType: FlexInputBorderType.outline,
|
||||
alignedDropdown: true,
|
||||
navigationRailUseIndicator: true,
|
||||
),
|
||||
// Direct ThemeData properties.
|
||||
visualDensity: FlexColorScheme.comfortablePlatformDensity,
|
||||
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
|
||||
).copyWith(
|
||||
scaffoldBackgroundColor: Color(0xff0b1623),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
|
||||
),
|
||||
);
|
||||
static ThemeData dark =
|
||||
FlexThemeData.dark(
|
||||
// Using FlexColorScheme built-in FlexScheme enum based colors.
|
||||
scheme: FlexScheme.redM3,
|
||||
// Component theme configurations for dark mode.
|
||||
subThemesData: const FlexSubThemesData(
|
||||
interactionEffects: true,
|
||||
tintedDisabledControls: true,
|
||||
blendOnColors: true,
|
||||
useM2StyleDividerInM3: true,
|
||||
inputDecoratorIsFilled: true,
|
||||
inputDecoratorBorderType: FlexInputBorderType.outline,
|
||||
alignedDropdown: true,
|
||||
navigationRailUseIndicator: true,
|
||||
),
|
||||
// Direct ThemeData properties.
|
||||
visualDensity: FlexColorScheme.comfortablePlatformDensity,
|
||||
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
|
||||
).copyWith(
|
||||
scaffoldBackgroundColor: Color(0xff0b1623),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import '../single_line_exception.dart';
|
||||
class AndroidActions extends BaseActions {
|
||||
WindowEvent? windowInfo;
|
||||
|
||||
AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]});
|
||||
|
||||
@override
|
||||
void init(SupportedApp? supportedApp) {
|
||||
super.init(supportedApp);
|
||||
@@ -41,14 +43,14 @@ class AndroidActions extends BaseActions {
|
||||
return "Key pressed: ${keyPair.toString()}";
|
||||
}
|
||||
}
|
||||
final point = supportedApp!.resolveTouchPosition(action: button, windowInfo: windowInfo);
|
||||
final point = resolveTouchPosition(action: button, windowInfo: windowInfo);
|
||||
if (point != Offset.zero) {
|
||||
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
|
||||
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown
|
||||
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()} -> ${isKeyDown && isKeyUp
|
||||
? "click"
|
||||
: isKeyDown
|
||||
? "down"
|
||||
: isKeyUp
|
||||
? "up"
|
||||
: "click"}";
|
||||
: "up"}";
|
||||
}
|
||||
return "No touch performed";
|
||||
}
|
||||
|
||||
@@ -1,18 +1,49 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../keymap/apps/supported_app.dart';
|
||||
|
||||
enum SupportedMode { keyboard, touch, media }
|
||||
|
||||
abstract class BaseActions {
|
||||
final List<SupportedMode> supportedModes;
|
||||
|
||||
SupportedApp? supportedApp;
|
||||
|
||||
BaseActions({required this.supportedModes});
|
||||
|
||||
void init(SupportedApp? supportedApp) {
|
||||
this.supportedApp = supportedApp;
|
||||
}
|
||||
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
|
||||
// convert relative position to absolute position based on window info
|
||||
if (windowInfo != null && windowInfo.right != 0) {
|
||||
final x = windowInfo.left + (keyPair.touchPosition.dx / 100) * (windowInfo.right - windowInfo.left);
|
||||
final y = windowInfo.top + (keyPair.touchPosition.dy / 100) * (windowInfo.bottom - windowInfo.top);
|
||||
return Offset(x, y);
|
||||
} else {
|
||||
// TODO support multiple screens
|
||||
final screenSize =
|
||||
WidgetsBinding.instance.platformDispatcher.views.first.display.size /
|
||||
WidgetsBinding.instance.platformDispatcher.views.first.devicePixelRatio;
|
||||
final x = (keyPair.touchPosition.dx / 100) * screenSize.width;
|
||||
final y = (keyPair.touchPosition.dy / 100) * screenSize.height;
|
||||
return Offset(x, y);
|
||||
}
|
||||
}
|
||||
return Offset.zero;
|
||||
}
|
||||
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
}
|
||||
|
||||
class StubActions extends BaseActions {
|
||||
StubActions({super.supportedModes = const []});
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
return Future.value(action.name);
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
|
||||
class DesktopActions extends BaseActions {
|
||||
DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]});
|
||||
|
||||
// Track keys that are currently held down in long press mode
|
||||
|
||||
@override
|
||||
@@ -19,30 +21,30 @@ class DesktopActions extends BaseActions {
|
||||
|
||||
// Handle regular key press mode (existing behavior)
|
||||
if (keyPair.physicalKey != null) {
|
||||
if (isKeyDown) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
return 'Key pressed: $keyPair';
|
||||
} else if (isKeyUp) {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key released: $keyPair';
|
||||
} else {
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key clicked: $keyPair';
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
return 'Key pressed: $keyPair';
|
||||
} else {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key released: $keyPair';
|
||||
}
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
if (isKeyDown) {
|
||||
final point = resolveTouchPosition(action: action, windowInfo: null);
|
||||
if (isKeyDown && isKeyUp) {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse clicked at: ${point.dx} ${point.dy}';
|
||||
} else if (isKeyDown) {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
return 'Mouse down at: ${point.dx} ${point.dy}';
|
||||
} else if (isKeyUp) {
|
||||
} else {
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse up at: ${point.dx} ${point.dy}';
|
||||
} else {
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
}
|
||||
return 'Mouse clicked at: ${point.dx} ${point.dy}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
84
lib/utils/actions/remote.dart
Normal file
@@ -0,0 +1,84 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../requirements/remote.dart';
|
||||
|
||||
class RemoteActions extends BaseActions {
|
||||
RemoteActions({super.supportedModes = const [SupportedMode.touch]});
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return 'Supported app is not set';
|
||||
}
|
||||
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair == null) {
|
||||
return 'Keymap entry not found for action: ${action.toString().splitByUpperCase()}';
|
||||
}
|
||||
|
||||
if (!(actionHandler as RemoteActions).isConnected) {
|
||||
return 'Not connected to a device';
|
||||
}
|
||||
|
||||
if (keyPair.physicalKey != null && keyPair.touchPosition == Offset.zero) {
|
||||
return ('Physical key actions are not supported, yet');
|
||||
} else {
|
||||
final point = resolveTouchPosition(action: action, windowInfo: null);
|
||||
final point2 = point; //Offset(100, 99.0);
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(1, point2.dx.toInt(), point2.dy.toInt());
|
||||
await sendAbsMouseReport(0, point2.dx.toInt(), point2.dy.toInt());
|
||||
return 'Mouse clicked at: ${point2.dx} ${point2.dy}';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
// for remote actions we use the relative position only
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair != null && keyPair.touchPosition != Offset.zero) {
|
||||
return keyPair.touchPosition;
|
||||
}
|
||||
return Offset.zero;
|
||||
}
|
||||
|
||||
Uint8List absMouseReport(int buttons3bit, int x, int y) {
|
||||
final b = buttons3bit & 0x07;
|
||||
final xi = x.clamp(0, 100);
|
||||
final yi = y.clamp(0, 100);
|
||||
return Uint8List.fromList([b, xi, yi]);
|
||||
}
|
||||
|
||||
// Send a relative mouse move + button state as 3-byte report: [buttons, dx, dy]
|
||||
Future<void> sendAbsMouseReport(int buttons, int dx, int dy) async {
|
||||
final bytes = absMouseReport(buttons, dx, dy);
|
||||
if (kDebugMode) {
|
||||
print('Preparing to send abs mouse report: buttons=$buttons, dx=$dx, dy=$dy');
|
||||
print('Sending abs mouse report: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0'))}');
|
||||
}
|
||||
|
||||
await peripheralManager.notifyCharacteristic(connectedCentral!, connectedCharacteristic!, value: bytes);
|
||||
}
|
||||
|
||||
Central? connectedCentral;
|
||||
GATTCharacteristic? connectedCharacteristic;
|
||||
|
||||
void setConnectedCentral(Central? central, GATTCharacteristic? gattCharacteristic) {
|
||||
connectedCentral = central;
|
||||
connectedCharacteristic = gattCharacteristic;
|
||||
|
||||
connection.signalChange(ZwiftClick(BleDevice(deviceId: 'deviceId', name: 'name')));
|
||||
}
|
||||
|
||||
bool get isConnected => connectedCentral != null;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class ChangelogEntry {
|
||||
final String version;
|
||||
final String date;
|
||||
final List<String> changes;
|
||||
|
||||
ChangelogEntry({
|
||||
required this.version,
|
||||
required this.date,
|
||||
required this.changes,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '### $version ($date)\n${changes.map((c) => '- $c').join('\n')}';
|
||||
}
|
||||
}
|
||||
|
||||
class ChangelogParser {
|
||||
static Future<List<ChangelogEntry>> parse() async {
|
||||
final content = await rootBundle.loadString('CHANGELOG.md');
|
||||
return parseContent(content);
|
||||
}
|
||||
|
||||
static List<ChangelogEntry> parseContent(String content) {
|
||||
final entries = <ChangelogEntry>[];
|
||||
final lines = content.split('\n');
|
||||
|
||||
ChangelogEntry? currentEntry;
|
||||
|
||||
for (var line in lines) {
|
||||
// Check if this is a version header (e.g., "### 2.6.0 (2025-09-28)")
|
||||
if (line.startsWith('### ')) {
|
||||
// Save previous entry if exists
|
||||
if (currentEntry != null) {
|
||||
entries.add(currentEntry);
|
||||
}
|
||||
|
||||
// Parse new entry
|
||||
final header = line.substring(4).trim();
|
||||
final match = RegExp(r'^(\S+)\s+\(([^)]+)\)').firstMatch(header);
|
||||
if (match != null) {
|
||||
currentEntry = ChangelogEntry(
|
||||
version: match.group(1)!,
|
||||
date: match.group(2)!,
|
||||
changes: [],
|
||||
);
|
||||
}
|
||||
} else if (line.startsWith('- ') && currentEntry != null) {
|
||||
// Add change to current entry
|
||||
currentEntry.changes.add(line.substring(2).trim());
|
||||
} else if (line.startsWith(' - ') && currentEntry != null) {
|
||||
// Sub-bullet point
|
||||
currentEntry.changes.add(line.substring(4).trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last entry
|
||||
if (currentEntry != null) {
|
||||
entries.add(currentEntry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
static Future<ChangelogEntry?> getLatestEntry() async {
|
||||
final entries = await parse();
|
||||
return entries.isNotEmpty ? entries.first : null;
|
||||
}
|
||||
|
||||
static Future<String?> getLatestEntryForPlayStore() async {
|
||||
final entry = await getLatestEntry();
|
||||
if (entry == null) return null;
|
||||
|
||||
// Format for Play Store: just the changes, no version header
|
||||
return entry.changes.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
@@ -42,8 +41,3 @@ class Biketerra extends SupportedApp {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
extension WindowSize on WindowEvent {
|
||||
int get width => right - left;
|
||||
int get height => bottom - top;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
|
||||
import '../../single_line_exception.dart';
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
|
||||
class CustomApp extends SupportedApp {
|
||||
CustomApp() : super(name: 'Custom', packageName: "custom", keymap: Keymap.custom);
|
||||
final String profileName;
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
final keyPair = keymap.getKeyPair(action);
|
||||
if (keyPair == null || keyPair.touchPosition == Offset.zero) {
|
||||
throw SingleLineException("No key pair found for action: $action. You may want to adjust the keymap.");
|
||||
}
|
||||
return keyPair.touchPosition;
|
||||
}
|
||||
CustomApp({this.profileName = 'Custom'})
|
||||
: super(
|
||||
name: profileName,
|
||||
packageName: "custom_$profileName",
|
||||
keymap: Keymap(keyPairs: []),
|
||||
);
|
||||
|
||||
List<String> encodeKeymap() {
|
||||
// encode to save in preferences
|
||||
@@ -40,7 +36,7 @@ class CustomApp extends SupportedApp {
|
||||
|
||||
void setKey(
|
||||
ZwiftButton zwiftButton, {
|
||||
required PhysicalKeyboardKey physicalKey,
|
||||
required PhysicalKeyboardKey? physicalKey,
|
||||
required LogicalKeyboardKey? logicalKey,
|
||||
bool isLongPress = false,
|
||||
Offset? touchPosition,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
|
||||
import '../../single_line_exception.dart';
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
|
||||
@@ -19,21 +16,27 @@ class MyWhoosh extends SupportedApp {
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyI,
|
||||
logicalKey: LogicalKeyboardKey.keyI,
|
||||
touchPosition: Offset(80, 94),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyK,
|
||||
logicalKey: LogicalKeyboardKey.keyK,
|
||||
touchPosition: Offset(98, 94),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyD,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
touchPosition: Offset(98, 80),
|
||||
isLongPress: true,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
touchPosition: Offset(32, 80),
|
||||
isLongPress: true,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
@@ -43,54 +46,4 @@ class MyWhoosh extends SupportedApp {
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
final superPosition = super.resolveTouchPosition(action: action, windowInfo: windowInfo);
|
||||
if (superPosition != Offset.zero) {
|
||||
return superPosition;
|
||||
}
|
||||
if (windowInfo == null) {
|
||||
throw SingleLineException("Window size not known - open $this first");
|
||||
}
|
||||
|
||||
// just my personal preference
|
||||
switch (action) {
|
||||
case ZwiftButton.y:
|
||||
accessibilityHandler.controlMedia(MediaAction.volumeUp);
|
||||
return Offset.zero;
|
||||
case ZwiftButton.b:
|
||||
accessibilityHandler.controlMedia(MediaAction.volumeDown);
|
||||
return Offset.zero;
|
||||
case ZwiftButton.a:
|
||||
accessibilityHandler.controlMedia(MediaAction.next);
|
||||
return Offset.zero;
|
||||
case ZwiftButton.z:
|
||||
accessibilityHandler.controlMedia(MediaAction.playPause);
|
||||
return Offset.zero;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return switch (action.action) {
|
||||
InGameAction.shiftUp => Offset(
|
||||
windowInfo.right - windowInfo.width * 0.02,
|
||||
windowInfo.bottom - windowInfo.height * 0.06,
|
||||
),
|
||||
InGameAction.shiftDown => Offset(
|
||||
windowInfo.right - windowInfo.width * 0.20,
|
||||
windowInfo.bottom - windowInfo.height * 0.06,
|
||||
),
|
||||
InGameAction.navigateRight => Offset(
|
||||
windowInfo.right - windowInfo.width * 0.02,
|
||||
windowInfo.bottom - windowInfo.height * 0.20,
|
||||
),
|
||||
_ => throw SingleLineException("Unsupported action for MyWhoosh: $action"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extension WindowSize on WindowEvent {
|
||||
int get width => right - left;
|
||||
int get height => bottom - top;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/biketerra.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/training_peaks.dart';
|
||||
|
||||
import '../../single_line_exception.dart';
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
import 'custom_app.dart';
|
||||
import 'my_whoosh.dart';
|
||||
@@ -15,17 +10,6 @@ abstract class SupportedApp {
|
||||
final String name;
|
||||
final Keymap keymap;
|
||||
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
if (this is CustomApp) {
|
||||
final keyPair = keymap.getKeyPair(action);
|
||||
if (keyPair == null || keyPair.touchPosition == Offset.zero) {
|
||||
throw SingleLineException("No key pair found for action: $action");
|
||||
}
|
||||
return keyPair.touchPosition;
|
||||
}
|
||||
return Offset.zero;
|
||||
}
|
||||
|
||||
const SupportedApp({required this.name, required this.packageName, required this.keymap});
|
||||
|
||||
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
@@ -20,11 +17,13 @@ class TrainingPeaks extends SupportedApp {
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
touchPosition: Offset(50 * 1.32, 74),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.numpadAdd,
|
||||
logicalKey: LogicalKeyboardKey.numpadAdd,
|
||||
touchPosition: Offset(50 * 1.15, 74),
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
@@ -54,20 +53,4 @@ class TrainingPeaks extends SupportedApp {
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
final superPosition = super.resolveTouchPosition(action: action, windowInfo: windowInfo);
|
||||
if (superPosition != Offset.zero) {
|
||||
return superPosition;
|
||||
}
|
||||
if (windowInfo == null) {
|
||||
throw SingleLineException("Window size not known - open $this first");
|
||||
}
|
||||
return switch (action.action) {
|
||||
InGameAction.shiftUp => Offset(windowInfo.width / 2 * 1.32, windowInfo.height * 0.74),
|
||||
InGameAction.shiftDown => Offset(windowInfo.width / 2 * 1.15, windowInfo.height * 0.74),
|
||||
_ => throw SingleLineException("Unsupported action for IndieVelo: $action"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum InGameAction {
|
||||
shiftUp,
|
||||
shiftDown,
|
||||
@@ -15,35 +17,37 @@ enum InGameAction {
|
||||
|
||||
enum ZwiftButton {
|
||||
// left controller
|
||||
navigationUp._(InGameAction.increaseResistance),
|
||||
navigationDown._(InGameAction.decreaseResistance),
|
||||
navigationLeft._(InGameAction.navigateLeft),
|
||||
navigationRight._(InGameAction.navigateRight),
|
||||
navigationUp._(InGameAction.increaseResistance, icon: Icons.keyboard_arrow_up, color: Colors.black),
|
||||
navigationDown._(InGameAction.decreaseResistance, icon: Icons.keyboard_arrow_down, color: Colors.black),
|
||||
navigationLeft._(InGameAction.navigateLeft, icon: Icons.keyboard_arrow_left, color: Colors.black),
|
||||
navigationRight._(InGameAction.navigateRight, icon: Icons.keyboard_arrow_right, color: Colors.black),
|
||||
onOffLeft._(InGameAction.toggleUi),
|
||||
sideButtonLeft._(InGameAction.shiftDown),
|
||||
paddleLeft._(InGameAction.shiftDown),
|
||||
|
||||
// zwift ride only
|
||||
shiftUpLeft._(InGameAction.shiftDown),
|
||||
shiftDownLeft._(InGameAction.shiftDown),
|
||||
shiftUpLeft._(InGameAction.shiftDown, icon: Icons.minimize_rounded, color: Colors.black),
|
||||
shiftDownLeft._(InGameAction.shiftDown, icon: Icons.minimize_rounded, color: Colors.black),
|
||||
powerUpLeft._(InGameAction.shiftDown),
|
||||
|
||||
// right controller
|
||||
a._(null),
|
||||
b._(null),
|
||||
z._(null),
|
||||
y._(null),
|
||||
a._(null, color: Colors.lightGreen),
|
||||
b._(null, color: Colors.pinkAccent),
|
||||
z._(null, color: Colors.deepOrangeAccent),
|
||||
y._(null, color: Colors.lightBlue),
|
||||
onOffRight._(InGameAction.toggleUi),
|
||||
sideButtonRight._(InGameAction.shiftUp),
|
||||
paddleRight._(InGameAction.shiftUp),
|
||||
|
||||
// zwift ride only
|
||||
shiftUpRight._(InGameAction.shiftUp),
|
||||
shiftUpRight._(InGameAction.shiftUp, icon: Icons.add, color: Colors.black),
|
||||
shiftDownRight._(InGameAction.shiftUp),
|
||||
powerUpRight._(InGameAction.shiftUp);
|
||||
|
||||
final InGameAction? action;
|
||||
const ZwiftButton._(this.action);
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
const ZwiftButton._(this.action, {this.color, this.icon});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../actions/base_actions.dart';
|
||||
|
||||
class Keymap {
|
||||
static Keymap custom = Keymap(keyPairs: []);
|
||||
|
||||
@@ -15,9 +19,8 @@ class Keymap {
|
||||
String toString() {
|
||||
return keyPairs.joinToString(
|
||||
separator: ('\n---------\n'),
|
||||
transform:
|
||||
(k) =>
|
||||
'''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}${k.isLongPress ? '\nLong Press: Enabled' : ''}''',
|
||||
transform: (k) =>
|
||||
'''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}${k.isLongPress ? '\nLong Press: Enabled' : ''}''',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +62,21 @@ class KeyPair {
|
||||
physicalKey == PhysicalKeyboardKey.audioVolumeUp ||
|
||||
physicalKey == PhysicalKeyboardKey.audioVolumeDown;
|
||||
|
||||
IconData get icon {
|
||||
return switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause ||
|
||||
PhysicalKeyboardKey.mediaStop ||
|
||||
PhysicalKeyboardKey.mediaTrackPrevious ||
|
||||
PhysicalKeyboardKey.mediaTrackNext ||
|
||||
PhysicalKeyboardKey.audioVolumeUp ||
|
||||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
|
||||
_ =>
|
||||
physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)
|
||||
? Icons.keyboard
|
||||
: Icons.touch_app,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return logicalKey?.keyLabel ??
|
||||
@@ -75,11 +93,12 @@ class KeyPair {
|
||||
|
||||
String encode() {
|
||||
// encode to save in preferences
|
||||
|
||||
return jsonEncode({
|
||||
'actions': buttons.map((e) => e.name).toList(),
|
||||
'logicalKey': logicalKey?.keyId.toString() ?? '0',
|
||||
'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
|
||||
'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
|
||||
if (logicalKey != null) 'logicalKey': logicalKey?.keyId.toString(),
|
||||
if (physicalKey != null) 'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
|
||||
if (touchPosition != Offset.zero) 'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
|
||||
'isLongPress': isLongPress,
|
||||
});
|
||||
}
|
||||
@@ -87,18 +106,26 @@ class KeyPair {
|
||||
static KeyPair? decode(String data) {
|
||||
// decode from preferences
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded['actions'] == null || decoded['logicalKey'] == null || decoded['physicalKey'] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Support both percentage-based (new) and pixel-based (old) formats for backward compatibility
|
||||
final Offset touchPosition = decoded.containsKey('touchPosition')
|
||||
? Offset(
|
||||
(decoded['touchPosition']['x'] as num).toDouble(),
|
||||
(decoded['touchPosition']['y'] as num).toDouble(),
|
||||
)
|
||||
: Offset.zero;
|
||||
|
||||
return KeyPair(
|
||||
buttons:
|
||||
decoded['actions']
|
||||
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
|
||||
.toList(),
|
||||
logicalKey: int.parse(decoded['logicalKey']) != 0 ? LogicalKeyboardKey(int.parse(decoded['logicalKey'])) : null,
|
||||
physicalKey:
|
||||
int.parse(decoded['physicalKey']) != 0 ? PhysicalKeyboardKey(int.parse(decoded['physicalKey'])) : null,
|
||||
touchPosition: Offset(decoded['touchPosition']['x'], decoded['touchPosition']['y']),
|
||||
buttons: decoded['actions']
|
||||
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
|
||||
.toList(),
|
||||
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
|
||||
? LogicalKeyboardKey(int.parse(decoded['logicalKey']))
|
||||
: null,
|
||||
physicalKey: decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0
|
||||
? PhysicalKeyboardKey(int.parse(decoded['physicalKey']))
|
||||
: null,
|
||||
touchPosition: touchPosition,
|
||||
isLongPress: decoded['isLongPress'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
|
||||
class AccessibilityRequirement extends PlatformRequirement {
|
||||
AccessibilityRequirement() : super('Allow Accessibility Service');
|
||||
AccessibilityRequirement()
|
||||
: super(
|
||||
'Allow Accessibility Service',
|
||||
description: 'SwiftControl needs accessibility permission to control your training apps.',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
return accessibilityHandler.openPermissions();
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
_showDisclosureDialog(context, onUpdate);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -20,31 +24,6 @@ class AccessibilityRequirement extends PlatformRequirement {
|
||||
status = await accessibilityHandler.hasPermission();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
if (status) {
|
||||
return null; // Already granted, no need for disclosure
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'SwiftControl needs accessibility permission to control your training apps.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => _showDisclosureDialog(context, onUpdate),
|
||||
child: const Text('Show Permission Details'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDisclosureDialog(BuildContext context, VoidCallback onUpdate) async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
@@ -72,7 +51,7 @@ class BluetoothScanRequirement extends PlatformRequirement {
|
||||
BluetoothScanRequirement() : super('Allow Bluetooth Scan');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
await Permission.bluetoothScan.request();
|
||||
}
|
||||
|
||||
@@ -87,7 +66,7 @@ class LocationRequirement extends PlatformRequirement {
|
||||
LocationRequirement() : super('Allow Location so Bluetooth scan works');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
await Permission.locationWhenInUse.request();
|
||||
}
|
||||
|
||||
@@ -102,7 +81,7 @@ class BluetoothConnectRequirement extends PlatformRequirement {
|
||||
BluetoothConnectRequirement() : super('Allow Bluetooth Connections');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
await Permission.bluetoothConnect.request();
|
||||
}
|
||||
|
||||
@@ -114,10 +93,11 @@ class BluetoothConnectRequirement extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class NotificationRequirement extends PlatformRequirement {
|
||||
NotificationRequirement() : super('Allow adding persistent Notification (keeps app alive)');
|
||||
NotificationRequirement()
|
||||
: super('Allow persistent Notification', description: 'This keeps the app alive in background');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
await flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestNotificationsPermission();
|
||||
@@ -170,7 +150,7 @@ class NotificationRequirement extends PlatformRequirement {
|
||||
await AndroidFlutterLocalNotificationsPlugin().startForegroundService(
|
||||
1,
|
||||
channelGroupId,
|
||||
'Bluetooth keep alive',
|
||||
'Allows SwiftControl to keep running in background',
|
||||
foregroundServiceTypes: {AndroidServiceForegroundType.foregroundServiceTypeConnectedDevice},
|
||||
notificationDetails: AndroidNotificationDetails(
|
||||
channelGroupId,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/scan.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/utils/requirements/remote.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
class KeyboardRequirement extends PlatformRequirement {
|
||||
KeyboardRequirement() : super('Keyboard access');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
await keyPressSimulator.requestAccess();
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
await keyPressSimulator.requestAccess(onlyOpenPrefPane: Platform.isMacOS);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -22,14 +27,19 @@ class BluetoothTurnedOn extends PlatformRequirement {
|
||||
BluetoothTurnedOn() : super('Bluetooth turned on');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
await UniversalBle.enableBluetooth();
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
// on iOS we cannot programmatically enable Bluetooth, just open settings
|
||||
await peripheralManager.showAppSettings();
|
||||
} else {
|
||||
await UniversalBle.enableBluetooth();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
final currentState = await UniversalBle.getBluetoothAvailabilityState();
|
||||
status = currentState == AvailabilityState.poweredOn;
|
||||
status = currentState == AvailabilityState.poweredOn || screenshotMode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,19 +49,19 @@ class UnsupportedPlatform extends PlatformRequirement {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> call() async {}
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {}
|
||||
}
|
||||
|
||||
class BluetoothScanning extends PlatformRequirement {
|
||||
BluetoothScanning() : super('Finding your Zwift® controller...') {
|
||||
BluetoothScanning() : super('Finding your Controller...') {
|
||||
status = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> call() async {}
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {}
|
||||
|
||||
@@ -5,28 +5,32 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/requirements/android.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/utils/requirements/remote.dart';
|
||||
|
||||
abstract class PlatformRequirement {
|
||||
String name;
|
||||
String? description;
|
||||
late bool status;
|
||||
|
||||
PlatformRequirement(this.name);
|
||||
PlatformRequirement(this.name, {this.description});
|
||||
|
||||
Future<void> getStatus();
|
||||
|
||||
Future<void> call();
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate);
|
||||
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlatformRequirement>> getRequirements() async {
|
||||
Future<List<PlatformRequirement>> getRequirements(bool local) async {
|
||||
List<PlatformRequirement> list;
|
||||
if (kIsWeb) {
|
||||
list = [BluetoothTurnedOn(), BluetoothScanning()];
|
||||
} else if (Platform.isMacOS || Platform.isIOS) {
|
||||
} else if (Platform.isMacOS) {
|
||||
list = [BluetoothTurnedOn(), KeyboardRequirement(), BluetoothScanning()];
|
||||
} else if (Platform.isIOS) {
|
||||
list = [BluetoothTurnedOn(), RemoteRequirement(), BluetoothScanning()];
|
||||
} else if (Platform.isWindows) {
|
||||
list = [BluetoothTurnedOn(), KeyboardRequirement(), BluetoothScanning()];
|
||||
} else if (Platform.isAndroid) {
|
||||
@@ -34,7 +38,7 @@ Future<List<PlatformRequirement>> getRequirements() async {
|
||||
final deviceInfo = await deviceInfoPlugin.androidInfo;
|
||||
list = [
|
||||
BluetoothTurnedOn(),
|
||||
AccessibilityRequirement(),
|
||||
if (local) AccessibilityRequirement() else RemoteRequirement(),
|
||||
NotificationRequirement(),
|
||||
if (deviceInfo.version.sdkInt <= 30)
|
||||
LocationRequirement()
|
||||
|
||||
340
lib/utils/requirements/remote.dart
Normal file
@@ -0,0 +1,340 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/remote.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
import '../../pages/markdown.dart';
|
||||
|
||||
final peripheralManager = PeripheralManager();
|
||||
bool _isAdvertising = false;
|
||||
bool _isLoading = false;
|
||||
bool _isServiceAdded = false;
|
||||
bool _isSubscribedToEvents = false;
|
||||
|
||||
class RemoteRequirement extends PlatformRequirement {
|
||||
RemoteRequirement() : super('Connect to your other device');
|
||||
|
||||
@override
|
||||
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
|
||||
|
||||
Future<void> reconnect() async {
|
||||
await peripheralManager.stopAdvertising();
|
||||
await peripheralManager.removeAllServices();
|
||||
_isServiceAdded = false;
|
||||
_isAdvertising = false;
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
startAdvertising(() {});
|
||||
}
|
||||
|
||||
Future<void> startAdvertising(VoidCallback onUpdate) async {
|
||||
// Input report characteristic (notify)
|
||||
final inputReport = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('2A4D'),
|
||||
permissions: [GATTCharacteristicPermission.read],
|
||||
properties: [GATTCharacteristicProperty.notify, GATTCharacteristicProperty.read],
|
||||
descriptors: [
|
||||
GATTDescriptor.immutable(
|
||||
// Report Reference: ID=1, Type=Input(1)
|
||||
uuid: UUID.fromString('2908'),
|
||||
value: Uint8List.fromList([0x01, 0x01]),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
peripheralManager.stateChanged.forEach((state) {
|
||||
print('Peripheral manager state: ${state.state}');
|
||||
});
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
if (Platform.isAndroid) {
|
||||
peripheralManager.connectionStateChanged.forEach((state) {
|
||||
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
|
||||
if (state.state == ConnectionState.connected) {
|
||||
/*(actionHandler as RemoteActions).setConnectedCentral(state.central, inputReport);
|
||||
//peripheralManager.stopAdvertising();
|
||||
onUpdate();*/
|
||||
} else if (state.state == ConnectionState.disconnected) {
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
onUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final status = await Permission.bluetoothAdvertise.request();
|
||||
if (!status.isGranted) {
|
||||
print('Bluetooth advertise permission not granted');
|
||||
_isAdvertising = false;
|
||||
onUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
while (peripheralManager.state != BluetoothLowEnergyState.poweredOn &&
|
||||
peripheralManager.state != BluetoothLowEnergyState.unknown) {
|
||||
print('Waiting for peripheral manager to be powered on...');
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
if (!_isServiceAdded) {
|
||||
await Future.delayed(Duration(seconds: 1));
|
||||
final reportMapDataAbsolute = Uint8List.fromList([
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x02, // Usage (Mouse)
|
||||
0xA1, 0x01, // Collection (Application)
|
||||
0x85, 0x01, // Report ID (1)
|
||||
0x09, 0x01, // Usage (Pointer)
|
||||
0xA1, 0x00, // Collection (Physical)
|
||||
0x05, 0x09, // Usage Page (Button)
|
||||
0x19, 0x01, // Usage Min (1)
|
||||
0x29, 0x03, // Usage Max (3)
|
||||
0x15, 0x00, // Logical Min (0)
|
||||
0x25, 0x01, // Logical Max (1)
|
||||
0x95, 0x03, // Report Count (3)
|
||||
0x75, 0x01, // Report Size (1)
|
||||
0x81, 0x02, // Input (Data,Var,Abs) // buttons
|
||||
0x95, 0x01, // Report Count (1)
|
||||
0x75, 0x05, // Report Size (5)
|
||||
0x81, 0x03, // Input (Const,Var,Abs) // padding
|
||||
0x05, 0x01, // Usage Page (Generic Desktop)
|
||||
0x09, 0x30, // Usage (X)
|
||||
0x09, 0x31, // Usage (Y)
|
||||
0x15, 0x00, // Logical Min (0)
|
||||
0x25, 0x64, // Logical Max (100)
|
||||
0x75, 0x08, // Report Size (8)
|
||||
0x95, 0x02, // Report Count (2)
|
||||
0x81, 0x02, // Input (Data,Var,Abs)
|
||||
0xC0,
|
||||
0xC0,
|
||||
]);
|
||||
|
||||
// 1) Build characteristics
|
||||
final hidInfo = GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A4A'),
|
||||
value: Uint8List.fromList([0x11, 0x01, 0x00, 0x02]),
|
||||
descriptors: [], // HID v1.11, country=0, flags=2
|
||||
);
|
||||
|
||||
final reportMap = GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A4B'),
|
||||
//properties: [GATTCharacteristicProperty.read],
|
||||
//permissions: [GATTCharacteristicPermission.read],
|
||||
value: reportMapDataAbsolute,
|
||||
descriptors: [
|
||||
GATTDescriptor.immutable(uuid: UUID.fromString('2908'), value: Uint8List.fromList([0x0, 0x0])),
|
||||
],
|
||||
);
|
||||
|
||||
final protocolMode = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('2A4E'),
|
||||
properties: [GATTCharacteristicProperty.read, GATTCharacteristicProperty.writeWithoutResponse],
|
||||
permissions: [GATTCharacteristicPermission.read, GATTCharacteristicPermission.write],
|
||||
descriptors: [],
|
||||
);
|
||||
|
||||
final hidControlPoint = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('2A4C'),
|
||||
properties: [GATTCharacteristicProperty.writeWithoutResponse],
|
||||
permissions: [GATTCharacteristicPermission.write],
|
||||
descriptors: [],
|
||||
);
|
||||
|
||||
// Input report characteristic (notify)
|
||||
final keyboardInputReport = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('2A4D'),
|
||||
permissions: [GATTCharacteristicPermission.read],
|
||||
properties: [GATTCharacteristicProperty.notify, GATTCharacteristicProperty.read],
|
||||
descriptors: [
|
||||
GATTDescriptor.immutable(
|
||||
// Report Reference: ID=1, Type=Input(1)
|
||||
uuid: UUID.fromString('2908'),
|
||||
value: Uint8List.fromList([0x02, 0x01]),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final outputReport = GATTCharacteristic.mutable(
|
||||
uuid: UUID.fromString('2A4D'),
|
||||
permissions: [GATTCharacteristicPermission.read, GATTCharacteristicPermission.write],
|
||||
properties: [
|
||||
GATTCharacteristicProperty.read,
|
||||
GATTCharacteristicProperty.write,
|
||||
GATTCharacteristicProperty.writeWithoutResponse,
|
||||
],
|
||||
descriptors: [
|
||||
GATTDescriptor.immutable(
|
||||
// Report Reference: ID=1, Type=Input(1)
|
||||
uuid: UUID.fromString('2908'),
|
||||
value: Uint8List.fromList([0x02, 0x02]),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// 2) HID service
|
||||
final hidService = GATTService(
|
||||
uuid: UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
hidInfo,
|
||||
reportMap,
|
||||
protocolMode,
|
||||
outputReport,
|
||||
hidControlPoint,
|
||||
keyboardInputReport,
|
||||
inputReport,
|
||||
],
|
||||
includedServices: [],
|
||||
);
|
||||
|
||||
if (!_isSubscribedToEvents) {
|
||||
_isSubscribedToEvents = true;
|
||||
peripheralManager.characteristicReadRequested.forEach((char) {
|
||||
print('Read request for characteristic: ${char}');
|
||||
// You can respond to read requests here if needed
|
||||
});
|
||||
|
||||
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
|
||||
if (char.characteristic.uuid == inputReport.uuid) {
|
||||
if (char.state) {
|
||||
(actionHandler as RemoteActions).setConnectedCentral(char.central, char.characteristic);
|
||||
} else {
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
}
|
||||
onUpdate();
|
||||
}
|
||||
print(
|
||||
'Notify state changed for characteristic: ${char.characteristic.uuid} vs ${char.characteristic.uuid == inputReport.uuid}: ${char.state}',
|
||||
);
|
||||
});
|
||||
}
|
||||
await peripheralManager.addService(hidService);
|
||||
|
||||
// 3) Optional Battery service
|
||||
await peripheralManager.addService(
|
||||
GATTService(
|
||||
uuid: UUID.fromString('180F'),
|
||||
isPrimary: true,
|
||||
characteristics: [
|
||||
GATTCharacteristic.immutable(
|
||||
uuid: UUID.fromString('2A19'),
|
||||
value: Uint8List.fromList([100]),
|
||||
descriptors: [],
|
||||
),
|
||||
],
|
||||
includedServices: [],
|
||||
),
|
||||
);
|
||||
_isServiceAdded = true;
|
||||
}
|
||||
|
||||
final advertisement = Advertisement(
|
||||
name:
|
||||
'SwiftControl ${Platform.isIOS
|
||||
? 'iOS'
|
||||
: Platform.isAndroid
|
||||
? 'Android'
|
||||
: ''}',
|
||||
serviceUUIDs: [UUID.fromString(Platform.isIOS ? '1812' : '00001812-0000-1000-8000-00805F9B34FB')],
|
||||
);
|
||||
/*pm.connectionStateChanged.forEach((state) {
|
||||
print('Peripheral connection state: $state');
|
||||
});*/
|
||||
print('Starting advertising with HID service...');
|
||||
|
||||
await peripheralManager.startAdvertising(advertisement);
|
||||
_isAdvertising = true;
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
return _PairWidget(onUpdate: onUpdate, requirement: this);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = (actionHandler as RemoteActions).isConnected || screenshotMode;
|
||||
}
|
||||
}
|
||||
|
||||
class _PairWidget extends StatefulWidget {
|
||||
final RemoteRequirement requirement;
|
||||
final VoidCallback onUpdate;
|
||||
const _PairWidget({super.key, required this.onUpdate, required this.requirement});
|
||||
|
||||
@override
|
||||
State<_PairWidget> createState() => _PairWidgetState();
|
||||
}
|
||||
|
||||
class _PairWidgetState extends State<_PairWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
toggle();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
spacing: 10,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 10,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await toggle();
|
||||
},
|
||||
child: Text(_isAdvertising ? 'Stop Pairing' : 'Start Pairing'),
|
||||
),
|
||||
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
|
||||
if (kDebugMode && !screenshotMode)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
(actionHandler as RemoteActions).sendAbsMouseReport(0, 90, 90);
|
||||
(actionHandler as RemoteActions).sendAbsMouseReport(1, 90, 90);
|
||||
(actionHandler as RemoteActions).sendAbsMouseReport(0, 90, 90);
|
||||
},
|
||||
child: Text('Test'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isAdvertising) ...[
|
||||
Text(
|
||||
'If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')));
|
||||
},
|
||||
child: Text('Check the troubleshooting guide'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggle() async {
|
||||
if (_isAdvertising) {
|
||||
await peripheralManager.stopAdvertising();
|
||||
_isAdvertising = false;
|
||||
(actionHandler as RemoteActions).setConnectedCentral(null, null);
|
||||
widget.onUpdate();
|
||||
_isLoading = false;
|
||||
setState(() {});
|
||||
} else {
|
||||
_isLoading = true;
|
||||
setState(() {});
|
||||
await widget.requirement.startAdvertising(widget.onUpdate);
|
||||
_isLoading = false;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
|
||||
@@ -6,57 +9,178 @@ import '../../main.dart';
|
||||
import '../keymap/apps/custom_app.dart';
|
||||
|
||||
class Settings {
|
||||
late final SharedPreferences _prefs;
|
||||
late final SharedPreferences prefs;
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
|
||||
try {
|
||||
final appSetting = _prefs.getStringList("customapp");
|
||||
if (appSetting != null) {
|
||||
final customApp = CustomApp();
|
||||
customApp.decodeKeymap(appSetting);
|
||||
// Get screen size for migrations
|
||||
Size? screenSize;
|
||||
try {
|
||||
final view = WidgetsBinding.instance.platformDispatcher.views.first;
|
||||
screenSize = view.physicalSize / view.devicePixelRatio;
|
||||
} catch (e) {
|
||||
screenSize = null;
|
||||
}
|
||||
|
||||
final appName = _prefs.getString('app');
|
||||
// Handle migration from old "customapp" key to new "customapp_Custom" key
|
||||
if (prefs.containsKey('customapp') && !prefs.containsKey('customapp_Custom')) {
|
||||
final oldCustomApp = prefs.getStringList('customapp');
|
||||
if (oldCustomApp != null) {
|
||||
// Migrate pixel-based to percentage-based if screen size available
|
||||
if (screenSize != null) {
|
||||
final migratedData = await _migrateToPercentageBased(oldCustomApp, screenSize);
|
||||
await prefs.setStringList('customapp_Custom', migratedData);
|
||||
} else {
|
||||
await prefs.setStringList('customapp_Custom', oldCustomApp);
|
||||
}
|
||||
await prefs.remove('customapp');
|
||||
}
|
||||
}
|
||||
|
||||
final appName = prefs.getString('app');
|
||||
if (appName == null) {
|
||||
return;
|
||||
}
|
||||
final app = SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
|
||||
|
||||
actionHandler.init(app);
|
||||
// Check if it's a custom app with a profile name
|
||||
if (appName.startsWith('Custom') || prefs.containsKey('customapp_$appName')) {
|
||||
final customApp = CustomApp(profileName: appName);
|
||||
final appSetting = prefs.getStringList('customapp_$appName');
|
||||
if (appSetting != null) {
|
||||
customApp.decodeKeymap(appSetting);
|
||||
}
|
||||
actionHandler.init(customApp);
|
||||
} else {
|
||||
final app = SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
|
||||
actionHandler.init(app);
|
||||
}
|
||||
} catch (e) {
|
||||
// couldn't decode, reset
|
||||
await _prefs.clear();
|
||||
await prefs.clear();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
await _prefs.clear();
|
||||
await prefs.clear();
|
||||
actionHandler.init(null);
|
||||
}
|
||||
|
||||
Future<void> setApp(SupportedApp app) async {
|
||||
if (app is CustomApp) {
|
||||
await _prefs.setStringList("customapp", app.encodeKeymap());
|
||||
await prefs.setStringList('customapp_${app.profileName}', app.encodeKeymap());
|
||||
}
|
||||
await prefs.setString('app', app.name);
|
||||
}
|
||||
|
||||
List<String> getCustomAppProfiles() {
|
||||
// Get all keys starting with 'customapp_'
|
||||
final keys = prefs.getKeys().where((key) => key.startsWith('customapp_')).toList();
|
||||
return keys.map((key) => key.replaceFirst('customapp_', '')).toList();
|
||||
}
|
||||
|
||||
List<String>? getCustomAppKeymap(String profileName) {
|
||||
return prefs.getStringList('customapp_$profileName');
|
||||
}
|
||||
|
||||
Future<void> deleteCustomAppProfile(String profileName) async {
|
||||
await prefs.remove('customapp_$profileName');
|
||||
// If the current app is the one being deleted, reset
|
||||
if (prefs.getString('app') == profileName) {
|
||||
actionHandler.init(null);
|
||||
await prefs.remove('app');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> duplicateCustomAppProfile(String sourceProfileName, String newProfileName) async {
|
||||
final sourceData = prefs.getStringList('customapp_$sourceProfileName');
|
||||
if (sourceData != null) {
|
||||
await prefs.setStringList('customapp_$newProfileName', sourceData);
|
||||
}
|
||||
}
|
||||
|
||||
String? exportCustomAppProfile(String profileName) {
|
||||
final data = prefs.getStringList('customapp_$profileName');
|
||||
if (data == null) return null;
|
||||
var encoder = JsonEncoder.withIndent(" ");
|
||||
return encoder.convert({
|
||||
'version': 1,
|
||||
'profileName': profileName,
|
||||
'keymap': data.map((e) => jsonDecode(e)).toList(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> importCustomAppProfile(String jsonData, {String? newProfileName}) async {
|
||||
try {
|
||||
final decoded = jsonDecode(jsonData);
|
||||
|
||||
// Validate the structure
|
||||
if (decoded['version'] == null || decoded['keymap'] == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final profileName = newProfileName ?? decoded['profileName'] ?? 'Imported';
|
||||
final keymap = (decoded['keymap'] as List).map((e) => jsonEncode(e)).toList().cast<String>();
|
||||
|
||||
await prefs.setStringList('customapp_$profileName', keymap);
|
||||
return true;
|
||||
} catch (e) {
|
||||
print(e);
|
||||
return false;
|
||||
}
|
||||
await _prefs.setString('app', app.name);
|
||||
}
|
||||
|
||||
String? getLastSeenVersion() {
|
||||
return _prefs.getString('last_seen_version');
|
||||
return prefs.getString('last_seen_version');
|
||||
}
|
||||
|
||||
Future<void> setLastSeenVersion(String version) async {
|
||||
await _prefs.setString('last_seen_version', version);
|
||||
await prefs.setString('last_seen_version', version);
|
||||
}
|
||||
|
||||
bool getVibrationEnabled() {
|
||||
return _prefs.getBool('vibration_enabled') ?? true;
|
||||
return prefs.getBool('vibration_enabled') ?? true;
|
||||
}
|
||||
|
||||
Future<void> setVibrationEnabled(bool enabled) async {
|
||||
await _prefs.setBool('vibration_enabled', enabled);
|
||||
await prefs.setBool('vibration_enabled', enabled);
|
||||
}
|
||||
|
||||
Future<List<String>> _migrateToPercentageBased(List<String> keymapData, Size screenSize) async {
|
||||
final migratedData = <String>[];
|
||||
|
||||
final needMigrations = keymapData.associateWith((encodedKeyPair) {
|
||||
final decoded = jsonDecode(encodedKeyPair);
|
||||
final touchPosData = decoded['touchPosition'];
|
||||
|
||||
// Convert pixel-based to percentage-based
|
||||
final x = (touchPosData['x'] as num).toDouble();
|
||||
final y = (touchPosData['y'] as num).toDouble();
|
||||
return x > 100.0 || y > 100.0;
|
||||
});
|
||||
|
||||
for (final entry in needMigrations.entries) {
|
||||
if (entry.value) {
|
||||
final decoded = jsonDecode(entry.key);
|
||||
final touchPosData = decoded['touchPosition'];
|
||||
|
||||
// Convert pixel-based to percentage-based
|
||||
final x = (touchPosData['x'] as num).toDouble();
|
||||
final y = (touchPosData['y'] as num).toDouble();
|
||||
final newX = (x / screenSize.width).clamp(0.0, 1.0) * 100.0;
|
||||
final newY = (y / screenSize.height).clamp(0.0, 1.0) * 100.0;
|
||||
|
||||
// Update the JSON structure
|
||||
decoded['touchPosition'] = {'x': newX, 'y': newY};
|
||||
|
||||
migratedData.add(jsonEncode(decoded));
|
||||
} else {
|
||||
migratedData.add(entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
return migratedData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/utils/changelog.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_md/flutter_md.dart';
|
||||
|
||||
class ChangelogDialog extends StatelessWidget {
|
||||
final ChangelogEntry entry;
|
||||
final Markdown entry;
|
||||
|
||||
const ChangelogDialog({super.key, required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latestVersion = Markdown(blocks: entry.blocks.skip(1).take(2).toList(), markdown: entry.markdown);
|
||||
return AlertDialog(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -16,28 +18,14 @@ class ChangelogDialog extends StatelessWidget {
|
||||
Text('What\'s New'),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Version ${entry.version}',
|
||||
'Version ${entry.blocks.first.text}',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children:
|
||||
entry.changes
|
||||
.map(
|
||||
(change) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('• ', style: TextStyle(fontSize: 16)),
|
||||
Expanded(child: Text(change, style: Theme.of(context).textTheme.bodyMedium)),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
content: Container(
|
||||
constraints: BoxConstraints(minWidth: 460),
|
||||
child: MarkdownWidget(markdown: latestVersion),
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Got it!'))],
|
||||
);
|
||||
@@ -47,9 +35,14 @@ class ChangelogDialog extends StatelessWidget {
|
||||
// Show dialog if this is a new version
|
||||
if (lastSeenVersion != currentVersion) {
|
||||
try {
|
||||
final entry = await ChangelogParser.getLatestEntry();
|
||||
if (entry != null && context.mounted) {
|
||||
showDialog(context: context, builder: (context) => ChangelogDialog(entry: entry));
|
||||
final entry = await rootBundle.loadString('CHANGELOG.md');
|
||||
if (context.mounted) {
|
||||
final markdown = Markdown.fromString(entry);
|
||||
showDialog(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => ChangelogDialog(entry: markdown),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Failed to load changelog for dialog: $e');
|
||||
|
||||
@@ -86,7 +86,7 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
return AlertDialog(
|
||||
content:
|
||||
_pressedButton == null
|
||||
? Text('Press a button on your Zwift device')
|
||||
? Text('Press a button on your Click device')
|
||||
: KeyboardListener(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/device.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
import '../pages/touch_area.dart';
|
||||
import '../utils/actions/base_actions.dart';
|
||||
|
||||
class KeymapExplanation extends StatelessWidget {
|
||||
final Keymap keymap;
|
||||
@@ -15,14 +18,18 @@ class KeymapExplanation extends StatelessWidget {
|
||||
final connectedDevice = connection.devices.firstOrNull;
|
||||
|
||||
final availableKeypairs = keymap.keyPairs.filter(
|
||||
(e) => connectedDevice?.availableButtons.containsAny(e.buttons) == true,
|
||||
(e) => connectedDevice?.availableButtons.containsAny(e.buttons) ?? true,
|
||||
);
|
||||
|
||||
final keyboardGroups = availableKeypairs
|
||||
.filter((e) => e.physicalKey != null)
|
||||
.filter((e) => e.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard))
|
||||
.groupBy((element) => '${element.physicalKey?.usbHidUsage}-${element.isLongPress}');
|
||||
final touchGroups = availableKeypairs
|
||||
.filter((e) => e.physicalKey == null && e.touchPosition != Offset.zero)
|
||||
.filter(
|
||||
(e) =>
|
||||
(e.physicalKey == null || !actionHandler.supportedModes.contains(SupportedMode.keyboard)) &&
|
||||
e.touchPosition != Offset.zero,
|
||||
)
|
||||
.groupBy((element) => '${element.touchPosition.dx}-${element.touchPosition.dy}-${element.isLongPress}');
|
||||
|
||||
return Column(
|
||||
@@ -34,14 +41,22 @@ class KeymapExplanation extends StatelessWidget {
|
||||
Text('No key mappings found. Please customize the keymap.')
|
||||
else
|
||||
Table(
|
||||
border: TableBorder.all(color: Theme.of(context).colorScheme.primaryContainer),
|
||||
border: TableBorder.symmetric(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
inside: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
outside: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
'Button on your ${connectedDevice?.device.name ?? connectedDevice?.runtimeType}',
|
||||
'Button on your ${connectedDevice?.device.name?.screenshot ?? connectedDevice?.runtimeType ?? 'device'}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@@ -65,12 +80,15 @@ class KeymapExplanation extends StatelessWidget {
|
||||
children: [
|
||||
for (final keyPair in pair.value)
|
||||
for (final button in keyPair.buttons)
|
||||
if (connectedDevice?.availableButtons.contains(button) == true)
|
||||
IntrinsicWidth(child: KeyWidget(label: button.name)),
|
||||
if (connectedDevice?.availableButtons.contains(button) ?? true)
|
||||
IntrinsicWidth(child: ButtonWidget(button: button)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.all(6), child: KeypairExplanation(keyPair: pair.value.first)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: KeypairExplanation(keyPair: pair.value.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -79,17 +97,21 @@ class KeymapExplanation extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final keyPair in pair.value)
|
||||
for (final button in keyPair.buttons)
|
||||
if (connectedDevice?.availableButtons.contains(button) == true)
|
||||
KeyWidget(label: button.name),
|
||||
if (connectedDevice?.availableButtons.contains(button) ?? true)
|
||||
ButtonWidget(button: button),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.all(6), child: KeypairExplanation(keyPair: pair.value.first)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: KeypairExplanation(keyPair: pair.value.first),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -130,6 +152,48 @@ class KeyWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class ButtonWidget extends StatelessWidget {
|
||||
final ZwiftButton button;
|
||||
final bool big;
|
||||
const ButtonWidget({super.key, required this.button, this.big = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicWidth(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
constraints: BoxConstraints(
|
||||
minWidth: big && button.color != null ? 40 : 30,
|
||||
minHeight: big && button.color != null ? 40 : 0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: button.color != null ? Colors.black : Theme.of(context).colorScheme.primary),
|
||||
shape: button.color != null || button.icon != null ? BoxShape.circle : BoxShape.rectangle,
|
||||
borderRadius: button.color != null || button.icon != null ? null : BorderRadius.circular(4),
|
||||
color: button.color ?? Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
child: Center(
|
||||
child: button.icon != null
|
||||
? Icon(
|
||||
button.icon,
|
||||
color: Colors.white,
|
||||
size: big && button.color != null ? null : 14,
|
||||
)
|
||||
: Text(
|
||||
button.name.splitByUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: big && button.color != null ? 20 : 12,
|
||||
fontWeight: button.color != null ? FontWeight.bold : null,
|
||||
color: button.color != null ? Colors.white : Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension SplitByUppercase on String {
|
||||
String splitByUpperCase() {
|
||||
return replaceAllMapped(RegExp(r'([a-z])([A-Z])'), (match) => '${match.group(1)} ${match.group(2)}').capitalize();
|
||||
|
||||
93
lib/widgets/loading_widget.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef RenderLoadCallback = Widget Function();
|
||||
typedef OnErrorCallback = void Function(BuildContext context, dynamic error);
|
||||
typedef OnLoadCallback = void Function(bool isLoading);
|
||||
typedef RenderChildCallback = Widget Function(bool isLoading, VoidCallback? onTap);
|
||||
typedef FutureCallback = Future Function();
|
||||
|
||||
enum LoadingState { Error, Loading, Success }
|
||||
|
||||
class LoadingWidget extends StatefulWidget {
|
||||
const LoadingWidget({
|
||||
super.key,
|
||||
this.renderLoad,
|
||||
this.renderChild,
|
||||
this.onErrorCallback,
|
||||
this.futureCallback,
|
||||
this.onLoadCallback,
|
||||
});
|
||||
|
||||
final RenderLoadCallback? renderLoad;
|
||||
final RenderChildCallback? renderChild;
|
||||
final OnErrorCallback? onErrorCallback;
|
||||
final OnLoadCallback? onLoadCallback;
|
||||
final FutureCallback? futureCallback;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => LoadingWidgetState();
|
||||
}
|
||||
|
||||
class LoadingWidgetState extends State<LoadingWidget> {
|
||||
var _loadingState = LoadingState.Success;
|
||||
dynamic _error;
|
||||
|
||||
Future<void> reloadState() {
|
||||
return _initState();
|
||||
}
|
||||
|
||||
Future<void> _initState() async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.onLoadCallback != null) {
|
||||
widget.onLoadCallback!(true);
|
||||
}
|
||||
setState(() {
|
||||
_loadingState = LoadingState.Loading;
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.futureCallback!();
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.onLoadCallback != null) {
|
||||
widget.onLoadCallback!(false);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingState = LoadingState.Success;
|
||||
});
|
||||
} catch (e) {
|
||||
if (widget.onLoadCallback != null) {
|
||||
widget.onLoadCallback!(false);
|
||||
}
|
||||
debugPrint(e.toString());
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e;
|
||||
_loadingState = LoadingState.Error;
|
||||
if (widget.onErrorCallback != null) {
|
||||
widget.onErrorCallback!(context, _error);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(_error.toString())));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loadingState == LoadingState.Loading && widget.renderLoad != null) {
|
||||
return widget.renderLoad!();
|
||||
}
|
||||
|
||||
final isLoading = _loadingState == LoadingState.Loading;
|
||||
return widget.renderChild!(isLoading, isLoading ? null : () => reloadState());
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,14 @@ class _LogviewerState extends State<LogViewer> {
|
||||
_actions.add((date: DateTime.now(), entry: data.toString()));
|
||||
_actions = _actions.takeLast(60).toList();
|
||||
});
|
||||
// scroll to the bottom
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 60),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
if (_scrollController.hasClients) {
|
||||
// scroll to the bottom
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 60),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -48,48 +50,56 @@ class _LogviewerState extends State<LogViewer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SelectionArea(
|
||||
child: GestureDetector(
|
||||
onLongPress: () {
|
||||
setState(() {
|
||||
_actions = [];
|
||||
});
|
||||
},
|
||||
child: ListView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: _scrollController,
|
||||
shrinkWrap: true,
|
||||
reverse: true,
|
||||
children:
|
||||
_actions
|
||||
.map(
|
||||
(action) => Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: action.date.toString().split(" ").last,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: "monospace",
|
||||
fontFamilyFallback: <String>["Courier"],
|
||||
return _actions.isEmpty
|
||||
? Container()
|
||||
: SafeArea(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: SelectionArea(
|
||||
child: GestureDetector(
|
||||
onLongPress: () {
|
||||
setState(() {
|
||||
_actions = [];
|
||||
});
|
||||
},
|
||||
child: ListView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: _scrollController,
|
||||
shrinkWrap: true,
|
||||
reverse: true,
|
||||
children: _actions
|
||||
.map(
|
||||
(action) => Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: action.date.toString().split(" ").last,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: "monospace",
|
||||
fontFamilyFallback: <String>["Courier"],
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " ${action.entry}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " ${action.entry}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,48 +3,54 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/markdown.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../pages/changelog.dart';
|
||||
import '../pages/device.dart';
|
||||
|
||||
List<Widget> buildMenuButtons() {
|
||||
return [
|
||||
PopupMenuButton(
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
child: Text('via Credit Card, Google Pay, Apple Pay and others'),
|
||||
onTap: () {
|
||||
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
|
||||
final link = switch (currency.currencyName) {
|
||||
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
|
||||
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
|
||||
};
|
||||
launchUrlString(link);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && Platform.isAndroid && !isFromPlayStore)
|
||||
if (kIsWeb || (!Platform.isIOS && !Platform.isMacOS)) ...[
|
||||
PopupMenuButton(
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
child: Text('by buying the app from Play Store'),
|
||||
child: Text('via Credit Card, Google Pay, Apple Pay and others'),
|
||||
onTap: () {
|
||||
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
|
||||
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
|
||||
final link = switch (currency.currencyName) {
|
||||
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
|
||||
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
|
||||
};
|
||||
launchUrlString(link);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('via PayPal'),
|
||||
onTap: () {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
icon: Text('Donate ♥', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
if (!kIsWeb && Platform.isAndroid && isFromPlayStore == false)
|
||||
PopupMenuItem(
|
||||
child: Text('by buying the app from Play Store'),
|
||||
onTap: () {
|
||||
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('via PayPal'),
|
||||
onTap: () {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
icon: Text(
|
||||
'Donate ♥',
|
||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
],
|
||||
const MenuButton(),
|
||||
SizedBox(width: 8),
|
||||
];
|
||||
@@ -56,61 +62,71 @@ class MenuButton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton(
|
||||
itemBuilder:
|
||||
(c) => [
|
||||
if (kDebugMode) ...[
|
||||
PopupMenuItem(
|
||||
child: PopupMenuButton(
|
||||
child: Text("Simulate buttons"),
|
||||
itemBuilder: (_) {
|
||||
return ZwiftButton.values
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
child: Text(e.name),
|
||||
onTap: () {
|
||||
Future.delayed(Duration(seconds: 2)).then((_) {
|
||||
actionHandler.performAction(e);
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Continue'),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Reset'),
|
||||
onTap: () async {
|
||||
await settings.reset();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(child: PopupMenuDivider()),
|
||||
],
|
||||
PopupMenuItem(
|
||||
child: Text('Changelog'),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => ChangelogPage()));
|
||||
itemBuilder: (c) => [
|
||||
if (kDebugMode) ...[
|
||||
PopupMenuItem(
|
||||
child: PopupMenuButton(
|
||||
child: Text("Simulate buttons"),
|
||||
itemBuilder: (_) {
|
||||
return ZwiftButton.values
|
||||
.map(
|
||||
(e) => PopupMenuItem(
|
||||
child: Text(e.name),
|
||||
onTap: () {
|
||||
Future.delayed(Duration(seconds: 2)).then((_) {
|
||||
connection.signalNotification(
|
||||
RideNotification(Uint8List(0), analogPaddleThreshold: 25)..buttonsClicked = [e],
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Feedback'),
|
||||
onTap: () {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('License'),
|
||||
onTap: () {
|
||||
showLicensePage(context: context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Continue'),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Reset'),
|
||||
onTap: () async {
|
||||
await settings.reset();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(child: PopupMenuDivider()),
|
||||
],
|
||||
PopupMenuItem(
|
||||
child: Text('Changelog'),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Troubleshooting Guide'),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Feedback'),
|
||||
onTap: () {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('License'),
|
||||
onTap: () {
|
||||
showLicensePage(context: context);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,23 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPointerUp(PointerUpEvent e) {
|
||||
if (!widget.enabled ||
|
||||
!widget.showTouches ||
|
||||
(e.kind != PointerDeviceKind.unknown && e.kind != PointerDeviceKind.mouse)) {
|
||||
return;
|
||||
}
|
||||
final sample = _TouchSample(
|
||||
pointer: e.pointer,
|
||||
position: e.position,
|
||||
timestamp: DateTime.now(),
|
||||
phase: _TouchPhase.up,
|
||||
);
|
||||
_active[e.pointer] = sample;
|
||||
_history.add(sample);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _onPointerCancel(PointerCancelEvent e) {
|
||||
if (!widget.enabled || !widget.showTouches || !mounted) return;
|
||||
_active.remove(e.pointer);
|
||||
@@ -130,6 +147,7 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _onPointerDown,
|
||||
onPointerUp: _onPointerUp,
|
||||
onPointerCancel: _onPointerCancel,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Focus(
|
||||
@@ -206,6 +224,8 @@ class _TouchesPainter extends CustomPainter {
|
||||
final age = now.difference(s.timestamp);
|
||||
if (age > duration) continue;
|
||||
|
||||
final color = s.phase == _TouchPhase.down ? this.color : Colors.red;
|
||||
|
||||
final t = age.inMilliseconds / duration.inMilliseconds.clamp(1, 1 << 30);
|
||||
final fade = (1.0 - t).clamp(0.0, 1.0);
|
||||
|
||||
|
||||
85
lib/widgets/title.dart
Normal file → Executable file
@@ -7,12 +7,16 @@ import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:in_app_update/in_app_update.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:restart/restart.dart';
|
||||
import 'package:restart_app/restart_app.dart';
|
||||
import 'package:shorebird_code_push/shorebird_code_push.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
String? _latestVersionUrlValue;
|
||||
PackageInfo? _packageInfoValue;
|
||||
bool isFromPlayStore = true;
|
||||
bool? isFromPlayStore;
|
||||
|
||||
class AppTitle extends StatefulWidget {
|
||||
const AppTitle({super.key});
|
||||
@@ -22,28 +26,33 @@ class AppTitle extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppTitleState extends State<AppTitle> {
|
||||
final updater = ShorebirdUpdater();
|
||||
Patch? _shorebirdPatch;
|
||||
|
||||
Future<String?> _getLatestVersionUrlIfNewer() async {
|
||||
final response = await http.get(Uri.parse('https://api.github.com/repos/jonasbark/swiftcontrol/releases/latest'));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final tagName = data['tag_name'] as String;
|
||||
final prerelase = data['prerelease'] as bool;
|
||||
final latestVersion = tagName.split('+').first;
|
||||
final currentVersion = 'v${_packageInfoValue!.version}';
|
||||
final latestVersion = Version.parse(tagName.split('+').first.replaceAll('v', ''));
|
||||
final currentVersion = Version.parse(_packageInfoValue!.version);
|
||||
|
||||
// +1337 releases are considered beta
|
||||
if (latestVersion != currentVersion && !prerelase) {
|
||||
if (latestVersion > currentVersion && !prerelase) {
|
||||
final assets = data['assets'] as List;
|
||||
if (Platform.isAndroid) {
|
||||
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
|
||||
return apkUrl;
|
||||
} else if (Platform.isMacOS) {
|
||||
final dmgUrl =
|
||||
assets.firstOrNullWhere((asset) => asset['name'].endsWith('.macos.zip'))['browser_download_url'];
|
||||
final dmgUrl = assets.firstOrNullWhere(
|
||||
(asset) => asset['name'].endsWith('.macos.zip'),
|
||||
)['browser_download_url'];
|
||||
return dmgUrl;
|
||||
} else if (Platform.isWindows) {
|
||||
final appImageUrl =
|
||||
assets.firstOrNullWhere((asset) => asset['name'].endsWith('.windows.zip'))['browser_download_url'];
|
||||
final appImageUrl = assets.firstOrNullWhere(
|
||||
(asset) => asset['name'].endsWith('.windows.zip'),
|
||||
)['browser_download_url'];
|
||||
return appImageUrl;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +63,15 @@ class _AppTitleState extends State<AppTitle> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (updater.isAvailable) {
|
||||
updater.readCurrentPatch().then((patch) {
|
||||
setState(() {
|
||||
_shorebirdPatch = patch;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (_packageInfoValue == null) {
|
||||
PackageInfo.fromPlatform().then((value) {
|
||||
setState(() {
|
||||
@@ -67,6 +85,28 @@ class _AppTitleState extends State<AppTitle> {
|
||||
}
|
||||
|
||||
void _checkForUpdate() async {
|
||||
if (updater.isAvailable) {
|
||||
final updateStatus = await updater.checkForUpdate();
|
||||
if (updateStatus == UpdateStatus.outdated) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('New version available'),
|
||||
duration: Duration(seconds: 1337),
|
||||
action: SnackBarAction(
|
||||
label: 'Update',
|
||||
onPressed: () {
|
||||
updater.update().then((value) {
|
||||
_showShorebirdRestartSnackbar();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (updateStatus == UpdateStatus.restartRequired) {
|
||||
_showShorebirdRestartSnackbar();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
try {
|
||||
final appUpdateInfo = await InAppUpdate.checkForUpdate();
|
||||
@@ -84,13 +124,15 @@ class _AppTitleState extends State<AppTitle> {
|
||||
),
|
||||
);
|
||||
}
|
||||
isFromPlayStore = true;
|
||||
return null;
|
||||
} on Exception catch (e) {
|
||||
isFromPlayStore = false;
|
||||
print('Failed to check for update: $e');
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
if (_latestVersionUrlValue == null && !kIsWeb) {
|
||||
if (_latestVersionUrlValue == null && !kIsWeb && !Platform.isIOS) {
|
||||
final url = await _getLatestVersionUrlIfNewer();
|
||||
if (url != null && mounted && !kDebugMode) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -114,10 +156,10 @@ class _AppTitleState extends State<AppTitle> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('SwiftControl'),
|
||||
Text('SwiftControl', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
if (_packageInfoValue != null)
|
||||
Text(
|
||||
'v${_packageInfoValue!.version}',
|
||||
'v${_packageInfoValue!.version}${_shorebirdPatch != null ? '+${_shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
|
||||
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
|
||||
)
|
||||
else
|
||||
@@ -125,4 +167,25 @@ class _AppTitleState extends State<AppTitle> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showShorebirdRestartSnackbar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Restart the app to use the new version'),
|
||||
duration: Duration(seconds: 10),
|
||||
action: Platform.isIOS || Platform.isAndroid
|
||||
? SnackBarAction(
|
||||
label: 'Restart',
|
||||
onPressed: () {
|
||||
if (Platform.isAndroid) {
|
||||
restart();
|
||||
} else if (Platform.isIOS) {
|
||||
Restart.restartApp();
|
||||
}
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <bluetooth_low_energy_linux/bluetooth_low_energy_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) bluetooth_low_energy_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "BluetoothLowEnergyLinuxPlugin");
|
||||
bluetooth_low_energy_linux_plugin_register_with_registrar(bluetooth_low_energy_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
bluetooth_low_energy_linux
|
||||
file_selector_linux
|
||||
screen_retriever_linux
|
||||
url_launcher_linux
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import bluetooth_low_energy_darwin
|
||||
import device_info_plus
|
||||
import file_selector_macos
|
||||
import flutter_local_notifications
|
||||
@@ -14,9 +15,11 @@ import screen_retriever_macos
|
||||
import shared_preferences_foundation
|
||||
import universal_ble
|
||||
import url_launcher_macos
|
||||
import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
BluetoothLowEnergyDarwinPlugin.register(with: registry.registrar(forPlugin: "BluetoothLowEnergyDarwinPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
@@ -26,5 +29,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
PODS:
|
||||
- bluetooth_low_energy_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- file_selector_macos (0.0.1):
|
||||
@@ -20,10 +23,13 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- window_manager (0.5.0):
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- bluetooth_low_energy_darwin (from `Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin`)
|
||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||
@@ -34,9 +40,12 @@ DEPENDENCIES:
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
||||
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
bluetooth_low_energy_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin
|
||||
device_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||
file_selector_macos:
|
||||
@@ -57,10 +66,13 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
wakelock_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
||||
window_manager:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
bluetooth_low_energy_darwin: 764d8d1ae5abefbcdb839e812b4b25c0061fcf8b
|
||||
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
|
||||
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
|
||||
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
|
||||
@@ -71,6 +83,7 @@ SPEC CHECKSUMS:
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
wakelock_plus: 9d63063ffb7af1c215209769067c57103bde719d
|
||||
window_manager: e25faf20d88283a0d46e7b1a759d07261ca27575
|
||||
|
||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||
|
||||
28
macos/Runner.xcodeproj/project.pbxproj
Normal file → Executable file
@@ -571,19 +571,22 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = UZRHKPVWN9;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "mac app store";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Profile;
|
||||
@@ -709,18 +712,18 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
|
||||
DEVELOPMENT_TEAM = UZRHKPVWN9;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -735,19 +738,22 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "3rd Party Mac Developer Application";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
|
||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = UZRHKPVWN9;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SwiftControl;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftcontrol.darwin;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "mac app store";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
|
||||
13
macos/Runner/Info.plist
Normal file → Executable file
@@ -2,12 +2,21 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-peripheral</string>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.healthcare-fitness</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string></string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
@@ -23,12 +32,14 @@
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>We need BT access because it's a BT App.</string>
|
||||
<string>SwiftControl requires Bluetooth to connect to your devices.</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>$(PRODUCT_COPYRIGHT)</string>
|
||||
<key>NSMainNibFile</key>
|
||||
<string>MainMenu</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
<string>SwiftControl needs to send keys to your trainer app.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
6
macos/Runner/Release.entitlements
Normal file → Executable file
@@ -2,9 +2,13 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
BIN
playstoreassets/mac_screenshot_1.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
playstoreassets/mac_screenshot_2.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
133
pubspec.lock
Normal file → Executable file
@@ -32,6 +32,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
bluetooth_low_energy:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bluetooth_low_energy
|
||||
sha256: "5dec5831412c7d82b77df878dd3e08a82132426d2fb4c5d7c98c9a8cd0ed79e0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
bluetooth_low_energy_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bluetooth_low_energy_android
|
||||
sha256: "32c0f84f88770845e3189e04b0ddf4780dc8743fd7a8ade60b99b6cb414b8a89"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
bluetooth_low_energy_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bluetooth_low_energy_darwin
|
||||
sha256: fbbe3be175cb54093884a84f6f0826d6e8a2a2e29dfeae9b367d5e8e9ee1db38
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
bluetooth_low_energy_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bluetooth_low_energy_linux
|
||||
sha256: a5c740f445dc8d2e940767fa94ed3bb24c32e77bc962a67ab23cb1f218180705
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
bluetooth_low_energy_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bluetooth_low_energy_platform_interface
|
||||
sha256: dd76c0f8e31dcfb984059b03e73cb2998c29cffd17425f4ce946365b63abb3dc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
bluetooth_low_energy_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bluetooth_low_energy_windows
|
||||
sha256: "7a651259f7bc3ae2bb042c21e15e1e4f88acea57da1f69b3165f239124724791"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
bluez:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -277,6 +325,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
flutter_md:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_md
|
||||
sha256: b5a67ae49135f7a76a0cc6f938ee3e8754e71d8448b97cf99c11512877f1d055
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.7"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -319,6 +375,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
hybrid_logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hybrid_logging
|
||||
sha256: "54248d52ce68c14702a42fbc4083bac5c6be30f6afad8a41be4bbadd197b8af5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -659,6 +723,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
restart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: restart
|
||||
sha256: "70c12d26900b7c451b99eb012dea7b0d5f229f93e705c3915df41c5ddd0b97cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0+1"
|
||||
restart_app:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: restart_app
|
||||
sha256: "00d5ec3e9de871cedbe552fc41e615b042b5ec654385e090e0983f6d02f655ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -711,10 +791,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e
|
||||
sha256: "0b0f98d535319cb5cdd4f65783c2a54ee6d417a2f093dbb18be3e36e4c3d181f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.13"
|
||||
version: "2.4.14"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -755,6 +835,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shorebird_code_push:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shorebird_code_push
|
||||
sha256: "82203f39a66c78548da944dbe4079c2aa2a60fa5bc1105ed707b144c94f04349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.5"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -843,10 +931,11 @@ packages:
|
||||
universal_ble:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: universal_ble
|
||||
sha256: "6a5c6c1fb295015934a5aef3dc751ae7e00721535275f8478bfe74db77b238c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "22b713600511ef5adff60aa70b941ada99ba2890"
|
||||
url: "https://github.com/jonasbark/universal_ble.git"
|
||||
source: git
|
||||
version: "0.21.1"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
@@ -860,10 +949,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "199bc33e746088546a39cc5f36bac5a278c5e53b40cb3196f99e7345fdcfae6b"
|
||||
sha256: c0fb544b9ac7efa10254efaf00a951615c362d1ea1877472f8f6c0fa00fcf15b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.22"
|
||||
version: "6.3.23"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -920,6 +1009,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
version:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: version
|
||||
sha256: "3d4140128e6ea10d83da32fef2fa4003fccbf6852217bb854845802f04191f94"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -928,6 +1025,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
wakelock_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -940,10 +1053,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.14.0"
|
||||
version: "5.15.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
24
pubspec.yaml
Normal file → Executable file
@@ -1,10 +1,10 @@
|
||||
name: swift_control
|
||||
description: "SwiftControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 2.6.2+8
|
||||
version: 3.0.3+26
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
sdk: ^3.9.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -14,7 +14,11 @@ dependencies:
|
||||
flutter_local_notifications: ^19.4.1
|
||||
universal_ble: ^0.21.1
|
||||
intl: any
|
||||
version: ^3.0.0
|
||||
bluetooth_low_energy: ^6.1.0
|
||||
wakelock_plus: ^1.4.0
|
||||
protobuf: ^4.2.0
|
||||
flutter_md: ^0.0.7
|
||||
permission_handler: ^12.0.1
|
||||
dartx: any
|
||||
image_picker: ^1.1.2
|
||||
@@ -25,12 +29,22 @@ dependencies:
|
||||
path: keypress_simulator/packages/keypress_simulator
|
||||
shared_preferences: ^2.5.3
|
||||
flex_color_scheme: ^8.3.0
|
||||
package_info_plus: ^9.0.0
|
||||
in_app_update: ^4.2.5
|
||||
accessibility:
|
||||
path: accessibility
|
||||
|
||||
# shorebird related
|
||||
shorebird_code_push: ^2.0.5
|
||||
restart_app: ^1.3.2
|
||||
restart: ^1.0.0+1
|
||||
package_info_plus: ^9.0.0
|
||||
in_app_update: ^4.2.5
|
||||
http: ^1.3.0
|
||||
|
||||
dependency_overrides:
|
||||
universal_ble:
|
||||
git:
|
||||
url: https://github.com/jonasbark/universal_ble.git
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -42,3 +56,5 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- CHANGELOG.md
|
||||
- TROUBLESHOOTING.md
|
||||
- shorebird.yaml
|
||||
|
||||
6
scripts/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,6 @@
|
||||
I recommend downloading from the official stores:
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a>
|
||||
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a>
|
||||
<a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a>
|
||||
|
||||
2
shorebird.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
app_id: 21d821f0-6795-4fca-aeb1-5a5dbe2d2b62
|
||||
auto_update: false
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/utils/changelog.dart';
|
||||
|
||||
void main() {
|
||||
group('ChangelogParser', () {
|
||||
test('parses changelog entries correctly', () {
|
||||
const testContent = '''
|
||||
### 2.6.0 (2025-09-28)
|
||||
- Fix crashes on some Android devices
|
||||
- refactor touch placements: show touches on screen
|
||||
- show firmware version of connected device
|
||||
|
||||
### 2.5.0 (2025-09-25)
|
||||
- Improve usability
|
||||
- SwiftControl is now available via the Play Store
|
||||
- SwiftControl will continue to be available to download for free on GitHub
|
||||
''';
|
||||
|
||||
final entries = ChangelogParser.parseContent(testContent);
|
||||
|
||||
expect(entries.length, 2);
|
||||
|
||||
expect(entries[0].version, '2.6.0');
|
||||
expect(entries[0].date, '2025-09-28');
|
||||
expect(entries[0].changes.length, 3);
|
||||
expect(entries[0].changes[0], 'Fix crashes on some Android devices');
|
||||
|
||||
expect(entries[1].version, '2.5.0');
|
||||
expect(entries[1].date, '2025-09-25');
|
||||
expect(entries[1].changes.length, 3);
|
||||
expect(entries[1].changes[0], 'Improve usability');
|
||||
expect(entries[1].changes[1], 'SwiftControl is now available via the Play Store');
|
||||
expect(entries[1].changes[2], 'SwiftControl will continue to be available to download for free on GitHub');
|
||||
});
|
||||
|
||||
test('handles empty content', () {
|
||||
const testContent = '';
|
||||
final entries = ChangelogParser.parseContent(testContent);
|
||||
expect(entries.length, 0);
|
||||
});
|
||||
|
||||
test('handles single entry', () {
|
||||
const testContent = '''
|
||||
### 1.0.0 (2025-01-01)
|
||||
- Initial release
|
||||
''';
|
||||
|
||||
final entries = ChangelogParser.parseContent(testContent);
|
||||
|
||||
expect(entries.length, 1);
|
||||
expect(entries[0].version, '1.0.0');
|
||||
expect(entries[0].changes.length, 1);
|
||||
expect(entries[0].changes[0], 'Initial release');
|
||||
});
|
||||
|
||||
test('ChangelogEntry toString formats correctly', () {
|
||||
final entry = ChangelogEntry(
|
||||
version: '1.0.0',
|
||||
date: '2025-01-01',
|
||||
changes: ['Change 1', 'Change 2'],
|
||||
);
|
||||
|
||||
final result = entry.toString();
|
||||
expect(result, contains('### 1.0.0 (2025-01-01)'));
|
||||
expect(result, contains('- Change 1'));
|
||||
expect(result, contains('- Change 2'));
|
||||
});
|
||||
});
|
||||
}
|
||||
145
test/custom_profile_test.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/settings/settings.dart';
|
||||
|
||||
void main() {
|
||||
group('Custom Profile Tests', () {
|
||||
late Settings settings;
|
||||
|
||||
setUp(() async {
|
||||
// Initialize SharedPreferences with in-memory storage for testing
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
settings = Settings();
|
||||
await settings.init();
|
||||
});
|
||||
|
||||
test('Should create custom app with default profile name', () {
|
||||
final customApp = CustomApp();
|
||||
expect(customApp.profileName, 'Custom');
|
||||
expect(customApp.name, 'Custom');
|
||||
});
|
||||
|
||||
test('Should create custom app with custom profile name', () {
|
||||
final customApp = CustomApp(profileName: 'Workout');
|
||||
expect(customApp.profileName, 'Workout');
|
||||
expect(customApp.name, 'Workout');
|
||||
});
|
||||
|
||||
test('Should save and retrieve custom profile', () async {
|
||||
final customApp = CustomApp(profileName: 'Race');
|
||||
await settings.setApp(customApp);
|
||||
|
||||
final profiles = settings.getCustomAppProfiles();
|
||||
expect(profiles.contains('Race'), true);
|
||||
});
|
||||
|
||||
test('Should list multiple custom profiles', () async {
|
||||
final workout = CustomApp(profileName: 'Workout');
|
||||
final race = CustomApp(profileName: 'Race');
|
||||
final event = CustomApp(profileName: 'Event');
|
||||
|
||||
await settings.setApp(workout);
|
||||
await settings.setApp(race);
|
||||
await settings.setApp(event);
|
||||
|
||||
final profiles = settings.getCustomAppProfiles();
|
||||
expect(profiles.contains('Workout'), true);
|
||||
expect(profiles.contains('Race'), true);
|
||||
expect(profiles.contains('Event'), true);
|
||||
expect(profiles.length, 3);
|
||||
});
|
||||
|
||||
test('Should duplicate custom profile', () async {
|
||||
final original = CustomApp(profileName: 'Original');
|
||||
await settings.setApp(original);
|
||||
|
||||
await settings.duplicateCustomAppProfile('Original', 'Copy');
|
||||
|
||||
final profiles = settings.getCustomAppProfiles();
|
||||
expect(profiles.contains('Original'), true);
|
||||
expect(profiles.contains('Copy'), true);
|
||||
expect(profiles.length, 2);
|
||||
});
|
||||
|
||||
test('Should delete custom profile', () async {
|
||||
final customApp = CustomApp(profileName: 'ToDelete');
|
||||
await settings.setApp(customApp);
|
||||
|
||||
var profiles = settings.getCustomAppProfiles();
|
||||
expect(profiles.contains('ToDelete'), true);
|
||||
|
||||
await settings.deleteCustomAppProfile('ToDelete');
|
||||
|
||||
profiles = settings.getCustomAppProfiles();
|
||||
expect(profiles.contains('ToDelete'), false);
|
||||
});
|
||||
|
||||
test('Should migrate old custom keymap to new format', () async {
|
||||
// Simulate old storage format
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'customapp': ['test_data'],
|
||||
'app': 'Custom',
|
||||
});
|
||||
|
||||
final newSettings = Settings();
|
||||
await newSettings.init();
|
||||
|
||||
// Check that migration happened
|
||||
expect(newSettings.prefs.containsKey('customapp'), false);
|
||||
expect(newSettings.prefs.containsKey('customapp_Custom'), true);
|
||||
});
|
||||
|
||||
test('Should not duplicate migration if already migrated', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'customapp': ['old_data'],
|
||||
'customapp_Custom': ['new_data'],
|
||||
'app': 'Custom',
|
||||
});
|
||||
|
||||
final newSettings = Settings();
|
||||
await newSettings.init();
|
||||
|
||||
// Old key should still exist because new key already existed
|
||||
expect(newSettings.getCustomAppKeymap('customapp'), null);
|
||||
final customKeymap = newSettings.getCustomAppKeymap('Custom');
|
||||
expect(customKeymap, isNotNull);
|
||||
});
|
||||
|
||||
test('Should export custom profile as JSON', () async {
|
||||
final customApp = CustomApp(profileName: 'TestProfile');
|
||||
await settings.setApp(customApp);
|
||||
|
||||
final jsonData = settings.exportCustomAppProfile('TestProfile');
|
||||
expect(jsonData, isNotNull);
|
||||
expect(jsonData, contains('version'));
|
||||
expect(jsonData, contains('profileName'));
|
||||
expect(jsonData, contains('keymap'));
|
||||
});
|
||||
|
||||
test('Should import custom profile from JSON', () async {
|
||||
// First export a profile
|
||||
final customApp = CustomApp(profileName: 'ExportTest');
|
||||
await settings.setApp(customApp);
|
||||
final jsonData = settings.exportCustomAppProfile('ExportTest');
|
||||
|
||||
// Import with a new name
|
||||
final success = await settings.importCustomAppProfile(jsonData!, newProfileName: 'ImportTest');
|
||||
|
||||
expect(success, true);
|
||||
final profiles = settings.getCustomAppProfiles();
|
||||
expect(profiles.contains('ImportTest'), true);
|
||||
});
|
||||
|
||||
test('Should fail to import invalid JSON', () async {
|
||||
final success = await settings.importCustomAppProfile('invalid json');
|
||||
expect(success, false);
|
||||
});
|
||||
|
||||
test('Should fail to import JSON with missing fields', () async {
|
||||
final invalidJson = '{"version": 1}';
|
||||
final success = await settings.importCustomAppProfile(invalidJson);
|
||||
expect(success, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
101
test/percentage_keymap_test.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
void main() {
|
||||
group('Percentage-based Keymap Tests', () {
|
||||
test('Should encode touch position as percentage using fallback screen size', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
|
||||
);
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
expect(encoded, contains('x_percent'));
|
||||
expect(encoded, contains('y_percent'));
|
||||
// Should use fallback screen size of 1920x1080
|
||||
expect(encoded, contains('0.5')); // 960/1920 and 540/1080 = 0.5
|
||||
});
|
||||
|
||||
test('Should encode touch position as percentages with fallback when screen size not available', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(960, 540), // Center of 1920x1080 fallback
|
||||
);
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
expect(encoded, contains('x_percent'));
|
||||
expect(encoded, contains('y_percent'));
|
||||
// Should use fallback screen size of 1920x1080
|
||||
expect(encoded, contains('0.5')); // 960/1920 and 540/1080 = 0.5
|
||||
});
|
||||
|
||||
test('Should decode percentage-based touch position correctly', () {
|
||||
final encoded =
|
||||
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x_percent":0.5,"y_percent":0.5},"isLongPress":false}';
|
||||
|
||||
final keyPair = KeyPair.decode(encoded);
|
||||
expect(keyPair, isNotNull);
|
||||
// Since no real screen is available in tests, it should return Offset.zero or use fallback
|
||||
expect(keyPair!.touchPosition, isNotNull);
|
||||
});
|
||||
|
||||
test('Should decode pixel-based touch position correctly (backward compatibility)', () {
|
||||
final encoded =
|
||||
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x":300,"y":600},"isLongPress":false}';
|
||||
|
||||
final keyPair = KeyPair.decode(encoded);
|
||||
expect(keyPair, isNotNull);
|
||||
expect(keyPair!.touchPosition.dx, 300);
|
||||
expect(keyPair.touchPosition.dy, 600);
|
||||
});
|
||||
|
||||
test('Should handle zero touch position correctly', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
touchPosition: Offset.zero,
|
||||
);
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
// Should encode as percentages even when position is zero
|
||||
expect(encoded, contains('x_percent'));
|
||||
expect(encoded, contains('y_percent'));
|
||||
expect(encoded, contains('0.0'));
|
||||
});
|
||||
|
||||
test('Should encode and decode with fallback screen size', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.paddleRight],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
touchPosition: Offset(480, 270), // 25% of 1920x1080
|
||||
);
|
||||
|
||||
// Encode (will use fallback screen size)
|
||||
final encoded = keyPair.encode();
|
||||
|
||||
// Decode (will also use fallback or available screen size)
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.touchPosition, isNotNull);
|
||||
});
|
||||
|
||||
test('Should handle decoding when no screen size available', () {
|
||||
final encoded =
|
||||
'{"actions":["leftButton"],"logicalKey":"0","physicalKey":"0","touchPosition":{"x_percent":0.5,"y_percent":0.5},"isLongPress":false}';
|
||||
|
||||
final keyPair = KeyPair.decode(encoded);
|
||||
expect(keyPair, isNotNull);
|
||||
// When no screen size is available, it may return Offset.zero as fallback
|
||||
expect(keyPair!.touchPosition, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
210
test/zwift_ride_analog_test.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
|
||||
void main() {
|
||||
group('Zwift Ride Analog Paddle - ZigZag Encoding Tests', () {
|
||||
test('Should correctly decode positive ZigZag values', () {
|
||||
// Test ZigZag decoding algorithm: (n >>> 1) ^ -(n & 1)
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
|
||||
expect(_zigzagDecode(0), 0); // 0 -> 0
|
||||
expect(_zigzagDecode(2), 1); // 2 -> 1
|
||||
expect(_zigzagDecode(4), 2); // 4 -> 2
|
||||
expect(_zigzagDecode(threshold * 2), threshold); // threshold value
|
||||
expect(_zigzagDecode(200), 100); // 200 -> 100 (max positive)
|
||||
});
|
||||
|
||||
test('Should correctly decode negative ZigZag values', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
|
||||
expect(_zigzagDecode(1), -1); // 1 -> -1
|
||||
expect(_zigzagDecode(3), -2); // 3 -> -2
|
||||
expect(_zigzagDecode(threshold * 2 - 1), -threshold); // negative threshold
|
||||
expect(_zigzagDecode(199), -100); // 199 -> -100 (max negative)
|
||||
});
|
||||
|
||||
test('Should handle boundary values correctly', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
|
||||
// Test values at the detection threshold
|
||||
expect(_zigzagDecode(threshold * 2).abs(), threshold);
|
||||
expect(_zigzagDecode(threshold * 2 - 1).abs(), threshold);
|
||||
|
||||
// Test maximum paddle values (±100)
|
||||
expect(_zigzagDecode(200), 100);
|
||||
expect(_zigzagDecode(199), -100);
|
||||
});
|
||||
});
|
||||
|
||||
group('Zwift Ride Analog Paddle - Protocol Buffer Varint Decoding', () {
|
||||
test('Should decode single-byte varint values', () {
|
||||
// Values 0-127 fit in a single byte
|
||||
final buffer1 = Uint8List.fromList([0x00]); // 0
|
||||
expect(_decodeVarint(buffer1, 0).$1, 0);
|
||||
expect(_decodeVarint(buffer1, 0).$2, 1); // Consumed 1 byte
|
||||
|
||||
final buffer2 = Uint8List.fromList([0x0A]); // 10
|
||||
expect(_decodeVarint(buffer2, 0).$1, 10);
|
||||
|
||||
final buffer3 = Uint8List.fromList([0x7F]); // 127
|
||||
expect(_decodeVarint(buffer3, 0).$1, 127);
|
||||
});
|
||||
|
||||
test('Should decode multi-byte varint values', () {
|
||||
// Values >= 128 require multiple bytes
|
||||
final buffer1 = Uint8List.fromList([0xC7, 0x01]); // ZigZag encoded -100 (199)
|
||||
expect(_decodeVarint(buffer1, 0).$1, 199);
|
||||
expect(_decodeVarint(buffer1, 0).$2, 2); // Consumed 2 bytes
|
||||
|
||||
final buffer2 = Uint8List.fromList([0xC8, 0x01]); // ZigZag encoded 100 (200)
|
||||
expect(_decodeVarint(buffer2, 0).$1, 200);
|
||||
expect(_decodeVarint(buffer2, 0).$2, 2);
|
||||
});
|
||||
|
||||
test('Should handle varint decoding with offset', () {
|
||||
// Test decoding from a specific offset in the buffer
|
||||
final buffer = Uint8List.fromList([0xFF, 0xFF, 0xC8, 0x01]); // Garbage + 200
|
||||
expect(_decodeVarint(buffer, 2).$1, 200);
|
||||
expect(_decodeVarint(buffer, 2).$2, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('Zwift Ride Analog Paddle - Protocol Buffer Wire Type Parsing', () {
|
||||
test('Should correctly extract field number and wire type from tag', () {
|
||||
// Tag format: (field_number << 3) | wire_type
|
||||
|
||||
// Field 1, wire type 0 (varint)
|
||||
final tag1 = 0x08; // 1 << 3 | 0
|
||||
expect(tag1 >> 3, 1); // field number
|
||||
expect(tag1 & 0x7, 0); // wire type
|
||||
|
||||
// Field 2, wire type 0 (varint)
|
||||
final tag2 = 0x10; // 2 << 3 | 0
|
||||
expect(tag2 >> 3, 2);
|
||||
expect(tag2 & 0x7, 0);
|
||||
|
||||
// Field 3, wire type 2 (length-delimited)
|
||||
final tag3 = 0x1a; // 3 << 3 | 2
|
||||
expect(tag3 >> 3, 3);
|
||||
expect(tag3 & 0x7, 2);
|
||||
});
|
||||
|
||||
test('Should identify location and value field tags', () {
|
||||
const locationTag = 0x08; // Field 1 (location), wire type 0
|
||||
const valueTag = 0x10; // Field 2 (value), wire type 0
|
||||
const nestedMessageTag = 0x1a; // Field 3 (nested), wire type 2
|
||||
|
||||
expect(locationTag >> 3, 1);
|
||||
expect(valueTag >> 3, 2);
|
||||
expect(nestedMessageTag >> 3, 3);
|
||||
expect(nestedMessageTag & 0x7, 2); // Length-delimited
|
||||
});
|
||||
});
|
||||
|
||||
group('Zwift Ride Analog Paddle - Real-world Scenarios', () {
|
||||
test('Threshold value should trigger paddle detection', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
// At threshold: ZigZag encoding of threshold
|
||||
final zigzagValue = threshold * 2;
|
||||
final decodedValue = _zigzagDecode(zigzagValue);
|
||||
expect(decodedValue, threshold);
|
||||
expect(decodedValue.abs() >= threshold, isTrue);
|
||||
});
|
||||
|
||||
test('Below threshold value should not trigger paddle detection', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
// Below threshold: value = threshold - 1
|
||||
final zigzagValue = (threshold - 1) * 2;
|
||||
final decodedValue = _zigzagDecode(zigzagValue);
|
||||
expect(decodedValue, threshold - 1);
|
||||
expect(decodedValue.abs() >= threshold, isFalse);
|
||||
});
|
||||
|
||||
test('Maximum paddle press (-100) should trigger left paddle', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
// Max left: value = -100, ZigZag = 199 = 0xC7 0x01
|
||||
final zigzagValue = 199;
|
||||
final decodedValue = _zigzagDecode(zigzagValue);
|
||||
expect(decodedValue, -100);
|
||||
expect(decodedValue.abs() >= threshold, isTrue);
|
||||
});
|
||||
|
||||
test('Maximum paddle press (100) should trigger right paddle', () {
|
||||
const threshold = ZwiftRide.analogPaddleThreshold;
|
||||
// Max right: value = 100, ZigZag = 200 = 0xC8 0x01
|
||||
final zigzagValue = 200;
|
||||
final decodedValue = _zigzagDecode(zigzagValue);
|
||||
expect(decodedValue, 100);
|
||||
expect(decodedValue.abs() >= threshold, isTrue);
|
||||
});
|
||||
|
||||
test('Paddle location mapping is correct', () {
|
||||
// Location 0 = left paddle
|
||||
// Location 1 = right paddle
|
||||
const leftPaddleLocation = 0;
|
||||
const rightPaddleLocation = 1;
|
||||
|
||||
expect(leftPaddleLocation, 0);
|
||||
expect(rightPaddleLocation, 1);
|
||||
});
|
||||
|
||||
test('Analog paddle threshold constant is accessible', () {
|
||||
expect(ZwiftRide.analogPaddleThreshold, 25);
|
||||
});
|
||||
});
|
||||
|
||||
group('Zwift Ride Analog Paddle - Message Structure Documentation', () {
|
||||
test('0x1a marker identifies analog message sections', () {
|
||||
const analogSectionMarker = 0x1a;
|
||||
// Field 3 << 3 | wire type 2 = 3 << 3 | 2 = 26 = 0x1a
|
||||
expect(analogSectionMarker, 0x1a);
|
||||
expect(analogSectionMarker >> 3, 3); // Field number
|
||||
expect(analogSectionMarker & 0x7, 2); // Wire type (length-delimited)
|
||||
});
|
||||
|
||||
test('Message offset 7 skips header and button map', () {
|
||||
// Offset breakdown:
|
||||
// [0]: Message type (0x23 for controller notification)
|
||||
// [1]: Button map field tag (0x05)
|
||||
// [2-6]: Button map (5 bytes)
|
||||
// [7]: Start of analog data
|
||||
const messageTypeOffset = 0;
|
||||
const buttonMapTagOffset = 1;
|
||||
const buttonMapOffset = 2;
|
||||
const buttonMapSize = 5;
|
||||
const analogDataOffset = 7;
|
||||
|
||||
expect(analogDataOffset, buttonMapOffset + buttonMapSize);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to test ZigZag decoding algorithm.
|
||||
/// ZigZag encoding maps signed integers to unsigned integers:
|
||||
/// 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3, 2 -> 4, etc.
|
||||
int _zigzagDecode(int n) {
|
||||
return (n >>> 1) ^ -(n & 1);
|
||||
}
|
||||
|
||||
/// Helper function to decode a Protocol Buffer varint from a buffer.
|
||||
/// Returns a record of (value, bytesConsumed).
|
||||
(int, int) _decodeVarint(Uint8List buffer, int offset) {
|
||||
var value = 0;
|
||||
var shift = 0;
|
||||
var bytesRead = 0;
|
||||
|
||||
while (offset + bytesRead < buffer.length) {
|
||||
final byte = buffer[offset + bytesRead];
|
||||
value |= (byte & 0x7f) << shift;
|
||||
bytesRead++;
|
||||
|
||||
if ((byte & 0x80) == 0) {
|
||||
// MSB is 0, we're done
|
||||
break;
|
||||
}
|
||||
shift += 7;
|
||||
}
|
||||
|
||||
return (value, bytesRead);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <bluetooth_low_energy_windows/bluetooth_low_energy_windows_plugin_c_api.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
@@ -15,6 +16,8 @@
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
BluetoothLowEnergyWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("BluetoothLowEnergyWindowsPluginCApi"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
bluetooth_low_energy_windows
|
||||
file_selector_windows
|
||||
keypress_simulator_windows
|
||||
permission_handler_windows
|
||||
|
||||