Compare commits

...

65 Commits

Author SHA1 Message Date
jonasbark
1e11d28765 Merge pull request #71 from jonasbark/copilot/fix-64
Fix Windows mouse clicks at wrong location due to display scaling
2025-09-17 08:49:53 +02:00
copilot-swe-agent[bot]
7ee9bc43a0 Fix changelog date to 2025-09-17
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-17 06:49:09 +00:00
copilot-swe-agent[bot]
372085ec0e Update version to 2.4.0+1 and add changelog entry
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-17 06:46:52 +00:00
copilot-swe-agent[bot]
e758b35837 Fix Windows mouse click scaling for high DPI displays
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-17 06:32:42 +00:00
copilot-swe-agent[bot]
dee7b86120 Initial plan 2025-09-17 06:28:06 +00:00
Jonas Bark
b3ec7e7a3a funding 2025-09-16 20:08:51 +02:00
Jonas Bark
bbd01d023a - Show an overview of the keymap bindings
- Allow customizing an existing keymap
2025-09-16 10:32:09 +02:00
Jonas Bark
36282c9fa9 better donate options 2025-09-16 08:59:50 +02:00
jonasbark
daea07c409 Clarify iOS not being supported 2025-09-15 08:08:07 +02:00
jonasbark
49d7445d0e Aktualisieren von README.md 2025-09-11 21:14:32 +02:00
jonasbark
9bb0e5616a Aktualisieren von pubspec.yaml 2025-09-11 19:27:47 +02:00
jonasbark
7e98f595ee Aktualisieren von CHANGELOG.md 2025-09-11 19:27:18 +02:00
Jonas Bark
a9fdc4b16e attempt to add support for Zwift Click v2 2025-09-10 17:40:14 +02:00
Jonas Bark
c06819b502 attempt to add support for Zwift Click v2 2025-09-10 08:42:55 +02:00
Jonas Bark
969faca658 attempt to add support for Zwift Click v2 2025-09-09 09:19:52 +02:00
Jonas Bark
61fbb099e2 actions fix 2025-09-08 16:55:28 +02:00
Jonas Bark
fbd6356be0 donate button change 2025-09-08 16:54:23 +02:00
Jonas Bark
1c40455bf3 update readme 2025-09-08 16:42:30 +02:00
Jonas Bark
15129634a6 update some libraries to ensure compatibility with latest Flutter 2025-09-08 16:23:20 +02:00
Jonas Bark
89d35d7734 update some libraries to ensure compatibility with latest Flutter 2025-09-08 15:49:31 +02:00
Jonas Bark
d959bfb4c9 Windows: adjust key sending method to improve compatibility with more apps (fixes #62) 2025-09-08 15:33:28 +02:00
Jonas Bark
9bc25514ae add launch.json for easier entry when using Visual Studio Code 2025-09-08 14:46:07 +02:00
Jonas Bark
25210b57ba try to add dlls to ZIP to potentially fix #54 2025-09-08 14:21:27 +02:00
jonasbark
c9317e369c Merge pull request #62 from jonasbark/copilot/fix-61
Add long press mode option for custom keymaps
2025-09-08 14:02:04 +02:00
Jonas Bark
2195c19ed9 allow long touches / keyboard presses (fixes #61) 2025-09-08 14:01:28 +02:00
Jonas Bark
d13a9d72c9 mark versions not ending with +0 as beta versions 2025-09-08 13:43:14 +02:00
Jonas Bark
55d230e41c Merge branch 'main' into copilot/fix-61 2025-09-08 12:59:14 +02:00
Jonas Bark
ffa604f921 fix logging messages 2025-09-08 12:59:04 +02:00
copilot-swe-agent[bot]
93bdfeeaa7 Refactor action method parameters from isPressed/isRepeated to isKeyDown/isKeyUp
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-07 10:18:20 +00:00
copilot-swe-agent[bot]
336c64e5a9 Update version to 2.2.0 in pubspec.yaml and CHANGELOG.md
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-06 07:03:19 +00:00
copilot-swe-agent[bot]
20a706d93d Address feedback: remove documentation file, revert README changes, bump version and update changelog
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-06 06:58:55 +00:00
copilot-swe-agent[bot]
21cb8844fc Complete long press feature implementation with cleanup, tests and documentation
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-06 06:48:13 +00:00
copilot-swe-agent[bot]
4bc1a3b1d0 Add long press functionality to KeyPair and update UI
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-09-06 06:46:23 +00:00
copilot-swe-agent[bot]
9df1f7cfa6 Initial plan 2025-09-06 06:39:18 +00:00
jonasbark
72cdf86802 Update README.md 2025-08-18 10:06:22 +02:00
jonasbark
9a53d5fdab Merge pull request #32 from jonasbark/copilot/fix-31
Fix Windows compilation error: FindTargetWindow identifier not found
2025-07-04 09:25:26 +02:00
copilot-swe-agent[bot]
458e6333a0 Fix C++ forward declaration error for FindTargetWindow
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-04 07:22:34 +00:00
copilot-swe-agent[bot]
f42e483260 Initial plan 2025-07-04 07:19:09 +00:00
jonasbark
dda2135129 Merge pull request #30 from jonasbark/copilot/fix-26
Implement window-focused key simulation for Windows applications
2025-07-04 08:51:42 +02:00
copilot-swe-agent[bot]
bc2831c17e Update version to 2.1.0 and add changelog entry for automatic window focusing feature
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-03 15:00:12 +00:00
copilot-swe-agent[bot]
310313c3b2 Add check to avoid focusing window if already in foreground
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-03 14:56:11 +00:00
copilot-swe-agent[bot]
2122568461 Remove documentation and utility files as requested, keep automatic window focusing in Windows C++ plugin
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-03 14:23:43 +00:00
copilot-swe-agent[bot]
144fd5b740 Remove Dart API changes and implement automatic window focusing in Windows SimulateKeyPress method
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-03 14:13:14 +00:00
copilot-swe-agent[bot]
5f7a1a8203 Complete window-focused key simulation implementation with utilities and README update
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-03 14:02:59 +00:00
copilot-swe-agent[bot]
258b396444 Add documentation and testing tools for window-focused key simulation feature
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-03 14:01:10 +00:00
copilot-swe-agent[bot]
5861533793 Improve window-focused key simulation with better error handling and SendInput fallback
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-03 13:59:47 +00:00
copilot-swe-agent[bot]
3106bd09e8 Implement window-focused key simulation for Windows applications
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-07-03 13:53:30 +00:00
copilot-swe-agent[bot]
a3475a02d2 Initial plan 2025-07-03 13:42:04 +00:00
Jonas Bark
fb1a1f35ad you can now assign Escape and arrow down key to your custom keymap (fixes #18) 2025-05-04 10:46:00 +02:00
Jonas Bark
71aadde901 more troubleshooting, always use light theme 2025-05-02 19:10:56 +02:00
Jonas Bark
f7bfd8c206 UX improvements 2025-04-25 09:23:58 +02:00
Jonas Bark
ff83e5271b add Biketerra keymap (fixes #17) 2025-04-23 08:29:34 +02:00
Jonas Bark
ec6edb2864 add Biketerra keymap (fixes #17) 2025-04-18 09:44:19 +02:00
Jonas Bark
4f4a6f60c5 fix MyWhoosh up / downshift button assignment (I key vs K key) 2025-04-15 11:18:42 +02:00
Jonas Bark
354e13678b fix Zwift Click button assignment #12 2025-04-13 20:47:42 +02:00
Jonas Bark
f1b8822e20 vibrate Zwift Play / Zwift Ride controller on gear shift (thanks @cagnulein, closes #16) 2025-04-10 13:31:55 +02:00
jonasbark
6bf83b1034 Aktualisieren von README.md 2025-04-09 17:43:06 +02:00
Jonas Bark
7b1e4ede2a version++ 2025-04-08 08:53:57 +02:00
Jonas Bark
a554820115 open menu to make it clear you can simulate touch *and* keyboard press 2025-04-08 08:42:43 +02:00
Jonas Bark
cb9f9ea5b3 reconnect device if connection is lost 2025-04-08 08:33:18 +02:00
Jonas Bark
4051553a56 fix button assignment and logging 2025-04-08 08:20:15 +02:00
Jonas Bark
01a213354b potentially fix #12 2025-04-08 08:05:37 +02:00
Jonas Bark
962abfb38e add some personal preference for MyWhoosh 2025-04-07 15:35:05 +02:00
Jonas Bark
ada4cf0dfd Android: better approximation of button placement for freeform windows 2025-04-07 15:21:45 +02:00
Jonas Bark
aff1137c3d fix bluetooth scan issues on older Android devices by asking for location permission 2025-04-07 12:48:20 +02:00
48 changed files with 1246 additions and 372 deletions

3
.github/FUNDING.yml vendored
View File

@@ -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"]

View File

@@ -179,6 +179,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

View File

@@ -1,7 +1,53 @@
#### 2.0.1 (2025-04-06)
### 2.4.0+1 (2025-09-17)
- Windows: fix mouse clicks at wrong location due to display scaling (fixes #64)
### 2.4.0 (2025-09-16)
- Show an overview of the keymap bindings
- Allow customizing an existing keymap
- Add more donation options
### 2.3.0 (2025-09-11)
- Add support for latest Zwift Click v2
### 2.2.0 (2025-09-08)
- Add Long Press Mode option for custom keymaps - buttons can now send sustained key presses instead of repeated taps, perfect for movement controls in games (fixes #61)
- Windows: adjust key sending method to improve compatibility with more apps (fixes #62)
### 2.1.0 (2025-07-03)
- Windows: automatically focus compatible training apps (MyWhoosh, IndieVelo, Biketerra) when sending keystrokes, enabling seamless multi-window usage
### 2.0.9 (2025-05-04)
- you can now assign Escape and arrow down key to your custom keymap (#18)
### 2.0.8 (2025-05-02)
- only use the light theme for the app
- more troubleshooting information
### 2.0.7 (2025-04-18)
- add Biketerra.com keymap
- some UX improvements
### 2.0.6 (2025-04-15)
- fix MyWhoosh up / downshift button assignment (I key vs K key)
### 2.0.5 (2025-04-13)
- fix Zwift Click button assignment (#12)
### 2.0.4 (2025-04-10)
- vibrate Zwift Play / Zwift Ride controller on gear shift (thanks @cagnulein, closes #16)
### 2.0.3 (2025-04-08)
- adjust TrainingPeaks Virtual key mapping (#12)
- attempt to reconnect device if connection is lost
- Android: detect freeform windows for MyWhoosh + TrainingPeaks Virtual keymaps
### 2.0.2 (2025-04-07)
- fix bluetooth scan issues on older Android devices by asking for location permission
### 2.0.1 (2025-04-06)
- long pressing a button will trigger the action again every 250ms
#### 2.0.0 (2025-04-06)
### 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

View File

@@ -4,7 +4,7 @@
## 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
@@ -23,30 +23,36 @@ Get the latest version 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
- 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).
- [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 a touch on a certain part of the screen is simulated to trigger the action.
- 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

View File

@@ -61,23 +61,29 @@ enum class MediaAction(val raw: Int) {
/** Generated class from Pigeon that represents data sent in messages. */
data class WindowEvent (
val packageName: String,
val windowHeight: Long,
val windowWidth: Long
val top: Long,
val bottom: Long,
val right: Long,
val left: Long
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): WindowEvent {
val packageName = pigeonVar_list[0] as String
val windowHeight = pigeonVar_list[1] as Long
val windowWidth = pigeonVar_list[2] as Long
return WindowEvent(packageName, windowHeight, windowWidth)
val top = pigeonVar_list[1] as Long
val bottom = pigeonVar_list[2] as Long
val right = pigeonVar_list[3] as Long
val left = pigeonVar_list[4] as Long
return WindowEvent(packageName, top, bottom, right, left)
}
}
fun toList(): List<Any?> {
return listOf(
packageName,
windowHeight,
windowWidth,
top,
bottom,
right,
left,
)
}
override fun equals(other: Any?): Boolean {
@@ -88,8 +94,10 @@ data class WindowEvent (
return true
}
return packageName == other.packageName
&& windowHeight == other.windowHeight
&& windowWidth == other.windowWidth
&& top == other.top
&& bottom == other.bottom
&& right == other.right
&& left == other.left
}
override fun hashCode(): Int = toList().hashCode()
@@ -131,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 {
@@ -181,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)

View File

@@ -7,6 +7,7 @@ import StreamEventsStreamHandler
import WindowEvent
import android.content.Context
import android.content.Intent
import android.graphics.Rect
import android.os.Bundle
import android.provider.Settings
import androidx.core.content.ContextCompat.startActivity
@@ -50,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)
}
}
@@ -84,8 +89,8 @@ class EventListener : StreamEventsStreamHandler(), Receiver {
eventSink = null
}
override fun onChange(packageName: String, windowWidth: Int, windowHeight: Int) {
eventSink?.success(WindowEvent(packageName = packageName, windowWidth = windowWidth.toLong(), windowHeight = windowHeight.toLong()))
override fun onChange(packageName: String, window: Rect) {
eventSink?.success(WindowEvent(packageName = packageName, right = window.right.toLong(), left = window.left.toLong(), bottom = window.bottom.toLong(), top = window.top.toLong()))
}
}

View File

@@ -5,7 +5,6 @@ import android.accessibilityservice.GestureDescription
import android.accessibilityservice.GestureDescription.StrokeDescription
import android.graphics.Path
import android.graphics.Rect
import android.os.Build
import android.util.Log
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityEvent
@@ -37,36 +36,29 @@ class AccessibilityService : AccessibilityService(), Listener {
}
val currentPackageName = event.packageName.toString()
val windowSize = getWindowSize()
Observable.fromService?.onChange(packageName = currentPackageName, windowHeight = windowSize.bottom, windowWidth = windowSize.right)
Observable.fromService?.onChange(packageName = currentPackageName, window = windowSize)
}
private fun getWindowSize(): Rect {
val outBounds = Rect()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
rootInActiveWindow.getBoundsInWindow(outBounds)
} else {
rootInActiveWindow.getBoundsInScreen(outBounds)
}
rootInActiveWindow.getBoundsInScreen(outBounds)
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)
}
}

View File

@@ -1,14 +1,16 @@
package de.jonasbark.accessibility
import android.graphics.Rect
object Observable {
var toService: Listener? = null
var fromService: Receiver? = null
}
interface Listener {
fun performTouch(x: Double, y: Double)
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
}
interface Receiver {
fun onChange(packageName: String, windowWidth: Int, windowHeight: Int)
}
fun onChange(packageName: String, window: Rect)
}

View File

@@ -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);
}
@@ -15,10 +15,18 @@ enum MediaAction { playPause, next, volumeUp, volumeDown }
class WindowEvent {
final String packageName;
final int windowHeight;
final int windowWidth;
final int top;
final int bottom;
final int right;
final int left;
WindowEvent({required this.packageName, required this.windowHeight, required this.windowWidth});
WindowEvent({
required this.packageName,
required this.left,
required this.right,
required this.top,
required this.bottom,
});
}
@EventChannelApi()

View File

@@ -25,21 +25,29 @@ enum MediaAction {
class WindowEvent {
WindowEvent({
required this.packageName,
required this.windowHeight,
required this.windowWidth,
required this.top,
required this.bottom,
required this.right,
required this.left,
});
String packageName;
int windowHeight;
int top;
int windowWidth;
int bottom;
int right;
int left;
List<Object?> _toList() {
return <Object?>[
packageName,
windowHeight,
windowWidth,
top,
bottom,
right,
left,
];
}
@@ -50,8 +58,10 @@ class WindowEvent {
result as List<Object?>;
return WindowEvent(
packageName: result[0]! as String,
windowHeight: result[1]! as int,
windowWidth: result[2]! as int,
top: result[1]! as int,
bottom: result[2]! as int,
right: result[3]! as int,
left: result[4]! as int,
);
}
@@ -66,8 +76,10 @@ class WindowEvent {
}
return
packageName == other.packageName
&& windowHeight == other.windowHeight
&& windowWidth == other.windowWidth;
&& top == other.top
&& bottom == other.bottom
&& right == other.right
&& left == other.left;
}
@override
@@ -175,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) {

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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);
}

View File

@@ -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.');
}
}

