Compare commits

...

41 Commits

Author SHA1 Message Date
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
42 changed files with 720 additions and 244 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,2 @@
github: [jonasbark]
open_collective: jonas-bark1
custom: ["https://paypal.me/boni"]

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

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,6 +23,7 @@ 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
@@ -34,21 +35,22 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
## Supported Platforms
- Android
- App is losing connection over time? Read about how to [keep the app alive](https://dontkillmyapp.com/).
- macOS
- Windows
- make sure you have installed the "[Microsoft Visual C++ Runtime libraries](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170)"
- Windows may flag the app as virus. I think it does so because the app does control the mouse and keyboard.
- Bluetooth connection unstable? You may need to use an [external Bluetooth adapter](https://github.com/jonasbark/swiftcontrol/issues/14#issuecomment-3193839509).
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
## 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

@@ -4,7 +4,12 @@
import android.util.Log
import io.flutter.plugin.common.*
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
@@ -134,7 +139,7 @@ val AccessibilityApiPigeonMethodCodec = StandardMethodCodec(AccessibilityApiPige
interface Accessibility {
fun hasPermission(): Boolean
fun openPermissions()
fun performTouch(x: Double, y: Double)
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
fun controlMedia(action: MediaAction)
companion object {
@@ -184,8 +189,10 @@ interface Accessibility {
val args = message as List<Any?>
val xArg = args[0] as Double
val yArg = args[1] as Double
val isKeyDownArg = args[2] as Boolean
val isKeyUpArg = args[3] as Boolean
val wrapped: List<Any?> = try {
api.performTouch(xArg, yArg)
api.performTouch(xArg, yArg, isKeyDownArg, isKeyUpArg)
listOf(null)
} catch (exception: Throwable) {
wrapError(exception)
@@ -235,9 +242,9 @@ private class AccessibilityApiPigeonStreamHandler<T>(
}
interface AccessibilityApiPigeonEventChannelWrapper<T> {
fun onListen(p0: Any?, sink: PigeonEventSink<T>) {}
open fun onListen(p0: Any?, sink: PigeonEventSink<T>) {}
fun onCancel(p0: Any?) {}
open fun onCancel(p0: Any?) {}
}
class PigeonEventSink<T>(private val sink: EventChannel.EventSink) {
@@ -253,7 +260,7 @@ class PigeonEventSink<T>(private val sink: EventChannel.EventSink) {
sink.endOfStream()
}
}
abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWrapper<WindowEvent> {
companion object {
fun register(messenger: BinaryMessenger, streamHandler: StreamEventsStreamHandler, instanceName: String = "") {
@@ -266,4 +273,4 @@ abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWra
}
}
}

View File

@@ -51,23 +51,27 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
}, Bundle.EMPTY)
}
override fun performTouch(x: Double, y: Double) {
Observable.toService?.performTouch(x = x, y = y) ?: error("Service not running")
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
Observable.toService?.performTouch(x = x, y = y, isKeyUp = isKeyUp, isKeyDown = isKeyDown) ?: error("Service not running")
}
override fun controlMedia(action: MediaAction) {
val audioService = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
when (action) {
MediaAction.PLAY_PAUSE -> {
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
}
MediaAction.NEXT -> {
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
}
MediaAction.VOLUME_DOWN -> {
audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI)
}
MediaAction.VOLUME_UP -> {
audioService.adjustVolume(android.media.AudioManager.ADJUST_RAISE, android.media.AudioManager.FLAG_SHOW_UI)
}
MediaAction.VOLUME_DOWN -> audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI)
MediaAction.VOLUME_UP -> audioService.adjustVolume(android.media.AudioManager.ADJUST_RAISE, android.media.AudioManager.FLAG_SHOW_UI)
}
}

View File

@@ -45,23 +45,20 @@ class AccessibilityService : AccessibilityService(), Listener {
return outBounds
}
private fun simulateTap(x: Double, y: Double) {
val gestureBuilder = GestureDescription.Builder()
val path = Path()
path.moveTo(x.toFloat(), y.toFloat())
path.lineTo(x.toFloat()+1, y.toFloat())
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong())
gestureBuilder.addStroke(stroke)
dispatchGesture(gestureBuilder.build(), null, null)
}
override fun onInterrupt() {
Log.d("AccessibilityService", "Service Interrupted")
}
override fun performTouch(x: Double, y: Double) {
simulateTap(x, y)
override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
val gestureBuilder = GestureDescription.Builder()
val path = Path()
path.moveTo(x.toFloat(), y.toFloat())
path.lineTo(x.toFloat()+1, y.toFloat())
val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown)
gestureBuilder.addStroke(stroke)
dispatchGesture(gestureBuilder.build(), null, null)
}
}

