Compare commits

...

123 Commits

Author SHA1 Message Date
Jonas Bark
0339089972 version++ 2025-11-16 11:58:05 +01:00
Jonas Bark
1e8bd61264 update changelog 2025-11-16 10:20:47 +01:00
Jonas Bark
79613bc8de resolve issue #179 2025-11-16 10:15:46 +01:00
Jonas Bark
d0ec785e32 remove settings file when corrupted #180 2025-11-16 10:13:37 +01:00
Jonas Bark
020b91fd21 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-15 12:54:53 +01:00
Jonas Bark
f2406152fd Merge branch 'copilot/update-cycplus-bc2-implementation' 2025-11-15 12:54:45 +01:00
Jonas Bark
ab3ef7be53 resolve #186 2025-11-15 12:54:30 +01:00
jonasbark
bb7484ff2e Merge pull request #185 from jonasbark/copilot/update-cycplus-bc2-implementation
Simplify Cycplus BC2 implementation to match reference state machine
2025-11-15 10:35:28 +01:00
copilot-swe-agent[bot]
80061fd076 Update Cycplus BC2 implementation to match reference
- Only look at bytes at index 6 and 7 (no full frame parsing)
- Implement state machine for pressed/released states
- Track state independently for each index
- Trigger on state transitions (pressed to different pressed)
- Reset state on release (0x00) or after successful trigger

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-15 09:27:45 +00:00
Jonas Bark
124e005fb1 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-15 10:27:11 +01:00
Jonas Bark
8e760ef202 more error checking 2025-11-15 10:27:07 +01:00
copilot-swe-agent[bot]
de740f6453 Initial plan 2025-11-15 09:20:32 +00:00
jonasbark
3bde90ae62 Merge pull request #182 from jonasbark/copilot/fix-ble-device-reconnection
Persist ignored BLE devices across app restarts
2025-11-15 09:04:53 +01:00
copilot-swe-agent[bot]
aee8dc2e07 Make getIgnoredDeviceIds and getIgnoredDeviceNames private
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-15 07:57:35 +00:00
jonasbark
b3952542f8 Fix grammar and formatting in README.md 2025-11-14 15:08:05 +01:00
Jonas Bark
910f23a3f6 Merge branch 'copilot/fix-cycplus-bc2-button-logic' 2025-11-14 14:57:47 +01:00
Jonas Bark
5cc9ac85af attempt to handle https://github.com/jonasbark/swiftcontrol/issues/179 2025-11-14 14:57:35 +01:00
copilot-swe-agent[bot]
e10e22d038 Add support for 0xfe button codes in CYCPLUS BC2 device
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-14 13:34:36 +00:00
copilot-swe-agent[bot]
aaeaec36a2 Initial plan 2025-11-14 13:29:59 +00:00
Jonas Bark
c16a593f3c potential fix for https://github.com/jonasbark/swiftcontrol/issues/183 2025-11-14 14:28:47 +01:00
Jonas Bark
50d9f47576 potential fix for https://github.com/jonasbark/swiftcontrol/issues/183 2025-11-14 12:23:30 +01:00
copilot-swe-agent[bot]
a53fb578ef Fix disconnect logic and improve UI flow
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-13 15:25:14 +00:00
copilot-swe-agent[bot]
1f89859a03 Implement persistent ignored devices feature
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-13 15:23:12 +00:00
copilot-swe-agent[bot]
7c1cee6748 Initial plan 2025-11-13 15:17:47 +00:00
Jonas Bark
a4949ad615 error handling 2025-11-12 22:22:44 +01:00
Jonas Bark
a57f4654b0 possible fix for https://github.com/jonasbark/swiftcontrol/issues/180#issuecomment-3523836191 2025-11-12 21:49:01 +01:00
Jonas Bark
8192d3addf show to user when MyWhoosh Link can't be started, cleanup 2025-11-12 09:22:41 +01:00
Jonas Bark
69f47fa984 bluetooth naming 2025-11-11 09:06:07 +01:00
Jonas Bark
2b106fd1c9 change mail address 2025-11-09 19:33:32 +01:00
Jonas Bark
1784e008ee a few fixes 2025-11-09 19:17:35 +01:00
Jonas Bark
33ccdbd7af a few fixes 2025-11-09 16:38:13 +01:00
Jonas Bark
d15f1ddc13 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-09 16:14:34 +01:00
Jonas Bark
e7ea01cd60 Merge branch 'copilot/create-landing-page-for-apps' 2025-11-09 16:14:29 +01:00
Jonas Bark
f7ed426441 cleanup 2025-11-09 16:14:15 +01:00
Jonas Bark
d30485b82e add a clarifying selection to help users choose where SwiftControl should run 2025-11-09 16:13:07 +01:00
Jonas Bark
4e646ab922 add dark mode, logs to the support entry, link to new landing page 2025-11-09 15:57:54 +01:00
jonasbark
4183ede58d Updated .gitignore 2025-11-09 08:54:17 +01:00
copilot-swe-agent[bot]
dd85e99e4b Update landing page per feedback: blue background, black headings, restructured compatibility checker, and instructions section
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-09 07:42:42 +00:00
jonasbark
2334a88452 Merge pull request #174 from jonasbark/copilot/add-changelog-to-releases
Add changelog to GitHub release body
2025-11-09 08:23:40 +01:00
copilot-swe-agent[bot]
ee64b18f75 Refactor: reuse get_latest_changelog.sh output
- Removed duplicate awk logic from generate_release_body.sh
- Now calls get_latest_changelog.sh for changelog content
- Simplified script by removing unused variables
- Adds version header separately for GitHub releases

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-09 07:21:17 +00:00
copilot-swe-agent[bot]
647dac9e7c Add interactive landing page with compatibility checker
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-09 07:14:46 +00:00
copilot-swe-agent[bot]
df2496eb67 Add changelog to GitHub release body
- Created generate_release_body.sh script to combine changelog with store links
- Updated build.yml workflow to use new release body
- Updated patch.yml workflow to use new release body
- Release body now includes latest changelog entry followed by store download links

Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-09 07:12:43 +00:00
copilot-swe-agent[bot]
859424b895 Initial plan 2025-11-09 07:08:36 +00:00
copilot-swe-agent[bot]
dde3f38bde Initial plan 2025-11-09 07:07:37 +00:00
Jonas Bark
01744c258e attempt to improve UX 2025-11-08 22:45:25 +01:00
Jonas Bark
231aadbc27 detect media key source - don't handle it when coming from phone #110 2025-11-08 22:31:12 +01:00
Jonas Bark
a806a628bd Merge remote-tracking branch 'origin/main' 2025-11-08 20:18:58 +01:00
Jonas Bark
c529fee1fa fix execution on Web 2025-11-08 20:18:42 +01:00
jonasbark
c36a0252e6 Aktualisieren von WINDOWS_STORE_VERSION.txt 2025-11-08 15:58:21 +01:00
Jonas Bark
66486ec38e make Di2 custom keymap requirement clearer #170 2025-11-08 12:55:05 +01:00
Jonas Bark
6f5c6bf1d9 Merge remote-tracking branch 'origin/main' 2025-11-08 09:00:51 +01:00
Jonas Bark
8fc8f2dfda increase version 2025-11-08 09:00:42 +01:00
jonasbark
d36e031e87 Set release date for version 3.4.0
Updated the release date for version 3.4.0 in the changelog.
2025-11-08 08:55:56 +01:00
Jonas Bark
efac0af4b9 update changelog to include Rouvy keymap 2025-11-08 08:54:59 +01:00
Jonas Bark
d7e73524ad Merge branch 'feature/bluetoothmedia' 2025-11-08 08:49:23 +01:00
Jonas Bark
80998c955f media key detection on iOS and macOS 2025-11-08 08:49:06 +01:00
Jonas Bark
d824cb6207 integrate media_key_detector package, add iOS implementation #1 2025-11-07 21:55:07 +01:00
Jonas Bark
ab80d679e1 update Rouvy keymap 2025-11-07 20:53:44 +01:00
Jonas Bark
d4881faab1 code fix, update changelog and readme 2025-11-07 20:49:59 +01:00
Jonas Bark
7c74d61b43 Merge branch 'feature/mediabutton' 2025-11-07 20:45:30 +01:00
Jonas Bark
8ad2906a17 Merge branch 'feature/ble_hid' 2025-11-07 20:37:44 +01:00
Jonas Bark
0f4d19080a prefill mail text when support is used 2025-11-07 20:37:10 +01:00
Jonas Bark
a9a13be6ca fix modifier detection 2025-11-07 20:36:35 +01:00
Jonas Bark
c66badf39e Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-07 19:41:15 +01:00
jonasbark
6c2fc54612 Merge pull request #166 from jonasbark/copilot/add-keyboard-combination-support
Add modifier key support for keyboard mappings
2025-11-07 19:40:45 +01:00
jonasbark
807c0eaa98 Merge pull request #167 from jonasbark/copilot/support-elite-square-control
Fix Elite Square button detection substring mismatch
2025-11-07 19:29:28 +01:00
copilot-swe-agent[bot]
7d7b1e89e9 Final implementation of modifier key support
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:17:59 +00:00
copilot-swe-agent[bot]
cafb7408d9 Refactor: Consolidate modifier key detection logic
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:17:07 +00:00
copilot-swe-agent[bot]
723f741bca Improve test code quality and fix edge cases
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:15:27 +00:00
copilot-swe-agent[bot]
6a3cc0f8be Refactor: Extract helper methods to reduce code duplication
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:15:07 +00:00
copilot-swe-agent[bot]
66c548fa75 Refactor tests to reduce code duplication
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:13:21 +00:00
copilot-swe-agent[bot]
0b42f7e9c5 Fix Elite Square button detection logic and add tests
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:11:18 +00:00
copilot-swe-agent[bot]
35a995eddc Add support for modifier keys in keyboard mapping
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-07 18:10:14 +00:00
copilot-swe-agent[bot]
c3afb23625 Initial plan 2025-11-07 18:05:00 +00:00
copilot-swe-agent[bot]
f15d97585b Initial plan 2025-11-07 18:02:22 +00:00
Jonas Bark
5f03c072ff fix di2 initialization 2025-11-06 18:45:46 +01:00
jonasbark
ce94aea51a Merge pull request #165 from jonasbark/copilot/fix-di-fly-button-triggering
[WIP] Fix Shimano DI2 implementation for DI Fly buttons
2025-11-06 18:44:38 +01:00
copilot-swe-agent[bot]
a27ae070fc Fix DI2 button trigger on startup and add tests
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-06 17:43:29 +00:00
copilot-swe-agent[bot]
7bbdc6a4e2 Initial plan 2025-11-06 17:38:51 +00:00
Jonas Bark
3188002ecb fix a few bugs and use some more clearer texts 2025-11-05 20:11:36 +01:00
Jonas Bark
284d2ca70f resolve yet another Sterzo issue https://github.com/jonasbark/swiftcontrol/issues/111#issuecomment-3487265558 2025-11-04 20:09:44 +01:00
Jonas Bark
57961aec5d bump version 2025-11-04 17:36:01 +01:00
Jonas Bark
1675d7f2d0 bump version 2025-11-04 17:35:32 +01:00
Jonas Bark
baec8d24c3 fix detection of Sterzo devices https://github.com/jonasbark/swiftcontrol/issues/111#issuecomment-3486678629 2025-11-04 17:34:25 +01:00
Jonas Bark
820d0b37db allow to ignore HID device input 2025-11-04 10:05:52 +01:00
Jonas Bark
c18ac16208 support HID key events on Android #110 2025-11-04 09:53:52 +01:00
Jonas Bark
2bbc09bf13 Merge branch 'main' into feature/ble_hid 2025-11-04 08:59:02 +01:00
Jonas Bark
a968723277 update gitignore 2025-11-04 08:58:54 +01:00
Jonas Bark
8668957738 fix name 2025-11-03 20:30:05 +01:00
Jonas Bark
4498729e75 initial support for BLE HID device 2025-11-03 20:26:21 +01:00
Jonas Bark
ac550fad5b do not offer MyWhoosh Link when not compatible 2025-11-03 18:58:41 +01:00
Jonas Bark
c511ac32b6 fix 'Exit' behavior on Android notification tap 2025-11-03 10:02:37 +01:00
Jonas Bark
ee48ce0f4e Merge remote-tracking branch 'origin/main' 2025-11-03 09:30:45 +01:00
Jonas Bark
8a3d64491b version++ 2025-11-03 09:30:38 +01:00
jonasbark
b72cc803f0 Clarify iOS, macOS, and Windows requirements
Updated iOS, macOS, and Windows requirements for Bluetooth buttons.
2025-11-03 09:12:00 +01:00
Jonas Bark
69dd5c85ef detect media keys on macOS / iOS #1 2025-11-03 08:58:16 +01:00
Jonas Bark
ea17b2e142 Merge remote-tracking branch 'origin/main' 2025-11-03 08:45:50 +01:00
Jonas Bark
da62fc4dc6 fix resetting keymap, show all touches by default 2025-11-03 08:45:39 +01:00
jonasbark
239630f681 Fix formatting and grammar issues in README.md
Corrected formatting and grammar in the README file.
2025-11-02 17:54:51 +01:00
Jonas Bark
d95d0cf8cf exit app on Android to apply patch 2025-11-02 17:48:34 +01:00
Jonas Bark
2b25ba942c Merge remote-tracking branch 'origin/main' 2025-11-02 16:22:14 +01:00
Jonas Bark
c65369a746 clarify pairing vs controller vs MyWhoosh Link 2025-11-02 16:22:06 +01:00
Jonas Bark
fa7d5e7853 clarify pairing vs controller vs MyWhoosh Link 2025-11-02 16:20:32 +01:00
Jonas Bark
8ac47cbd4d make Link / emulation UI clearer 2025-11-02 16:02:07 +01:00
Jonas Bark
eb85844503 Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-11-02 13:56:12 +01:00
Jonas Bark
010d0ed331 UI adjustments 2025-11-02 13:55:56 +01:00
Jonas Bark
1f8f7765a3 fix SwiftControl Web, more generic battery and firmware version reads 2025-11-02 13:24:19 +01:00
Jonas Bark
68f416dda3 fix SwiftControl Web, more generic battery and firmware version reads 2025-11-02 13:10:35 +01:00
Jonas Bark
49e45faec0 fix SwiftControl Web 2025-11-02 10:50:44 +01:00
Jonas Bark
c81516350a initial support for Shimano Di2 D-Fly channel buttons 2025-11-02 10:45:51 +01:00
jonasbark
890f393fd6 Merge pull request #151 from jonasbark/copilot/add-cycplus-bc2-support
Add CYCPLUS BC2 virtual shifter support via Nordic UART Service
2025-11-02 08:26:42 +01:00
copilot-swe-agent[bot]
e46969c5c4 Add CYCPLUS BC2 virtual shifter support
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-02 06:45:59 +00:00
copilot-swe-agent[bot]
1ec9b55645 Initial plan 2025-11-02 06:39:40 +00:00
Jonas Bark
b0caf7c13b UI adjustments 2025-11-01 19:43:30 +01:00
Jonas Bark
302fc15dd7 don't reset after a minute 2025-11-01 19:40:23 +01:00
jonasbark
6a2cf1a1c9 Merge pull request #147 from jonasbark/copilot/fix-miui-service-issue
Add MIUI device detection and battery optimization warning with dismissal
2025-11-01 19:36:16 +01:00
jonasbark
8ea73bc54a Update Windows Store version to 3.3.0 2025-11-01 19:27:07 +01:00
copilot-swe-agent[bot]
7cbab3925f Use AndroidActions type check instead of negated RemoteActions
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-01 10:15:16 +00:00
copilot-swe-agent[bot]
246a1bd2be Add dismiss button to MIUI warning with persistent state
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-11-01 09:54:20 +00:00
copilot-swe-agent[bot]
f7e2a89ed6 Move MIUI warning from requirements to DevicePage widget
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:48:58 +00:00
copilot-swe-agent[bot]
f94252edb9 Address code review feedback - improve comments and efficiency
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:37:58 +00:00
copilot-swe-agent[bot]
b7b6b9803f Add MIUI device detection and warning for accessibility service
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-10-31 18:31:30 +00:00
copilot-swe-agent[bot]
807d868b74 Initial plan 2025-10-31 18:25:43 +00:00
203 changed files with 7422 additions and 1086 deletions

View File

@@ -31,7 +31,7 @@ on:
build_web:
description: 'Build for Web'
required: false
default: true
default: false
type: boolean
env:
@@ -152,6 +152,12 @@ jobs:
mkdir -p whatsnew
./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
- name: Generate release body
if: inputs.build_github
run: |
chmod +x scripts/generate_release_body.sh
./scripts/generate_release_body.sh > /tmp/release_body.md
- name: 🚀 Shorebird Release iOS
if: inputs.build_ios
uses: shorebirdtech/shorebird-release@v1
@@ -248,7 +254,7 @@ jobs:
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
allowUpdates: true
prerelease: true
bodyFile: scripts/RELEASE_NOTES.md
bodyFile: /tmp/release_body.md
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
@@ -340,13 +346,19 @@ jobs:
}
echo "VERSION=$version" >> $env:GITHUB_ENV
- name: Generate release body (Windows)
shell: bash
run: |
chmod +x scripts/generate_release_body.sh
./scripts/generate_release_body.sh > /tmp/release_body.md
- name: Update Release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip,build/windows/x64/runner/Release/SwiftControl.windows.msix"
bodyFile: scripts/RELEASE_NOTES.md
bodyFile: /tmp/release_body.md
prerelease: true
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}

View File

@@ -9,6 +9,7 @@ env:
jobs:
build:
if: false
name: Patch iOS, Android & macOS
runs-on: macos-latest
@@ -75,6 +76,7 @@ jobs:
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
- name: 🚀 Shorebird Patch macOS
if: false # patch doesn't work: https://github.com/jonasbark/swiftcontrol/issues/143
uses: shorebirdtech/shorebird-patch@v1
with:
platform: macos
@@ -124,13 +126,19 @@ jobs:
path: |
build/macos/Build/Products/Release/SwiftControl.macos.zip
- name: Generate release body
run: |
chmod +x scripts/generate_release_body.sh
./scripts/generate_release_body.sh > /tmp/release_body.md
# add artifact to release
- name: Create Release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/macos/Build/Products/Release/SwiftControl.macos.zip"
bodyFile: scripts/RELEASE_NOTES.md
bodyFile: /tmp/release_body.md
prerelease: true
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
@@ -143,13 +151,6 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v3
#2 Setup Java
- name: Set Up Java
uses: actions/setup-java@v3.12.0
with:
distribution: 'oracle'
java-version: '17'
- name: 🐦 Setup Shorebird
uses: shorebirdtech/setup-shorebird@v1
with:

2
.gitignore vendored
View File