View File

@@ -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) {

View File

@@ -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
View 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"
}
]
}

View File

@@ -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,7 +22,13 @@ 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]);
// these don't actually seem to matter, its just the header has to be 7 bytes RIDEON + 2
static final REQUEST_START = Uint8List.fromList([0, 9]); //byteArrayOf(1, 2)
@@ -45,6 +51,8 @@ class Constants {
enum DeviceType {
click,
clickV2Right,
clickV2Left,
playLeft,
playRight,
rideRight,
@@ -60,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:

View File

@@ -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}'));
}
}
};
@@ -56,6 +60,7 @@ class Connection {
Future<void> performScanning() async {
isScanning.value = true;
_actionStreams.add(LogNotification('Scanning for devices...'));
// does not work on web, may not work on Windows
if (!kIsWeb && !Platform.isWindows) {
@@ -126,11 +131,21 @@ class Connection {
final actionSubscription = bleDevice.actionStream.listen((data) {
_actionStreams.add(data);
});
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((
state,
) async {
bleDevice.isConnected = state.isConnected;
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((state) {
bleDevice.isConnected = state;
_connectionStreams.add(bleDevice);
if (!bleDevice.isConnected) {
devices.remove(bleDevice);
_streamSubscriptions[bleDevice]?.cancel();
_streamSubscriptions.remove(bleDevice);
_connectionSubscriptions[bleDevice]?.cancel();
_connectionSubscriptions.remove(bleDevice);
_lastScanResult.clear();
// try reconnect
if (!isScanning.value) {
performScanning();
}
}
});
_connectionSubscriptions[bleDevice] = connectionStateSubscription;

View File

@@ -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';
@@ -27,7 +29,9 @@ abstract class BaseDevice {
bool supportsEncryption = true;
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;
@@ -38,7 +42,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,
};
@@ -58,8 +62,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,
};
}
@@ -114,7 +120,7 @@ abstract class BaseDevice {
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
);
final syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
);
@@ -122,42 +128,32 @@ 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(syncRxCharacteristic);
await _setupHandshake();
}
Future<void> _setupHandshake(BleCharacteristic syncRxCharacteristic) async {
Future<void> _setupHandshake() async {
if (supportsEncryption) {
await UniversalBle.writeValue(
await UniversalBle.write(
device.deviceId,
customServiceId,
syncRxCharacteristic.uuid,
syncRxCharacteristic!.uuid,
Uint8List.fromList([
...Constants.RIDE_ON,
...Constants.REQUEST_START,
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
]),
BleOutputProperty.withoutResponse,
withoutResponse: true,
);
} else {
await UniversalBle.writeValue(
await UniversalBle.write(
device.deviceId,
customServiceId,
syncRxCharacteristic.uuid,
syncRxCharacteristic!.uuid,
Constants.RIDE_ON,
BleOutputProperty.withoutResponse,
withoutResponse: true,
);
}
}
@@ -208,7 +204,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;
}
@@ -240,16 +240,38 @@ abstract class BaseDevice {
} else if (buttonsClicked.isEmpty) {
actionStreamInternal.add(LogNotification('Buttons released'));
_longPressTimer?.cancel();
} else {
_longPressTimer?.cancel();
_longPressTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) async {
for (final action in buttonsClicked) {
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
}
});
for (final action in buttonsClicked) {
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
// Handle release events for long press keys
final buttonsReleased = _previouslyPressedButtons.toList();
if (buttonsReleased.isNotEmpty) {
await _performRelease(buttonsReleased);
}
_previouslyPressedButtons.clear();
} else {
// Handle release events for buttons that are no longer pressed
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
if (buttonsReleased.isNotEmpty) {
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);
}
})
.catchError((e) {
@@ -260,4 +282,48 @@ abstract class BaseDevice {
}
Future<List<ZwiftButton>?> processClickNotification(Uint8List message);
Future<void> _performActions(List<ZwiftButton> buttonsClicked, bool repeated) async {
if (!repeated &&
buttonsClicked.any(((e) => e.action == InGameAction.shiftDown || e.action == InGameAction.shiftUp))) {
await _vibrate();
}
for (final action in buttonsClicked) {
// For repeated actions, don't trigger key down/up events (useful for long press)
final isKeyDown = !repeated;
actionStreamInternal.add(
LogNotification(await actionHandler.performAction(action, isKeyDown: isKeyDown, isKeyUp: false)),
);
}
}
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.write(
device.deviceId,
customServiceId,
syncRxCharacteristic!.uuid,
supportsEncryption ? zapEncryption.encrypt(vibrateCommand) : vibrateCommand,
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;
}
}

View File

@@ -0,0 +1,5 @@
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
class ZwiftClickV2 extends ZwiftRide {
ZwiftClickV2(super.scanResult);
}

View File

@@ -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,
];
}

