mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ee91c17a | ||
|
|
323a344c3a | ||
|
|
0172b1cf90 | ||
|
|
5a5e4066f6 | ||
|
|
3256f5aa15 | ||
|
|
476a9a337f | ||
|
|
1f1ce58bd9 | ||
|
|
bbb3dd3397 | ||
|
|
d7cee77c8b | ||
|
|
e2ac975c75 | ||
|
|
5e9352316c | ||
|
|
c73adb7c0d | ||
|
|
c3b41f56d4 | ||
|
|
6fe841af58 | ||
|
|
d97307de6f | ||
|
|
826dc2327f | ||
|
|
3466e504e3 | ||
|
|
ebd7f80947 | ||
|
|
43e827d8f5 | ||
|
|
5d5dc2e152 | ||
|
|
c0d2eaa897 | ||
|
|
13c70fc445 | ||
|
|
1e11d28765 | ||
|
|
7ee9bc43a0 | ||
|
|
372085ec0e | ||
|
|
e758b35837 | ||
|
|
dee7b86120 | ||
|
|
b3ec7e7a3a | ||
|
|
bbd01d023a | ||
|
|
36282c9fa9 | ||
|
|
daea07c409 | ||
|
|
49d7445d0e | ||
|
|
9bb0e5616a | ||
|
|
7e98f595ee | ||
|
|
a9fdc4b16e | ||
|
|
c06819b502 | ||
|
|
969faca658 | ||
|
|
61fbb099e2 | ||
|
|
fbd6356be0 | ||
|
|
1c40455bf3 | ||
|
|
15129634a6 | ||
|
|
89d35d7734 | ||
|
|
d959bfb4c9 | ||
|
|
9bc25514ae | ||
|
|
25210b57ba | ||
|
|
c9317e369c | ||
|
|
2195c19ed9 | ||
|
|
d13a9d72c9 | ||
|
|
55d230e41c | ||
|
|
ffa604f921 | ||
|
|
93bdfeeaa7 | ||
|
|
336c64e5a9 | ||
|
|
20a706d93d | ||
|
|
21cb8844fc | ||
|
|
4bc1a3b1d0 | ||
|
|
9df1f7cfa6 | ||
|
|
72cdf86802 | ||
|
|
9a53d5fdab | ||
|
|
458e6333a0 | ||
|
|
f42e483260 | ||
|
|
dda2135129 | ||
|
|
bc2831c17e | ||
|
|
310313c3b2 | ||
|
|
2122568461 | ||
|
|
144fd5b740 | ||
|
|
5f7a1a8203 | ||
|
|
258b396444 | ||
|
|
5861533793 | ||
|
|
3106bd09e8 | ||
|
|
a3475a02d2 | ||
|
|
fb1a1f35ad | ||
|
|
71aadde901 | ||
|
|
f7bfd8c206 | ||
|
|
ff83e5271b | ||
|
|
ec6edb2864 | ||
|
|
4f4a6f60c5 | ||
|
|
354e13678b |
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +1,2 @@
|
||||
github: [jonasbark]
|
||||
open_collective: jonas-bark1
|
||||
custom: ["https://paypal.me/boni"]
|
||||
custom: ["https://paypal.me/boni", "https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200"]
|
||||
|
||||
35
.github/workflows/build.yml
vendored
35
.github/workflows/build.yml
vendored
@@ -82,10 +82,12 @@ jobs:
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
|
||||
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
|
||||
|
||||
#6 Building APK
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Build Bundle
|
||||
run: flutter build appbundle --release
|
||||
|
||||
- name: Build Web
|
||||
run: flutter build web --release --base-href "/swiftcontrol/"
|
||||
|
||||
@@ -134,6 +136,8 @@ jobs:
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
|
||||
allowUpdates: true
|
||||
body: "You can also download the Android version from the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"
|
||||
tag: v${{ env.VERSION }}
|
||||
token: ${{ secrets.TOKEN }}
|
||||
|
||||
@@ -146,6 +150,16 @@ jobs:
|
||||
- name: Web Deploy
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
- name: Upload to Play Store
|
||||
# only upload when env.VERSION does not end with 1337, which is our indicator for beta releases
|
||||
if: "!endsWith(env.VERSION, '1337')"
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
packageName: de.jonasbark.swiftcontrol
|
||||
releaseFiles: build/app/outputs/bundle/release/app-release.aab
|
||||
track: production
|
||||
|
||||
windows:
|
||||
needs: build
|
||||
name: Build & Release on Windows
|
||||
@@ -179,6 +193,25 @@ jobs:
|
||||
- name: Zip directory (Windows)
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
$source = "C:\Windows\System32"
|
||||
$destination = "build\windows\x64\runner\Release"
|
||||
|
||||
# List of required DLLs
|
||||
$dlls = @("msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll")
|
||||
|
||||
# Copy each file
|
||||
foreach ($dll in $dlls) {
|
||||
$srcPath = Join-Path $source $dll
|
||||
$destPath = Join-Path $destination $dll
|
||||
|
||||
if (Test-Path $srcPath) {
|
||||
Copy-Item -Path $srcPath -Destination $destPath -Force
|
||||
Write-Output "Copied $dll to $destination"
|
||||
} else {
|
||||
Write-Warning "$dll not found in $source"
|
||||
}
|
||||
}
|
||||
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/SwiftControl.windows.zip"
|
||||
|
||||
#9 Upload Artifacts
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,3 +45,5 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
service-account.json
|
||||
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -1,18 +1,59 @@
|
||||
#### 2.0.4 (2025-04-10)
|
||||
### 2.5.0 (2025-09-25)
|
||||
- Improve usability
|
||||
- SwiftControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
|
||||
- SwiftControl will continue to be available to download for free on GitHub
|
||||
- contact me if you already donated and I'll get a voucher for you :)
|
||||
|
||||
### 2.4.0+1 (2025-09-17)
|
||||
- Windows: fix mouse clicks at wrong location due to display scaling (fixes #64)
|
||||
|
||||
### 2.4.0 (2025-09-16)
|
||||
- Show an overview of the keymap bindings
|
||||
- Allow customizing an existing keymap
|
||||
- Add more donation options
|
||||
|
||||
### 2.3.0 (2025-09-11)
|
||||
- Add support for latest Zwift Click v2
|
||||
|
||||
### 2.2.0 (2025-09-08)
|
||||
- Add Long Press Mode option for custom keymaps - buttons can now send sustained key presses instead of repeated taps, perfect for movement controls in games (fixes #61)
|
||||
- Windows: adjust key sending method to improve compatibility with more apps (fixes #62)
|
||||
|
||||
### 2.1.0 (2025-07-03)
|
||||
- Windows: automatically focus compatible training apps (MyWhoosh, IndieVelo, Biketerra) when sending keystrokes, enabling seamless multi-window usage
|
||||
|
||||
### 2.0.9 (2025-05-04)
|
||||
- you can now assign Escape and arrow down key to your custom keymap (#18)
|
||||
|
||||
### 2.0.8 (2025-05-02)
|
||||
- only use the light theme for the app
|
||||
- more troubleshooting information
|
||||
|
||||
### 2.0.7 (2025-04-18)
|
||||
- add Biketerra.com keymap
|
||||
- some UX improvements
|
||||
|
||||
### 2.0.6 (2025-04-15)
|
||||
- fix MyWhoosh up / downshift button assignment (I key vs K key)
|
||||
|
||||
### 2.0.5 (2025-04-13)
|
||||
- fix Zwift Click button assignment (#12)
|
||||
|
||||
### 2.0.4 (2025-04-10)
|
||||
- vibrate Zwift Play / Zwift Ride controller on gear shift (thanks @cagnulein, closes #16)
|
||||
|
||||
#### 2.0.3 (2025-04-08)
|
||||
### 2.0.3 (2025-04-08)
|
||||
- adjust TrainingPeaks Virtual key mapping (#12)
|
||||
- attempt to reconnect device if connection is lost
|
||||
- Android: detect freeform windows for MyWhoosh + TrainingPeaks Virtual keymaps
|
||||
|
||||
#### 2.0.2 (2025-04-07)
|
||||
### 2.0.2 (2025-04-07)
|
||||
- fix bluetooth scan issues on older Android devices by asking for location permission
|
||||
|
||||
#### 2.0.1 (2025-04-06)
|
||||
### 2.0.1 (2025-04-06)
|
||||
- long pressing a button will trigger the action again every 250ms
|
||||
|
||||
#### 2.0.0 (2025-04-06)
|
||||
### 2.0.0 (2025-04-06)
|
||||
- You can now customize the actions (touches, mouse clicks or keyboard keys) for all buttons on all supported Zwift devices
|
||||
- now shows the battery level of the connected devices
|
||||
- add more troubleshooting information
|
||||
|
||||
31
README.md
31
README.md
@@ -4,13 +4,15 @@
|
||||
|
||||
## Description
|
||||
|
||||
With SwiftControl you can control your favorite trainer app using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
|
||||
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
|
||||
- Virtual Gear shifting
|
||||
- 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
|
||||
|
||||
**Android AccessibilityService Usage**: On Android, SwiftControl uses the AccessibilityService API to simulate touch gestures on your screen, allowing your Zwift devices to control training apps. This service only monitors which app window is active and performs touch gestures at the locations you configure. No personal data is accessed or collected.
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
@@ -18,42 +20,53 @@ https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
|
||||
|
||||
## Downloads
|
||||
Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
|
||||
<a href="https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol"><img src="https://storage.googleapis.com/pe-portal-consumer-prod-wagtail-static/images/googleplay-badge-01-getit.max-1920x1070.format-webp.webp?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=wagtail%40pe-portal-consumer-prod.iam.gserviceaccount.com%2F20250925%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20250925T084315Z&X-Goog-Expires=86400&X-Goog-SignedHeaders=host&X-Goog-Signature=6eab941e460ae5973f162ce5740adf1e71cf8dd47fd5a9ba60ec673d31f807b0bea359f123a5f5151eb2315fac9c2aa641886e9fda8c545837274a04ca2e8c3217f54495f3b225ecf55a1ba1a34fe52836562583f387c62a4e140c64d1a13094d455a157df514bf7ea088ec2a2aa294ec5e594aea873ab3b63fc9f6d586ac15c04a0d05a4ec557bcb9cb9de48087508219ebf4bc5686dd8051c9949024baba1933cecdc6035b3766ff9fb9a9dd0c3418b225c155173d3b6911043244966a9df1f06ede2c5128fa7625d168c0c4bebf4e9b4c47439b4056c9fe9056e07399e85f3d875ac3478224e226d778fe8d9e7a8d54cae1a7dceb36494aa0326477ca7ffd" width="220"></a>
|
||||
|
||||
Get the latest version for free for Windows, macOS and Android here: https://github.com/jonasbark/swiftcontrol/releases
|
||||
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- indieVelo / Training Peaks
|
||||
- Biketerra.com
|
||||
- any other:
|
||||
- Android: you can customize simulated touch points of all your buttons in the app
|
||||
- Desktop: you can customize keyboard shortcuts and mouse clicks in the app
|
||||
|
||||
## Supported Devices
|
||||
- Zwift Click
|
||||
- Zwift Click v2
|
||||
- Zwift Ride
|
||||
- Zwift Play
|
||||
|
||||
## Supported Platforms
|
||||
- Android
|
||||
- App is losing connection over time? Read about how to [keep the app alive](https://dontkillmyapp.com/).
|
||||
- macOS
|
||||
- Windows
|
||||
- make sure you have installed the "[Microsoft Visual C++ Runtime libraries](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170)"
|
||||
- Windows may flag the app as virus. I think it does so because the app does control the mouse and keyboard.
|
||||
- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).
|
||||
- Make sure your Zwift device is not paired with Windows Bluetooth settings: [more information](https://github.com/jonasbark/swiftcontrol/issues/70).
|
||||
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
|
||||
- NOT SUPPORTED: iOS (iPhone, iPad) as Apple does not provide any way to simulate touches or keyboard events
|
||||
|
||||
## Troubleshooting
|
||||
Your Zwift device is found but connection does not work properly? You may need to update the firmware in Zwift Companion app.
|
||||
- Your Zwift device is found but connection does not work properly? You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## How does it work?
|
||||
The app connects to your Zwift device automatically.
|
||||
The app connects to your Zwift device automatically. It does not connect to your trainer itself.
|
||||
|
||||
- When using Android a "click" on a certain part of the screen is simulated to trigger the action.
|
||||
- When using Android: SwiftControl uses the AccessibilityService API to simulate touch gestures on specific parts of your screen to trigger actions in training apps. The service monitors which training app window is currently active to ensure gestures are sent to the correct app.
|
||||
- When using macOS or Windows a keyboard or mouse click is used to trigger the action.
|
||||
- there are predefined Keymaps for MyWhoosh and indieVelo / Training Peaks
|
||||
- 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)
|
||||
|
||||
## Donate
|
||||
Please consider donating to support the development of this app :)
|
||||
|
||||
[](https://paypal.me/boni)
|
||||
|
||||
- [via PayPal](https://paypal.me/boni)
|
||||
- [via CreditCard (USD)](https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201)
|
||||
- [via CreditCard (EUR)](https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200)
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.*
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMethodCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
@@ -134,7 +139,7 @@ val AccessibilityApiPigeonMethodCodec = StandardMethodCodec(AccessibilityApiPige
|
||||
interface Accessibility {
|
||||
fun hasPermission(): Boolean
|
||||
fun openPermissions()
|
||||
fun performTouch(x: Double, y: Double)
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
fun controlMedia(action: MediaAction)
|
||||
|
||||
companion object {
|
||||
@@ -184,8 +189,10 @@ interface Accessibility {
|
||||
val args = message as List<Any?>
|
||||
val xArg = args[0] as Double
|
||||
val yArg = args[1] as Double
|
||||
val isKeyDownArg = args[2] as Boolean
|
||||
val isKeyUpArg = args[3] as Boolean
|
||||
val wrapped: List<Any?> = try {
|
||||
api.performTouch(xArg, yArg)
|
||||
api.performTouch(xArg, yArg, isKeyDownArg, isKeyUpArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
wrapError(exception)
|
||||
@@ -235,9 +242,9 @@ private class AccessibilityApiPigeonStreamHandler<T>(
|
||||
}
|
||||
|
||||
interface AccessibilityApiPigeonEventChannelWrapper<T> {
|
||||
fun onListen(p0: Any?, sink: PigeonEventSink<T>) {}
|
||||
open fun onListen(p0: Any?, sink: PigeonEventSink<T>) {}
|
||||
|
||||
fun onCancel(p0: Any?) {}
|
||||
open fun onCancel(p0: Any?) {}
|
||||
}
|
||||
|
||||
class PigeonEventSink<T>(private val sink: EventChannel.EventSink) {
|
||||
@@ -253,7 +260,7 @@ class PigeonEventSink<T>(private val sink: EventChannel.EventSink) {
|
||||
sink.endOfStream()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWrapper<WindowEvent> {
|
||||
companion object {
|
||||
fun register(messenger: BinaryMessenger, streamHandler: StreamEventsStreamHandler, instanceName: String = "") {
|
||||
@@ -266,4 +273,4 @@ abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWra
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -51,23 +51,27 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
}, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
override fun performTouch(x: Double, y: Double) {
|
||||
Observable.toService?.performTouch(x = x, y = y) ?: error("Service not running")
|
||||
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
|
||||
Observable.toService?.performTouch(x = x, y = y, isKeyUp = isKeyUp, isKeyDown = isKeyDown) ?: error("Service not running")
|
||||
}
|
||||
|
||||
override fun controlMedia(action: MediaAction) {
|
||||
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(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))
|
||||
}
|
||||
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(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))
|
||||
}
|
||||
MediaAction.VOLUME_DOWN -> {
|
||||
audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI)
|
||||
}
|
||||
MediaAction.VOLUME_UP -> {
|
||||
audioService.adjustVolume(android.media.AudioManager.ADJUST_RAISE, android.media.AudioManager.FLAG_SHOW_UI)
|
||||
}
|
||||
MediaAction.VOLUME_DOWN -> audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI)
|
||||
MediaAction.VOLUME_UP -> audioService.adjustVolume(android.media.AudioManager.ADJUST_RAISE, android.media.AudioManager.FLAG_SHOW_UI)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,23 +45,20 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
return outBounds
|
||||
}
|
||||
|
||||
private fun simulateTap(x: Double, y: Double) {
|
||||
val gestureBuilder = GestureDescription.Builder()
|
||||
val path = Path()
|
||||
path.moveTo(x.toFloat(), y.toFloat())
|
||||
path.lineTo(x.toFloat()+1, y.toFloat())
|
||||
|
||||
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong())
|
||||
gestureBuilder.addStroke(stroke)
|
||||
|
||||
dispatchGesture(gestureBuilder.build(), null, null)
|
||||
}
|
||||
|
||||
override fun onInterrupt() {
|
||||
Log.d("AccessibilityService", "Service Interrupted")
|
||||
}
|
||||
|
||||
override fun performTouch(x: Double, y: Double) {
|
||||
simulateTap(x, y)
|
||||
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
|
||||
val gestureBuilder = GestureDescription.Builder()
|
||||
val path = Path()
|
||||
path.moveTo(x.toFloat(), y.toFloat())
|
||||
path.lineTo(x.toFloat()+1, y.toFloat())
|
||||
|
||||
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown)
|
||||
gestureBuilder.addStroke(stroke)
|
||||
|
||||
dispatchGesture(gestureBuilder.build(), null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ object Observable {
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun performTouch(x: Double, y: Double)
|
||||
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
|
||||
}
|
||||
|
||||
interface Receiver {
|
||||
fun onChange(packageName: String, window: Rect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ abstract class Accessibility {
|
||||
|
||||
void openPermissions();
|
||||
|
||||
void performTouch(double x, double y);
|
||||
void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
|
||||
void controlMedia(MediaAction action);
|
||||
}
|
||||
|
||||
@@ -187,14 +187,14 @@ class Accessibility {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performTouch(double x, double y) async {
|
||||
Future<void> performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false, }) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.performTouch$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[x, y]);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[x, y, isKeyDown, isKeyUp]);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
|
||||
@@ -14,7 +14,7 @@ val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
|
||||
android {
|
||||
namespace = "de.jonasbark.swift_play"
|
||||
namespace = "de.jonasbark.swiftcontrol"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
|
||||
@@ -32,7 +32,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "de.jonasbark.swift_play"
|
||||
applicationId = "de.jonasbark.swiftcontrol"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 24
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.jonasbark.swift_play
|
||||
package de.jonasbark.swiftcontrol
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
@@ -18,8 +18,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -20,8 +20,12 @@ class KeyPressSimulator {
|
||||
return _platform.requestAccess(onlyOpenPrefPane: onlyOpenPrefPane);
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClick(Offset position) {
|
||||
return _platform.simulateMouseClick(position);
|
||||
Future<void> simulateMouseClickDown(Offset position) {
|
||||
return _platform.simulateMouseClick(position, keyDown: true);
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClickUp(Offset position) {
|
||||
return _platform.simulateMouseClick(position, keyDown: false);
|
||||
}
|
||||
|
||||
/// Simulate key down.
|
||||
|
||||
@@ -62,6 +62,7 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
let x: Double = args["x"] as! Double
|
||||
let y: Double = args["y"] as! Double
|
||||
let keyDown: Bool = args["keyDown"] as! Bool
|
||||
|
||||
let point = CGPoint(x: x, y: y)
|
||||
|
||||
@@ -72,19 +73,21 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
mouseButton: .left)
|
||||
move?.post(tap: .cghidEventTap)*/
|
||||
|
||||
// Mouse down
|
||||
let mouseDown = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .leftMouseDown,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
mouseDown?.post(tap: .cghidEventTap)
|
||||
|
||||
// Mouse up
|
||||
let mouseUp = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .leftMouseUp,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
mouseUp?.post(tap: .cghidEventTap)
|
||||
if (keyDown) {
|
||||
// Mouse down
|
||||
let mouseDown = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .leftMouseDown,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
mouseDown?.post(tap: .cghidEventTap)
|
||||
} else {
|
||||
// Mouse up
|
||||
let mouseUp = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .leftMouseUp,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
mouseUp?.post(tap: .cghidEventTap)
|
||||
}
|
||||
result(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,11 @@ class MethodChannelKeyPressSimulator extends KeyPressSimulatorPlatform {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> simulateMouseClick(Offset position) async {
|
||||
final Map<String, double> arguments = {
|
||||
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) async {
|
||||
final Map<String, Object?> arguments = {
|
||||
'x': position.dx,
|
||||
'y': position.dy,
|
||||
'keyDown': keyDown,
|
||||
};
|
||||
await methodChannel.invokeMethod('simulateMouseClick', arguments);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ abstract class KeyPressSimulatorPlatform extends PlatformInterface {
|
||||
throw UnimplementedError('simulateKeyPress() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClick(Offset position) {
|
||||
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) {
|
||||
throw UnimplementedError('simulateKeyPress() has not been implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
// This must be included before many other Windows headers.
|
||||
#include <windows.h>
|
||||
#include <psapi.h>
|
||||
#include <string.h>
|
||||
#include <flutter_windows.h>
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
@@ -16,6 +19,16 @@ using flutter::EncodableValue;
|
||||
|
||||
namespace keypress_simulator_windows {
|
||||
|
||||
// Forward declarations
|
||||
struct FindWindowData {
|
||||
std::string targetProcessName;
|
||||
std::string targetWindowTitle;
|
||||
HWND foundWindow;
|
||||
};
|
||||
|
||||
BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam);
|
||||
HWND FindTargetWindow(const std::string& processName, const std::string& windowTitle);
|
||||
|
||||
// static
|
||||
void KeypressSimulatorWindowsPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarWindows* registrar) {
|
||||
@@ -54,33 +67,43 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
modifiers.push_back(key_modifier);
|
||||
}
|
||||
|
||||
INPUT input[6];
|
||||
// List of compatible training apps to look for
|
||||
std::vector<std::string> compatibleApps = {
|
||||
"MyWhooshHD.exe",
|
||||
"indieVelo.exe",
|
||||
"biketerra.exe"
|
||||
};
|
||||
|
||||
for (int32_t i = 0; i < modifiers.size(); i++) {
|
||||
if (modifiers[i].compare("shiftModifier") == 0) {
|
||||
input[i].ki.wVk = VK_SHIFT;
|
||||
} else if (modifiers[i].compare("controlModifier") == 0) {
|
||||
input[i].ki.wVk = VK_CONTROL;
|
||||
} else if (modifiers[i].compare("altModifier") == 0) {
|
||||
input[i].ki.wVk = VK_MENU;
|
||||
} else if (modifiers[i].compare("metaModifier") == 0) {
|
||||
input[i].ki.wVk = VK_LWIN;
|
||||
// Try to find and focus a compatible app
|
||||
HWND targetWindow = NULL;
|
||||
for (const std::string& processName : compatibleApps) {
|
||||
targetWindow = FindTargetWindow(processName, "");
|
||||
if (targetWindow != NULL) {
|
||||
// Only focus the window if it's not already in the foreground
|
||||
if (GetForegroundWindow() != targetWindow) {
|
||||
SetForegroundWindow(targetWindow);
|
||||
Sleep(50); // Brief delay to ensure window is focused
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
input[i].ki.dwFlags = keyDown ? 0 : KEYEVENTF_KEYUP;
|
||||
input[i].type = INPUT_KEYBOARD;
|
||||
}
|
||||
|
||||
/*int keyIndex = static_cast<int>(modifiers.size());
|
||||
input[keyIndex].ki.wVk = static_cast<WORD>(keyCode);
|
||||
input[keyIndex].ki.dwFlags = keyDown ? 0 : KEYEVENTF_KEYUP;
|
||||
input[keyIndex].type = INPUT_KEYBOARD;*/
|
||||
WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
|
||||
|
||||
// Send key sequence to system
|
||||
//SendInput(static_cast<UINT>(std::size(input)), input, sizeof(INPUT));
|
||||
INPUT in = {0};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wVk = 0; // when using SCANCODE, set VK=0
|
||||
in.ki.wScan = sc;
|
||||
in.ki.dwFlags = KEYEVENTF_SCANCODE | (keyDown ? 0 : KEYEVENTF_KEYUP);
|
||||
if (keyCode == VK_LEFT || keyCode == VK_RIGHT || keyCode == VK_UP || keyCode == VK_DOWN ||
|
||||
keyCode == VK_INSERT || keyCode == VK_DELETE || keyCode == VK_HOME || keyCode == VK_END ||
|
||||
keyCode == VK_PRIOR || keyCode == VK_NEXT) {
|
||||
in.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||
}
|
||||
SendInput(1, &in, sizeof(INPUT));
|
||||
|
||||
BYTE byteValue = static_cast<BYTE>(keyCode);
|
||||
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);
|
||||
/*BYTE byteValue = static_cast<BYTE>(keyCode);
|
||||
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);*/
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
}
|
||||
@@ -93,6 +116,7 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
|
||||
bool keyDown = std::get<bool>(args.at(EncodableValue("keyDown")));
|
||||
auto it_x = args.find(EncodableValue("x"));
|
||||
if (it_x != args.end() && std::holds_alternative<double>(it_x->second)) {
|
||||
x = std::get<double>(it_x->second);
|
||||
@@ -103,24 +127,97 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
y = std::get<double>(it_y->second);
|
||||
}
|
||||
|
||||
// Get the monitor containing the target point and its DPI
|
||||
const POINT target_point = {static_cast<LONG>(x), static_cast<LONG>(y)};
|
||||
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||
double scale_factor = dpi / 96.0;
|
||||
|
||||
// Scale the coordinates according to the DPI scaling
|
||||
int scaled_x = static_cast<int>(x * scale_factor);
|
||||
int scaled_y = static_cast<int>(y * scale_factor);
|
||||
|
||||
// Move the mouse to the specified coordinates
|
||||
SetCursorPos(static_cast<int>(x), static_cast<int>(y));
|
||||
SetCursorPos(scaled_x, scaled_y);
|
||||
|
||||
// Prepare input for mouse down and up
|
||||
INPUT input = {0};
|
||||
input.type = INPUT_MOUSE;
|
||||
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
if (keyDown) {
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
|
||||
// Mouse left button up
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
} else {
|
||||
// Mouse left button up
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
}
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
}
|
||||
|
||||
BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
FindWindowData* data = reinterpret_cast<FindWindowData*>(lParam);
|
||||
|
||||
// Check if window is visible and not minimized
|
||||
if (!IsWindowVisible(hwnd) || IsIconic(hwnd)) {
|
||||
return TRUE; // Continue enumeration
|
||||
}
|
||||
|
||||
// Get window title
|
||||
char windowTitle[256];
|
||||
GetWindowTextA(hwnd, windowTitle, sizeof(windowTitle));
|
||||
|
||||
// Get process name
|
||||
DWORD processId;
|
||||
GetWindowThreadProcessId(hwnd, &processId);
|
||||
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
|
||||
char processName[MAX_PATH];
|
||||
if (hProcess) {
|
||||
DWORD size = sizeof(processName);
|
||||
if (QueryFullProcessImageNameA(hProcess, 0, processName, &size)) {
|
||||
// Extract just the filename from the full path
|
||||
char* filename = strrchr(processName, '\\');
|
||||
if (filename) {
|
||||
filename++; // Skip the backslash
|
||||
} else {
|
||||
filename = processName;
|
||||
}
|
||||
|
||||
// Check if this matches our target
|
||||
if (!data->targetProcessName.empty() &&
|
||||
_stricmp(filename, data->targetProcessName.c_str()) == 0) {
|
||||
data->foundWindow = hwnd;
|
||||
return FALSE; // Stop enumeration
|
||||
}
|
||||
}
|
||||
CloseHandle(hProcess);
|
||||
}
|
||||
|
||||
// Check window title if process name didn't match
|
||||
if (!data->targetWindowTitle.empty() &&
|
||||
_stricmp(windowTitle, data->targetWindowTitle.c_str()) == 0) {
|
||||
data->foundWindow = hwnd;
|
||||
return FALSE; // Stop enumeration
|
||||
}
|
||||
|
||||
return TRUE; // Continue enumeration
|
||||
}
|
||||
|
||||
HWND FindTargetWindow(const std::string& processName, const std::string& windowTitle) {
|
||||
FindWindowData data;
|
||||
data.targetProcessName = processName;
|
||||
data.targetWindowTitle = windowTitle;
|
||||
data.foundWindow = NULL;
|
||||
|
||||
EnumWindows(EnumWindowsCallback, reinterpret_cast<LPARAM>(&data));
|
||||
return data.foundWindow;
|
||||
}
|
||||
|
||||
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
|
||||
@@ -30,6 +30,8 @@ class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
|
||||
|
||||
// Called when a method is called on this plugin's channel from Dart.
|
||||
void HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
|
||||
23
launch.json
Normal file
23
launch.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "swiftcontrol",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "swiftcontrol (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "swiftcontrol (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class BleUuid {
|
||||
}
|
||||
|
||||
class Constants {
|
||||
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc
|
||||
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A
|
||||
|
||||
// Zwift Play = RC1
|
||||
static const RC1_LEFT_SIDE = 0x03;
|
||||
@@ -22,6 +22,11 @@ class Constants {
|
||||
// Zwift Click = BC1
|
||||
static const BC1 = 0x09;
|
||||
|
||||
// Zwift Click v2 Right (unconfirmed)
|
||||
static const CLICK_V2_RIGHT_SIDE = 0x0A;
|
||||
// Zwift Click v2 Right (unconfirmed)
|
||||
static const CLICK_V2_LEFT_SIDE = 0x0B;
|
||||
|
||||
static final RIDE_ON = Uint8List.fromList([0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e]);
|
||||
static final VIBRATE_PATTERN = Uint8List.fromList([0x12, 0x12, 0x08, 0x0A, 0x06, 0x08, 0x02, 0x10, 0x00, 0x18]);
|
||||
|
||||
@@ -46,6 +51,8 @@ class Constants {
|
||||
|
||||
enum DeviceType {
|
||||
click,
|
||||
clickV2Right,
|
||||
clickV2Left,
|
||||
playLeft,
|
||||
playRight,
|
||||
rideRight,
|
||||
@@ -61,6 +68,10 @@ enum DeviceType {
|
||||
switch (data) {
|
||||
case Constants.BC1:
|
||||
return DeviceType.click;
|
||||
case Constants.CLICK_V2_RIGHT_SIDE:
|
||||
return DeviceType.clickV2Right;
|
||||
case Constants.CLICK_V2_LEFT_SIDE:
|
||||
return DeviceType.clickV2Left;
|
||||
case Constants.RC1_LEFT_SIDE:
|
||||
return DeviceType.playLeft;
|
||||
case Constants.RC1_RIGHT_SIDE:
|
||||
|
||||
@@ -21,7 +21,7 @@ class Connection {
|
||||
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => _actionStreams.stream;
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<BleConnectionUpdate>> _connectionSubscriptions = {};
|
||||
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
Stream<BaseDevice> get connectionStream => _connectionStreams.stream;
|
||||
|
||||
@@ -34,11 +34,15 @@ class Connection {
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
|
||||
_lastScanResult.add(result);
|
||||
final scanResult = BaseDevice.fromScanResult(result);
|
||||
_actionStreams.add(
|
||||
LogNotification('Found new device: ${result.name ?? scanResult?.runtimeType ?? result.deviceId}'),
|
||||
);
|
||||
|
||||
if (scanResult != null) {
|
||||
_actionStreams.add(LogNotification('Found new device: ${scanResult.runtimeType}'));
|
||||
_addDevices([scanResult]);
|
||||
} else {
|
||||
final manufacturerData = result.manufacturerDataList;
|
||||
final data =
|
||||
manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
|
||||
_actionStreams.add(LogNotification('Found unknown device with identifier: ${data?.firstOrNull}'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -128,7 +132,7 @@ class Connection {
|
||||
_actionStreams.add(data);
|
||||
});
|
||||
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((state) {
|
||||
bleDevice.isConnected = state.isConnected;
|
||||
bleDevice.isConnected = state;
|
||||
_connectionStreams.add(bleDevice);
|
||||
if (!bleDevice.isConnected) {
|
||||
devices.remove(bleDevice);
|
||||
@@ -159,6 +163,7 @@ class Connection {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_actionStreams.add(LogNotification('Disconnecting all devices'));
|
||||
UniversalBle.stopScan();
|
||||
isScanning.value = false;
|
||||
for (var device in devices) {
|
||||
|
||||
@@ -5,9 +5,11 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_clickv2.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/crypto/local_key_provider.dart';
|
||||
import 'package:swift_control/utils/crypto/zap_crypto.dart';
|
||||
import 'package:swift_control/utils/single_line_exception.dart';
|
||||
@@ -19,7 +21,9 @@ import '../messages/notification.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
BaseDevice(this.scanResult);
|
||||
final List<ZwiftButton> availableButtons;
|
||||
|
||||
BaseDevice(this.scanResult, {required this.availableButtons});
|
||||
|
||||
final zapEncryption = ZapCrypto(LocalKeyProvider());
|
||||
|
||||
@@ -29,6 +33,7 @@ abstract class BaseDevice {
|
||||
|
||||
BleCharacteristic? syncRxCharacteristic;
|
||||
Timer? _longPressTimer;
|
||||
Set<ZwiftButton> _previouslyPressedButtons = <ZwiftButton>{};
|
||||
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
|
||||
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
@@ -39,7 +44,7 @@ abstract class BaseDevice {
|
||||
//'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),
|
||||
//'Zwift Click' => ZwiftClick(scanResult), special case for Zwift Click v2: we must only connect to the left controller
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -59,8 +64,10 @@ abstract class BaseDevice {
|
||||
DeviceType.click => ZwiftClick(scanResult),
|
||||
DeviceType.playRight => ZwiftPlay(scanResult),
|
||||
DeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
DeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
DeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -123,25 +130,15 @@ abstract class BaseDevice {
|
||||
throw Exception('Characteristics not found');
|
||||
}
|
||||
|
||||
await UniversalBle.setNotifiable(
|
||||
device.deviceId,
|
||||
customService.uuid,
|
||||
asyncCharacteristic.uuid,
|
||||
BleInputProperty.notification,
|
||||
);
|
||||
await UniversalBle.setNotifiable(
|
||||
device.deviceId,
|
||||
customService.uuid,
|
||||
syncTxCharacteristic.uuid,
|
||||
BleInputProperty.indication,
|
||||
);
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
|
||||
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
|
||||
|
||||
await _setupHandshake();
|
||||
}
|
||||
|
||||
Future<void> _setupHandshake() async {
|
||||
if (supportsEncryption) {
|
||||
await UniversalBle.writeValue(
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
@@ -150,15 +147,15 @@ abstract class BaseDevice {
|
||||
...Constants.REQUEST_START,
|
||||
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
|
||||
]),
|
||||
BleOutputProperty.withoutResponse,
|
||||
withoutResponse: true,
|
||||
);
|
||||
} else {
|
||||
await UniversalBle.writeValue(
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
Constants.RIDE_ON,
|
||||
BleOutputProperty.withoutResponse,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -209,7 +206,11 @@ abstract class BaseDevice {
|
||||
final payload = bytes.sublist(4);
|
||||
|
||||
if (zapEncryption.encryptionKeyBytes == null) {
|
||||
actionStreamInternal.add(LogNotification('Encryption not initialized, yet.'));
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,14 +242,35 @@ abstract class BaseDevice {
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
actionStreamInternal.add(LogNotification('Buttons released'));
|
||||
_longPressTimer?.cancel();
|
||||
|
||||
// Handle release events for long press keys
|
||||
final buttonsReleased = _previouslyPressedButtons.toList();
|
||||
if (buttonsReleased.isNotEmpty) {
|
||||
await _performRelease(buttonsReleased);
|
||||
}
|
||||
_previouslyPressedButtons.clear();
|
||||
} else {
|
||||
if (!(buttonsClicked.singleOrNull == ZwiftButton.onOffLeft ||
|
||||
buttonsClicked.singleOrNull == ZwiftButton.onOffRight)) {
|
||||
// we don't want to trigger the long press timer for the on/off buttons
|
||||
// Handle release events for buttons that are no longer pressed
|
||||
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
|
||||
if (buttonsReleased.isNotEmpty) {
|
||||
await _performRelease(buttonsReleased);
|
||||
}
|
||||
|
||||
final isLongPress =
|
||||
buttonsClicked.singleOrNull != null &&
|
||||
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
|
||||
|
||||
if (!isLongPress &&
|
||||
!(buttonsClicked.singleOrNull == ZwiftButton.onOffLeft ||
|
||||
buttonsClicked.singleOrNull == ZwiftButton.onOffRight)) {
|
||||
// we don't want to trigger the long press timer for the on/off buttons, also not when it's a long press key
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) async {
|
||||
_performActions(buttonsClicked, true);
|
||||
});
|
||||
} else if (isLongPress) {
|
||||
// Update currently pressed buttons
|
||||
_previouslyPressedButtons = buttonsClicked.toSet();
|
||||
}
|
||||
|
||||
_performActions(buttonsClicked, false);
|
||||
@@ -269,18 +291,41 @@ abstract class BaseDevice {
|
||||
await _vibrate();
|
||||
}
|
||||
for (final action in buttonsClicked) {
|
||||
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
|
||||
// For repeated actions, don't trigger key down/up events (useful for long press)
|
||||
final isKeyDown = !repeated;
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: isKeyDown, isKeyUp: false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performRelease(List<ZwiftButton> buttonsReleased) async {
|
||||
for (final action in buttonsReleased) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _vibrate() async {
|
||||
final vibrateCommand = Uint8List.fromList([...Constants.VIBRATE_PATTERN, 0x20]);
|
||||
await UniversalBle.writeValue(
|
||||
await UniversalBle.write(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic!.uuid,
|
||||
supportsEncryption ? zapEncryption.encrypt(vibrateCommand) : vibrateCommand,
|
||||
BleOutputProperty.withoutResponse,
|
||||
withoutResponse: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
_longPressTimer?.cancel();
|
||||
_previouslyPressedButtons.clear();
|
||||
// Release any held keys in long press mode
|
||||
if (actionHandler is DesktopActions) {
|
||||
await (actionHandler as DesktopActions).releaseAllHeldKeys();
|
||||
}
|
||||
await UniversalBle.disconnect(device.deviceId);
|
||||
isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import '../messages/click_notification.dart';
|
||||
|
||||
class ZwiftClick extends BaseDevice {
|
||||
ZwiftClick(super.scanResult);
|
||||
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButton.shiftUpRight, ZwiftButton.shiftDownLeft]);
|
||||
|
||||
ClickNotification? _lastClickNotification;
|
||||
|
||||
|
||||
8
lib/bluetooth/devices/zwift_clickv2.dart
Normal file
8
lib/bluetooth/devices/zwift_clickv2.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult);
|
||||
|
||||
@override
|
||||
bool get supportsEncryption => false;
|
||||
}
|
||||
@@ -8,7 +8,25 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import '../ble.dart';
|
||||
|
||||
class ZwiftPlay extends BaseDevice {
|
||||
ZwiftPlay(super.scanResult);
|
||||
ZwiftPlay(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButton.y,
|
||||
ZwiftButton.z,
|
||||
ZwiftButton.a,
|
||||
ZwiftButton.b,
|
||||
ZwiftButton.onOffRight,
|
||||
ZwiftButton.sideButtonRight,
|
||||
ZwiftButton.paddleRight,
|
||||
ZwiftButton.navigationUp,
|
||||
ZwiftButton.navigationLeft,
|
||||
ZwiftButton.navigationRight,
|
||||
ZwiftButton.navigationDown,
|
||||
ZwiftButton.onOffLeft,
|
||||
ZwiftButton.sideButtonLeft,
|
||||
ZwiftButton.paddleLeft,
|
||||
],
|
||||
);
|
||||
|
||||
PlayNotification? _lastControllerNotification;
|
||||
|
||||
|
||||
@@ -7,7 +7,29 @@ import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import '../ble.dart';
|
||||
|
||||
class ZwiftRide extends BaseDevice {
|
||||
ZwiftRide(super.scanResult);
|
||||
ZwiftRide(super.scanResult)
|
||||
: super(
|
||||
availableButtons: [
|
||||
ZwiftButton.navigationLeft,
|
||||
ZwiftButton.navigationRight,
|
||||
ZwiftButton.navigationUp,
|
||||
ZwiftButton.navigationDown,
|
||||
ZwiftButton.a,
|
||||
ZwiftButton.b,
|
||||
ZwiftButton.y,
|
||||
ZwiftButton.z,
|
||||
ZwiftButton.shiftUpLeft,
|
||||
ZwiftButton.shiftDownLeft,
|
||||
ZwiftButton.shiftUpRight,
|
||||
ZwiftButton.shiftDownRight,
|
||||
ZwiftButton.powerUpLeft,
|
||||
ZwiftButton.powerUpRight,
|
||||
ZwiftButton.onOffLeft,
|
||||
ZwiftButton.onOffRight,
|
||||
ZwiftButton.paddleLeft,
|
||||
ZwiftButton.paddleRight,
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
String get customServiceId => BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID;
|
||||
|
||||
@@ -12,8 +12,8 @@ class ClickNotification extends BaseNotification {
|
||||
ClickNotification(Uint8List message) {
|
||||
final status = ClickKeyPadStatus.fromBuffer(message);
|
||||
buttonsClicked = [
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.shiftUpLeft,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButton.shiftDownRight,
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButton.shiftDownLeft,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class SwiftPlayApp extends StatelessWidget {
|
||||
title: 'SwiftControl',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.system,
|
||||
themeMode: ThemeMode.light,
|
||||
home: const RequirementsPage(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/touch_area.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
@@ -57,93 +58,111 @@ class _DevicePageState extends State<DevicePage> {
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: Padding(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Text('Connected Devices:', style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(
|
||||
'Devices:\n${connection.devices.joinToString(separator: '\n', transform: (it) {
|
||||
return "${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}";
|
||||
})}',
|
||||
connection.devices.joinToString(
|
||||
separator: '\n',
|
||||
transform: (it) {
|
||||
return "${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}";
|
||||
},
|
||||
),
|
||||
),
|
||||
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
|
||||
if (!kIsWeb)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownMenu<SupportedApp>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries:
|
||||
SupportedApp.supportedApps
|
||||
.map(
|
||||
(app) => DropdownMenuEntry<SupportedApp>(
|
||||
value: app,
|
||||
label: app.name,
|
||||
trailingIcon: IconButton(
|
||||
icon: Icon(Icons.info_outline),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text(app.name),
|
||||
content: SelectableText(app.keymap.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
Flex(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
direction: MediaQuery.sizeOf(context).width > 600 ? Axis.horizontal : Axis.vertical,
|
||||
spacing: 8,
|
||||
children: [
|
||||
DropdownMenu<SupportedApp>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries:
|
||||
SupportedApp.supportedApps
|
||||
.map((app) => DropdownMenuEntry<SupportedApp>(value: app, label: app.name))
|
||||
.toList(),
|
||||
label: Text('Select Keymap / app'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
}
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
settings.setApp(app);
|
||||
setState(() {});
|
||||
if (app is! CustomApp && !kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Customize the keymap if you experience any issues (e.g. wrong keyboard output)',
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
label: Text('Keymap'),
|
||||
onSelected: (app) async {
|
||||
if (app == null) {
|
||||
return;
|
||||
}
|
||||
controller.text = app.name ?? '';
|
||||
actionHandler.supportedApp = app;
|
||||
settings.setApp(app);
|
||||
setState(() {});
|
||||
if (app is! CustomApp && !kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
|
||||
_snackBarMessengerKey.currentState!.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Use Custom keymap if you experience any issues (e.g. wrong keyboard output)',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
|
||||
if (actionHandler.supportedApp is CustomApp)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final result = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
|
||||
if (result == true && actionHandler.supportedApp is CustomApp) {
|
||||
settings.setApp(actionHandler.supportedApp!);
|
||||
}
|
||||
if (actionHandler.supportedApp != null)
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (actionHandler.supportedApp! is! CustomApp) {
|
||||
final customApp = CustomApp();
|
||||
|
||||
actionHandler.supportedApp!.keymap.keyPairs.forEachIndexed((pair, index) {
|
||||
pair.buttons.forEachIndexed((button, indexB) {
|
||||
customApp.setKey(
|
||||
button,
|
||||
physicalKey: pair.physicalKey!,
|
||||
logicalKey: pair.logicalKey,
|
||||
isLongPress: pair.isLongPress,
|
||||
touchPosition:
|
||||
pair.touchPosition != Offset.zero
|
||||
? pair.touchPosition
|
||||
: Offset(((indexB + 1)) * 100, 200 + (index * 100)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
actionHandler.supportedApp = customApp;
|
||||
settings.setApp(customApp);
|
||||
}
|
||||
final result = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
|
||||
if (result == true && actionHandler.supportedApp is CustomApp) {
|
||||
settings.setApp(actionHandler.supportedApp!);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
child: Text('Customize Keymap'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actionHandler.supportedApp != null)
|
||||
KeymapExplanation(
|
||||
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),
|
||||
keymap: actionHandler.supportedApp!.keymap,
|
||||
onUpdate: () {
|
||||
setState(() {});
|
||||
controller.text = actionHandler.supportedApp?.name ?? '';
|
||||
},
|
||||
child: Text('Customize Keymap'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(child: LogViewer()),
|
||||
SizedBox(height: 800, child: LogViewer()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -31,7 +31,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
settings.init().then((_) {
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
// add more delay due tu CBManagerStateUnknown
|
||||
// add more delay due to CBManagerStateUnknown
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
_reloadRequirements();
|
||||
});
|
||||
@@ -72,55 +72,68 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
body:
|
||||
_requirements.isEmpty
|
||||
? Center(child: CircularProgressIndicator())
|
||||
: Stepper(
|
||||
currentStep: _currentStep,
|
||||
connectorColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onStepContinue:
|
||||
_currentStep < _requirements.length
|
||||
? () {
|
||||
setState(() {
|
||||
_currentStep += 1;
|
||||
});
|
||||
: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0),
|
||||
child: Text(
|
||||
'Please complete the following requirements to make the app work correctly:',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Stepper(
|
||||
currentStep: _currentStep,
|
||||
connectorColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) => Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
onStepContinue:
|
||||
_currentStep < _requirements.length
|
||||
? () {
|
||||
setState(() {
|
||||
_currentStep += 1;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onStepTapped: (step) {
|
||||
if (_requirements[step].status) {
|
||||
return;
|
||||
}
|
||||
: null,
|
||||
onStepTapped: (step) {
|
||||
if (_requirements[step].status) {
|
||||
return;
|
||||
}
|
||||
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
|
||||
if (hasEarlierIncomplete) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_currentStep = step;
|
||||
});
|
||||
},
|
||||
controlsBuilder: (context, details) => Container(),
|
||||
steps:
|
||||
_requirements
|
||||
.mapIndexed(
|
||||
(index, req) => Step(
|
||||
title: Text(req.name),
|
||||
content: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child:
|
||||
(index == _currentStep
|
||||
? req.build(context, () {
|
||||
_reloadRequirements();
|
||||
})
|
||||
: null) ??
|
||||
ElevatedButton(
|
||||
onPressed: req.status ? null : () => _callRequirement(req),
|
||||
child: Text(req.name),
|
||||
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
|
||||
if (hasEarlierIncomplete) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_currentStep = step;
|
||||
});
|
||||
},
|
||||
controlsBuilder: (context, details) => Container(),
|
||||
steps:
|
||||
_requirements
|
||||
.mapIndexed(
|
||||
(index, req) => Step(
|
||||
title: Text(req.name),
|
||||
content: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child:
|
||||
(index == _currentStep
|
||||
? req.build(context, () {
|
||||
_reloadRequirements();
|
||||
})
|
||||
: null) ??
|
||||
ElevatedButton(
|
||||
onPressed: req.status ? null : () => _callRequirement(req),
|
||||
child: Text(req.name),
|
||||
),
|
||||
),
|
||||
),
|
||||
state: req.status ? StepState.complete : StepState.indexed,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
state: req.status ? StepState.complete : StepState.indexed,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: 200),
|
||||
child: ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
shrinkWrap: true,
|
||||
child: Column(
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: connection.isScanning,
|
||||
|
||||
@@ -85,10 +85,13 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
final KeyPair keyPair;
|
||||
actionHandler.supportedApp!.keymap.keyPairs.add(
|
||||
keyPair = KeyPair(
|
||||
touchPosition: context.size!.center(Offset.zero),
|
||||
touchPosition: context.size!
|
||||
.center(Offset.zero)
|
||||
.translate(actionHandler.supportedApp!.keymap.keyPairs.length * 40, 0),
|
||||
buttons: [_pressedButton!],
|
||||
physicalKey: null,
|
||||
logicalKey: null,
|
||||
isLongPress: false,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
@@ -96,7 +99,8 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
// open menu
|
||||
if (Platform.isMacOS || Platform.isWindows) {
|
||||
await Future.delayed(Duration(milliseconds: 300));
|
||||
await keyPressSimulator.simulateMouseClick(keyPair.touchPosition);
|
||||
await keyPressSimulator.simulateMouseClickDown(keyPair.touchPosition);
|
||||
await keyPressSimulator.simulateMouseClickUp(keyPair.touchPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,10 +123,14 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
(context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: const Text('Set Keyboard shortcut'),
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.keyboard_alt_outlined),
|
||||
title: const Text('Simulate Keyboard shortcut'),
|
||||
),
|
||||
onTap: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // enable Escape key
|
||||
builder:
|
||||
(c) =>
|
||||
HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair),
|
||||
@@ -132,7 +140,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: const Text('Use as touch button'),
|
||||
child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)),
|
||||
onTap: () {
|
||||
keyPair.physicalKey = null;
|
||||
keyPair.logicalKey = null;
|
||||
@@ -141,7 +149,68 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: const Text('Remove'),
|
||||
onTap: () {
|
||||
keyPair.isLongPress = !keyPair.isLongPress;
|
||||
setState(() {});
|
||||
},
|
||||
child: CheckboxListTile(
|
||||
value: keyPair.isLongPress,
|
||||
onChanged: (value) {
|
||||
keyPair.isLongPress = value ?? false;
|
||||
setState(() {});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
title: const Text('Long Press Mode (vs. repeating)'),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
child: PopupMenuButton<PhysicalKeyboardKey>(
|
||||
padding: EdgeInsets.zero,
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaPlayPause,
|
||||
child: const Text('Media: Play/Pause'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaStop,
|
||||
child: const Text('Media: Stop'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackPrevious,
|
||||
child: const Text('Media: Previous'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackNext,
|
||||
child: const Text('Media: Next'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeUp,
|
||||
child: const Text('Media: Volume Up'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeDown,
|
||||
child: const Text('Media: Volume Down'),
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
keyPair.physicalKey = key;
|
||||
keyPair.logicalKey = null;
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.music_note_outlined),
|
||||
trailing: Icon(Icons.arrow_right),
|
||||
title: Text('Simulate Media key'),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
child: ListTile(title: const Text('Delete Keymap'), leading: Icon(Icons.delete, color: Colors.red)),
|
||||
onTap: () {
|
||||
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
|
||||
setState(() {});
|
||||
@@ -271,28 +340,41 @@ class _TouchDot extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.6),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black, width: 2),
|
||||
border: Border.all(
|
||||
color: keyPair.isLongPress ? Colors.green : Colors.black,
|
||||
width: keyPair.isLongPress ? 3 : 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
keyPair.isSpecialKey
|
||||
? Icons.music_note_outlined
|
||||
: keyPair.physicalKey != null
|
||||
? Icons.keyboard_alt_outlined
|
||||
: Icons.add,
|
||||
: Icons.touch_app_outlined,
|
||||
),
|
||||
),
|
||||
|
||||
Text(label, style: TextStyle(color: Colors.black, fontSize: 12)),
|
||||
if (keyPair.physicalKey != null)
|
||||
Text(switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Media: Play/Pause',
|
||||
PhysicalKeyboardKey.mediaStop => 'Media: Stop',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => 'Media: Previous',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Media: Next',
|
||||
PhysicalKeyboardKey.audioVolumeUp => 'Media: Volume Up',
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Media: Volume Down',
|
||||
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
|
||||
}, style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Container(
|
||||
color: Colors.white.withAlpha(180),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: TextStyle(color: Colors.black, fontSize: 12)),
|
||||
if (keyPair.physicalKey != null)
|
||||
Text(switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Media: Play/Pause',
|
||||
PhysicalKeyboardKey.mediaStop => 'Media: Stop',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => 'Media: Previous',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Media: Next',
|
||||
PhysicalKeyboardKey.audioVolumeUp => 'Media: Volume Up',
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Media: Volume Down',
|
||||
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
|
||||
}, style: TextStyle(color: Colors.black87, fontSize: 12)),
|
||||
if (keyPair.isLongPress)
|
||||
Text('Long Press', style: TextStyle(color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class AndroidActions extends BaseActions {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton button) async {
|
||||
Future<String> performAction(ZwiftButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ("Could not perform ${button.name}: No keymap set");
|
||||
}
|
||||
@@ -42,9 +42,9 @@ class AndroidActions extends BaseActions {
|
||||
}
|
||||
final point = supportedApp!.resolveTouchPosition(action: button, windowInfo: windowInfo);
|
||||
if (point != Offset.zero) {
|
||||
accessibilityHandler.performTouch(point.dx, point.dy);
|
||||
return "No touch performed";
|
||||
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
|
||||
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()}";
|
||||
}
|
||||
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()}";
|
||||
return "No touch performed";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ abstract class BaseActions {
|
||||
this.supportedApp = supportedApp;
|
||||
}
|
||||
|
||||
Future<String> performAction(ZwiftButton action);
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
}
|
||||
|
||||
class StubActions extends BaseActions {
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action) {
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
return Future.value(action.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class DesktopActions extends BaseActions {
|
||||
// Track keys that are currently held down in long press mode
|
||||
final Set<ZwiftButton> _heldKeys = <ZwiftButton>{};
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action) async {
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ('Supported app is not set');
|
||||
}
|
||||
@@ -14,14 +17,60 @@ class DesktopActions extends BaseActions {
|
||||
return ('Keymap entry not found for action: $action');
|
||||
}
|
||||
|
||||
if (keyPair.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key pressed: ${keyPair.logicalKey?.keyLabel}';
|
||||
// Handle long press mode
|
||||
if (keyPair.isLongPress) {
|
||||
if (isKeyDown && !isKeyUp) {
|
||||
// Key press: start long press
|
||||
if (!_heldKeys.contains(action)) {
|
||||
_heldKeys.add(action);
|
||||
if (keyPair.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
return 'Long press started: ${keyPair.logicalKey?.keyLabel}';
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
return 'Long Mouse click started at: $point';
|
||||
}
|
||||
}
|
||||
} else if (isKeyUp && !isKeyDown) {
|
||||
// Key release: end long press
|
||||
if (_heldKeys.contains(action)) {
|
||||
_heldKeys.remove(action);
|
||||
if (keyPair.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Long press ended: ${keyPair.logicalKey?.keyLabel}';
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Long Mouse click ended at: $point';
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore other combinations in long press mode
|
||||
return 'Long press active';
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateMouseClick(point);
|
||||
return 'Mouse clicked at: $point';
|
||||
// Handle regular key press mode (existing behavior)
|
||||
if (keyPair.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key pressed: ${keyPair.logicalKey?.keyLabel}';
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse clicked at: $point';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release all held keys (useful for cleanup)
|
||||
Future<void> releaseAllHeldKeys() async {
|
||||
for (final action in _heldKeys.toList()) {
|
||||
final keyPair = supportedApp?.keymap.getKeyPair(action);
|
||||
if (keyPair?.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair!.physicalKey);
|
||||
}
|
||||
}
|
||||
_heldKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
49
lib/utils/keymap/apps/biketerra.dart
Normal file
49
lib/utils/keymap/apps/biketerra.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
|
||||
class Biketerra extends SupportedApp {
|
||||
Biketerra()
|
||||
: super(
|
||||
name: 'Biketerra',
|
||||
packageName: "biketerra",
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyS,
|
||||
logicalKey: LogicalKeyboardKey.keyS,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyW,
|
||||
logicalKey: LogicalKeyboardKey.keyW,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyU,
|
||||
logicalKey: LogicalKeyboardKey.keyU,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
extension WindowSize on WindowEvent {
|
||||
int get width => right - left;
|
||||
int get height => bottom - top;
|
||||
}
|
||||
@@ -42,14 +42,26 @@ class CustomApp extends SupportedApp {
|
||||
ZwiftButton zwiftButton, {
|
||||
required PhysicalKeyboardKey physicalKey,
|
||||
required LogicalKeyboardKey? logicalKey,
|
||||
bool isLongPress = false,
|
||||
Offset? touchPosition,
|
||||
}) {
|
||||
// set the key for the zwift button
|
||||
final keyPair = keymap.getKeyPair(zwiftButton);
|
||||
if (keyPair != null) {
|
||||
keyPair.physicalKey = physicalKey;
|
||||
keyPair.logicalKey = logicalKey;
|
||||
keyPair.isLongPress = isLongPress;
|
||||
keyPair.touchPosition = touchPosition ?? Offset.zero;
|
||||
} else {
|
||||
keymap.keyPairs.add(KeyPair(buttons: [zwiftButton], physicalKey: physicalKey, logicalKey: logicalKey));
|
||||
keymap.keyPairs.add(
|
||||
KeyPair(
|
||||
buttons: [zwiftButton],
|
||||
physicalKey: physicalKey,
|
||||
logicalKey: logicalKey,
|
||||
isLongPress: isLongPress,
|
||||
touchPosition: touchPosition ?? Offset.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ class MyWhoosh extends SupportedApp {
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyK,
|
||||
logicalKey: LogicalKeyboardKey.keyK,
|
||||
physicalKey: PhysicalKeyboardKey.keyI,
|
||||
logicalKey: LogicalKeyboardKey.keyI,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyI,
|
||||
logicalKey: LogicalKeyboardKey.keyI,
|
||||
physicalKey: PhysicalKeyboardKey.keyK,
|
||||
logicalKey: LogicalKeyboardKey.keyK,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/biketerra.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/training_peaks.dart';
|
||||
|
||||
import '../../single_line_exception.dart';
|
||||
@@ -27,7 +28,7 @@ abstract class SupportedApp {
|
||||
|
||||
const SupportedApp({required this.name, required this.packageName, required this.keymap});
|
||||
|
||||
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), CustomApp()];
|
||||
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -17,7 +17,7 @@ class Keymap {
|
||||
separator: ('\n---------\n'),
|
||||
transform:
|
||||
(k) =>
|
||||
'''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}''',
|
||||
'''Button: ${k.buttons.joinToString(transform: (e) => e.name)}\nKeyboard key: ${k.logicalKey?.keyLabel ?? 'Not assigned'}\nAction: ${k.buttons.firstOrNull?.action}${k.touchPosition != Offset.zero ? '\nTouch Position: ${k.touchPosition.toString()}' : ''}${k.isLongPress ? '\nLong Press: Enabled' : ''}''',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,12 +41,14 @@ class KeyPair {
|
||||
PhysicalKeyboardKey? physicalKey;
|
||||
LogicalKeyboardKey? logicalKey;
|
||||
Offset touchPosition;
|
||||
bool isLongPress;
|
||||
|
||||
KeyPair({
|
||||
required this.buttons,
|
||||
required this.physicalKey,
|
||||
required this.logicalKey,
|
||||
this.touchPosition = Offset.zero,
|
||||
this.isLongPress = false,
|
||||
});
|
||||
|
||||
bool get isSpecialKey =>
|
||||
@@ -78,6 +80,7 @@ class KeyPair {
|
||||
'logicalKey': logicalKey?.keyId.toString() ?? '0',
|
||||
'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
|
||||
'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
|
||||
'isLongPress': isLongPress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ class KeyPair {
|
||||
physicalKey:
|
||||
int.parse(decoded['physicalKey']) != 0 ? PhysicalKeyboardKey(int.parse(decoded['physicalKey'])) : null,
|
||||
touchPosition: Offset(decoded['touchPosition']['x'], decoded['touchPosition']['y']),
|
||||
isLongPress: decoded['isLongPress'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
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/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
|
||||
class AccessibilityRequirement extends PlatformRequirement {
|
||||
AccessibilityRequirement() : super('Allow Accessibility Service');
|
||||
@@ -17,6 +19,53 @@ class AccessibilityRequirement extends PlatformRequirement {
|
||||
Future<void> getStatus() async {
|
||||
status = await accessibilityHandler.hasPermission();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
if (status) {
|
||||
return null; // Already granted, no need for disclosure
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'SwiftControl needs accessibility permission to control your training apps.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => _showDisclosureDialog(context, onUpdate),
|
||||
child: const Text('Show Permission Details'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDisclosureDialog(BuildContext context, VoidCallback onUpdate) async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // Prevent dismissing by tapping outside
|
||||
builder: (BuildContext context) {
|
||||
return AccessibilityDisclosureDialog(
|
||||
onAccept: () {
|
||||
Navigator.of(context).pop();
|
||||
// Open accessibility settings after user consents
|
||||
accessibilityHandler.openPermissions().then((_) {
|
||||
onUpdate();
|
||||
});
|
||||
},
|
||||
onDeny: () {
|
||||
Navigator.of(context).pop();
|
||||
// User denied, no action taken
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BluetoothScanRequirement extends PlatformRequirement {
|
||||
|
||||
@@ -46,7 +46,7 @@ class UnsupportedPlatform extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class BluetoothScanning extends PlatformRequirement {
|
||||
BluetoothScanning() : super('Bluetooth Scanning') {
|
||||
BluetoothScanning() : super('Finding your Zwift® controller...') {
|
||||
status = false;
|
||||
}
|
||||
|
||||
|
||||
77
lib/widgets/accessibility_disclosure_dialog.dart
Normal file
77
lib/widgets/accessibility_disclosure_dialog.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
|
||||
class AccessibilityDisclosureDialog extends StatelessWidget {
|
||||
final VoidCallback onAccept;
|
||||
final VoidCallback onDeny;
|
||||
|
||||
const AccessibilityDisclosureDialog({
|
||||
super.key,
|
||||
required this.onAccept,
|
||||
required this.onDeny,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false, // Prevent back navigation from dismissing dialog
|
||||
child: AlertDialog(
|
||||
title: const Text('Accessibility Service Permission Required'),
|
||||
content: const SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'SwiftControl needs to use Android\'s AccessibilityService API to function properly.',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text('Why is this permission needed?'),
|
||||
SizedBox(height: 8),
|
||||
Text('• To simulate touch gestures on your screen for controlling trainer apps'),
|
||||
Text('• To detect which training app window is currently active'),
|
||||
Text('• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'How does SwiftControl use this permission?',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('• When you press buttons on your Zwift Click, Zwift Ride, or Zwift Play devices, SwiftControl simulates touch gestures at specific screen locations'),
|
||||
Text('• The app monitors which training app window is active to ensure gestures are sent to the correct app'),
|
||||
Text('• No personal data is accessed or collected through this service'),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'SwiftControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'You must choose to either Allow or Deny this permission to continue.',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, color: Colors.deepOrange),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: onDeny,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.red,
|
||||
),
|
||||
child: const Text('Deny'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: onAccept,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Allow'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,11 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
setState(() {
|
||||
if (event is KeyDownEvent) {
|
||||
_pressedKey = event;
|
||||
} else if (event is KeyUpEvent) {
|
||||
widget.customApp.setKey(
|
||||
_pressedButton!,
|
||||
physicalKey: _pressedKey!.physicalKey,
|
||||
logicalKey: _pressedKey!.logicalKey,
|
||||
touchPosition: widget.keyPair?.touchPosition,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -99,43 +99,6 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
|
||||
children: [
|
||||
Text("Press a key on your keyboard to assign to ${_pressedButton.toString()}"),
|
||||
Text(_formatKey(_pressedKey)),
|
||||
PopupMenuButton<PhysicalKeyboardKey>(
|
||||
tooltip: 'Drag or click for special keys',
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaPlayPause,
|
||||
child: const Text('Media: Play/Pause'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaStop,
|
||||
child: const Text('Media: Stop'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackPrevious,
|
||||
child: const Text('Media: Previous'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.mediaTrackNext,
|
||||
child: const Text('Media: Next'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeUp,
|
||||
child: const Text('Media: Volume Up'),
|
||||
),
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: PhysicalKeyboardKey.audioVolumeDown,
|
||||
child: const Text('Media: Volume Down'),
|
||||
),
|
||||
],
|
||||
onSelected: (key) {
|
||||
widget.customApp.setKey(_pressedButton!, physicalKey: key, logicalKey: null);
|
||||
Navigator.pop(context, key);
|
||||
},
|
||||
child: IgnorePointer(
|
||||
child: ElevatedButton(onPressed: () {}, child: Text('Or choose special key')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
157
lib/widgets/keymap_explanation.dart
Normal file
157
lib/widgets/keymap_explanation.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
|
||||
class KeymapExplanation extends StatelessWidget {
|
||||
final Keymap keymap;
|
||||
final VoidCallback onUpdate;
|
||||
const KeymapExplanation({super.key, required this.keymap, required this.onUpdate});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectedDevice = connection.devices.firstOrNull;
|
||||
|
||||
final availableKeypairs = keymap.keyPairs.filter(
|
||||
(e) => connectedDevice?.availableButtons.containsAny(e.buttons) == true,
|
||||
);
|
||||
|
||||
final keyboardGroups = availableKeypairs
|
||||
.filter((e) => e.physicalKey != null)
|
||||
.groupBy((element) => '${element.physicalKey}-${element.isLongPress}');
|
||||
final touchGroups = availableKeypairs
|
||||
.filter((e) => e.physicalKey == null && e.touchPosition != Offset.zero)
|
||||
.groupBy((element) => '${element.touchPosition}-${element.isLongPress}');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (keymap.keyPairs.isEmpty)
|
||||
Text('No key mappings found. Please customize the keymap.')
|
||||
else
|
||||
Table(
|
||||
border: TableBorder.all(color: Theme.of(context).colorScheme.primaryContainer),
|
||||
children: [
|
||||
TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
'Button on your ${connectedDevice?.device.name ?? connectedDevice?.runtimeType}',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
'Action',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final pair in keyboardGroups.entries) ...[
|
||||
TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final keyPair in pair.value)
|
||||
for (final button in keyPair.buttons)
|
||||
if (connectedDevice?.availableButtons.contains(button) == true)
|
||||
IntrinsicWidth(child: _KeyWidget(label: button.name.splitByUpperCase())),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(Icons.keyboard, size: 16),
|
||||
_KeyWidget(label: pair.value.first.logicalKey?.keyLabel ?? ''),
|
||||
if (pair.value.first.isLongPress) Text('using long press'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
for (final pair in touchGroups.entries) ...[
|
||||
TableRow(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final keyPair in pair.value)
|
||||
for (final button in keyPair.buttons)
|
||||
if (connectedDevice?.availableButtons.contains(button) == true)
|
||||
_KeyWidget(label: button.name.splitByUpperCase()),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(Icons.touch_app, size: 16),
|
||||
_KeyWidget(
|
||||
label:
|
||||
'x: ${pair.value.first.touchPosition.dx.toInt()}, y: ${pair.value.first.touchPosition.dy.toInt()}',
|
||||
),
|
||||
|
||||
if (pair.value.first.isLongPress) Text('using long press'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _KeyWidget extends StatelessWidget {
|
||||
final String label;
|
||||
const _KeyWidget({super.key, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
constraints: BoxConstraints(minWidth: 30),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Theme.of(context).colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on String {
|
||||
String splitByUpperCase() {
|
||||
return replaceAllMapped(RegExp(r'([a-z])([A-Z])'), (match) => '${match.group(1)} ${match.group(2)}').capitalize();
|
||||
}
|
||||
}
|
||||
@@ -48,52 +48,45 @@ class _LogviewerState extends State<LogViewer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
SelectionArea(
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
children:
|
||||
_actions
|
||||
.map(
|
||||
(action) => Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: action.date.toString().split(" ").last,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: "monospace",
|
||||
fontFamilyFallback: <String>["Courier"],
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " ${action.entry}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
return SelectionArea(
|
||||
child: ListView(
|
||||
controller: _scrollController,
|
||||
children: [
|
||||
..._actions.map(
|
||||
(action) => Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: action.date.toString().split(" ").last,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontFamily: "monospace",
|
||||
fontFamilyFallback: <String>["Courier"],
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: " ${action.entry}",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_actions.clear();
|
||||
setState(() {});
|
||||
},
|
||||
icon: Icon(Icons.clear),
|
||||
child: Text('Clear Log'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../pages/device.dart';
|
||||
|
||||
List<Widget> buildMenuButtons() {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
PopupMenuButton(
|
||||
itemBuilder: (BuildContext context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
child: Text('via Credit Card, Google Pay, Apple Pay and others'),
|
||||
onTap: () {
|
||||
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
|
||||
final link = switch (currency.currencyName) {
|
||||
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
|
||||
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
|
||||
};
|
||||
launchUrlString(link);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && Platform.isAndroid && !isFromPlayStore)
|
||||
PopupMenuItem(
|
||||
child: Text('by buying the app from Play Store'),
|
||||
onTap: () {
|
||||
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('via PayPal'),
|
||||
onTap: () {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
child: Text('Donate ♥'),
|
||||
icon: Text('Donate ♥', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
const MenuButton(),
|
||||
SizedBox(width: 8),
|
||||
];
|
||||
@@ -37,6 +69,13 @@ class MenuButton extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
PopupMenuItem(child: PopupMenuDivider()),
|
||||
PopupMenuItem(
|
||||
child: Text('Continue'),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (c) => DevicePage()));
|
||||
},
|
||||
),
|
||||
PopupMenuItem(child: PopupMenuDivider()),
|
||||
],
|
||||
PopupMenuItem(
|
||||
child: Text('Feedback'),
|
||||
|
||||
@@ -5,12 +5,14 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:in_app_update/in_app_update.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
String? _latestVersionUrlValue;
|
||||
PackageInfo? _packageInfoValue;
|
||||
bool isFromPlayStore = true;
|
||||
|
||||
class AppTitle extends StatefulWidget {
|
||||
const AppTitle({super.key});
|
||||
@@ -20,14 +22,16 @@ class AppTitle extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppTitleState extends State<AppTitle> {
|
||||
Future<String?> getLatestVersionUrlIfNewer() async {
|
||||
Future<String?> _getLatestVersionUrlIfNewer() async {
|
||||
final response = await http.get(Uri.parse('https://api.github.com/repos/jonasbark/swiftcontrol/releases/latest'));
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
final latestVersion = data['tag_name'].split('+').first;
|
||||
final tagName = data['tag_name'] as String;
|
||||
final latestVersion = tagName.split('+').first;
|
||||
final currentVersion = 'v${_packageInfoValue!.version}';
|
||||
|
||||
if (latestVersion != null && latestVersion != currentVersion) {
|
||||
// +1337 releases are considered beta
|
||||
if (latestVersion != currentVersion && !tagName.endsWith("+1337")) {
|
||||
final assets = data['assets'] as List;
|
||||
if (Platform.isAndroid) {
|
||||
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
|
||||
@@ -54,16 +58,39 @@ class _AppTitleState extends State<AppTitle> {
|
||||
setState(() {
|
||||
_packageInfoValue = value;
|
||||
});
|
||||
_loadLatestVersionUrl();
|
||||
_checkForUpdate();
|
||||
});
|
||||
} else {
|
||||
_loadLatestVersionUrl();
|
||||
_checkForUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void _loadLatestVersionUrl() async {
|
||||
void _checkForUpdate() async {
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
final appUpdateInfo = await InAppUpdate.checkForUpdate();
|
||||
if (context.mounted && appUpdateInfo.updateAvailability == UpdateAvailability.updateAvailable) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('New version available'),
|
||||
duration: Duration(seconds: 1337),
|
||||
action: SnackBarAction(
|
||||
label: 'Update',
|
||||
onPressed: () {
|
||||
InAppUpdate.performImmediateUpdate();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} on Exception catch (e) {
|
||||
isFromPlayStore = false;
|
||||
print('Failed to check for update: $e');
|
||||
}
|
||||
}
|
||||
if (_latestVersionUrlValue == null && !kIsWeb) {
|
||||
final url = await getLatestVersionUrlIfNewer();
|
||||
final url = await _getLatestVersionUrlIfNewer();
|
||||
if (url != null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -89,7 +116,7 @@ class _AppTitleState extends State<AppTitle> {
|
||||
Text('SwiftControl'),
|
||||
if (_packageInfoValue != null)
|
||||
Text(
|
||||
'v${_packageInfoValue!.version}',
|
||||
'v${_packageInfoValue!.version}+${_packageInfoValue!.buildNumber}',
|
||||
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
|
||||
)
|
||||
else
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
platform :osx, '10.14'
|
||||
platform :osx, '10.15'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -64,7 +64,7 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
|
||||
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
|
||||
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
|
||||
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
|
||||
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
|
||||
@@ -73,6 +73,6 @@ SPEC CHECKSUMS:
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
||||
|
||||
PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82
|
||||
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -555,7 +555,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
@@ -643,7 +643,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -693,7 +693,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
|
||||
BIN
playstoreassets/appicon.png
Normal file
BIN
playstoreassets/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 517 KiB |
BIN
playstoreassets/logo.png
Normal file
BIN
playstoreassets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
BIN
playstoreassets/mob1.png
Normal file
BIN
playstoreassets/mob1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
BIN
playstoreassets/mob2.png
Normal file
BIN
playstoreassets/mob2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
BIN
playstoreassets/tab1.png
Normal file
BIN
playstoreassets/tab1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 305 KiB |
BIN
playstoreassets/tab2.png
Normal file
BIN
playstoreassets/tab2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
76
pubspec.lock
76
pubspec.lock
@@ -28,10 +28,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
version: "2.13.0"
|
||||
bluez:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -148,10 +148,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -212,10 +212,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flex_color_scheme
|
||||
sha256: "3344f8f6536c6ce0473b98e9f084ef80ca89024ad3b454f9c32cf840206f4387"
|
||||
sha256: "034d5720747e6af39b2ad090d82dd92d33fde68e7964f1814b714c9d49ddbd64"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.0"
|
||||
version: "8.3.0"
|
||||
flex_seed_scheme:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -249,10 +249,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: d59eeafd6df92174b1d5f68fc9d66634c97ce2e7cfe2293476236547bb19bbbd
|
||||
sha256: a9966c850de5e445331b854fa42df96a8020066d67f125a5964cbc6556643f68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "19.0.0"
|
||||
version: "19.4.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -265,18 +265,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "2569b973fc9d1f63a37410a9f7c1c552081226c597190cb359ef5d5762d1631c"
|
||||
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
version: "9.1.0"
|
||||
flutter_local_notifications_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_windows
|
||||
sha256: f8fc0652a601f83419d623c85723a3e82ad81f92b33eaa9bcc21ea1b94773e6e
|
||||
sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.0.2"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -294,10 +294,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_bluetooth
|
||||
sha256: "1363831def5eed1e1064d1eca04e8ccb35446e8f758579c3c519e156b77926da"
|
||||
sha256: ad26a1b3fef95b86ea5f63793b9a0cdc1a33490f35d754e4e711046cae3ebbf8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -391,6 +391,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1+1"
|
||||
in_app_update:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: in_app_update
|
||||
sha256: "9924a3efe592e1c0ec89dda3683b3cfec3d4cd02d908e6de00c24b759038ddb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.5"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -431,26 +447,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
|
||||
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.8"
|
||||
version: "11.0.1"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -788,10 +804,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
version: "0.7.6"
|
||||
time:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -828,10 +844,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: universal_ble
|
||||
sha256: "35d210e93a5938c6a6d1fd3c710cf4ac90b1bdd1b11c8eb2beeb32600672e6e6"
|
||||
sha256: "6a5c6c1fb295015934a5aef3dc751ae7e00721535275f8478bfe74db77b238c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.0"
|
||||
version: "0.21.1"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -900,18 +916,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.3.1"
|
||||
version: "15.0.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -969,5 +985,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.7.0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
dart: ">=3.8.0-0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
10
pubspec.yaml
10
pubspec.yaml
@@ -1,7 +1,7 @@
|
||||
name: swift_control
|
||||
description: "SwiftControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 2.0.4+0
|
||||
version: 2.5.0+5
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
@@ -11,8 +11,9 @@ dependencies:
|
||||
sdk: flutter
|
||||
|
||||
url_launcher: ^6.3.1
|
||||
flutter_local_notifications: ^19.0.0
|
||||
universal_ble: any
|
||||
flutter_local_notifications: ^19.4.1
|
||||
universal_ble: ^0.21.1
|
||||
intl: any
|
||||
protobuf: ^3.1.0
|
||||
permission_handler: ^11.4.0
|
||||
dartx: any
|
||||
@@ -23,8 +24,9 @@ dependencies:
|
||||
keypress_simulator:
|
||||
path: keypress_simulator/packages/keypress_simulator
|
||||
shared_preferences: ^2.5.3
|
||||
flex_color_scheme: ^8.2.0
|
||||
flex_color_scheme: ^8.3.0
|
||||
package_info_plus: ^8.3.0
|
||||
in_app_update: ^4.2.5
|
||||
accessibility:
|
||||
path: accessibility
|
||||
http: ^1.3.0
|
||||
|
||||
86
test/accessibility_disclosure_test.dart
Normal file
86
test/accessibility_disclosure_test.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:swift_control/widgets/accessibility_disclosure_dialog.dart';
|
||||
|
||||
void main() {
|
||||
group('AccessibilityDisclosureDialog', () {
|
||||
testWidgets('shows proper consent options with two buttons', (WidgetTester tester) async {
|
||||
bool acceptCalled = false;
|
||||
bool denyCalled = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AccessibilityDisclosureDialog(
|
||||
onAccept: () => acceptCalled = true,
|
||||
onDeny: () => denyCalled = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify dialog shows proper title
|
||||
expect(find.text('Accessibility Service Permission Required'), findsOneWidget);
|
||||
|
||||
// Verify both consent options are present
|
||||
expect(find.text('Allow'), findsOneWidget);
|
||||
expect(find.text('Deny'), findsOneWidget);
|
||||
|
||||
// Verify explanation text is present
|
||||
expect(find.textContaining('AccessibilityService API'), findsOneWidget);
|
||||
expect(find.textContaining('simulate touch gestures'), findsOneWidget);
|
||||
expect(find.textContaining('No personal data'), findsOneWidget);
|
||||
|
||||
// Test deny button
|
||||
await tester.tap(find.text('Deny'));
|
||||
await tester.pump();
|
||||
expect(denyCalled, isTrue);
|
||||
|
||||
// Reset and test accept button
|
||||
denyCalled = false;
|
||||
await tester.tap(find.text('Allow'));
|
||||
await tester.pump();
|
||||
expect(acceptCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('includes required disclosure information', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AccessibilityDisclosureDialog(
|
||||
onAccept: () {},
|
||||
onDeny: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Check for key disclosure elements required by Play Store
|
||||
expect(find.textContaining('Why is this permission needed?'), findsOneWidget);
|
||||
expect(find.textContaining('How does SwiftControl use this permission?'), findsOneWidget);
|
||||
expect(find.textContaining('Zwift Click, Zwift Ride, or Zwift Play'), findsOneWidget);
|
||||
expect(find.textContaining('training app window is active'), findsOneWidget);
|
||||
expect(find.textContaining('You must choose to either Allow or Deny'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('prevents dismissal via back navigation', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: AccessibilityDisclosureDialog(
|
||||
onAccept: () {},
|
||||
onDeny: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Verify PopScope is present to prevent back navigation
|
||||
expect(find.byType(PopScope), findsOneWidget);
|
||||
|
||||
// Get the PopScope widget and verify canPop is false
|
||||
final popScope = tester.widget<PopScope>(find.byType(PopScope));
|
||||
expect(popScope.canPop, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
75
test/long_press_test.dart
Normal file
75
test/long_press_test.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
void main() {
|
||||
group('Long Press KeyPair Tests', () {
|
||||
test('KeyPair should encode and decode isLongPress property', () {
|
||||
// Create a KeyPair with long press enabled
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
isLongPress: true,
|
||||
);
|
||||
|
||||
// Encode the KeyPair
|
||||
final encoded = keyPair.encode();
|
||||
|
||||
// Decode the KeyPair
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
// Verify the decoded KeyPair has the correct properties
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, true);
|
||||
expect(decoded.buttons, equals([ZwiftButton.a]));
|
||||
expect(decoded.physicalKey, equals(PhysicalKeyboardKey.keyA));
|
||||
expect(decoded.logicalKey, equals(LogicalKeyboardKey.keyA));
|
||||
});
|
||||
|
||||
test('KeyPair should default isLongPress to false when not specified in decode', () {
|
||||
// Create a legacy encoded KeyPair without isLongPress property
|
||||
const legacyEncoded = '''
|
||||
{
|
||||
"actions": ["a"],
|
||||
"logicalKey": "97",
|
||||
"physicalKey": "458752",
|
||||
"touchPosition": {"x": 0.0, "y": 0.0}
|
||||
}
|
||||
''';
|
||||
|
||||
// Decode the legacy KeyPair
|
||||
final decoded = KeyPair.decode(legacyEncoded);
|
||||
|
||||
// Verify the decoded KeyPair defaults isLongPress to false
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, false);
|
||||
});
|
||||
|
||||
test('KeyPair constructor should default isLongPress to false', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
);
|
||||
|
||||
expect(keyPair.isLongPress, false);
|
||||
});
|
||||
|
||||
test('KeyPair should correctly encode isLongPress false', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
isLongPress: false,
|
||||
);
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user