View File

@@ -8,9 +8,9 @@ object Observable {
}
interface Listener {
fun performTouch(x: Double, y: Double)
fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
}
interface Receiver {
fun onChange(packageName: String, window: Rect)
}
}

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

View File

@@ -187,14 +187,14 @@ class Accessibility {
}
}
Future<void> performTouch(double x, double y) async {
Future<void> performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false, }) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.performTouch$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[x, y]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[x, y, isKeyDown, isKeyUp]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {

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,8 @@
// This must be included before many other Windows headers.
#include <windows.h>
#include <psapi.h>
#include <string.h>
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
@@ -16,6 +18,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 +66,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 +115,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);
@@ -110,17 +133,80 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
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,6 +22,9 @@ class Constants {
// Zwift Click = BC1
static const BC1 = 0x09;
// Zwift Click v2 Right (unconfirmed)
static const CLICK_RIGHT_SIDE = 0x0A;
static final RIDE_ON = Uint8List.fromList([0x52, 0x69, 0x64, 0x65, 0x4f, 0x6e]);
static final VIBRATE_PATTERN = Uint8List.fromList([0x12, 0x12, 0x08, 0x0A, 0x06, 0x08, 0x02, 0x10, 0x00, 0x18]);
@@ -46,6 +49,7 @@ class Constants {
enum DeviceType {
click,
clickV2Right,
playLeft,
playRight,
rideRight,
@@ -61,6 +65,8 @@ enum DeviceType {
switch (data) {
case Constants.BC1:
return DeviceType.click;
case Constants.CLICK_RIGHT_SIDE:
return DeviceType.clickV2Right;
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}'));
}
}
};
@@ -128,7 +132,7 @@ class Connection {
_actionStreams.add(data);
});
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((state) {
bleDevice.isConnected = state.isConnected;
bleDevice.isConnected = state;
_connectionStreams.add(bleDevice);
if (!bleDevice.isConnected) {
devices.remove(bleDevice);

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';
@@ -29,6 +31,7 @@ abstract class BaseDevice {
BleCharacteristic? syncRxCharacteristic;
Timer? _longPressTimer;
Set<ZwiftButton> _previouslyPressedButtons = <ZwiftButton>{};
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
@@ -39,7 +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 right controller
_ => null,
};
@@ -61,6 +64,7 @@ abstract class BaseDevice {
DeviceType.playLeft => ZwiftPlay(scanResult),
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
DeviceType.rideLeft => ZwiftRide(scanResult),
DeviceType.clickV2Right => ZwiftClickV2(scanResult),
_ => null,
};
}
@@ -123,25 +127,15 @@ abstract class BaseDevice {
throw Exception('Characteristics not found');
}
await UniversalBle.setNotifiable(
device.deviceId,
customService.uuid,
asyncCharacteristic.uuid,
BleInputProperty.notification,
);
await UniversalBle.setNotifiable(
device.deviceId,
customService.uuid,
syncTxCharacteristic.uuid,
BleInputProperty.indication,
);
await UniversalBle.subscribeNotifications(device.deviceId, customService.uuid, asyncCharacteristic.uuid);
await UniversalBle.subscribeIndications(device.deviceId, customService.uuid, syncTxCharacteristic.uuid);
await _setupHandshake();
}
Future<void> _setupHandshake() async {
if (supportsEncryption) {
await UniversalBle.writeValue(
await UniversalBle.write(
device.deviceId,
customServiceId,
syncRxCharacteristic!.uuid,
@@ -150,15 +144,15 @@ abstract class BaseDevice {
...Constants.REQUEST_START,
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
]),
BleOutputProperty.withoutResponse,
withoutResponse: true,
);
} else {
await UniversalBle.writeValue(
await UniversalBle.write(
device.deviceId,
customServiceId,
syncRxCharacteristic!.uuid,
Constants.RIDE_ON,
BleOutputProperty.withoutResponse,
withoutResponse: true,
);
}
}
@@ -209,7 +203,11 @@ abstract class BaseDevice {
final payload = bytes.sublist(4);
if (zapEncryption.encryptionKeyBytes == null) {
actionStreamInternal.add(LogNotification('Encryption not initialized, yet.'));
actionStreamInternal.add(
LogNotification(
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
),
);
return;
}
@@ -241,14 +239,35 @@ abstract class BaseDevice {
} else if (buttonsClicked.isEmpty) {
actionStreamInternal.add(LogNotification('Buttons released'));
_longPressTimer?.cancel();
// Handle release events for long press keys
final buttonsReleased = _previouslyPressedButtons.toList();
if (buttonsReleased.isNotEmpty) {
await _performRelease(buttonsReleased);
}
_previouslyPressedButtons.clear();
} else {
if (!(buttonsClicked.singleOrNull == ZwiftButton.onOffLeft ||
buttonsClicked.singleOrNull == ZwiftButton.onOffRight)) {
// we don't want to trigger the long press timer for the on/off buttons
// Handle release events for buttons that are no longer pressed
final buttonsReleased = _previouslyPressedButtons.difference(buttonsClicked.toSet()).toList();
if (buttonsReleased.isNotEmpty) {
await _performRelease(buttonsReleased);
}
final isLongPress =
buttonsClicked.singleOrNull != null &&
actionHandler.supportedApp?.keymap.getKeyPair(buttonsClicked.single)?.isLongPress == true;
if (!isLongPress &&
!(buttonsClicked.singleOrNull == ZwiftButton.onOffLeft ||
buttonsClicked.singleOrNull == ZwiftButton.onOffRight)) {
// we don't want to trigger the long press timer for the on/off buttons, also not when it's a long press key
_longPressTimer?.cancel();
_longPressTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) async {
_performActions(buttonsClicked, true);
});
} else if (isLongPress) {
// Update currently pressed buttons
_previouslyPressedButtons = buttonsClicked.toSet();
}
_performActions(buttonsClicked, false);
@@ -269,7 +288,19 @@ abstract class BaseDevice {
await _vibrate();
}
for (final action in buttonsClicked) {
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
// For repeated actions, don't trigger key down/up events (useful for long press)
final isKeyDown = !repeated;
actionStreamInternal.add(
LogNotification(await actionHandler.performAction(action, isKeyDown: isKeyDown, isKeyUp: false)),
);
}
}
Future<void> _performRelease(List<ZwiftButton> buttonsReleased) async {
for (final action in buttonsReleased) {
actionStreamInternal.add(
LogNotification(await actionHandler.performAction(action, isKeyDown: false, isKeyUp: true)),
);
}
}
@@ -283,4 +314,15 @@ abstract class BaseDevice {
BleOutputProperty.withoutResponse,
);
}
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_play.dart';
class ZwiftClickV2 extends ZwiftPlay {
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

@@ -105,7 +105,7 @@ class _DevicePageState extends State<DevicePage> {
),
)
.toList(),
label: Text('Keymap'),
label: Text('Select Keymap'),
onSelected: (app) async {
if (app == null) {
return;

View File

@@ -85,10 +85,13 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
final KeyPair keyPair;
actionHandler.supportedApp!.keymap.keyPairs.add(
keyPair = KeyPair(
touchPosition: context.size!.center(Offset.zero),
touchPosition: context.size!
.center(Offset.zero)
.translate(actionHandler.supportedApp!.keymap.keyPairs.length * 40, 0),
buttons: [_pressedButton!],
physicalKey: null,
logicalKey: null,
isLongPress: false,
),
);
setState(() {});
@@ -96,7 +99,8 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
// open menu
if (Platform.isMacOS || Platform.isWindows) {
await Future.delayed(Duration(milliseconds: 300));
await keyPressSimulator.simulateMouseClick(keyPair.touchPosition);
await keyPressSimulator.simulateMouseClickDown(keyPair.touchPosition);
await keyPressSimulator.simulateMouseClickUp(keyPair.touchPosition);
}
}
}
@@ -119,10 +123,14 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: const Text('Set Keyboard shortcut'),
child: ListTile(
leading: Icon(Icons.keyboard_alt_outlined),
title: const Text('Simulate Keyboard shortcut'),
),
onTap: () async {
await showDialog<void>(
context: context,
barrierDismissible: false, // enable Escape key
builder:
(c) =>
HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair),
@@ -132,7 +140,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: const Text('Use as touch button'),
child: ListTile(title: const Text('Simulate Touch'), leading: Icon(Icons.touch_app_outlined)),
onTap: () {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
@@ -141,7 +149,68 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: const Text('Remove'),
onTap: () {
keyPair.isLongPress = !keyPair.isLongPress;
setState(() {});
},
child: CheckboxListTile(
value: keyPair.isLongPress,
onChanged: (value) {
keyPair.isLongPress = value ?? false;
setState(() {});
Navigator.of(context).pop();
},
title: const Text('Long Press Mode (vs. repeating)'),
),
),
PopupMenuDivider(),
PopupMenuItem(
child: PopupMenuButton<PhysicalKeyboardKey>(
padding: EdgeInsets.zero,
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaPlayPause,
child: const Text('Media: Play/Pause'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaStop,
child: const Text('Media: Stop'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackPrevious,
child: const Text('Media: Previous'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackNext,
child: const Text('Media: Next'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeUp,
child: const Text('Media: Volume Up'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeDown,
child: const Text('Media: Volume Down'),
),
],
onSelected: (key) {
keyPair.physicalKey = key;
keyPair.logicalKey = null;
setState(() {});
},
child: ListTile(
leading: Icon(Icons.music_note_outlined),
trailing: Icon(Icons.arrow_right),
title: Text('Simulate Media key'),
),
),
),
PopupMenuDivider(),
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: ListTile(title: const Text('Delete Keymap'), leading: Icon(Icons.delete, color: Colors.red)),
onTap: () {
actionHandler.supportedApp!.keymap.keyPairs.remove(keyPair);
setState(() {});
@@ -271,7 +340,10 @@ 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
@@ -282,17 +354,27 @@ class _TouchDot extends StatelessWidget {
),
),
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");
}
@@ -42,9 +42,9 @@ class AndroidActions extends BaseActions {
}
final point = supportedApp!.resolveTouchPosition(action: button, windowInfo: windowInfo);
if (point != Offset.zero) {
accessibilityHandler.performTouch(point.dx, point.dy);
return "No touch performed";
accessibilityHandler.performTouch(point.dx, point.dy, isKeyDown: isKeyDown, isKeyUp: isKeyUp);
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()}";
}
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()}";
return "No touch performed";
}
}

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,21 @@ class CustomApp extends SupportedApp {
ZwiftButton zwiftButton, {
required PhysicalKeyboardKey physicalKey,
required LogicalKeyboardKey? logicalKey,
bool isLongPress = false,
}) {
// set the key for the zwift button
final keyPair = keymap.getKeyPair(zwiftButton);
if (keyPair != null) {
keyPair.physicalKey = physicalKey;
keyPair.logicalKey = logicalKey;
keyPair.isLongPress = isLongPress;
} else {
keymap.keyPairs.add(KeyPair(buttons: [zwiftButton], physicalKey: physicalKey, logicalKey: logicalKey));
keymap.keyPairs.add(KeyPair(
buttons: [zwiftButton],
physicalKey: physicalKey,
logicalKey: logicalKey,
isLongPress: isLongPress,
));
}
}
}