View File

@@ -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(),
);
}

View File

@@ -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,7 +58,7 @@ 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,
@@ -70,80 +71,92 @@ class _DevicePageState extends State<DevicePage> {
),
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,
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'),
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!);
}
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()),
],
),
),

View File

@@ -30,9 +30,13 @@ class _ScanWidgetState extends State<ScanWidget> {
WidgetsBinding.instance.addPostFrameCallback((_) {
// must be called from a button
if (!kIsWeb) {
Future.delayed(Duration(seconds: 1)).then((_) {
connection.performScanning();
});
Future.delayed(Duration(seconds: 1))
.then((_) {
return connection.performScanning();
})
.catchError((e) {
print(e);
});
}
});
}

View File

@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:swift_control/main.dart';
import 'package:window_manager/window_manager.dart';
@@ -65,7 +66,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
windowManager.setFullScreen(true);
}
_actionSubscription = connection.actionStream.listen((data) {
_actionSubscription = connection.actionStream.listen((data) async {
if (!mounted) {
return;
}
@@ -81,15 +82,26 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
if (_pressedButton != null) {
if (actionHandler.supportedApp!.keymap.getKeyPair(_pressedButton!) == null) {
final KeyPair keyPair;
actionHandler.supportedApp!.keymap.keyPairs.add(
KeyPair(
touchPosition: context.size!.center(Offset.zero),
keyPair = KeyPair(
touchPosition: context.size!
.center(Offset.zero)
.translate(actionHandler.supportedApp!.keymap.keyPairs.length * 40, 0),
buttons: [_pressedButton!],
physicalKey: null,
logicalKey: null,
isLongPress: false,
),
);
setState(() {});
// open menu
if (Platform.isMacOS || Platform.isWindows) {
await Future.delayed(Duration(milliseconds: 300));
await keyPressSimulator.simulateMouseClickDown(keyPair.touchPosition);
await keyPressSimulator.simulateMouseClickUp(keyPair.touchPosition);
}
}
}
});
@@ -111,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),
@@ -122,16 +138,84 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
setState(() {});
},
),
if (keyPair.physicalKey != null)
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: const Text('Use as touch button'),
onTap: () {
keyPair.physicalKey = null;
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)),
onTap: () {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
setState(() {});
},
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
onTap: () {
keyPair.isLongPress = !keyPair.isLongPress;
setState(() {});
},
child: CheckboxListTile(
value: keyPair.isLongPress,
onChanged: (value) {
keyPair.isLongPress = value ?? false;
setState(() {});
Navigator.of(context).pop();
},
title: const Text('Long Press Mode (vs. repeating)'),
),
),
PopupMenuDivider(),
PopupMenuItem(
child: PopupMenuButton<PhysicalKeyboardKey>(
padding: EdgeInsets.zero,
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaPlayPause,
child: const Text('Media: Play/Pause'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaStop,
child: const Text('Media: Stop'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackPrevious,
child: const Text('Media: Previous'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackNext,
child: const Text('Media: Next'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeUp,
child: const Text('Media: Volume Up'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeDown,
child: const Text('Media: Volume Down'),
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: ListTile(
leading: Icon(Icons.music_note_outlined),
trailing: Icon(Icons.arrow_right),
title: Text('Simulate Media key'),
),
),
),
PopupMenuDivider(),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Delete Keymap'), leading: Icon(Icons.delete, color: Colors.red)),
onTap: () {
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
setState(() {});
},
),
],
onSelected: (key) {
keyPair.physicalKey = key;
@@ -148,12 +232,8 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
color: Colors.transparent,
child: _TouchDot(color: Colors.yellow, label: label, keyPair: keyPair),
),
onDragUpdate: (details) {
print('Dragging: ${details.localPosition}');
},
childWhenDragging: const SizedBox.shrink(),
onDraggableCanceled: (_, offset) {
print('Drag canceled: ${offset}');
setState(() => onPositionChanged(offset));
},
child: _TouchDot(color: color, label: label, keyPair: keyPair),
@@ -260,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)),
],
),
),
],
);
}