@@ -10,6 +10,7 @@
.history
.svn/
.swiftpm/
debug/
migrate_working_dir/
android/keystore.properties
@@ -47,3 +48,4 @@ app.*.map.json
/android/app/release
service-account.json
.env

View File

@@ -1,6 +1,26 @@
### 3.5.0 (16-11-2025)
**New Features:**
- Dark mode support
- Cycplus BC2 support (thanks @schneewoehner)
- Ignored devices now persist across app restarts - remove them from ignored devices via the menu
**Fixes:**
- resolve issues during app start
### 3.4.0 (08-11-2025)
**New Features:**
- Support for Shimano Di2
- Support Keyboard shortcuts with modifier keys (Ctrl, Alt, Shift, ...)
- Support cheap BLE HID remotes
- add Keymap for Rouvy, supporting the new keyboard shortcuts for virtual shifting
**Fixes:**
- fix detection of Elite Square Sterzo devices
- recognize cheap Bluetooth device clicks also when SwiftControl is in the background
### 3.3.0 (31-10-2025)
**New Feature:**
**New Features:**
- Support for Elite Sterzo (thanks @michidk)
- Support for Gamepads
- Support for cheap bluetooth remotes (such as [these](https://www.amazon.com/s?k=bluetooth+remote))

View File

@@ -1,8 +1,10 @@
**Instructions for using the MyWhoosh Link method**
**Instructions for using the MyWhoosh Direct Connect method**
1) launch MyWhoosh on the device of your choice
2) launch MyWhoosh Link, check if the "Link" connection works
3) close MyWhoosh Link, open SwiftControl, follow on screen instructions
Step 2 is not necessary after that once step 3 works
3) close MyWhoosh Link
4) open SwiftControl, follow on screen instructions
Once you've confirmed the connection in SwiftControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
And here's a video with a few explanations:

View File

@@ -6,10 +6,10 @@
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, or other similar devices. Here's what you can do with it, depending on your configuration:
- Virtual Gear shifting
- Steering / turning
- Steering/turning
- adjust workout intensity
- control music on your device
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
- more? If you can do it via keyboard, mouse, or touch, you can do it with SwiftControl
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
@@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
## Downloads
Check the compatibility matrix below!
Best follow our landing page and the "Get Started" button: [swiftcontrol.app](https://swiftcontrol.app/) to understand on which platform you want to run SwiftControl.
<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>
@@ -34,41 +34,33 @@ Check the compatibility matrix below!
- Biketerra.com
- Rouvy
- Zwift
- only Android and Windows support virtual shifting and in-app-navigation
- iOS / macOS only support controlling Zwift via keyboard shortcuts or touch controls
- running SwiftControl on Android or Windows is required to act as a "Controllable" in Zwift - iOS and macOS are not able to do so
- any other!
- you can add custom mapping and adjust touch points or keyboard shortcuts to your liking
- You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
## Supported Devices
- Zwift Click
- Zwift Click v2 (mostly, see issue #68)
- Zwift Ride
- Zwift Play
- Shimano Di2
- Configure your levers to use D-Fly channels with Shimano E-Tube app
- Wahoo Kickr Bike Shift
- CYCPLUS BC2 Virtual Shifter
- Elite Sterzo Smart (for steering support)
- Elite Square Smart Frame (beta)
- Gamepads (beta)
- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
- BLE HID devices and classic Bluetooth HID devices are supported
- works on Android
- on iOS it would require playing back an audio file - let me know if that is of interest to you
- on iOS and macOS requires SwiftControl to act as media player
Support for other devices can be added - check the issues tab here on GithUb.
Support for other devices can be added; check the issues tab here on GitHub.
## Supported Platforms
Follow this compatibility matrix. It all depends on where you want to run your trainer app (e.g. MyWhoosh on):
| Run Trainer app (MyWhoosh, ...) on: | Possible | Link | Information |
|-------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Android | ✅ | <a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img width="270" height="80" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/a059d5a1-2efb-4f65-8117-ef6a99823b21" /></a> | |
| iPad | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=iphone"><img width="270" height="80" alt="App Store" src="https://github.com/user-attachments/assets/c23f977a-48f6-4951-811e-ae530dbfa014" /></a> | You will need to use SwiftControl as a "remote" to control the trainer app on your iPad. Typically you would use an iPhone or an Android phone for that. |
| Windows | ✅ | <a href="https://apps.microsoft.com/detail/9NP42GS03Z26"><img width="270" alt="Microsoft Store" src="https://github.com/user-attachments/assets/7a8a3cd6-ec26-4678-a850-732eedd27c48" /></a> | - Windows may flag the app as virus. It likely does so because the app controls the mouse and keyboard.<br>- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).<br>- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70). |
| macOS | ✅ | <a href="https://apps.apple.com/us/app/swiftcontrol/id6753721284?platform=mac"><img width="270" height="80" alt="Mac App Store" src="https://github.com/user-attachments/assets/b3552436-409c-43b0-ba7d-b6a72ae30ff1" /></a> | |
| iPhone | (✅) | <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> | Note that you can't run SwiftControl and your trainer app on the same iPhone due to iOS limitations, but you could use the Link method on another device to control MyWhoosh (and only MyWhoosh) on an iPhone. |
| Apple TV | (✅*) | | *only MyWhoosh using the Link method is supported - but you cannot also use MyWhoosh Link at the same time |
For testing purposes you can also run it on [Web](https://jonasbark.github.io/swiftcontrol/) but this is just a tech demo - you won't be able to control other apps.
Follow the "Get Started" button over at [swiftcontrol.app](https://swiftcontrol.app) to understand on which platform you want to run SwiftControl.
You can even try it out in your [Browser](https://jonasbark.github.io/swiftcontrol/), if it supports Bluetooth connections. No controlling possible, though.
## Troubleshooting
Check the troubleshooting guide [here](TROUBLESHOOTING.md).
@@ -77,15 +69,15 @@ Check the troubleshooting guide [here](TROUBLESHOOTING.md).
The app connects to your Controller devices (such as Zwift ones) automatically. It does not connect to your trainer itself.
- **Android**: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
- **iOS**: use SwiftControl as "remote control" for other devices, such as an iPad. Example scenario:
- **iOS**: use SwiftControl as a "remote control" for other devices, such as an iPad. Example scenario:
- your phone (Android/iOS) runs SwiftControl and connects to your Controller devices
- your iPad or other tablet runs e.g. MyWhoosh (does not need to have SwiftControl installed)
- if you want to use MyWhoosh you can use the Link method to directly connect to MyWhoosh
- for other trainer apps you need to pair SwiftControl to your iPad / tablet via Bluetooth and your phone will send the button presses to your iPad / tablet
- **macOS** / **Windows** a keyboard or mouse click is used to trigger the action.
- there are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
- you can also create your own Keymaps for any other app
- you can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
- If you want to use MyWhoosh, you can use the Link method to connect to MyWhoosh directly
- For other trainer apps, you need to pair SwiftControl to your iPad / tablet via Bluetooth, and your phone will send the button presses to your iPad / tablet
- **macOS** / **Windows** A keyboard or mouse click is used to trigger the action.
- There are predefined Keymaps for MyWhoosh, indieVelo / Training Peaks, and others
- You can also create your own Keymaps for any other app
- You can also use the mouse to click on a certain part of the screen, or use keyboard shortcuts
## Alternatives
- [qdomyos-zwift](https://www.qzfitness.com/) directly controls the trainer (as opposed to controlling the trainer app). This can be useful if your trainer app does not support virtual shifting.

View File

@@ -37,12 +37,13 @@ switch the setting to None, then back to Single-Tap and it should work again
## SwiftControl crashes on Windows when searching for the device
You're probably running into [this](https://github.com/jonasbark/swiftcontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
## Link requirement for MyWhoosh stuck at "Waiting for MyWhoosh"
## MyWhoosh Direct Connect never connects
The same network restrictions apply for SwiftControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
Here are some instructions that can help:
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
[INSTRUCTIONS_IOS.md](INSTRUCTIONS_IOS.md)
In essence:
- your two devices (phone, tablet) need to be on the same WiFi network

View File

@@ -1 +1 @@
3.2.0
3.4.0

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v25.2.0), do not edit directly.
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -12,25 +12,57 @@ import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object AccessibilityApiPigeonUtils {
private fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
private fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
return a.contentEquals(b)
}
if (a is Array<*> && b is Array<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is List<*> && b is List<*>) {
return a.size == b.size &&
a.indices.all{ deepEquals(a[it], b[it]) }
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size && a.all {
(b as Map<Any?, Any?>).containsKey(it.key) &&
deepEquals(it.value, b[it.key])
}
}
return a == b
}
}
/**
@@ -93,12 +125,7 @@ data class WindowEvent (
if (this === other) {
return true
}
return packageName == other.packageName
&& top == other.top
&& bottom == other.bottom
&& right == other.right
&& left == other.left
}
return AccessibilityApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
@@ -141,6 +168,7 @@ interface Accessibility {
fun openPermissions()
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
fun controlMedia(action: MediaAction)
fun ignoreHidDevices()
companion object {
/** The codec used by Accessibility. */
@@ -158,7 +186,7 @@ interface Accessibility {
val wrapped: List<Any?> = try {
listOf(api.hasPermission())
} catch (exception: Throwable) {
wrapError(exception)
AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -174,7 +202,7 @@ interface Accessibility {
api.openPermissions()
listOf(null)
} catch (exception: Throwable) {
wrapError(exception)
AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -195,7 +223,7 @@ interface Accessibility {
api.performTouch(xArg, yArg, isKeyDownArg, isKeyUpArg)
listOf(null)
} catch (exception: Throwable) {
wrapError(exception)
AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -213,7 +241,23 @@ interface Accessibility {
api.controlMedia(actionArg)
listOf(null)
} catch (exception: Throwable) {
wrapError(exception)
AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
api.ignoreHidDevices()
listOf(null)
} catch (exception: Throwable) {
AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -274,3 +318,16 @@ abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWra
}
}
abstract class HidKeyPressedStreamHandler : AccessibilityApiPigeonEventChannelWrapper<String> {
companion object {
fun register(messenger: BinaryMessenger, streamHandler: HidKeyPressedStreamHandler, instanceName: String = "") {
var channelName: String = "dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed"
if (instanceName.isNotEmpty()) {
channelName += ".$instanceName"
}
val internalStreamHandler = AccessibilityApiPigeonStreamHandler<String>(streamHandler)
EventChannel(messenger, channelName, AccessibilityApiPigeonMethodCodec).setStreamHandler(internalStreamHandler)
}
}
}

View File

@@ -1,6 +1,7 @@
package de.jonasbark.accessibility
import Accessibility
import HidKeyPressedStreamHandler
import MediaAction
import PigeonEventSink
import StreamEventsStreamHandler
@@ -10,6 +11,7 @@ import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.provider.Settings
import android.view.KeyEvent
import androidx.core.content.ContextCompat.startActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
@@ -23,17 +25,21 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
/// when the Flutter Engine is detached from the Activity
private lateinit var channel : MethodChannel
private lateinit var context: Context
private lateinit var eventHandler: EventListener
private lateinit var windowEventHandler: WindowEventListener
private lateinit var hidEventHandler: HidEventListener
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "accessibility")
eventHandler = EventListener()
windowEventHandler = WindowEventListener()
hidEventHandler = HidEventListener()
context = flutterPluginBinding.applicationContext
Accessibility.setUp(flutterPluginBinding.binaryMessenger, this)
StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, eventHandler)
Observable.fromService = eventHandler
StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, windowEventHandler)
HidKeyPressedStreamHandler.register(flutterPluginBinding.binaryMessenger, hidEventHandler)
Observable.fromServiceWindow = windowEventHandler
Observable.fromServiceKeys = hidEventHandler
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -59,12 +65,12 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
val audioService = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
when (action) {
MediaAction.PLAY_PAUSE -> {
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
}
MediaAction.NEXT -> {
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT))
audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_NEXT))
}
MediaAction.VOLUME_DOWN -> {
audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI)
@@ -75,16 +81,20 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
}
}
override fun ignoreHidDevices() {
Observable.ignoreHidDevices = true
}
}
class EventListener : StreamEventsStreamHandler(), Receiver {
class WindowEventListener : StreamEventsStreamHandler(), Receiver {
private var eventSink: PigeonEventSink<WindowEvent>? = null
override fun onListen(p0: Any?, sink: PigeonEventSink<WindowEvent>) {
eventSink = sink
}
fun onEventsDone() {
override fun onCancel(p0: Any?) {
eventSink?.endOfStream()
eventSink = null
}
@@ -93,4 +103,27 @@ class EventListener : StreamEventsStreamHandler(), Receiver {
eventSink?.success(WindowEvent(packageName = packageName, right = window.right.toLong(), left = window.left.toLong(), bottom = window.bottom.toLong(), top = window.top.toLong()))
}
override fun onKeyEvent(event: KeyEvent) {
}
}
class HidEventListener : HidKeyPressedStreamHandler(), Receiver {
private var keyEventSink: PigeonEventSink<String>? = null
override fun onListen(p0: Any?, sink: PigeonEventSink<String>) {
keyEventSink = sink
}
override fun onChange(packageName: String, window: Rect) {
}
override fun onKeyEvent(event: KeyEvent) {
val keyString = KeyEvent.keyCodeToString(event.keyCode)
keyEventSink?.success(keyString)
}
}

View File

@@ -3,10 +3,15 @@ package de.jonasbark.accessibility
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.accessibilityservice.GestureDescription.StrokeDescription
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.graphics.Path
import android.graphics.Rect
import android.media.AudioManager
import android.os.Build
import android.util.Log
import android.view.InputDevice
import android.view.KeyEvent
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
@@ -37,7 +42,7 @@ class AccessibilityService : AccessibilityService(), Listener {
}
val currentPackageName = event.packageName.toString()
val windowSize = getWindowSize()
Observable.fromService?.onChange(packageName = currentPackageName, window = windowSize)
Observable.fromServiceWindow?.onChange(packageName = currentPackageName, window = windowSize)
}
private fun getWindowSize(): Rect {
@@ -51,6 +56,47 @@ class AccessibilityService : AccessibilityService(), Listener {
Log.d("AccessibilityService", "Service Interrupted")
}
override fun onServiceConnected() {
super.onServiceConnected()
// Request key event filtering so we receive onKeyEvent for hardware/HID media keys
try {
val info = serviceInfo ?: AccessibilityServiceInfo()
info.flags = info.flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
// keep other capabilities as defined in XML
setServiceInfo(info)
} catch (e: Exception) {
Log.w("AccessibilityService", "Failed to set service info for key events: ${e.message}")
}
}
override fun onKeyEvent(event: KeyEvent): Boolean {
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
// Handle media and volume keys from HID devices here
Log.d(
"AccessibilityService",
"onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}"
)
// Forward key events to the plugin (Flutter) and swallow them so they don't propagate.
if (event.action == KeyEvent.ACTION_DOWN) {
Observable.fromServiceKeys?.onKeyEvent(event)
}
// Return true to indicate we've handled the event and it should be swallowed.
return true
} else {
return false
}
}
private fun isBleRemote(event: KeyEvent): Boolean {
val dev = InputDevice.getDevice(event.deviceId) ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
dev.isExternal
} else {
true
}
}
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
val gestureBuilder = GestureDescription.Builder()
val path = Path()

View File

@@ -1,10 +1,13 @@
package de.jonasbark.accessibility
import android.graphics.Rect
import android.view.KeyEvent
object Observable {
var toService: Listener? = null
var fromService: Receiver? = null
var fromServiceWindow: Receiver? = null
var fromServiceKeys: Receiver? = null
var ignoreHidDevices: Boolean = false
}
interface Listener {
@@ -13,4 +16,5 @@ interface Listener {
interface Receiver {
fun onChange(packageName: String, window: Rect)
fun onKeyEvent(event: KeyEvent)
}

View File

@@ -9,6 +9,8 @@ abstract class Accessibility {
void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false});
void controlMedia(MediaAction action);
void ignoreHidDevices();
}
enum MediaAction { playPause, next, volumeUp, volumeDown }
@@ -32,4 +34,5 @@ class WindowEvent {
@EventChannelApi()
abstract class EventChannelMethods {
WindowEvent streamEvents();
String hidKeyPressed();
}

View File

@@ -1,4 +1,4 @@
// Autogenerated from Pigeon (v25.2.0), do not edit directly.
// Autogenerated from Pigeon (v25.5.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
@@ -14,6 +14,20 @@ PlatformException _createConnectionError(String channelName) {
message: 'Unable to establish connection on channel: "$channelName".',
);
}
bool _deepEquals(Object? a, Object? b) {
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
}
if (a is Map && b is Map) {
return a.length == b.length && a.entries.every((MapEntry<Object?, Object?> entry) =>
(b as Map<Object?, Object?>).containsKey(entry.key) &&
_deepEquals(entry.value, b[entry.key]));
}
return a == b;
}
enum MediaAction {
playPause,
@@ -74,12 +88,7 @@ class WindowEvent {
if (identical(this, other)) {
return true;
}
return
packageName == other.packageName
&& top == other.top
&& bottom == other.bottom
&& right == other.right
&& left == other.left;
return _deepEquals(encode(), other.encode());
}
@override
@@ -232,6 +241,29 @@ class Accessibility {
return;
}
}
Future<void> ignoreHidDevices() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}
Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
@@ -245,3 +277,14 @@ Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
});
}
Stream<String> hidKeyPressed( {String instanceName = ''}) {
if (instanceName.isNotEmpty) {
instanceName = '.$instanceName';
}
final EventChannel hidKeyPressedChannel =
EventChannel('dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed$instanceName', pigeonMethodCodec);
return hidKeyPressedChannel.receiveBroadcastStream().map((dynamic event) {
return event as String;
});
}

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeViewClicked"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:accessibilityFlags="flagDefault|flagRequestFilterKeyEvents"
android:canRetrieveWindowContent="true"
android:canRequestFilterKeyEvents="true"
android:canPerformGestures="true"
android:notificationTimeout="100"/>

View File