View File

@@ -17,13 +17,13 @@ class MyWhoosh extends SupportedApp {
keyPairs: [
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
physicalKey: PhysicalKeyboardKey.keyK,
logicalKey: LogicalKeyboardKey.keyK,
physicalKey: PhysicalKeyboardKey.keyI,
logicalKey: LogicalKeyboardKey.keyI,
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
physicalKey: PhysicalKeyboardKey.keyI,
logicalKey: LogicalKeyboardKey.keyI,
physicalKey: PhysicalKeyboardKey.keyK,
logicalKey: LogicalKeyboardKey.keyK,
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),

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

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

@@ -67,7 +67,6 @@ class _HotKeyListenerState extends State<HotKeyListenerDialog> {
setState(() {
if (event is KeyDownEvent) {
_pressedKey = event;
} else if (event is KeyUpEvent) {
widget.customApp.setKey(
_pressedButton!,
physicalKey: _pressedKey!.physicalKey,
@@ -99,43 +98,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

@@ -6,12 +6,26 @@ import 'package:url_launcher/url_launcher_string.dart';
List<Widget> buildMenuButtons() {
return [
TextButton(
onPressed: () {
launchUrlString('https://paypal.me/boni');
PopupMenuButton(
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Text('PayPal'),
onTap: () {
launchUrlString('https://paypal.me/boni');
},
),
PopupMenuItem(
child: Text('Other'),
onTap: () {
launchUrlString('https://github.com/sponsors/jonasbark?frequency=one-time');
},
),
];
},
child: Text('Donate ♥'),
icon: Text('Donate ♥', style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
),
SizedBox(width: 8),
const MenuButton(),
SizedBox(width: 8),
];

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'];

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

@@ -64,7 +64,7 @@ SPEC CHECKSUMS:
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
@@ -73,6 +73,6 @@ SPEC CHECKSUMS:
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
COCOAPODS: 1.16.2

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:
@@ -148,10 +148,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version: "1.3.3"
ffi:
dependency: transitive
description:
@@ -212,10 +212,10 @@ packages:
dependency: "direct main"
description:
name: flex_color_scheme
sha256: "3344f8f6536c6ce0473b98e9f084ef80ca89024ad3b454f9c32cf840206f4387"
sha256: "034d5720747e6af39b2ad090d82dd92d33fde68e7964f1814b714c9d49ddbd64"
url: "https://pub.dev"
source: hosted
version: "8.2.0"
version: "8.3.0"
flex_seed_scheme:
dependency: transitive
description:
@@ -249,10 +249,10 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: d59eeafd6df92174b1d5f68fc9d66634c97ce2e7cfe2293476236547bb19bbbd
sha256: a9966c850de5e445331b854fa42df96a8020066d67f125a5964cbc6556643f68
url: "https://pub.dev"
source: hosted
version: "19.0.0"
version: "19.4.1"
flutter_local_notifications_linux:
dependency: transitive
description:
@@ -265,18 +265,18 @@ packages:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "2569b973fc9d1f63a37410a9f7c1c552081226c597190cb359ef5d5762d1631c"
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
url: "https://pub.dev"
source: hosted
version: "9.0.0"
version: "9.1.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: f8fc0652a601f83419d623c85723a3e82ad81f92b33eaa9bcc21ea1b94773e6e
sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.0.2"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -294,10 +294,10 @@ packages:
dependency: transitive
description:
name: flutter_web_bluetooth
sha256: "1363831def5eed1e1064d1eca04e8ccb35446e8f758579c3c519e156b77926da"
sha256: ad26a1b3fef95b86ea5f63793b9a0cdc1a33490f35d754e4e711046cae3ebbf8
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.1.0"
flutter_web_plugins:
dependency: transitive
description: flutter
@@ -431,26 +431,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
url: "https://pub.dev"
source: hosted
version: "10.0.8"
version: "11.0.1"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -788,10 +788,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.6"
time:
dependency: transitive
description:
@@ -828,10 +828,10 @@ packages:
dependency: "direct main"
description:
name: universal_ble
sha256: "35d210e93a5938c6a6d1fd3c710cf4ac90b1bdd1b11c8eb2beeb32600672e6e6"
sha256: "6a5c6c1fb295015934a5aef3dc751ae7e00721535275f8478bfe74db77b238c5"
url: "https://pub.dev"
source: hosted
version: "0.17.0"
version: "0.21.1"
url_launcher:
dependency: "direct main"
description:
@@ -900,18 +900,18 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "14.3.1"
version: "15.0.0"
web:
dependency: transitive
description:
@@ -969,5 +969,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.4+0
version: 2.2.1+1
environment:
sdk: ^3.7.0
@@ -11,8 +11,8 @@ 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
protobuf: ^3.1.0
permission_handler: ^11.4.0
dartx: any
@@ -23,7 +23,7 @@ dependencies:
keypress_simulator:
path: keypress_simulator/packages/keypress_simulator
shared_preferences: ^2.5.3
flex_color_scheme: ^8.2.0
flex_color_scheme: ^8.3.0
package_info_plus: ^8.3.0
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);
});
});
}