View File

@@ -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");
}
@@ -41,7 +41,10 @@ class AndroidActions extends BaseActions {
}
}
final point = supportedApp!.resolveTouchPosition(action: button, windowInfo: windowInfo);
accessibilityHandler.performTouch(point.dx, point.dy);
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()}";
if (point != Offset.zero) {
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()}";
}
return "No touch performed";
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View 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;
}

View File

@@ -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,
),
);
}
}
}

View File

@@ -1,6 +1,7 @@
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import '../../single_line_exception.dart';
@@ -16,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(),
@@ -52,10 +53,44 @@ class MyWhoosh extends SupportedApp {
if (windowInfo == null) {
throw SingleLineException("Window size not known - open $this first");
}
// just my personal preference
switch (action) {
case ZwiftButton.y:
accessibilityHandler.controlMedia(MediaAction.volumeUp);
return Offset.zero;
case ZwiftButton.b:
accessibilityHandler.controlMedia(MediaAction.volumeDown);
return Offset.zero;
case ZwiftButton.a:
accessibilityHandler.controlMedia(MediaAction.next);
return Offset.zero;
case ZwiftButton.z:
accessibilityHandler.controlMedia(MediaAction.playPause);
return Offset.zero;
default:
break;
}
return switch (action.action) {
InGameAction.shiftUp => Offset(windowInfo.windowWidth * 0.98, windowInfo.windowHeight * 0.94),
InGameAction.shiftDown => Offset(windowInfo.windowWidth * 0.80, windowInfo.windowHeight * 0.94),
InGameAction.shiftUp => Offset(
windowInfo.right - windowInfo.width * 0.02,
windowInfo.bottom - windowInfo.height * 0.06,
),
InGameAction.shiftDown => Offset(
windowInfo.right - windowInfo.width * 0.20,
windowInfo.bottom - windowInfo.height * 0.06,
),
InGameAction.navigateRight => Offset(
windowInfo.right - windowInfo.width * 0.02,
windowInfo.bottom - windowInfo.height * 0.20,
),
_ => throw SingleLineException("Unsupported action for MyWhoosh: $action"),
};
}
}
extension WindowSize on WindowEvent {
int get width => right - left;
int get height => bottom - top;
}