@@ -11,6 +11,8 @@ PODS:
- Flutter
- image_picker_ios (0.0.1):
- Flutter
- media_key_detector_ios (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -38,6 +40,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- media_key_detector_ios (from `.symlinks/plugins/media_key_detector_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
@@ -60,6 +63,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/gamepads_ios/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
media_key_detector_ios:
:path: ".symlinks/plugins/media_key_detector_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -84,6 +89,7 @@ SPEC CHECKSUMS:
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
gamepads_ios: 1d2930c7a4450a9a1b57444ebf305a6a6cbeea0b
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
media_key_detector_ios: 7ff9aefdfea00bb7b71e184132381b7d0e7e1269
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23504" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23506"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
@@ -14,13 +16,14 @@
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-16" y="-40"/>
</scene>
</scenes>
</document>

View File

@@ -24,21 +24,22 @@
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>This app connects to your trainer app on your local network.</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>SwiftControl uses Bluetooth to connect to accessories.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>This app connects to your trainer app on your local network.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-peripheral</string>
<string>bluetooth-central</string>
<string>audio</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View File

@@ -88,6 +88,38 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
}
}
// Helper function to send modifier key events
auto sendModifierKey = [](UINT vkCode, bool down) {
WORD sc = (WORD)MapVirtualKey(vkCode, MAPVK_VK_TO_VSC);
INPUT in = {0};
in.type = INPUT_KEYBOARD;
in.ki.wVk = 0;
in.ki.wScan = sc;
in.ki.dwFlags = KEYEVENTF_SCANCODE | (down ? 0 : KEYEVENTF_KEYUP);
SendInput(1, &in, sizeof(INPUT));
};
// Helper function to process modifiers
auto processModifiers = [&sendModifierKey](const std::vector<std::string>& mods, bool down) {
for (const std::string& modifier : mods) {
if (modifier == "shiftModifier") {
sendModifierKey(VK_SHIFT, down);
} else if (modifier == "controlModifier") {
sendModifierKey(VK_CONTROL, down);
} else if (modifier == "altModifier") {
sendModifierKey(VK_MENU, down);
} else if (modifier == "metaModifier") {
sendModifierKey(VK_LWIN, down);
}
}
};
// Press modifier keys first (if keyDown)
if (keyDown) {
processModifiers(modifiers, true);
}
// Send the main key
WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
INPUT in = {0};
@@ -102,6 +134,11 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
}
SendInput(1, &in, sizeof(INPUT));
// Release modifier keys (if keyUp)
if (!keyDown) {
processModifiers(modifiers, false);
}
/*BYTE byteValue = static_cast<BYTE>(keyCode);
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);*/

View File

@@ -6,17 +6,19 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:gamepads/gamepads.dart';
import 'package:media_key_detector/media_key_detector.dart';
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
import 'package:swift_control/bluetooth/devices/gamepad/gamepad_device.dart';
import 'package:swift_control/bluetooth/devices/hid/hid_device.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
import 'package:swift_control/utils/requirements/android.dart';
import 'package:universal_ble/universal_ble.dart';
import '../utils/keymap/apps/my_whoosh.dart';
import 'devices/base_device.dart';
import 'devices/link/link_device.dart';
import 'devices/zwift/constants.dart';
import 'messages/notification.dart';
@@ -25,8 +27,9 @@ class Connection {
List<BluetoothDevice> get bluetoothDevices => devices.whereType<BluetoothDevice>().toList();
List<GamepadDevice> get gamepadDevices => devices.whereType<GamepadDevice>().toList();
List<BaseDevice> get controllerDevices => [...bluetoothDevices, ...gamepadDevices];
List<BaseDevice> get remoteDevices => devices.whereNot((d) => d is BluetoothDevice || d is GamepadDevice).toList();
List<BaseDevice> get controllerDevices => [...bluetoothDevices, ...gamepadDevices, ...devices.whereType<HidDevice>()];
List<BaseDevice> get remoteDevices =>
devices.whereNot((d) => d is BluetoothDevice || d is GamepadDevice || d is HidDevice).toList();
var _androidNotificationsSetup = false;
@@ -36,6 +39,7 @@ class Connection {
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
Stream<BaseNotification> get actionStream => _actionStreams.stream;
List<({DateTime date, String entry})> lastLogEntries = [];
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
@@ -44,10 +48,26 @@ class Connection {
final _lastScanResult = <BleDevice>[];
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
final ValueNotifier<bool> isScanning = ValueNotifier(false);
final ValueNotifier<bool> isMediaKeyDetectionEnabled = ValueNotifier(false);
Timer? _gamePadSearchTimer;
void initialize() {
actionStream.listen((log) {
lastLogEntries.add((date: DateTime.now(), entry: log.toString()));
lastLogEntries = lastLogEntries.takeLast(20).toList();
});
isMediaKeyDetectionEnabled.addListener(() {
if (!isMediaKeyDetectionEnabled.value) {
mediaKeyDetector.setIsPlaying(isPlaying: false);
mediaKeyDetector.removeListener(_onMediaKeyDetectedListener);
} else {
mediaKeyDetector.addListener(_onMediaKeyDetectedListener);
mediaKeyDetector.setIsPlaying(isPlaying: true);
}
});
UniversalBle.onAvailabilityChange = (available) {
_actionStreams.add(BluetoothAvailabilityNotification(available == AvailabilityState.poweredOn));
if (available == AvailabilityState.poweredOn && !kIsWeb) {
@@ -66,7 +86,7 @@ class Connection {
_connectionStreams.add(existingDevice); // Notify UI of update
}
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
_lastScanResult.add(result);
if (kDebugMode) {
@@ -77,7 +97,7 @@ class Connection {
if (scanResult != null) {
_actionStreams.add(LogNotification('Found new device: ${scanResult.runtimeType}'));
_addDevices([scanResult]);
addDevices([scanResult]);
} else {
final manufacturerData = result.manufacturerDataList;
final data = manufacturerData
@@ -90,14 +110,26 @@ class Connection {
}
};
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) async {
final device = bluetoothDevices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
if (device == null) {
_actionStreams.add(LogNotification('Device not found: $deviceId'));
UniversalBle.disconnect(deviceId);
return;
} else {
device.processCharacteristic(characteristicUuid, value);
try {
await device.processCharacteristic(characteristicUuid, value);
} catch (e, backtrace) {
_actionStreams.add(
LogNotification(
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
),
);
if (kDebugMode) {
print(e);
print("backtrace: $backtrace");
}
}
}
};
@@ -124,7 +156,7 @@ class Connection {
).then((devices) async {
final baseDevices = devices.mapNotNull(BluetoothDevice.fromScanResult).toList();
if (baseDevices.isNotEmpty) {
_addDevices(baseDevices);
addDevices(baseDevices);
}
});
}
@@ -135,69 +167,80 @@ class Connection {
platformConfig: PlatformConfig(web: WebOptions(optionalServices: BluetoothDevice.servicesToScan)),
);
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
if (!kIsWeb) {
_gamePadSearchTimer = Timer.periodic(Duration(seconds: 3), (_) {
Gamepads.list().then((list) {
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
addDevices(pads);
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
for (var device in removedDevices) {
devices.remove(device);
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
signalChange(device);
}
});
});
Gamepads.list().then((list) {
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
_addDevices(pads);
final removedDevices = gamepadDevices.where((device) => list.none((pad) => pad.id == device.id)).toList();
for (var device in removedDevices) {
devices.remove(device);
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
signalChange(device);
}
addDevices(pads);
});
});
Gamepads.list().then((list) {
final pads = list.map((pad) => GamepadDevice(pad.name, id: pad.id)).toList();
_addDevices(pads);
});
}
if (settings.getMyWhooshLinkEnabled() && settings.getTrainerApp() is MyWhoosh && !whooshLink.isStarted.value) {
startMyWhooshServer();
if (settings.getMyWhooshLinkEnabled() &&
settings.getTrainerApp() is MyWhoosh &&
!whooshLink.isStarted.value &&
whooshLink.isCompatible(settings.getLastTarget()!)) {
startMyWhooshServer().catchError((e) {
_actionStreams.add(
LogNotification(
'Error starting MyWhoosh Direct Connect server. Please make sure the "MyWhoosh Link" app is not already running on this device.\n$e',
),
);
});
}
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
_androidNotificationsSetup = true;
// start foreground service only when app is in foreground
NotificationRequirement.setup().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
Future<void> startMyWhooshServer() {
return whooshLink.startServer(
onConnected: (socket) {
final existing = remoteDevices.firstOrNullWhere(
(e) => e is LinkDevice && e.identifier == socket.remoteAddress.address,
);
if (existing != null) {
existing.isConnected = true;
signalChange(existing);
}
},
onDisconnected: (socket) {
final device = devices.firstOrNullWhere(
(device) => device is LinkDevice && device.identifier == socket.remoteAddress.address,
);
if (device != null) {
devices.remove(device);
signalChange(device);
}
},
onConnected: (socket) {},
onDisconnected: (socket) {},
);
}
void _addDevices(List<BaseDevice> dev) {
final newDevices = dev.where((device) => !devices.contains(device)).toList();
void addDevices(List<BaseDevice> dev) {
final ignoredDevices = settings.getIgnoredDevices();
final ignoredDeviceIds = ignoredDevices.map((d) => d.id).toSet();
final newDevices = dev.where((device) {
if (devices.contains(device)) return false;
// Check if device is in the ignored list
if (device is BluetoothDevice) {
if (ignoredDeviceIds.contains(device.device.deviceId)) {
return false;
}
}
return true;
}).toList();
devices.addAll(newDevices);
_connectionQueue.addAll(newDevices);
_handleConnectionQueue();
hasDevices.value = devices.isNotEmpty;
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
_androidNotificationsSetup = true;
NotificationRequirement.setup().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
void _handleConnectionQueue() {
@@ -236,7 +279,7 @@ class Connection {
device.isConnected = state;
_connectionStreams.add(device);
if (!device.isConnected) {
disconnect(device, forget: true);
disconnect(device, forget: false);
// try reconnect
performScanning();
}
@@ -310,17 +353,41 @@ class Connection {
if (device.isConnected) {
await device.disconnect();
}
if (device is! LinkDevice) {
// keep it in the list to allow reconnect
devices.remove(device);
}
if (!forget && device is BluetoothDevice) {
if (device is BluetoothDevice) {
if (forget) {
// Add device to ignored list when forgetting
await settings.addIgnoredDevice(device.device.deviceId, device.name);
_actionStreams.add(LogNotification('Device ignored: ${device.name}'));
}
// Clean up subscriptions and scan results for reconnection
_lastScanResult.removeWhere((b) => b.deviceId == device.device.deviceId);
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
// Remove device from the list
devices.remove(device);
hasDevices.value = devices.isNotEmpty;
}
signalChange(device);
}
void _onMediaKeyDetectedListener(MediaKey mediaKey) {
final hidDevice = HidDevice('HID Device');
final keyPressed = mediaKey.name;
final button = actionHandler.supportedApp!.keymap.getOrAddButton(keyPressed, () => ControllerButton(keyPressed));
var availableDevice = connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
if (availableDevice == null) {
connection.addDevices([hidDevice]);
availableDevice = hidDevice;
}
availableDevice.handleButtonsClicked([button]);
availableDevice.handleButtonsClicked([]);
}
}

View File

@@ -29,7 +29,7 @@ abstract class BaseDevice {
@override
String toString() {
return runtimeType.toString();
return name;
}
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();

View File

@@ -3,7 +3,9 @@ import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/devices/shimano/shimano_di2.dart';
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_click.dart';
@@ -16,6 +18,7 @@ import 'package:swift_control/pages/device.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:universal_ble/universal_ble.dart';
import 'cycplus/cycplus_bc2.dart';
import 'elite/elite_square.dart';
import 'elite/elite_sterzo.dart';
@@ -37,6 +40,8 @@ abstract class BluetoothDevice extends BaseDevice {
SquareConstants.SERVICE_UUID,
WahooKickrBikeShiftConstants.SERVICE_UUID,
SterzoConstants.SERVICE_UUID,
CycplusBc2Constants.SERVICE_UUID,
ShimanoDi2Constants.SERVICE_UUID,
];
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
@@ -48,32 +53,31 @@ abstract class BluetoothDevice extends BaseDevice {
'Zwift Play' => ZwiftPlay(scanResult),
'Zwift Click' => ZwiftClickV2(scanResult),
'SQUARE' => EliteSquare(scanResult),
null => null,
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
CycplusBc2(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
_ => null,
};
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
device = WahooKickrBikeShift(scanResult);
}
if (scanResult.name != null && scanResult.name!.toUpperCase().startsWith('STERZO')) {
device = EliteSterzo(scanResult);
}
} else {
device = switch (scanResult.name) {
null => null,
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
'Zwift Play' => ZwiftPlay(scanResult),
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
_ when scanResult.name!.toUpperCase().startsWith('SQUARE') => EliteSquare(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('STERZO') => EliteSterzo(scanResult),
_ when scanResult.name!.toUpperCase().contains('KICKR BIKE SHIFT') => WahooKickrBikeShift(scanResult),
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
CycplusBc2(scanResult),
_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
// otherwise the service UUIDs will be used
_ => null,
};
if (scanResult.name != null) {
if (scanResult.name!.toUpperCase().startsWith('STERZO')) {
device = EliteSterzo(scanResult);
} else if (scanResult.name!.toUpperCase().startsWith('KICKR BIKE SHIFT')) {
return WahooKickrBikeShift(scanResult);
}
}
}
if (device != null) {
@@ -103,10 +107,6 @@ abstract class BluetoothDevice extends BaseDevice {
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
_ => null,
};
} else if (scanResult.services.contains(SquareConstants.SERVICE_UUID)) {
return EliteSquare(scanResult);
} else if (scanResult.services.contains(SterzoConstants.SERVICE_UUID)) {
return EliteSterzo(scanResult);
} else {
return null;
}
@@ -115,14 +115,14 @@ abstract class BluetoothDevice extends BaseDevice {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
other is BluetoothDevice && runtimeType == other.runtimeType && scanResult.deviceId == other.scanResult.deviceId;
@override
int get hashCode => scanResult.hashCode;
int get hashCode => scanResult.deviceId.hashCode;
@override
String toString() {
return runtimeType.toString();
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
}
BleDevice get device => scanResult;
@@ -140,6 +140,41 @@ abstract class BluetoothDevice extends BaseDevice {
}
final services = await UniversalBle.discoverServices(device.deviceId);
final deviceInformationService = services.firstOrNullWhere(
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
);
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
);
if (firmwareCharacteristic != null) {
final firmwareData = await UniversalBle.read(
device.deviceId,
deviceInformationService!.uuid,
firmwareCharacteristic.uuid,
);
firmwareVersion = String.fromCharCodes(firmwareData);
connection.signalChange(this);
}
final batteryService = services.firstOrNullWhere(
(service) => service.uuid == BleUuid.DEVICE_BATTERY_SERVICE_UUID.toLowerCase(),
);
final batteryCharacteristic = batteryService?.characteristics.firstOrNullWhere(
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL.toLowerCase(),
);
if (batteryCharacteristic != null) {
final batteryData = await UniversalBle.read(
device.deviceId,
batteryService!.uuid,
batteryCharacteristic.uuid,
);
if (batteryData.isNotEmpty) {
batteryLevel = batteryData.first;
connection.signalChange(this);
}
}
await handleServices(services);
}
@@ -157,7 +192,7 @@ abstract class BluetoothDevice extends BaseDevice {
return Row(
children: [
Text(
device.name?.screenshot ?? device.runtimeType.toString(),
device.name?.screenshot ?? runtimeType.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),
@@ -172,7 +207,7 @@ abstract class BluetoothDevice extends BaseDevice {
}),
Text('$batteryLevel%'),
],
if (firmwareVersion != null) Text(' - Firmware: $firmwareVersion'),
if (firmwareVersion != null) Text(' - v$firmwareVersion'),
if (firmwareVersion != null &&
this is ZwiftDevice &&
firmwareVersion != (this as ZwiftDevice).latestFirmwareVersion) ...[

View File

@@ -0,0 +1,128 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth_device.dart';
class CycplusBc2 extends BluetoothDevice {
CycplusBc2(super.scanResult)
: super(
availableButtons: CycplusBc2Buttons.values,
);
@override
Future<void> handleServices(List<BleService> services) async {
final service = services.firstWhere(
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.SERVICE_UUID.toLowerCase(),
orElse: () => throw Exception('Service not found: ${CycplusBc2Constants.SERVICE_UUID}'),
);
final characteristic = service.characteristics.firstWhere(
(e) => e.uuid.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase(),
orElse: () => throw Exception('Characteristic not found: ${CycplusBc2Constants.TX_CHARACTERISTIC_UUID}'),
);
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
}
// Track last state for index 6 and 7
int _lastStateIndex6 = 0x00;
int _lastStateIndex7 = 0x00;
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
if (characteristic.toLowerCase() == CycplusBc2Constants.TX_CHARACTERISTIC_UUID.toLowerCase()) {
if (bytes.length > 7) {
final buttonsToPress = <ControllerButton>[];
// Process index 6 (shift up)
final currentByte6 = bytes[6];
if (_shouldTriggerShift(currentByte6, _lastStateIndex6)) {
buttonsToPress.add(CycplusBc2Buttons.shiftUp);
_lastStateIndex6 = 0x00; // Reset after successful press
} else {
_updateState(currentByte6, (val) => _lastStateIndex6 = val);
}
// Process index 7 (shift down)
final currentByte7 = bytes[7];
if (_shouldTriggerShift(currentByte7, _lastStateIndex7)) {
buttonsToPress.add(CycplusBc2Buttons.shiftDown);
_lastStateIndex7 = 0x00; // Reset after successful press
} else {
_updateState(currentByte7, (val) => _lastStateIndex7 = val);
}
handleButtonsClicked(buttonsToPress);
} else {
actionStreamInternal.add(
LogNotification(
'CYCPLUS BC2 received unexpected packet: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join()}',
),
);
handleButtonsClicked([]);
}
}
return Future.value();
}
// Check if we should trigger a shift based on current and last state
bool _shouldTriggerShift(int currentByte, int lastByte) {
const pressedValues = {0x01, 0x02, 0x03};
// State change from one pressed value to another different pressed value
// This is the ONLY time we trigger a shift
if (pressedValues.contains(currentByte) && pressedValues.contains(lastByte) && currentByte != lastByte) {
return true;
}
return false;
}
// Update state tracking
void _updateState(int currentByte, void Function(int) setState) {
const pressedValues = {0x01, 0x02, 0x03};
const releaseValue = 0x00;
// Button released: current is 0x00 and last was pressed
if (currentByte == releaseValue) {
setState(releaseValue);
}
// Lock the new pressed state
else if (pressedValues.contains(currentByte)) {
setState(currentByte);
}
}
}
class CycplusBc2Constants {
// Nordic UART Service (NUS) - commonly used by CYCPLUS BC2
static const String SERVICE_UUID = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
// TX Characteristic - device sends data to app
static const String TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
// RX Characteristic - app sends data to device (not used for button reading)
static const String RX_CHARACTERISTIC_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
}
class CycplusBc2Buttons {
static const ControllerButton shiftUp = ControllerButton(
'shiftUp',
action: InGameAction.shiftUp,
icon: Icons.add,
);
static const ControllerButton shiftDown = ControllerButton(
'shiftDown',
action: InGameAction.shiftDown,
icon: Icons.remove,
);
static const List<ControllerButton> values = [
shiftUp,
shiftDown,
];
}

View File

@@ -40,11 +40,11 @@ class EliteSquare extends BluetoothDevice {
actionStreamInternal.add(LogNotification('Received $fullValue - vs $currentValue (last: $_lastValue)'));
if (_lastValue != null) {
final currentRelevantPart = fullValue.length >= 19
? fullValue.substring(6, fullValue.length - 13)
final currentRelevantPart = fullValue.length >= 14
? fullValue.substring(6, 14)
: fullValue.substring(6);
final lastRelevantPart = _lastValue!.length >= 19
? _lastValue!.substring(6, _lastValue!.length - 13)
final lastRelevantPart = _lastValue!.length >= 14
? _lastValue!.substring(6, 14)
: _lastValue!.substring(6);
if (currentRelevantPart != lastRelevantPart) {

View File

@@ -66,7 +66,7 @@ class EliteSterzo extends BluetoothDevice {
);
// Subscribe to measurement notifications
await UniversalBle.subscribeIndications(
await UniversalBle.subscribeNotifications(
device.deviceId,
service.uuid,
measurementChar.uuid,

View File

@@ -7,7 +7,6 @@ import 'package:swift_control/main.dart';
import 'package:swift_control/pages/device.dart';
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import '../../../widgets/warning.dart';
@@ -24,25 +23,12 @@ class GamepadDevice extends BaseDevice {
Gamepads.eventsByGamepad(id).listen((event) {
actionStreamInternal.add(LogNotification('Gamepad event: $event'));
ControllerButton? button = availableButtons.firstOrNullWhere((b) => b.name == event.key);
ControllerButton? button = actionHandler.supportedApp?.keymap.getOrAddButton(
event.key,
() => ControllerButton(event.key),
);
if (button == null) {
button = ControllerButton(event.key);
if (actionHandler.supportedApp is CustomApp) {
availableButtons.add(button);
actionHandler.supportedApp?.keymap.addKeyPair(
KeyPair(
touchPosition: Offset.zero,
buttons: [button],
physicalKey: null,
logicalKey: null,
isLongPress: false,
),
);
}
}
final buttonsClicked = event.value == 0.0 ? [button] : <ControllerButton>[];
final buttonsClicked = event.value == 0.0 && button != null ? [button] : <ControllerButton>[];
if (_lastButtonsClicked.contentEquals(buttonsClicked) == false) {
handleButtonsClicked(buttonsClicked);
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/android.dart';
class HidDevice extends BaseDevice {
HidDevice(super.name, {super.availableButtons = const []});
@override
Future<void> connect() {
return Future.value(null);
}
@override
Widget showInformation(BuildContext context) {
return Row(
children: [
Expanded(child: Text(name)),
PopupMenuButton(
itemBuilder: (c) => [
PopupMenuItem(
child: Text('Ignore'),
onTap: () {
connection.disconnect(this, forget: true);
if (actionHandler is AndroidActions) {
(actionHandler as AndroidActions).ignoreHidDevices();
} else if (connection.isMediaKeyDetectionEnabled.value) {
connection.isMediaKeyDetectionEnabled.value = false;
}
},
),
],
),
],
);
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/requirements/multi.dart';
class WhooshLink {
Socket? _socket;
@@ -37,45 +38,56 @@ class WhooshLink {
required void Function(Socket socket) onConnected,
required void Function(Socket socket) onDisconnected,
}) async {
// Create and bind server socket
_server = await ServerSocket.bind(
InternetAddress.anyIPv6,
21587,
shared: true,
v6Only: false,
);
try {
// Create and bind server socket
_server = await ServerSocket.bind(
InternetAddress.anyIPv6,
21587,
shared: true,
v6Only: false,
);
} catch (e) {
if (kDebugMode) {
print('Failed to start server: $e');
}
isConnected.value = false;
isStarted.value = false;
rethrow;
}
isStarted.value = true;
if (kDebugMode) {
print('Server started on port ${_server!.port}');
}
// Accept connection
_server!.listen((Socket socket) {
_socket = socket;
onConnected(socket);
isConnected.value = true;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
}
_server!.listen(
(Socket socket) {
_socket = socket;
onConnected(socket);
isConnected.value = true;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
}
// Listen for data from the client
socket.listen(
(List<int> data) {
try {
if (kDebugMode) {
// TODO we could check if virtual shifting is enabled
final message = utf8.decode(data);
print('Received message: $message');
}
} catch (_) {}
},
onDone: () {
print('Client disconnected: $socket');
onDisconnected(socket);
isConnected.value = false;
},
);
});
// Listen for data from the client
socket.listen(
(List<int> data) {
try {
if (kDebugMode) {
// TODO we could check if virtual shifting is enabled
final message = utf8.decode(data);
print('Received message: $message');
}
} catch (_) {}
},
onDone: () {
print('Client disconnected: $socket');
onDisconnected(socket);
isConnected.value = false;
},
);
},
);
}
String sendAction(InGameAction action, int? value) {
@@ -138,4 +150,13 @@ class WhooshLink {
return 'No action available for button: $action';
}
}
bool isCompatible(Target target) {
return kIsWeb
? false
: switch (target) {
Target.thisDevice => Platform.isAndroid || Platform.isWindows,
_ => true,
};
}
}

View File

@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:url_launcher/url_launcher_string.dart';
class LinkDevice extends BaseDevice {
String identifier;
LinkDevice(this.identifier) : super('MyWhoosh Link', availableButtons: []);
@override
Future<void> connect() async {
isConnected = true;
}
@override
Future<void> disconnect() async {
super.disconnect();
whooshLink.stopServer();
isConnected = false;
}
@override
Widget showInformation(BuildContext context) {
return ValueListenableBuilder(
valueListenable: whooshLink.isConnected,
builder: (context, isConnected, _) {
return StatefulBuilder(
builder: (context, setState) {
return Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: settings.getMyWhooshLinkEnabled(),
onChanged: (value) {
settings.setMyWhooshLinkEnabled(value);
if (!value) {
disconnect();
connection.disconnect(this, forget: true);
} else if (value) {
connection.startMyWhooshServer();
}
setState(() {});
},
title: Text('Enable MyWhoosh Link'),
subtitle: Row(
spacing: 12,
children: [
if (!settings.getMyWhooshLinkEnabled())
Text('Disabled')
else ...[
Text(
isConnected ? "Connected" : "Connecting to MyWhoosh...",
),
if (!isConnected) SmallProgressIndicator(),
],
],
),
),
),
IconButton(
onPressed: () {
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
},
icon: Icon(Icons.help_outline),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,101 @@
import 'dart:typed_data';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth_device.dart';
class ShimanoDi2 extends BluetoothDevice {
ShimanoDi2(super.scanResult) : super(availableButtons: []);
@override
Future<void> handleServices(List<BleService> services) async {
final service = services.firstWhere(
(e) => e.uuid.toLowerCase() == ShimanoDi2Constants.SERVICE_UUID.toLowerCase(),
orElse: () => throw Exception('Service not found: ${ShimanoDi2Constants.SERVICE_UUID}'),
);
final characteristic = service.characteristics.firstWhere(
(e) => e.uuid.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID.toLowerCase(),
orElse: () => throw Exception('Characteristic not found: ${ShimanoDi2Constants.D_FLY_CHANNEL_UUID}'),
);
await UniversalBle.subscribeIndications(device.deviceId, service.uuid, characteristic.uuid);
}
final _lastButtons = <int, int>{};
bool _isInitialized = false;
@override
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
if (characteristic.toLowerCase() == ShimanoDi2Constants.D_FLY_CHANNEL_UUID) {
final channels = bytes.sublist(1);
// On first data reception, just initialize the state without triggering buttons
if (!_isInitialized) {
channels.forEachIndexed((int value, int index) {
final readableIndex = index + 1;
_lastButtons[index] = value;
actionHandler.supportedApp?.keymap.getOrAddButton(
'D-Fly Channel $readableIndex',
() => ControllerButton('D-Fly Channel $readableIndex'),
);
});
_isInitialized = true;
return Future.value();
}
final clickedButtons = <ControllerButton>[];
channels.forEachIndexed((int value, int index) {
final didChange = _lastButtons[index] != value;
_lastButtons[index] = value;
final readableIndex = index + 1;
final button = actionHandler.supportedApp?.keymap.getOrAddButton(
'D-Fly Channel $readableIndex',
() => ControllerButton('D-Fly Channel $readableIndex'),
);
if (didChange && button != null) {
clickedButtons.add(button);
}
});
if (clickedButtons.isNotEmpty) {
handleButtonsClicked(clickedButtons);
handleButtonsClicked([]);
}
}
return Future.value();
}
@override
Widget showInformation(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
super.showInformation(context),
Text(
'Make sure to set your Di2 buttons to D-Fly channels in the Shimano E-TUBE app.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (actionHandler.supportedApp is! CustomApp)
Text(
'Use a custom keymap to support ${scanResult.name}',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
);
}
}
class ShimanoDi2Constants {
static const String SERVICE_UUID = "000018ef-5348-494d-414e-4f5f424c4500";
static const String D_FLY_CHANNEL_UUID = "00002ac2-5348-494d-414e-4f5f424c4500";
}

View File

@@ -6,14 +6,14 @@ import 'package:swift_control/utils/keymap/buttons.dart';
import 'constants.dart';
class ZwiftClick extends ZwiftDevice {
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftDownLeft]);
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftUpLeft]);
@override
List<ControllerButton> processClickNotification(Uint8List message) {
final status = ClickKeyPadStatus.fromBuffer(message);
final buttonsClicked = [
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButtons.shiftUpRight,
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftDownLeft,
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButtons.shiftUpLeft,
];
return buttonsClicked;
}

View File

@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/bluetooth_device.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
@@ -32,29 +31,6 @@ abstract class ZwiftDevice extends BluetoothDevice {
);
}
final deviceInformationService = services.firstOrNullWhere(
(service) => service.uuid == BleUuid.DEVICE_INFORMATION_SERVICE_UUID.toLowerCase(),
);
final firmwareCharacteristic = deviceInformationService?.characteristics.firstOrNullWhere(
(c) => c.uuid == BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_FIRMWARE_REVISION.toLowerCase(),
);
if (firmwareCharacteristic != null) {
final firmwareData = await UniversalBle.read(
device.deviceId,
deviceInformationService!.uuid,
firmwareCharacteristic.uuid,
);
firmwareVersion = String.fromCharCodes(firmwareData);
connection.signalChange(this);
if (firmwareVersion != latestFirmwareVersion) {
actionStreamInternal.add(
LogNotification(
'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.',
),
);
}
}
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == ZwiftConstants.ZWIFT_ASYNC_CHARACTERISTIC_UUID.toLowerCase(),
);
@@ -73,6 +49,14 @@ abstract class ZwiftDevice extends BluetoothDevice {
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
await setupHandshake();
if (firmwareVersion != latestFirmwareVersion) {
actionStreamInternal.add(
LogNotification(
'A new firmware version is available for ${device.name ?? device.rawName}: $latestFirmwareVersion (current: $firmwareVersion). Please update it in Zwift Companion app.',
),
);
}
}
Future<void> setupHandshake() async {

View File

@@ -296,7 +296,7 @@ class ZwiftEmulator {
),
],
);
print('Starting advertising with HID service...');
print('Starting advertising with Zwift service...');
await peripheralManager.startAdvertising(advertisement);
_isAdvertising = true;

View File

@@ -5,7 +5,6 @@ import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:swift_control/bluetooth/devices/zwift/protocol/zp_vendor.pb.dart';
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_clickv2.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/main.dart';
@@ -61,19 +60,6 @@ class ZwiftRide extends ZwiftDevice {
);
}
if (this is ZwiftClickV2 &&
(bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_1) ||
bytes.startsWith(ZwiftConstants.RESPONSE_STOPPED_CLICK_V2_VARIANT_2))) {
actionStreamInternal.add(
LogNotification(
'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);
}
}
switch (opcode) {
case Opcode.RIDE_ON:
//print("Empty RideOn response - unencrypted mode");

View File

@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:accessibility/accessibility.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@@ -18,7 +17,6 @@ import 'utils/actions/base_actions.dart';
final connection = Connection();
final navigatorKey = GlobalKey<NavigatorState>();
late BaseActions actionHandler;
final accessibilityHandler = Accessibility();
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final settings = Settings();
final whooshLink = WhooshLink();
@@ -26,8 +24,8 @@ const screenshotMode = false;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const SwiftPlayApp());
final error = await settings.init();
runApp(SwiftPlayApp(error: error));
}
enum ConnectionType {
@@ -62,7 +60,8 @@ Future<void> initializeActions(ConnectionType connectionType) async {
}
class SwiftPlayApp extends StatelessWidget {
const SwiftPlayApp({super.key});
final String? error;
const SwiftPlayApp({super.key, this.error});
@override
Widget build(BuildContext context) {
@@ -72,8 +71,10 @@ class SwiftPlayApp extends StatelessWidget {
title: 'SwiftControl',
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.light,
home: const RequirementsPage(),
themeMode: ThemeMode.system,
home: error != null
? Text('There was an error starting the App. Please contact support:\n$error')
: const RequirementsPage(),
);
}
}

View File

@@ -2,16 +2,18 @@ import 'dart:async';
import 'dart:io';
import 'package:device_auto_rotate_checker/device_auto_rotate_checker.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:swift_control/bluetooth/devices/link/link_device.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/keymap/manager.dart';
import 'package:swift_control/utils/requirements/zwift.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/widgets/apps/mywhoosh_link_tile.dart';
import 'package:swift_control/widgets/apps/zwift_tile.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/logviewer.dart';
@@ -21,9 +23,11 @@ import 'package:swift_control/widgets/testbed.dart';
import 'package:swift_control/widgets/title.dart';
import 'package:swift_control/widgets/warning.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../bluetooth/devices/base_device.dart';
import '../utils/actions/android.dart';
import '../utils/actions/remote.dart';
import '../utils/keymap/apps/custom_app.dart';
import '../utils/keymap/apps/supported_app.dart';
@@ -43,6 +47,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
final _snackBarMessengerKey = GlobalKey<ScaffoldMessengerState>();
bool _showAutoRotationWarning = false;
bool _showMiuiWarning = false;
StreamSubscription<bool>? _autoRotateStream;
@override
@@ -57,18 +62,20 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
_checkAndShowChangelog();
});
whooshLink.isStarted.addListener(() {
if (mounted) setState(() {});
});
zwiftEmulator.isConnected.addListener(() {
if (mounted) setState(() {});
});
if (settings.getZwiftEmulatorEnabled() && actionHandler.supportedApp?.supportsZwiftEmulation == true) {
zwiftEmulator.startAdvertising(() {
if (!kIsWeb) {
whooshLink.isStarted.addListener(() {
if (mounted) setState(() {});
});
zwiftEmulator.isConnected.addListener(() {
if (mounted) setState(() {});
});
if (settings.getZwiftEmulatorEnabled() && actionHandler.supportedApp?.supportsZwiftEmulation == true) {
zwiftEmulator.startAdvertising(() {
if (mounted) setState(() {});
});
}
}
if (actionHandler is RemoteActions && !kIsWeb && Platform.isIOS && (actionHandler as RemoteActions).isConnected) {
@@ -99,6 +106,11 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
_showAutoRotationWarning = !isEnabled;
});
});
// Check if device is MIUI and using local accessibility service
if (actionHandler is AndroidActions) {
_checkMiuiDevice();
}
}
}
@@ -132,6 +144,29 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
}
}
Future<void> _checkMiuiDevice() async {
try {
// Don't show if user has dismissed the warning
if (settings.getMiuiWarningDismissed()) {
return;
}
final deviceInfo = await DeviceInfoPlugin().androidInfo;
final isMiui =
deviceInfo.manufacturer.toLowerCase() == 'xiaomi' ||
deviceInfo.brand.toLowerCase() == 'xiaomi' ||
deviceInfo.brand.toLowerCase() == 'redmi' ||
deviceInfo.brand.toLowerCase() == 'poco';
if (isMiui && mounted) {
setState(() {
_showMiuiWarning = true;
});
}
} catch (e) {
// Silently fail if device info is not available
}
}
Future<void> _checkAndShowChangelog() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
@@ -169,7 +204,9 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
appBar: AppBar(
title: AppTitle(),
actions: buildMenuButtons(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
backgroundColor: Theme.brightnessOf(context) == Brightness.light
? Theme.of(context).colorScheme.inversePrimary
: null,
),
body: SingleChildScrollView(
padding: EdgeInsets.only(
@@ -187,6 +224,71 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
Text('Enable auto-rotation on your device to make sure the app works correctly.'),
],
),
if (_showMiuiWarning)
Warning(
children: [
Row(
children: [
Icon(Icons.warning_amber, color: Theme.of(context).colorScheme.error),
SizedBox(width: 8),
Expanded(
child: Text(
'MIUI Device Detected',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
),
),
IconButton(
icon: Icon(Icons.close),
onPressed: () async {
await settings.setMiuiWarningDismissed(true);
setState(() {
_showMiuiWarning = false;
});
},
tooltip: 'Dismiss',
padding: EdgeInsets.zero,
constraints: BoxConstraints(),
),
],
),
SizedBox(height: 8),
Text(
'Your device is running MIUI, which is known to aggressively kill background services and accessibility services.',
style: TextStyle(fontSize: 14),
),
SizedBox(height: 8),
Text(
'To ensure SwiftControl works properly:',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
Text(
'• Disable battery optimization for SwiftControl',
style: TextStyle(fontSize: 14),
),
Text(
'• Enable autostart for SwiftControl',
style: TextStyle(fontSize: 14),
),
Text(
'• Lock the app in recent apps',
style: TextStyle(fontSize: 14),
),
SizedBox(height: 12),
ElevatedButton.icon(
onPressed: () async {
final url = Uri.parse('https://dontkillmyapp.com/xiaomi');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
},
icon: Icon(Icons.open_in_new),
label: Text('View Detailed Instructions'),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Connected Devices', style: Theme.of(context).textTheme.titleMedium),
@@ -235,7 +337,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
if (connection.remoteDevices.isNotEmpty ||
actionHandler is RemoteActions ||
settings.getTrainerApp() is MyWhoosh ||
whooshLink.isCompatible(settings.getLastTarget() ?? Target.thisDevice) ||
actionHandler.supportedApp?.supportsZwiftEmulation == true)
Container(
margin: const EdgeInsets.only(bottom: 8.0),
@@ -259,19 +361,22 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
(device) => device.showInformation(context),
),
if (settings.getTrainerApp() is MyWhoosh && !whooshLink.isConnected.value)
LinkDevice('').showInformation(context),
if (actionHandler.supportedApp?.supportsZwiftEmulation == true)
ZwiftRequirement().build(context, () {
setState(() {});
})!,
if (settings.getTrainerApp() is MyWhoosh &&
whooshLink.isCompatible(settings.getLastTarget()!))
MyWhooshLinkTile(),
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
ZwiftTile(
onUpdate: () {
setState(() {});
},
),
if (actionHandler is RemoteActions)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected'}',
'Remote Control Mode: ${(actionHandler as RemoteActions).isConnected ? 'Connected' : 'Not connected (optional)'}',
),
PopupMenuButton(
itemBuilder: (_) => [
@@ -291,11 +396,14 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
),
),
SizedBox(height: 20),
if (!kIsWeb) ...[
SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('Customize', style: Theme.of(context).textTheme.titleMedium),
child: Text(
'Customize ${settings.getTrainerApp()?.name} on ${settings.getLastTarget()?.title}',
style: Theme.of(context).textTheme.titleMedium,
),
),
Card(
child: Padding(

View File

@@ -54,7 +54,9 @@ class _ChangelogPageState extends State<MarkdownPage> {
return Scaffold(
appBar: AppBar(
title: Text(widget.assetPath.replaceAll('.md', '').toLowerCase().capitalize),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
backgroundColor: Theme.brightnessOf(context) == Brightness.light
? Theme.of(context).colorScheme.inversePrimary
: null,
),
body: _error != null
? Center(child: Text(_error!))
@@ -68,7 +70,10 @@ class _ChangelogPageState extends State<MarkdownPage> {
child: MarkdownWidget(
markdown: _markdown!,
theme: MarkdownThemeData(
textStyle: TextStyle(fontSize: 14.0, color: Colors.black87),
textStyle: TextStyle(
fontSize: 14.0,
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
),
onLinkTap: (title, url) {
launchUrlString(url);
},

View File

@@ -3,10 +3,12 @@ import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/menu.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:swift_control/widgets/title.dart';
import 'device.dart';
@@ -30,16 +32,14 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
// call after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
settings.init().then((_) {
if (!kIsWeb && Platform.isMacOS) {
// add more delay due to CBManagerStateUnknown
Future.delayed(const Duration(seconds: 2), () {
_reloadRequirements();
});
} else {
if (!kIsWeb && Platform.isMacOS) {
// add more delay due to CBManagerStateUnknown
Future.delayed(const Duration(seconds: 2), () {
_reloadRequirements();
}
});
});
} else {
_reloadRequirements();
}
});
}
@@ -61,8 +61,10 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
return Scaffold(
appBar: AppBar(
title: AppTitle(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: buildMenuButtons(),
backgroundColor: Theme.brightnessOf(context) == Brightness.light
? Theme.of(context).colorScheme.inversePrimary
: null,
),
body: SingleChildScrollView(
child: Column(
@@ -101,10 +103,11 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
],
),
_requirements.isEmpty
? Center(child: CircularProgressIndicator())
? Center(child: SmallProgressIndicator())
: Card(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Stepper(
key: ObjectKey(_requirements.length),
physics: NeverScrollableScrollPhysics(),
currentStep: _currentStep,
connectorColor: WidgetStateProperty.resolveWith<Color>(
@@ -169,16 +172,28 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
void _callRequirement(PlatformRequirement req, BuildContext context, VoidCallback onUpdate) {
req.call(context, onUpdate).then((_) {
_reloadRequirements();
});
req
.call(context, onUpdate)
.then((_) {
return _reloadRequirements();
})
.catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error handling requirement "${req.name}": $e'),
),
);
});
}
void _reloadRequirements() {
getRequirements(
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
).then((req) {
void _reloadRequirements() async {
try {
final req = await getRequirements(
settings.getLastTarget()?.connectionType ?? ConnectionType.unknown,
);
_requirements = req;
_currentStep = _currentStep >= _requirements.length ? 0 : _currentStep;
setState(() {});
final unresolvedIndex = req.indexWhere((req) => !req.status);
if (unresolvedIndex != -1) {
_currentStep = unresolvedIndex;
@@ -198,9 +213,16 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
);
}
}
if (mounted) {
setState(() {});
}
});
} catch (e) {
connection.signalNotification(LogNotification('Error loading requirements: $e'));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading requirements: $e'),
),
);
_currentStep = 0;
_requirements = [ErrorRequirement('Error loading requirements: $e')];
setState(() {});
}
}
}

View File

@@ -9,8 +9,8 @@ import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/widgets/button_widget.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/testbed.dart';
@@ -30,12 +30,12 @@ class TouchAreaSetupPage extends StatefulWidget {
}
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
File? _backgroundImage;
Uint8List? _backgroundImage;
final TransformationController _transformationController = TransformationController();
late Rect _imageRect;
bool _showAll = false;
bool _showFaded = true;
Future<void> _pickScreenshot() async {
final picker = ImagePicker();
@@ -45,7 +45,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
final Directory tempDir = await getTemporaryDirectory();
final tempImage = File('${tempDir.path}/${actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png');
await image.copy(tempImage.path);
_backgroundImage = tempImage;
_backgroundImage = tempImage.readAsBytesSync();
await _calculateBounds();
}
}
@@ -54,7 +54,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
if (_backgroundImage == null) return;
// need to decode image to get its size so we can have a percentage mapping
final decodedImage = await decodeImageFromList(_backgroundImage!.readAsBytesSync());
final decodedImage = await decodeImageFromList(_backgroundImage!);
// calculate image rectangle in the current screen, given it's boxfit contain
final screenSize = MediaQuery.sizeOf(context);
final imageAspectRatio = decodedImage.width / decodedImage.height;
@@ -115,7 +115,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
getTemporaryDirectory().then((tempDir) async {
final tempImage = File('${tempDir.path}/${actionHandler.supportedApp?.name ?? 'temp'}_screenshot.png');
if (tempImage.existsSync()) {
_backgroundImage = tempImage;
_backgroundImage = tempImage.readAsBytesSync();
setState(() {});
// wait a bit until device rotation is done
@@ -198,41 +198,50 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
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: AnimatedOpacity(
opacity: _showFaded && widget.keyPair != keyPair ? 0.2 : 1.0,
duration: Duration(milliseconds: 300),
child: Draggable(
dragAnchorStrategy: (widget, context, position) {
final scale = _transformationController.value.getMaxScaleOnAxis();
final RenderBox renderObject = context.findRenderObject() as RenderBox;
return renderObject.globalToLocal(position).scale(scale, scale);
},
feedback: Material(
color: Colors.transparent,
child: icon,
),
childWhenDragging: const SizedBox.shrink(),
onDragStarted: () {
// Capture the starting position to calculate drag distance later
dragStartPosition = position;
if (keyPair != widget.keyPair && _showFaded) {
setState(() {
_showFaded = false;
});
}
},
onDragEnd: (details) {
// Calculate drag distance to prevent accidental repositioning from clicks
// while allowing legitimate drags even with low velocity (e.g., when overlapping buttons)
final dragDistance = dragStartPosition != null
? (details.offset - dragStartPosition!).distance
: double.infinity;
// Only update position if dragged more than 5 pixels (prevents accidental clicks)
if (dragDistance > 5) {
final matrix = Matrix4.inverted(_transformationController.value);
final height = 0;
final sceneY = details.offset.dy - height;
final viewportPoint = MatrixUtils.transformPoint(
matrix,
Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2),
);
setState(() => onPositionChanged(viewportPoint));
}
},
child: icon,
),
childWhenDragging: const SizedBox.shrink(),
onDragStarted: () {
// Capture the starting position to calculate drag distance later
dragStartPosition = position;
},
onDragEnd: (details) {
// Calculate drag distance to prevent accidental repositioning from clicks
// while allowing legitimate drags even with low velocity (e.g., when overlapping buttons)
final dragDistance = dragStartPosition != null
? (details.offset - dragStartPosition!).distance
: double.infinity;
// Only update position if dragged more than 5 pixels (prevents accidental clicks)
if (dragDistance > 5) {
final matrix = Matrix4.inverted(_transformationController.value);
final height = 0;
final sceneY = details.offset.dy - height;
final viewportPoint = MatrixUtils.transformPoint(
matrix,
Offset(details.offset.dx, sceneY) + Offset(iconSize / 2, differenceInHeight + iconSize / 2),
);
setState(() => onPositionChanged(viewportPoint));
}
},
child: icon,
),
),
);
@@ -246,12 +255,11 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
if (_backgroundImage == null && constraints.biggest != _imageRect.size) {
_imageRect = Rect.fromLTWH(0, 0, constraints.maxWidth, constraints.maxHeight);
}
final keyPairsToShow = _showAll
? actionHandler.supportedApp?.keymap.keyPairs
.where((kp) => kp.touchPosition != Offset.zero && !kp.isSpecialKey)
.toList() ??
[]
: [widget.keyPair];
final keyPairsToShow =
actionHandler.supportedApp?.keymap.keyPairs
.where((kp) => kp.touchPosition != Offset.zero && !kp.isSpecialKey)
.toList() ??
[];
return InteractiveViewer(
transformationController: _transformationController,
child: Stack(
@@ -260,7 +268,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
Positioned.fill(
child: Opacity(
opacity: 0.5,
child: Image.file(
child: Image.memory(
_backgroundImage!,
fit: BoxFit.contain,
),
@@ -339,23 +347,9 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
PopupMenuButton(
itemBuilder: (c) => [
PopupMenuItem(
child: Row(
children: [
Checkbox(
value: _showAll,
onChanged: (_) {
setState(() {
_showAll = !_showAll;
});
},
),
Text('Show all touch areas'),
],
),
child: Text('Choose another screenshot'),
onTap: () {
setState(() {
_showAll = !_showAll;
});
_pickScreenshot();
},
),
PopupMenuItem(
@@ -401,7 +395,9 @@ class KeypairExplanation extends StatelessWidget {
)
else
Icon(keyPair.icon),
if (keyPair.inGameAction != null && (whooshLink.isConnected.value || zwiftEmulator.isConnected.value))
if (keyPair.inGameAction != null &&
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())))
_KeyWidget(
label: [
keyPair.inGameAction.toString().split('.').last,
@@ -422,7 +418,10 @@ class KeypairExplanation extends StatelessWidget {
)
else if (keyPair.physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard)) ...[
_KeyWidget(
label: keyPair.logicalKey?.keyLabel ?? 'Unknown',
label: [
...keyPair.modifiers.map((e) => e.name.replaceAll('Modifier', '')),
keyPair.logicalKey?.keyLabel ?? 'Unknown',
].joinToString(separator: '+'),
),
if (keyPair.isLongPress) Text('long\npress', style: TextStyle(fontSize: 10)),
] else ...[

View File

@@ -30,6 +30,9 @@ abstract final class AppTheme {
FlexThemeData.dark(
// Using FlexColorScheme built-in FlexScheme enum based colors.
scheme: FlexScheme.redM3,
primary: Color(0xFF0E74B7),
primaryContainer: Color(0x7C0E9297),
onPrimaryContainer: Colors.white,
// Component theme configurations for dark mode.
subThemesData: const FlexSubThemesData(
interactionEffects: true,
@@ -45,9 +48,11 @@ abstract final class AppTheme {
visualDensity: FlexColorScheme.comfortablePlatformDensity,
cupertinoOverrideTheme: const CupertinoThemeData(applyThemeToAll: true),
).copyWith(
scaffoldBackgroundColor: Color(0xff0b1623),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
appBarTheme: AppBarTheme(
backgroundColor: Color(0xFF0E74B7),
),
cardTheme: CardThemeData(
color: Colors.white24,
),
);
}

View File

@@ -1,8 +1,11 @@
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/bluetooth/devices/hid/hid_device.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/base_actions.dart';
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
@@ -12,6 +15,8 @@ import '../single_line_exception.dart';
class AndroidActions extends BaseActions {
WindowEvent? windowInfo;
final accessibilityHandler = Accessibility();
AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]});
@override
@@ -22,6 +27,21 @@ class AndroidActions extends BaseActions {
windowInfo = windowEvent;
}
});
hidKeyPressed().listen((keyPressed) {
if (supportedApp is CustomApp) {
final button = supportedApp.keymap.getOrAddButton(keyPressed, () => ControllerButton(keyPressed));
final hidDevice = HidDevice('HID Device');
var availableDevice = connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
if (availableDevice == null) {
connection.addDevices([hidDevice]);
availableDevice = hidDevice;
}
availableDevice.handleButtonsClicked([button]);
availableDevice.handleButtonsClicked([]);
}
});
}
@override
@@ -66,4 +86,8 @@ class AndroidActions extends BaseActions {
}
return "No action assigned";
}
void ignoreHidDevices() {
accessibilityHandler.ignoreHidDevices();
}
}

View File

@@ -99,8 +99,11 @@ abstract class BaseActions {
class StubActions extends BaseActions {
StubActions({super.supportedModes = const []});
final List<ControllerButton> performedActions = [];
@override
Future<String> performAction(ControllerButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
performedActions.add(action);
return Future.value(action.name);
}
}

View File

@@ -30,14 +30,14 @@ class DesktopActions extends BaseActions {
return zwiftEmulator.sendAction(keyPair.inGameAction!, keyPair.inGameActionValue);
} else if (keyPair.physicalKey != null) {
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
return 'Key clicked: $keyPair';
} else if (isKeyDown) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
return 'Key pressed: $keyPair';
} else {
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
return 'Key released: $keyPair';
}
} else {

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
@@ -14,15 +15,17 @@ class CustomApp extends SupportedApp {
CustomApp({this.profileName = 'Other'})
: super(
name: profileName,
compatibleTargets: [
if (!Platform.isIOS) Target.thisDevice,
Target.macOS,
Target.windows,
Target.iOS,
Target.android,
],
compatibleTargets: kIsWeb
? [Target.thisDevice]
: [
if (!Platform.isIOS) Target.thisDevice,
Target.macOS,
Target.windows,
Target.iOS,
Target.android,
],
packageName: "custom_$profileName",
supportsZwiftEmulation: !(Platform.isIOS || Platform.isMacOS),
supportsZwiftEmulation: !kIsWeb && !(Platform.isIOS || Platform.isMacOS),
keymap: Keymap(keyPairs: []),
);
@@ -49,24 +52,33 @@ class CustomApp extends SupportedApp {
ControllerButton zwiftButton, {
required PhysicalKeyboardKey? physicalKey,
required LogicalKeyboardKey? logicalKey,
List<ModifierKey> modifiers = const [],
bool isLongPress = false,
Offset? touchPosition,
InGameAction? inGameAction,
int? inGameActionValue,
}) {
// set the key for the zwift button
final keyPair = keymap.getKeyPair(zwiftButton);
if (keyPair != null) {
keyPair.physicalKey = physicalKey;
keyPair.logicalKey = logicalKey;
keyPair.modifiers = modifiers;
keyPair.isLongPress = isLongPress;
keyPair.touchPosition = touchPosition ?? Offset.zero;
keyPair.inGameAction = inGameAction;
keyPair.inGameActionValue = inGameActionValue;
} else {
keymap.addKeyPair(
KeyPair(
buttons: [zwiftButton],
physicalKey: physicalKey,
logicalKey: logicalKey,
modifiers: modifiers,
isLongPress: isLongPress,
touchPosition: touchPosition ?? Offset.zero,
inGameAction: inGameAction,
inGameActionValue: inGameActionValue,
),
);
}

View File

@@ -1,4 +1,6 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/bluetooth/devices/zwift/constants.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/requirements/multi.dart';
@@ -14,17 +16,25 @@ class Rouvy extends SupportedApp {
supportsZwiftEmulation: true,
keymap: Keymap(
keyPairs: [
// https://support.rouvy.com/hc/de/articles/32452137189393-Virtuelles-Schalten#h_01K5GMVG4KVYZ0Y6W7RBRZC9MA
KeyPair(
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
inGameAction: InGameAction.shiftDown,
physicalKey: null,
logicalKey: null,
physicalKey: PhysicalKeyboardKey.numpadSubtract,
logicalKey: LogicalKeyboardKey.numpadSubtract,
),
KeyPair(
buttons: ControllerButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
inGameAction: InGameAction.shiftUp,
physicalKey: null,
logicalKey: null,
physicalKey: PhysicalKeyboardKey.numpadAdd,
logicalKey: LogicalKeyboardKey.numpadAdd,
),
// like escape
KeyPair(
buttons: [ZwiftButtons.b],
physicalKey: PhysicalKeyboardKey.keyB,
logicalKey: LogicalKeyboardKey.keyB,
inGameAction: InGameAction.back,
),
],
),

View File

@@ -1,5 +1,6 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/devices/cycplus/cycplus_bc2.dart';
import 'package:swift_control/bluetooth/devices/elite/elite_square.dart';
import 'package:swift_control/bluetooth/devices/elite/elite_sterzo.dart';
import 'package:swift_control/bluetooth/devices/wahoo/wahoo_kickr_bike_shift.dart';
@@ -75,5 +76,6 @@ class ControllerButton {
...ZwiftButtons.values,
...EliteSquareButtons.values,
...WahooKickrShiftButtons.values,
...CycplusBc2Buttons.values,
].distinct().toList();
}

View File

@@ -5,6 +5,7 @@ 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/apps/my_whoosh.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../actions/base_actions.dart';
@@ -40,7 +41,14 @@ class Keymap {
}
void reset() {
keyPairs = [];
for (final keyPair in keyPairs) {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
keyPair.touchPosition = Offset.zero;
keyPair.isLongPress = false;
keyPair.inGameAction = null;
keyPair.inGameActionValue = null;
}
_updateStream.add(null);
}
@@ -52,12 +60,32 @@ class Keymap {
settings.setKeyMap(actionHandler.supportedApp!);
}
}
ControllerButton getOrAddButton(String name, ControllerButton Function() button) {
final allButtons = keyPairs.expand((kp) => kp.buttons).toSet().toList();
if (allButtons.none((b) => b.name == name)) {
final newButton = button();
addKeyPair(
KeyPair(
touchPosition: Offset.zero,
buttons: [newButton],
physicalKey: null,
logicalKey: null,
isLongPress: false,
),
);
return newButton;
} else {
return allButtons.firstWhere((b) => b.name == name);
}
}
}
class KeyPair {
final List<ControllerButton> buttons;
PhysicalKeyboardKey? physicalKey;
LogicalKeyboardKey? logicalKey;
List<ModifierKey> modifiers;
Offset touchPosition;
bool isLongPress;
InGameAction? inGameAction;
@@ -67,6 +95,7 @@ class KeyPair {
required this.buttons,
required this.physicalKey,
required this.logicalKey,
this.modifiers = const [],
this.touchPosition = Offset.zero,
this.isLongPress = false,
this.inGameAction,
@@ -90,14 +119,18 @@ class KeyPair {
PhysicalKeyboardKey.audioVolumeUp ||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
_ when physicalKey != null && actionHandler.supportedModes.contains(SupportedMode.keyboard) => Icons.keyboard,
_ when inGameAction != null && whooshLink.isConnected.value => Icons.link,
_
when inGameAction != null &&
((settings.getTrainerApp() is MyWhoosh && settings.getMyWhooshLinkEnabled()) ||
(settings.getTrainerApp()?.supportsZwiftEmulation == true && settings.getZwiftEmulatorEnabled())) =>
Icons.link,
_ => Icons.touch_app,
};
}
@override
String toString() {
return logicalKey?.keyLabel ??
final baseKey = logicalKey?.keyLabel ??
switch (physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
PhysicalKeyboardKey.mediaTrackNext => 'Next Track',
@@ -107,6 +140,24 @@ class KeyPair {
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
_ => 'Not assigned',
};
if (modifiers.isEmpty || baseKey == 'Not assigned') {
return baseKey;
}
// Format modifiers + key (e.g., "Ctrl+Alt+R")
final modifierStrings = modifiers.map((m) {
return switch (m) {
ModifierKey.shiftModifier => 'Shift',
ModifierKey.controlModifier => 'Ctrl',
ModifierKey.altModifier => 'Alt',
ModifierKey.metaModifier => 'Meta',
ModifierKey.functionModifier => 'Fn',
_ => m.name,
};
}).toList();
return '${modifierStrings.join('+')}+$baseKey';
}
String encode() {
@@ -116,6 +167,7 @@ class KeyPair {
'actions': buttons.map((e) => e.name).toList(),
if (logicalKey != null) 'logicalKey': logicalKey?.keyId.toString(),
if (physicalKey != null) 'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
if (modifiers.isNotEmpty) 'modifiers': modifiers.map((e) => e.name).toList(),
if (touchPosition != Offset.zero) 'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
'isLongPress': isLongPress,
'inGameAction': inGameAction?.name,
@@ -144,6 +196,15 @@ class KeyPair {
if (buttons.isEmpty) {
return null;
}
// Decode modifiers if present
final List<ModifierKey> modifiers = decoded.containsKey('modifiers')
? (decoded['modifiers'] as List)
.map<ModifierKey?>((e) => ModifierKey.values.firstOrNullWhere((element) => element.name == e))
.whereType<ModifierKey>()
.toList()
: [];
return KeyPair(
buttons: buttons,
logicalKey: decoded.containsKey('logicalKey') && int.parse(decoded['logicalKey']) != 0
@@ -152,6 +213,7 @@ class KeyPair {
physicalKey: decoded.containsKey('physicalKey') && int.parse(decoded['physicalKey']) != 0
? PhysicalKeyboardKey(int.parse(decoded['physicalKey']))
: null,
modifiers: modifiers,
touchPosition: touchPosition,
isLongPress: decoded['isLongPress'] ?? false,
inGameAction: decoded.containsKey('inGameAction')

View File

@@ -233,8 +233,8 @@ class KeymapManager {
);
}
Future<String?> duplicate(BuildContext context, String currentProfile) async {
final newName = await _showDuplicateProfileDialog(context, currentProfile);
Future<String?> duplicate(BuildContext context, String currentProfile, {String? skipName}) async {
final newName = skipName ?? await _showDuplicateProfileDialog(context, currentProfile);
if (newName != null && newName.isNotEmpty) {
if (actionHandler.supportedApp is CustomApp) {
await settings.duplicateCustomAppProfile(currentProfile, newName);
@@ -261,6 +261,8 @@ class KeymapManager {
logicalKey: pair.logicalKey,
isLongPress: pair.isLongPress,
touchPosition: pair.touchPosition,
inGameAction: pair.inGameAction,
inGameActionValue: pair.inGameActionValue,
);
});
});

View File

@@ -1,11 +1,14 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
import 'package:universal_ble/universal_ble.dart';
class AccessibilityRequirement extends PlatformRequirement {
AccessibilityRequirement()
@@ -21,7 +24,7 @@ class AccessibilityRequirement extends PlatformRequirement {
@override
Future<void> getStatus() async {
status = await accessibilityHandler.hasPermission();
status = await (actionHandler as AndroidActions).accessibilityHandler.hasPermission();
}
Future<void> _showDisclosureDialog(BuildContext context, VoidCallback onUpdate) async {
@@ -33,7 +36,7 @@ class AccessibilityRequirement extends PlatformRequirement {
onAccept: () {
Navigator.of(context).pop();
// Open accessibility settings after user consents
accessibilityHandler.openPermissions().then((_) {
(actionHandler as AndroidActions).accessibilityHandler.openPermissions().then((_) {
onUpdate();
});
},
@@ -152,20 +155,37 @@ class NotificationRequirement extends PlatformRequirement {
channelGroupId,
'Allows SwiftControl to keep running in background',
foregroundServiceTypes: {AndroidServiceForegroundType.foregroundServiceTypeConnectedDevice},
startType: AndroidServiceStartType.startRedeliverIntent,
notificationDetails: AndroidNotificationDetails(
channelGroupId,
'Keep Alive',
actions: [AndroidNotificationAction('Exit', 'Exit', cancelNotification: true, showsUserInterface: false)],
actions: [
AndroidNotificationAction(
'Disconnect Devices',
'Disconnect Devices',
cancelNotification: true,
showsUserInterface: false,
),
],
),
);
final receivePort = ReceivePort();
IsolateNameServer.registerPortWithName(receivePort.sendPort, '_backgroundChannelKey');
final backgroundMessagePort = receivePort.asBroadcastStream();
backgroundMessagePort.listen((_) {
UniversalBle.onAvailabilityChange = null;
connection.reset();
//exit(0);
});
}
}
@pragma('vm:entry-point')
void notificationTapBackground(NotificationResponse notificationResponse) {
if (notificationResponse.actionId != null) {
AndroidFlutterLocalNotificationsPlugin().stopForegroundService().then((_) {
exit(0);
});
final sendPort = IsolateNameServer.lookupPortByName('_backgroundChannelKey');
sendPort?.send('notificationResponse');
//exit(0);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
@@ -83,6 +84,20 @@ class UnsupportedPlatform extends PlatformRequirement {
Future<void> getStatus() async {}
}
class ErrorRequirement extends PlatformRequirement {
ErrorRequirement(super.name) {
status = false;
}
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
onUpdate();
}
@override
Future<void> getStatus() async {}
}
typedef BoolFunction = bool Function();
enum Target {
@@ -90,6 +105,10 @@ enum Target {
title: 'This Device',
icon: Icons.devices,
),
otherDevice(
title: 'Other Device',
icon: Icons.settings_remote_outlined,
),
iOS(
title: 'iPhone / iPad / Apple TV',
icon: Icons.settings_remote_outlined,
@@ -136,13 +155,15 @@ enum Target {
'Due to platform restrictions only controlling ${app?.name ?? 'the Trainer app'} on other devices is supported.',
Target.thisDevice => 'Run ${app?.name ?? 'the Trainer app'} on this device.',
Target.iOS =>
'Run ${app?.name ?? 'the Trainer app'} on your Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
'Run ${app?.name ?? 'the Trainer app'} on an Apple device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.android =>
'Run ${app?.name ?? 'the Trainer app'} on your Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
'Run ${app?.name ?? 'the Trainer app'} on an Android device and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.macOS =>
'Run ${app?.name ?? 'the Trainer app'} on your Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
'Run ${app?.name ?? 'the Trainer app'} on a Mac and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.windows =>
'Run ${app?.name ?? 'the Trainer app'} on your Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Link method' : ''}.',
'Run ${app?.name ?? 'the Trainer app'} on a Windows PC and control it remotely from this device${app is MyWhoosh ? ', e.g. by using MyWhoosh Direct Connect' : ''}.',
Target.otherDevice =>
'Run ${app?.name ?? 'the Trainer app'} on another device and control it remotely from this device.',
};
}
@@ -158,9 +179,9 @@ enum Target {
"Select 'This device' unless you want to control another macOS device. Are you sure?",
Target.windows when Platform.isWindows =>
"Select 'This device' unless you want to control another Windows device. Are you sure?",
Target.android => "Download and use SwiftControl on that Android device.",
Target.macOS => "Download and use SwiftControl on that macOS device.",
Target.windows => "Download and use SwiftControl on that Windows device.",
Target.android => "We highly recommended to download and use SwiftControl on that Android device.",
Target.macOS => "We highly recommended to download and use SwiftControl on that macOS device.",
Target.windows => "We highly recommended to download and use SwiftControl on that Windows device.",
_ => null,
};
}
@@ -199,106 +220,167 @@ class TargetRequirement extends PlatformRequirement {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Select Trainer App', style: TextStyle(fontWeight: FontWeight.bold)),
DropdownMenu<SupportedApp>(
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
return DropdownMenuEntry(
value: app,
label: app.name,
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(app.name),
Row(
children: [
Expanded(
child: Text(
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
style: TextStyle(fontSize: 12, color: Colors.grey),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
child: DropdownMenu<SupportedApp>(
dropdownMenuEntries: SupportedApp.supportedApps.map((app) {
return DropdownMenuEntry(
value: app,
label: app.name,
labelWidget: app is Zwift && !(Platform.isWindows || Platform.isAndroid)
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(app.name),
Row(
children: [
Expanded(
child: Text(
'When running SwiftControl on Apple devices you are limited to on-screen controls (so no virtual shifting) only due to platform restrictions :(',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
),
Icon(Icons.warning_amber),
],
),
],
)
: null,
);
}).toList(),
hintText: 'Select Trainer app',
initialSelection: settings.getTrainerApp(),
onSelected: (selectedApp) async {
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
whooshLink.stopServer();
}
settings.setTrainerApp(selectedApp!);
if (actionHandler.supportedApp == null ||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
actionHandler.init(selectedApp);
settings.setKeyMap(selectedApp);
}
setState(() {});
},
Icon(Icons.warning_amber),
],
),
],
)
: null,
);
}).toList(),
hintText: 'Select Trainer app',
initialSelection: settings.getTrainerApp(),
onSelected: (selectedApp) async {
if (settings.getTrainerApp() is MyWhoosh && selectedApp is! MyWhoosh && whooshLink.isStarted.value) {
whooshLink.stopServer();
}
settings.setTrainerApp(selectedApp!);
if (settings.getLastTarget() == null && Target.thisDevice.isCompatible) {
await settings.setLastTarget(Target.thisDevice);
}
if (actionHandler.supportedApp == null ||
(actionHandler.supportedApp is! CustomApp && selectedApp is! CustomApp)) {
actionHandler.init(selectedApp);
settings.setKeyMap(selectedApp);
}
setState(() {});
},
),
),
SizedBox(height: 8),
Text(
'Select Target where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
style: TextStyle(fontWeight: FontWeight.bold),
),
DropdownMenu<Target>(
dropdownMenuEntries: Target.values.map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
trailingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
if (target.isBeta) BetaPill(),
],
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
child: DropdownMenu<Target>(
dropdownMenuEntries: [Target.thisDevice, Target.otherDevice].map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
leadingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(target.title),
Text(
target.getDescription(settings.getTrainerApp()),
style: TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
),
);
}).toList(),
hintText: 'Select Target device',
initialSelection: settings.getLastTarget() != Target.thisDevice ? Target.otherDevice : Target.thisDevice,
enabled: settings.getTrainerApp() != null,
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
Text(
target.getDescription(settings.getTrainerApp()),
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (target == Target.thisDevice)
Container(
margin: EdgeInsets.only(top: 12),
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(4),
);
}
setState(() {});
}
},
),
),
if (settings.getLastTarget() != null && settings.getLastTarget() != Target.thisDevice) ...[
SizedBox(height: 8),
Text(
'Select the other device where ${settings.getTrainerApp()?.name ?? 'the Trainer app'} runs on',
style: TextStyle(fontWeight: FontWeight.bold),
),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 400),
child: DropdownMenu<Target>(
dropdownMenuEntries: Target.values
.whereNot((e) => [Target.thisDevice, Target.otherDevice].contains(e))
.map((target) {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
leadingIcon: Icon(target.icon),
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
target.title,
style: TextStyle(
fontWeight: target == Target.thisDevice && target.isCompatible
? FontWeight.bold
: null,
),
),
if (target.isBeta) BetaPill(),
],
),
Text(
target.getDescription(settings.getTrainerApp()),
style: TextStyle(fontSize: 10, color: Colors.grey),
),
],
),
),
],
),
),
);
}).toList(),
hintText: 'Select Target device',
initialSelection: settings.getLastTarget(),
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
initializeActions(target.connectionType);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
);
}
setState(() {});
}
},
),
);
})
.toList(),
hintText: 'Select Target device',
initialSelection: settings.getLastTarget(),
enabled: settings.getTrainerApp() != null,
onSelected: (target) async {
if (target != null) {
await settings.setLastTarget(target);
initializeActions(target.connectionType);
if (target.warning != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(target.warning!),
duration: Duration(seconds: 10),
),
);
}
setState(() {});
}
},
),
),
],
SizedBox(height: 8),
ElevatedButton(
onPressed: settings.getTrainerApp() != null && settings.getLastTarget() != null
? () {

View File

@@ -10,6 +10,7 @@ import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import '../../pages/markdown.dart';
@@ -31,16 +32,7 @@ class RemoteRequirement extends PlatformRequirement {
@override
Widget? buildDescription() {
return settings.getLastTarget() == null
? null
: Text(
switch (settings.getLastTarget()) {
Target.iOS =>
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
_ =>
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required if you want to use the remote control feature.',
},
);
return Text('Choose your preferred connection method');
}
Future<void> reconnect() async {
@@ -311,24 +303,41 @@ class _PairWidgetState extends State<_PairWidget> {
@override
Widget build(BuildContext context) {
return Column(
spacing: 10,
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 10,
children: [
ElevatedButton(
onPressed: () async {
await toggle();
},
child: Text(
_isAdvertising ? 'Stop Pairing' : 'Start Pairing',
if (settings.getTrainerApp() is MyWhoosh)
ElevatedButton(
onPressed: () async {
settings.setMyWhooshLinkEnabled(true);
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => DevicePage(),
settings: RouteSettings(name: '/device'),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Connect via MyWhoosh Direct Connect'),
Text(
'Most reliable way to control MyWhoosh.',
style: TextStyle(
fontSize: 12,
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
fontWeight: FontWeight.normal,
),
),
],
),
),
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
],
),
if (settings.getTrainerApp() is MyWhoosh)
),
if (settings.getTrainerApp()?.supportsZwiftEmulation == true)
ElevatedButton(
onPressed: () async {
Navigator.push(
@@ -344,33 +353,64 @@ class _PairWidgetState extends State<_PairWidget> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Use MyWhoosh Link only'),
Text('Connect to ${settings.getTrainerApp()?.name} as controller'),
Text(
'No pairing required, connect directly via MyWhoosh Link.',
style: TextStyle(fontSize: 10, color: Colors.black87),
'Most reliable way to control ${settings.getTrainerApp()?.name}.',
style: TextStyle(
fontSize: 12,
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
fontWeight: FontWeight.normal,
),
),
],
),
),
),
if (actionHandler.supportedApp?.supportsZwiftEmulation == true) ...[
Text(
'You can also skip pairing and directly connect to ${settings.getTrainerApp()?.name} by enabling the Zwift Controller.',
style: TextStyle(fontSize: 12),
),
ElevatedButton(
onPressed: () async {
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => DevicePage(),
settings: RouteSettings(name: '/device'),
Row(
spacing: 10,
children: [
ElevatedButton(
onPressed: () async {
await toggle();
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_isAdvertising ? 'Stop Pairing process' : 'Start Pairing',
),
Text(
'Pairing allows full customizability,\nbut may not work on all devices.',
style: TextStyle(
fontSize: 12,
color: Theme.brightnessOf(context) == Brightness.dark ? Colors.white70 : Colors.black87,
fontWeight: FontWeight.normal,
),
),
],
),
BetaPill(),
],
),
);
),
),
if (_isAdvertising || _isLoading) SizedBox(height: 20, width: 20, child: SmallProgressIndicator()),
],
),
if (_isAdvertising)
Text(
switch (settings.getLastTarget()) {
Target.iOS =>
'On your iPad go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.',
_ =>
'On your ${settings.getLastTarget()?.title} go into Bluetooth settings and look for SwiftControl or your machines name. Pairing is required if you want to use the remote control feature.',
},
child: Text('Connect to ${settings.getTrainerApp()?.name} directly as controller'),
),
],
if (_isAdvertising) ...[
TextButton(
onPressed: () {

View File

@@ -1,7 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider_windows/path_provider_windows.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_windows/shared_preferences_windows.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:window_manager/window_manager.dart';
@@ -13,22 +17,40 @@ import '../keymap/apps/custom_app.dart';
class Settings {
late final SharedPreferences prefs;
Future<void> init() async {
prefs = await SharedPreferences.getInstance();
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
}
Future<String?> init({bool retried = false}) async {
try {
prefs = await SharedPreferences.getInstance();
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
if (actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
}
final app = getKeyMap();
actionHandler.init(app);
} catch (e) {
// couldn't decode, reset
await prefs.clear();
rethrow;
return null;
} catch (e, s) {
if (!retried) {
if (Platform.isWindows) {
// delete settings file
final fs = SharedPreferencesWindows.instance.fs;
final pathProvider = PathProviderWindows();
final String? directory = await pathProvider.getApplicationSupportPath();
if (directory == null) {
return null;
}
final String fileLocation = path.join(directory, 'shared_preferences.json');
final file = fs.file(fileLocation);
if (await file.exists()) {
await file.delete();
}
}
return init(retried: true);
} else {
return '$e\n$s';
}
}
}
@@ -144,6 +166,7 @@ class Settings {
Future<void> setLastTarget(Target target) async {
await prefs.setString('last_target', target.name);
initializeActions(target.connectionType);
}
Future<void> setLastSeenVersion(String version) async {
@@ -173,4 +196,56 @@ class Settings {
Future<void> setZwiftEmulatorEnabled(bool enabled) async {
await prefs.setBool('zwift_emulator_enabled', enabled);
}
bool getMiuiWarningDismissed() {
return prefs.getBool('miui_warning_dismissed') ?? false;
}
Future<void> setMiuiWarningDismissed(bool dismissed) async {
await prefs.setBool('miui_warning_dismissed', dismissed);
}
List<String> _getIgnoredDeviceIds() {
return prefs.getStringList('ignored_device_ids') ?? [];
}
List<String> _getIgnoredDeviceNames() {
return prefs.getStringList('ignored_device_names') ?? [];
}
Future<void> addIgnoredDevice(String deviceId, String deviceName) async {
final ids = _getIgnoredDeviceIds();
final names = _getIgnoredDeviceNames();
if (!ids.contains(deviceId)) {
ids.add(deviceId);
names.add(deviceName);
await prefs.setStringList('ignored_device_ids', ids);
await prefs.setStringList('ignored_device_names', names);
}
}
Future<void> removeIgnoredDevice(String deviceId) async {
final ids = _getIgnoredDeviceIds();
final names = _getIgnoredDeviceNames();
final index = ids.indexOf(deviceId);
if (index != -1) {
ids.removeAt(index);
names.removeAt(index);
await prefs.setStringList('ignored_device_ids', ids);
await prefs.setStringList('ignored_device_names', names);
}
}
List<({String id, String name})> getIgnoredDevices() {
final ids = _getIgnoredDeviceIds();
final names = _getIgnoredDeviceNames();
final result = <({String id, String name})>[];
for (int i = 0; i < ids.length && i < names.length; i++) {
result.add((id: ids[i], name: names[i]));
}
return result;
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../small_progress_indicator.dart';
class MyWhooshLinkTile extends StatefulWidget {
const MyWhooshLinkTile({super.key});
@override
State<MyWhooshLinkTile> createState() => _MywhooshLinkTileState();
}
class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: whooshLink.isStarted,
builder: (context, isStarted, _) {
return ValueListenableBuilder(
valueListenable: whooshLink.isConnected,
builder: (context, isConnected, _) {
return StatefulBuilder(
builder: (context, setState) {
final myWhooshExplanation = actionHandler is RemoteActions
? 'MyWhoosh Direct Connect allows you to do some additional features such as Emotes and turn directions.'
: 'MyWhoosh Direct Connect is optional, but allows you to do some additional features such as Emotes and turn directions.';
return Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: isStarted,
onChanged: (value) {
settings.setMyWhooshLinkEnabled(value);
if (!value) {
whooshLink.stopServer();
} else if (value) {
connection.startMyWhooshServer().catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Error starting MyWhoosh Direct Connect server. Please make sure the "MyWhoosh Link" app is not already running on this device.',
),
),
);
});
}
setState(() {});
},
title: Text('Enable MyWhoosh Direct Connect'),
subtitle: Row(
spacing: 12,
children: [
if (!isStarted)
Expanded(
child: Text(
myWhooshExplanation,
style: TextStyle(fontSize: 12),
),
)
else ...[
Expanded(
child: Text(
isConnected ? "Connected" : "Connecting to MyWhoosh...\n$myWhooshExplanation",
style: TextStyle(fontSize: 12),
),
),
if (isStarted) SmallProgressIndicator(),
],
],
),
),
),
IconButton(
onPressed: () {
launchUrlString('https://www.youtube.com/watch?v=p8sgQhuufeI');
},
icon: Icon(Icons.help_outline),
),
],
);
},
);
},
);
},
);
}
}

View File

@@ -1,31 +1,22 @@
import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter/material.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_emulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/rouvy.dart';
import 'package:swift_control/utils/keymap/apps/zwift.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
class ZwiftRequirement extends PlatformRequirement {
ZwiftRequirement()
: super(
'Pair SwiftControl with Zwift',
);
class ZwiftTile extends StatefulWidget {
final VoidCallback onUpdate;
const ZwiftTile({super.key, required this.onUpdate});
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
State<ZwiftTile> createState() => _ZwiftTileState();
}
class _ZwiftTileState extends State<ZwiftTile> {
@override
Widget? buildDescription() {
return settings.getLastTarget() == null
? null
: Text(
'In Zwift on your ${settings.getLastTarget()?.title} go into the Pairing settings and select SwiftControl from the list of available controllers.',
);
}
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: zwiftEmulator.isConnected,
builder: (context, isConnected, _) {
@@ -39,7 +30,7 @@ class ZwiftRequirement extends PlatformRequirement {
if (!value) {
zwiftEmulator.stopAdvertising();
} else if (value) {
zwiftEmulator.startAdvertising(onUpdate);
zwiftEmulator.startAdvertising(widget.onUpdate);
}
setState(() {});
},
@@ -75,9 +66,4 @@ class ZwiftRequirement extends PlatformRequirement {
},
);
}
@override
Future<void> getStatus() async {
status = zwiftEmulator.isConnected.value || screenshotMode;
}
}

View File

@@ -30,7 +30,12 @@ class ChangelogDialog extends StatelessWidget {
constraints: BoxConstraints(minWidth: 460),
child: MarkdownWidget(markdown: latestVersion),
),
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Got it!'))],
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Got it!'),
),
],
);
}

View File

@@ -25,6 +25,7 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
final FocusNode _focusNode = FocusNode();
KeyDownEvent? _pressedKey;
ControllerButton? _pressedButton;
final Set<ModifierKey> _activeModifiers = {};
@override
void initState() {
@@ -52,20 +53,85 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
void _onKey(KeyEvent event) {
setState(() {
// Track modifier keys
if (event is KeyDownEvent) {
_pressedKey = event;
widget.customApp.setKey(
_pressedButton!,
physicalKey: _pressedKey!.physicalKey,
logicalKey: _pressedKey!.logicalKey,
touchPosition: widget.keyPair?.touchPosition,
);
final wasModifier = _updateModifierState(event.logicalKey, add: true);
// Regular key pressed - record it along with active modifiers
if (!wasModifier) {
if (_pressedKey?.logicalKey != event.logicalKey) {}
_pressedKey = event;
widget.customApp.setKey(
_pressedButton!,
physicalKey: _pressedKey!.physicalKey,
logicalKey: _pressedKey!.logicalKey,
modifiers: _activeModifiers.toList(),
touchPosition: widget.keyPair?.touchPosition,
);
}
} else if (event is KeyUpEvent) {
// Clear modifier when released
_updateModifierState(event.logicalKey, add: false);
}
});
}
bool _updateModifierState(LogicalKeyboardKey key, {required bool add}) {
ModifierKey? modifier;
if (key == LogicalKeyboardKey.shift ||
key == LogicalKeyboardKey.shiftLeft ||
key == LogicalKeyboardKey.shiftRight) {
modifier = ModifierKey.shiftModifier;
} else if (key == LogicalKeyboardKey.control ||
key == LogicalKeyboardKey.controlLeft ||
key == LogicalKeyboardKey.controlRight) {
modifier = ModifierKey.controlModifier;
} else if (key == LogicalKeyboardKey.alt ||
key == LogicalKeyboardKey.altLeft ||
key == LogicalKeyboardKey.altRight) {
modifier = ModifierKey.altModifier;
} else if (key == LogicalKeyboardKey.meta ||
key == LogicalKeyboardKey.metaLeft ||
key == LogicalKeyboardKey.metaRight) {
modifier = ModifierKey.metaModifier;
} else if (key == LogicalKeyboardKey.fn) {
modifier = ModifierKey.functionModifier;
}
if (modifier != null) {
if (add) {
_activeModifiers.add(modifier);
} else {
_activeModifiers.remove(modifier);
}
return true;
}
return false;
}
String _formatModifierName(ModifierKey m) {
return switch (m) {
ModifierKey.shiftModifier => 'Shift',
ModifierKey.controlModifier => 'Ctrl',
ModifierKey.altModifier => 'Alt',
ModifierKey.metaModifier => 'Meta',
ModifierKey.functionModifier => 'Fn',
_ => m.name,
};
}
String _formatKey(KeyDownEvent? key) {
return key?.logicalKey.keyLabel ?? 'Waiting...';
if (key == null) {
return _activeModifiers.isEmpty ? 'Waiting...' : '${_activeModifiers.map(_formatModifierName).join('+')}+...';
}
if (_activeModifiers.isEmpty) {
return key.logicalKey.keyLabel;
}
final modifierStrings = _activeModifiers.map(_formatModifierName);
return '${modifierStrings.join('+')}+${key.logicalKey.keyLabel}';
}
@override

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
class IgnoredDevicesDialog extends StatefulWidget {
const IgnoredDevicesDialog({super.key});
@override
State<IgnoredDevicesDialog> createState() => _IgnoredDevicesDialogState();
}
class _IgnoredDevicesDialogState extends State<IgnoredDevicesDialog> {
List<({String id, String name})> _ignoredDevices = [];
@override
void initState() {
super.initState();
_loadIgnoredDevices();
}
void _loadIgnoredDevices() {
setState(() {
_ignoredDevices = settings.getIgnoredDevices();
});
}
Future<void> _removeDevice(String deviceId) async {
await settings.removeIgnoredDevice(deviceId);
_loadIgnoredDevices();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Ignored Devices'),
content: SizedBox(
width: double.maxFinite,
child: _ignoredDevices.isEmpty
? Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'No ignored devices.',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
shrinkWrap: true,
itemCount: _ignoredDevices.length,
itemBuilder: (context, index) {
final device = _ignoredDevices[index];
return ListTile(
title: Text(device.name),
subtitle: Text(
device.id,
style: TextStyle(fontSize: 12),
),
trailing: IconButton(
icon: Icon(Icons.delete_outline),
tooltip: 'Remove from ignored list',
onPressed: () => _removeDevice(device.id),
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close'),
),
],
);
}
}

View File

@@ -143,8 +143,8 @@ class _ButtonEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
final actions = [
if (whooshLink.isConnected.value)
final actions = <PopupMenuEntry>[
if (settings.getMyWhooshLinkEnabled() && whooshLink.isCompatible(settings.getLastTarget()!))
PopupMenuItem<PhysicalKeyboardKey>(
child: PopupMenuButton(
itemBuilder: (_) => WhooshLink.supportedActions.map(
@@ -182,15 +182,20 @@ class _ButtonEditor extends StatelessWidget {
);
},
).toList(),
child: Row(
children: [
Expanded(child: Text('MyWhoosh Link Action')),
Icon(Icons.arrow_right),
],
child: SizedBox(
height: 52,
child: Row(
spacing: 14,
children: [
Icon(Icons.link),
Expanded(child: Text('MyWhoosh Direct Connect Action')),
Icon(Icons.arrow_right),
],
),
),
),
),
if (zwiftEmulator.isConnected.value)
if (settings.getZwiftEmulatorEnabled() && settings.getTrainerApp()?.supportsZwiftEmulation == true)
PopupMenuItem<PhysicalKeyboardKey>(
child: PopupMenuButton(
itemBuilder: (_) => ZwiftEmulator.supportedActions.map(
@@ -228,11 +233,16 @@ class _ButtonEditor extends StatelessWidget {
);
},
).toList(),
child: Row(
children: [
Expanded(child: Text('Zwift Action')),
Icon(Icons.arrow_right),
],
child: SizedBox(
height: 52,
child: Row(
spacing: 14,
children: [
Icon(Icons.link),
Expanded(child: Text('Zwift Controller Action')),
Icon(Icons.arrow_right),
],
),
),
),
),
@@ -331,23 +341,28 @@ class _ButtonEditor extends StatelessWidget {
),
),
PopupMenuDivider(),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
onTap: () {
keyPair.isLongPress = !keyPair.isLongPress;
onUpdate();
},
child: ListTile(
title: const Text('Long Press Mode (vs. repeating)'),
trailing: Checkbox(
value: keyPair.isLongPress,
onChanged: (value) {
keyPair.isLongPress = value ?? false;
padding: EdgeInsets.zero,
child: Row(
spacing: 6,
children: [
Checkbox(
value: keyPair.isLongPress,
onChanged: (value) {
keyPair.isLongPress = value ?? false;
onUpdate();
Navigator.of(context).pop();
},
),
onUpdate();
Navigator.of(context).pop();
},
),
const Text('Long Press Mode (vs. repeating)'),
],
),
),
PopupMenuItem<PhysicalKeyboardKey>(
@@ -356,19 +371,26 @@ class _ButtonEditor extends StatelessWidget {
keyPair.isLongPress = false;
keyPair.physicalKey = null;
keyPair.logicalKey = null;
keyPair.modifiers = [];
keyPair.touchPosition = Offset.zero;
keyPair.inGameAction = null;
keyPair.inGameActionValue = null;
onUpdate();
},
child: const Text('Unassign action'),
child: Row(
spacing: 14,
children: [
Icon(Icons.delete_outline),
const Text('Unassign action'),
],
),
),
];
return Container(
constraints: BoxConstraints(minHeight: kMinInteractiveDimension - 6),
padding: EdgeInsets.only(right: actionHandler.supportedApp is CustomApp ? 4 : 0),
child: PopupMenuButton(
child: PopupMenuButton<dynamic>(
itemBuilder: (c) => actions,
enabled: actionHandler.supportedApp is CustomApp,
child: Row(
@@ -394,7 +416,16 @@ class _ButtonEditor extends StatelessWidget {
IconButton(
onPressed: () async {
final currentProfile = actionHandler.supportedApp!.name;
await KeymapManager().duplicate(context, currentProfile);
final newName = await KeymapManager().duplicate(
context,
currentProfile,
skipName: '$currentProfile (Copy)',
);
if (newName != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Created a new custom profile: $newName')));
}
onUpdate();
},
icon: Icon(Icons.edit),

View File

@@ -1,54 +0,0 @@
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';
class LinkWidget extends StatefulWidget {
final VoidCallback onUpdate;
const LinkWidget({super.key, required this.onUpdate});
@override
State<LinkWidget> createState() => _LinkWidgetState();
}
class _LinkWidgetState extends State<LinkWidget> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
ValueListenableBuilder(
valueListenable: whooshLink.isStarted,
builder: (BuildContext context, value, Widget? child) {
return Row(
spacing: 8,
children: [
ElevatedButton(
onPressed: value ? null : () async {},
child: Text(value ? 'Waiting for MyWhoosh...' : 'Start Listening for MyWhoosh'),
),
if (value) SmallProgressIndicator(),
],
);
},
),
Text('Verify with the MyWhoosh Link app if connection is possible.'),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
child: const Text("Show Troubleshooting Guide"),
),
],
);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@@ -8,9 +9,11 @@ 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.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../pages/device.dart';
import 'ignored_devices_dialog.dart';
List<Widget> buildMenuButtons() {
return [
@@ -90,8 +93,26 @@ List<Widget> buildMenuButtons() {
child: Text('Get Support'),
onTap: () {
final isFromStore = (Platform.isAndroid ? isFromPlayStore == true : Platform.isIOS);
final suffix = isFromStore ? '' : 'ler';
launchUrlString('mailto:jonas.t.bark+swiftcontrol$suffix@gmail.com');
final suffix = isFromStore ? '' : '-sw';
String email = Uri.encodeComponent('jonas$suffix@swiftcontrol.app');
String subject = Uri.encodeComponent("Help requested for SwiftControl v${packageInfoValue?.version}");
String body = Uri.encodeComponent("""
---
App Version: ${packageInfoValue?.version}${shorebirdPatch?.number != null ? '+${shorebirdPatch!.number}' : ''}
Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}
Target: ${settings.getLastTarget()?.title ?? '-'}
Trainer App: ${settings.getTrainerApp()?.name ?? '-'}
Connected Controllers: ${connection.devices.map((e) => e.toString()).join(', ')}
Logs:
${connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')}
Please don't remove this information, it helps me to assist you better.""");
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
launchUrl(mail);
},
),
];
@@ -154,7 +175,15 @@ class MenuButton extends StatelessWidget {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
},
),
PopupMenuItem(
child: Text('Ignored Devices'),
onTap: () {
showDialog(
context: context,
builder: (context) => IgnoredDevicesDialog(),
);
},
),
PopupMenuItem(
child: Text('License'),
onTap: () {

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
@@ -57,6 +59,28 @@ class _ScanWidgetState extends State<ScanWidget> {
Text(
'Scanning for devices... Make sure they are powered on and in range and not connected to another device.',
),
if (!kIsWeb && (Platform.isMacOS || Platform.isIOS || Platform.isWindows))
ValueListenableBuilder(
valueListenable: connection.isMediaKeyDetectionEnabled,
builder: (context, value, child) {
return SwitchListTile.adaptive(
value: value,
contentPadding: EdgeInsets.zero,
dense: true,
subtitle: Text(
'Enable this option to allow Swift Control to detect bluetooth remotes. In order to do so SwiftControl needs to act as a media player.',
),
title: Row(
children: [
const Text("Enable Media Key Detection"),
],
),
onChanged: (change) {
connection.isMediaKeyDetectionEnabled.value = change;
},
);
},
),
TextButton(
onPressed: () {
Navigator.push(

View File

@@ -15,6 +15,7 @@ import 'package:version/version.dart';
PackageInfo? packageInfoValue;
bool? isFromPlayStore;
Patch? shorebirdPatch;
class AppTitle extends StatefulWidget {
const AppTitle({super.key});
@@ -25,7 +26,6 @@ class AppTitle extends StatefulWidget {
class _AppTitleState extends State<AppTitle> {
final updater = ShorebirdUpdater();
Patch? _shorebirdPatch;
@override
void initState() {
@@ -34,7 +34,7 @@ class _AppTitleState extends State<AppTitle> {
if (updater.isAvailable) {
updater.readCurrentPatch().then((patch) {
setState(() {
_shorebirdPatch = patch;
shorebirdPatch = patch;
});
});
}
@@ -145,7 +145,7 @@ class _AppTitleState extends State<AppTitle> {
Text('SwiftControl', style: TextStyle(fontWeight: FontWeight.bold)),
if (packageInfoValue != null)
Text(
'v${packageInfoValue!.version}${_shorebirdPatch != null ? '+${_shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''}${kIsWeb || (Platform.isAndroid && isFromPlayStore == false) ? ' (sideloaded)' : ''}',
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
)
else
@@ -160,9 +160,9 @@ class _AppTitleState extends State<AppTitle> {
content: Text('Force-close the app to use the new version'),
duration: Duration(seconds: 10),
action: SnackBarAction(
label: 'Attempt Restart',
label: 'Restart',
onPressed: () {
if (Platform.isIOS || Platform.isAndroid) {
if (Platform.isIOS) {
connection.reset();
Restart.restartApp(delayBeforeRestart: 1000);
} else {

View File

@@ -13,7 +13,7 @@ class Warning extends StatelessWidget {
color: Theme.of(context).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,

View File

@@ -9,6 +9,7 @@
#include <bluetooth_low_energy_linux/bluetooth_low_energy_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <gamepads_linux/gamepads_linux_plugin.h>
#include <media_key_detector_linux/media_key_detector_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>
@@ -23,6 +24,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) gamepads_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GamepadsLinuxPlugin");
gamepads_linux_plugin_register_with_registrar(gamepads_linux_registrar);
g_autoptr(FlPluginRegistrar) media_key_detector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKeyDetectorPlugin");
media_key_detector_plugin_register_with_registrar(media_key_detector_linux_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
bluetooth_low_energy_linux
file_selector_linux
gamepads_linux
media_key_detector_linux
screen_retriever_linux
url_launcher_linux
window_manager

View File

@@ -11,6 +11,7 @@ import file_selector_macos
import flutter_local_notifications
import gamepads_darwin
import keypress_simulator_macos
import media_key_detector_macos
import package_info_plus
import path_provider_foundation
import screen_retriever_macos
@@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GamepadsDarwinPlugin.register(with: registry.registrar(forPlugin: "GamepadsDarwinPlugin"))
KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin"))
MediaKeyDetectorPlugin.register(with: registry.registrar(forPlugin: "MediaKeyDetectorPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))

View File

@@ -13,6 +13,8 @@ PODS:
- FlutterMacOS
- keypress_simulator_macos (0.0.1):
- FlutterMacOS
- media_key_detector_macos (0.0.1):
- FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
@@ -41,6 +43,7 @@ DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`)
- gamepads_darwin (from `Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos`)
- keypress_simulator_macos (from `Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos`)
- media_key_detector_macos (from `Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
@@ -65,6 +68,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/gamepads_darwin/macos
keypress_simulator_macos:
:path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos
media_key_detector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/media_key_detector_macos/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
@@ -90,6 +95,7 @@ SPEC CHECKSUMS:
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
gamepads_darwin: 07af6c60c282902b66574c800e20b2b26e68fda8
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
media_key_detector_macos: a93757a483b4b47283ade432b1af9e427c47329f
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161

48
media_key_detector/.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
.DS_Store
.atom/
.idea/
.vscode/
.packages
.pub/
.dart_tool/
pubspec.lock
flutter_export_environment.sh
coverage/
Podfile.lock
Pods/
.symlinks/
**/Flutter/App.framework/
**/Flutter/ephemeral/
**/Flutter/Flutter.podspec
**/Flutter/Flutter.framework/
**/Flutter/Generated.xcconfig
**/Flutter/flutter_assets/
ServiceDefinitions.json
xcuserdata/
**/DerivedData/
local.properties
keystore.properties
.gradle/
gradlew
gradlew.bat
gradle-wrapper.jar
.flutter-plugins-dependencies
*.iml
generated_plugin_registrant.cc
generated_plugin_registrant.h
generated_plugin_registrant.dart
GeneratedPluginRegistrant.java
GeneratedPluginRegistrant.h
GeneratedPluginRegistrant.m
GeneratedPluginRegistrant.swift
build/
.flutter-plugins
.project
.classpath
.settings

View File

@@ -0,0 +1,43 @@
# media_key_detector
[![Very Good Ventures][logo_white]][very_good_ventures_link_dark]
[![Very Good Ventures][logo_black]][very_good_ventures_link_light]
Developed with 💙 by [Very Good Ventures][very_good_ventures_link] 🦄
![coverage][coverage_badge]
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![License: MIT][license_badge]][license_link]
A Very Good Flutter Federated Plugin created by the [Very Good Ventures Team][very_good_ventures_link].
Generated by the [Very Good CLI][very_good_cli_link] 🤖
### Integration tests 🧪
Very Good Flutter Plugin uses [fluttium][fluttium_link] for integration tests. Those tests are located
in the front facing package `media_key_detector` example.
**❗ In order to run the integration tests, you need to have the `fluttium_cli` installed. [See how][fluttium_install].**
To run the integration tests, run the following command from the root of the project:
```sh
cd media_key_detector/example
fluttium test flows/test_platform_name.yaml
```
[coverage_badge]: media_key_detector/coverage_badge.svg
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
[license_link]: https://opensource.org/licenses/MIT
[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only
[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis
[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli
[very_good_ventures_link]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core
[very_good_ventures_link_dark]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-dark-mode-only
[very_good_ventures_link_light]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-light-mode-only
[fluttium_link]: https://fluttium.dev/
[fluttium_install]: https://fluttium.dev/docs/getting-started/installing-cli

View File

@@ -0,0 +1,7 @@
# 0.0.2
- TBD
# 0.0.1
- Initial Release

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2024 Evan Kaiser
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,110 @@
[![Pub][pub_badge]][pub_link]
[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![License: MIT][license_badge]][license_link]
# media_key_detector
Triggers events when keyboard media keys are pressed. On MacOS, it registers
your app as a [Now Playable App](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app),
allowing it to respond to media events, regardless of whether the event came
from a keyboard, headset, or media remote.
## Features
- Captures and triggeres events for the following keys:
- Play / Pause
- Previous / Rewind
- Next / Fast-forward
## Rationale
In general media key capture works fine using normal keyboard approaches, such
as RawKeyListener or FocusNode. However, it only works on Windows/Linux. In
MacOS, the key events are not detected, and often will even send the events to
an inactive window, like Music.
## Getting started
1. Add the latest version of this package:
- Run `flutter pub add media_key_detector` -or-
- Edit `pubspec.yaml` and then run `flutter pub get`:
```yaml
dependencies:
media_key_detector: ^latest_version
```
2. Import the package
```
import 'package:media_key_detector/media_key_detector.dart';
```
## Usage
```dart
void _mediaKeyListener(MediaKey mediaKey) {
debugPrint('$mediaKey pressed');
}
@override
void initState() {
super.initState();
mediaKeyDetector.addListener(_mediaKeyListener);
}
@override
void dispose() {
mediaKeyDetector.removeListener(_mediaKeyListener);
super.dispose();
}
/// The following two methods are only really needed if you're relying on
/// the plugin to track the playing state. On MacOS/iOS, this is helpful to
/// display the status in the "Command Center".
/// Get whether the media is playing. Note that it starts out "paused",
/// so if your app plays media on open, you should call
/// [mediaKeyDetector.setIsPlaying(true)]
Future<bool> getIsMediaPlaying() async {
return await mediaKeyDetector.getIsPlaying();
}
/// The app tracks the playing state when the user presses the media key,
/// but there are some cases, i.e. when a play button is pressed on the UI
/// interface, where you may need to set it yourself
Future play() async {
return await mediaKeyDetector.setIsPlaying(isPlaying: true);
}
```
## Code Generation
[![Very Good Ventures][logo_white]][very_good_ventures_link_dark]
[![Very Good Ventures][logo_black]][very_good_ventures_link_light]
Developed with 💙 by [Very Good Ventures][very_good_ventures_link] 🦄
A Very Good Flutter Federated Plugin created by the [Very Good Ventures Team][very_good_ventures_link].
Generated by the [Very Good CLI][very_good_cli_link] 🤖
## Support
You can support me by buying me a coffee <a href="https://www.buymeacoffee.com/honeydoodat"><img src="https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png" alt="Buy me a coffee" width="100" /></a>
And also don't forget to star this package on GitHub <a href="https://github.com/holotrek/media_key_detector"><img src="https://img.shields.io/github/stars/holotrek/media_key_detector?logo=github&style=flat-square"></a>
[pub_badge]: https://img.shields.io/pub/v/media_key_detector
[pub_link]: https://pub.dev/packages/media_key_detector
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
[license_link]: https://opensource.org/licenses/MIT
[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only
[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis
[very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli
[very_good_ventures_link]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core
[very_good_ventures_link_dark]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-dark-mode-only
[very_good_ventures_link_light]: https://verygood.ventures/?utm_source=github&utm_medium=banner&utm_campaign=core#gh-light-mode-only

View File

@@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.5.1.0.yaml

View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="102" height="20">
<linearGradient id="b" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1" />
<stop offset="1" stop-opacity=".1" />
</linearGradient>
<clipPath id="a">
<rect width="102" height="20" rx="3" fill="#fff" />
</clipPath>
<g clip-path="url(#a)">
<path fill="#555" d="M0 0h59v20H0z" />
<path fill="#44cc11" d="M59 0h43v20H59z" />
<path fill="url(#b)" d="M0 0h102v20H0z" />
</g>
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="110">
<text x="305" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="490">coverage</text>
<text x="305" y="140" transform="scale(.1)" textLength="490">coverage</text>
<text x="795" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text>
<text x="795" y="140" transform="scale(.1)" textLength="330">100%</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,49 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Fluttium related files
.fluttium_*_launcher.dart

View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
version:
revision: 7048ed95a5ad3e43d697e0c397464193991fc230
channel: stable
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
- platform: android
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
- platform: ios
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
- platform: linux
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
- platform: macos
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
- platform: web
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
- platform: windows
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

View File

@@ -0,0 +1,3 @@
# media_key_detector_example
Demonstrates how to use the media_key_detector plugin.

View File

@@ -0,0 +1,6 @@
include: package:very_good_analysis/analysis_options.5.1.0.yaml
linter:
rules:
public_member_api_docs: false
require_trailing_commas: false
lines_longer_than_80_chars: false

View File

@@ -0,0 +1,144 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:media_key_detector/media_key_detector.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: HomePage());
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
String? _platformName;
bool _isPlaying = false;
Map<MediaKey, bool> keyPressed = {
MediaKey.playPause: false,
MediaKey.rewind: false,
MediaKey.fastForward: false,
};
void _mediaKeyListener(MediaKey mediaKey) {
debugPrint('$mediaKey pressed');
mediaKeyDetector
.getIsPlaying()
.then((playing) => setState(() => _isPlaying = playing));
if (keyPressed[mediaKey] == false) {
setState(() => keyPressed[mediaKey] = true);
Timer(const Duration(seconds: 3), () {
setState(() => keyPressed[mediaKey] = false);
});
}
}
Future<void> _togglePlayPause() async {
setState(() => _isPlaying = !_isPlaying);
await mediaKeyDetector.setIsPlaying(isPlaying: _isPlaying);
}
@override
void initState() {
super.initState();
mediaKeyDetector.addListener(_mediaKeyListener);
}
@override
void dispose() {
mediaKeyDetector.removeListener(_mediaKeyListener);
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('MediaKeyDetector Example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Press a Media button on your IO device to highlight the corresponding icon.'),
const Text(
'Press the play/pause button to send now playing info to plugin.'),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.fast_rewind_rounded,
size: 40,
color: (keyPressed[MediaKey.rewind] ?? false)
? colors.inversePrimary
: colors.onBackground,
),
IconButton.filled(
onPressed: _togglePlayPause,
style:
IconButton.styleFrom(backgroundColor: colors.secondary),
icon: Icon(
_isPlaying
? Icons.pause_circle_rounded
: Icons.play_circle_rounded,
size: 40,
color: (keyPressed[MediaKey.playPause] ?? false)
? colors.inversePrimary
: colors.onPrimary,
),
),
Icon(
Icons.fast_forward_rounded,
size: 40,
color: (keyPressed[MediaKey.fastForward] ?? false)
? colors.inversePrimary
: colors.onBackground,
),
],
),
Text('Is currently playing: $_isPlaying'),
if (_platformName == null)
const SizedBox.shrink()
else
Text(
'Platform Name: $_platformName',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () async {
if (!context.mounted) return;
try {
final result = await getPlatformName();
setState(() => _platformName = result);
} catch (error) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Theme.of(context).primaryColor,
content: Text('$error'),
),
);
}
},
child: const Text('Get Platform Name'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1 @@
flutter/ephemeral

View File

@@ -0,0 +1,106 @@
cmake_minimum_required(VERSION 3.10)
project(runner LANGUAGES CXX)
set(BINARY_NAME "example")
set(APPLICATION_ID "com.example.verygoodcore_example")
cmake_policy(SET CMP0063 NEW)
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Configure build options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
# Flutter library and tool build rules.
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Application build
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
apply_standard_settings(${BINARY_NAME})
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
if(PLUGIN_BUNDLED_LIBRARIES)
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@@ -0,0 +1,88 @@
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
pkg_check_modules(BLKID REQUIRED IMPORTED_TARGET blkid)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
PkgConfig::BLKID
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
linux-x64 ${CMAKE_BUILD_TYPE}
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

@@ -0,0 +1,24 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
media_key_detector_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@@ -0,0 +1,15 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "my_application.h"
int main(int argc, char** argv) {
// Only X11 is currently supported.
// Wayland support is being developed:
// https://github.com/flutter/flutter/issues/57932.
gdk_set_allowed_backends("x11");
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

View File

@@ -0,0 +1,49 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "example");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
gtk_window_set_default_size(window, 1280, 720);
gtk_widget_show(GTK_WIDGET(window));
g_autoptr(FlDartProject) project = fl_dart_project_new();
FlView* view = fl_view_new(project);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
return MY_APPLICATION(g_object_new(
my_application_get_type(), "application-id", APPLICATION_ID, nullptr));
}

View File

@@ -0,0 +1,22 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_

View File

@@ -0,0 +1,7 @@
# Flutter-related
**/Flutter/ephemeral/
**/Pods/
# Xcode-related
**/dgph
**/xcuserdata/

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"

View File

@@ -0,0 +1,40 @@
platform :osx, '10.14'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\""
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_macos_podfile_setup
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__))
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
end
end

View File

@@ -0,0 +1,633 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
33CC111A2044C6BA0003C045 /* Flutter Assemble */ = {
isa = PBXAggregateTarget;
buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */;
buildPhases = (
33CC111E2044C6BF0003C045 /* ShellScript */,
);
dependencies = (
);
name = "Flutter Assemble";
productName = FLX;
};
/* End PBXAggregateTarget section */
/* Begin PBXBuildFile section */
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; };
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; };
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
8056B9E1FA9168F10A6879E3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF88737E4227AF4483D73A72 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 33CC10E52044A3C60003C045 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 33CC111A2044C6BA0003C045;
remoteInfo = FLX;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
33CC110E2044A8840003C045 /* Bundle Framework */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Bundle Framework";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
082C0DA75B3DC0A853AE53D1 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = "<group>"; };
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = "<group>"; };
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = "<group>"; };
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = "<group>"; };
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = "<group>"; };
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
604426B4C55353DB93E63726 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
7CC079B1077C0BC3449BC7C1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
BF88737E4227AF4483D73A72 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
33CC10EA2044A3C60003C045 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
8056B9E1FA9168F10A6879E3 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0EEA7CE099CD931C8BF18132 /* Pods */ = {
isa = PBXGroup;
children = (
604426B4C55353DB93E63726 /* Pods-Runner.debug.xcconfig */,
7CC079B1077C0BC3449BC7C1 /* Pods-Runner.release.xcconfig */,
082C0DA75B3DC0A853AE53D1 /* Pods-Runner.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
33BA886A226E78AF003329D5 /* Configs */ = {
isa = PBXGroup;
children = (
33E5194F232828860026EE4D /* AppInfo.xcconfig */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
333000ED22D3DE5D00554162 /* Warnings.xcconfig */,
);
path = Configs;
sourceTree = "<group>";
};
33CC10E42044A3C60003C045 = {
isa = PBXGroup;
children = (
33FAB671232836740065AC1E /* Runner */,
33CEB47122A05771004F2AC0 /* Flutter */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
0EEA7CE099CD931C8BF18132 /* Pods */,
);
sourceTree = "<group>";
};
33CC10EE2044A3C60003C045 /* Products */ = {
isa = PBXGroup;
children = (
33CC10ED2044A3C60003C045 /* example.app */,
);
name = Products;
sourceTree = "<group>";
};
33CC11242044D66E0003C045 /* Resources */ = {
isa = PBXGroup;
children = (
33CC10F22044A3C60003C045 /* Assets.xcassets */,
33CC10F42044A3C60003C045 /* MainMenu.xib */,
33CC10F72044A3C60003C045 /* Info.plist */,
);
name = Resources;
path = ..;
sourceTree = "<group>";
};
33CEB47122A05771004F2AC0 /* Flutter */ = {
isa = PBXGroup;
children = (
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */,
33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */,
33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */,
33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */,
);
path = Flutter;
sourceTree = "<group>";
};
33FAB671232836740065AC1E /* Runner */ = {
isa = PBXGroup;
children = (
33CC10F02044A3C60003C045 /* AppDelegate.swift */,
33CC11122044BFA00003C045 /* MainFlutterWindow.swift */,
33E51913231747F40026EE4D /* DebugProfile.entitlements */,
33E51914231749380026EE4D /* Release.entitlements */,
33CC11242044D66E0003C045 /* Resources */,
33BA886A226E78AF003329D5 /* Configs */,
);
path = Runner;
sourceTree = "<group>";
};
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
BF88737E4227AF4483D73A72 /* Pods_Runner.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
33CC10EC2044A3C60003C045 /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
BA51270DFB65A51AF6561C86 /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
01C1BE47E39E4FF65E75DFDD /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
dependencies = (
33CC11202044C79F0003C045 /* PBXTargetDependency */,
);
name = Runner;
productName = Runner;
productReference = 33CC10ED2044A3C60003C045 /* example.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
33CC10E52044A3C60003C045 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
};
};
};
33CC111A2044C6BA0003C045 = {
CreatedOnToolsVersion = 9.2;
ProvisioningStyle = Manual;
};
};
};
buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 33CC10E42044A3C60003C045;
productRefGroup = 33CC10EE2044A3C60003C045 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
33CC10EC2044A3C60003C045 /* Runner */,
33CC111A2044C6BA0003C045 /* Flutter Assemble */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
33CC10EB2044A3C60003C045 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */,
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
01C1BE47E39E4FF65E75DFDD /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n";
};
33CC111E2044C6BF0003C045 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
Flutter/ephemeral/FlutterInputs.xcfilelist,
);
inputPaths = (
Flutter/ephemeral/tripwire,
);
outputFileListPaths = (
Flutter/ephemeral/FlutterOutputs.xcfilelist,
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
BA51270DFB65A51AF6561C86 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
33CC10E92044A3C60003C045 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */,
33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */,
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
33CC11202044C79F0003C045 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */;
targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
33CC10F42044A3C60003C045 /* MainMenu.xib */ = {
isa = PBXVariantGroup;
children = (
33CC10F52044A3C60003C045 /* Base */,
);
name = MainMenu.xib;
path = Runner;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
338D0CE9231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Profile;
};
338D0CEA231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Profile;
};
338D0CEB231458BD00FA5F75 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Profile;
};
33CC10F92044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
33CC10FA2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CODE_SIGN_IDENTITY = "-";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
33CC10FC2044A3C60003C045 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
33CC10FD2044A3C60003C045 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
name = Release;
};
33CC111C2044C6BA0003C045 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Manual;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
};
33CC111D2044C6BA0003C045 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10F92044A3C60003C045 /* Debug */,
33CC10FA2044A3C60003C045 /* Release */,
338D0CE9231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC10FC2044A3C60003C045 /* Debug */,
33CC10FD2044A3C60003C045 /* Release */,
338D0CEA231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = {
isa = XCConfigurationList;
buildConfigurations = (
33CC111C2044C6BA0003C045 /* Debug */,
33CC111D2044C6BA0003C045 /* Release */,
338D0CEB231458BD00FA5F75 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 33CC10E52044A3C60003C045 /* Project object */;
}

View File

@@ -0,0 +1,8 @@
<?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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "example.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "example.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "example.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
BuildableName = "example.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,9 @@
import Cocoa
import FlutterMacOS
@NSApplicationMain
class AppDelegate: FlutterAppDelegate {
override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
}

View File

@@ -0,0 +1,68 @@
{
"images" : [
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_16.png",
"scale" : "1x"
},
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"scale" : "2x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_32.png",
"scale" : "1x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "app_icon_64.png",
"scale" : "2x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"scale" : "2x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_256.png",
"scale" : "1x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"scale" : "2x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_512.png",
"scale" : "1x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "app_icon_1024.png",
"scale" : "2x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Some files were not shown because too many files have changed in this diff Show More