View File

@@ -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() {

View File

@@ -1,6 +1,7 @@
import 'package:accessibility/accessibility.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/utils/single_line_exception.dart';
@@ -17,13 +18,13 @@ class TrainingPeaks extends SupportedApp {
// https://help.trainingpeaks.com/hc/en-us/articles/31340399556877-TrainingPeaks-Virtual-Controls-and-Keyboard-Shortcuts
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
physicalKey: PhysicalKeyboardKey.minus,
logicalKey: LogicalKeyboardKey.minus,
physicalKey: PhysicalKeyboardKey.numpadSubtract,
logicalKey: LogicalKeyboardKey.numpadSubtract,
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
physicalKey: PhysicalKeyboardKey.equal,
logicalKey: LogicalKeyboardKey.equal,
physicalKey: PhysicalKeyboardKey.numpadAdd,
logicalKey: LogicalKeyboardKey.numpadAdd,
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
@@ -64,8 +65,8 @@ class TrainingPeaks extends SupportedApp {
throw SingleLineException("Window size not known - open $this first");
}
return switch (action.action) {
InGameAction.shiftUp => Offset(windowInfo.windowWidth / 2 * 1.32, windowInfo.windowHeight * 0.74),
InGameAction.shiftDown => Offset(windowInfo.windowWidth / 2 * 1.15, windowInfo.windowHeight * 0.74),
InGameAction.shiftUp => Offset(windowInfo.width / 2 * 1.32, windowInfo.height * 0.74),
InGameAction.shiftDown => Offset(windowInfo.width / 2 * 1.15, windowInfo.height * 0.74),
_ => throw SingleLineException("Unsupported action for IndieVelo: $action"),
};
}

View File

@@ -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,
);
}
}

View File

@@ -34,6 +34,21 @@ class BluetoothScanRequirement extends PlatformRequirement {
}
}
class LocationRequirement extends PlatformRequirement {
LocationRequirement() : super('Allow Location so Bluetooth scan works');
@override
Future<void> call() async {
await Permission.locationWhenInUse.request();
}
@override
Future<void> getStatus() async {
final state = await Permission.locationWhenInUse.status;
status = state.isGranted || state.isLimited;
}
}
class BluetoothConnectRequirement extends PlatformRequirement {
BluetoothConnectRequirement() : super('Allow Bluetooth Connections');

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/utils/requirements/android.dart';
@@ -29,12 +30,18 @@ Future<List<PlatformRequirement>> getRequirements() async {
} else if (Platform.isWindows) {
list = [BluetoothTurnedOn(), KeyboardRequirement(), BluetoothScanning()];
} else if (Platform.isAndroid) {
final deviceInfoPlugin = DeviceInfoPlugin();
final deviceInfo = await deviceInfoPlugin.androidInfo;
list = [
BluetoothTurnedOn(),
AccessibilityRequirement(),
NotificationRequirement(),
BluetoothScanRequirement(),
BluetoothConnectRequirement(),
if (deviceInfo.version.sdkInt <= 30)
LocationRequirement()
else ...[
BluetoothScanRequirement(),
BluetoothConnectRequirement(),
],
BluetoothScanning(),
];
} else {

View File

@@ -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')),
),
),
],
),
),

View File

@@ -0,0 +1,148 @@
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 keyboardGroups = keymap.keyPairs
.filter((e) => e.physicalKey != null)
.groupBy((element) => '${element.physicalKey}-${element.isLongPress}');
final touchGroups = keymap.keyPairs
.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 ${connection.devices.firstOrNull?.device.name ?? connection.devices.firstOrNull?.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)
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) _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();
}
}

View File

@@ -1,17 +1,41 @@
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: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);
},
),
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 +61,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'),

View File

@@ -24,10 +24,12 @@ class _AppTitleState extends State<AppTitle> {
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) {
// we anything but +0 is considered beta
if (latestVersion != currentVersion && tagName.endsWith("+0")) {
final assets = data['assets'] as List;
if (Platform.isAndroid) {
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
@@ -89,7 +91,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

View File

@@ -5,6 +5,7 @@
import FlutterMacOS
import Foundation
import device_info_plus
import file_selector_macos
import flutter_local_notifications
import keypress_simulator_macos
@@ -16,6 +17,7 @@ import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin"))

View File

@@ -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'

View File

@@ -1,4 +1,6 @@
PODS:
- device_info_plus (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
- FlutterMacOS
- flutter_local_notifications (0.0.1):
@@ -22,6 +24,7 @@ PODS:
- FlutterMacOS
DEPENDENCIES:
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
@@ -34,6 +37,8 @@ DEPENDENCIES:
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
EXTERNAL SOURCES:
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
flutter_local_notifications:
@@ -56,9 +61,10 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
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
@@ -67,6 +73,6 @@ SPEC CHECKSUMS:
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
COCOAPODS: 1.16.2

View File

@@ -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;

View File

@@ -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:
@@ -128,14 +128,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "306b78788d1bb569edb7c55d622953c2414ca12445b41c9117963e03afc5c513"
url: "https://pub.dev"
source: hosted
version: "11.3.3"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "0b04e02b30791224b31969eb1b50d723498f402971bff3630bca2ba839bd1ed2"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
fake_async:
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:
@@ -196,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:
@@ -233,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:
@@ -249,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:
@@ -278,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
@@ -375,6 +391,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation:
dependency: transitive
description:
@@ -415,26 +439,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:
@@ -772,10 +796,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:
@@ -812,10 +836,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:
@@ -884,18 +908,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:
@@ -912,6 +936,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.12.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
window_manager:
dependency: "direct main"
description:
@@ -945,5 +977,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"

View File

@@ -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.1+0
version: 2.4.0+1
environment:
sdk: ^3.7.0
@@ -11,18 +11,20 @@ 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
image_picker: ^1.1.2
pointycastle: any
window_manager: ^0.4.3
device_info_plus: ^11.3.3
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
accessibility:
path: accessibility

75
test/long_press_test.dart Normal file
View 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);
});
});
}