mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e11d28765 | ||
|
|
7ee9bc43a0 | ||
|
|
372085ec0e | ||
|
|
e758b35837 | ||
|
|
dee7b86120 | ||
|
|
b3ec7e7a3a | ||
|
|
bbd01d023a | ||
|
|
36282c9fa9 | ||
|
|
daea07c409 | ||
|
|
49d7445d0e | ||
|
|
9bb0e5616a | ||
|
|
7e98f595ee | ||
|
|
a9fdc4b16e | ||
|
|
c06819b502 | ||
|
|
969faca658 | ||
|
|
61fbb099e2 | ||
|
|
fbd6356be0 | ||
|
|
1c40455bf3 | ||
|
|
15129634a6 | ||
|
|
89d35d7734 | ||
|
|
d959bfb4c9 | ||
|
|
9bc25514ae | ||
|
|
25210b57ba | ||
|
|
c9317e369c | ||
|
|
2195c19ed9 | ||
|
|
d13a9d72c9 | ||
|
|
55d230e41c | ||
|
|
ffa604f921 | ||
|
|
93bdfeeaa7 | ||
|
|
336c64e5a9 | ||
|
|
20a706d93d | ||
|
|
21cb8844fc | ||
|
|
4bc1a3b1d0 | ||
|
|
9df1f7cfa6 | ||
|
|
72cdf86802 | ||
|
|
9a53d5fdab | ||
|
|
458e6333a0 | ||
|
|
f42e483260 | ||
|
|
dda2135129 | ||
|
|
bc2831c17e | ||
|
|
310313c3b2 | ||
|
|
2122568461 | ||
|
|
144fd5b740 | ||
|
|
5f7a1a8203 | ||
|
|
258b396444 | ||
|
|
5861533793 | ||
|
|
3106bd09e8 | ||
|
|
a3475a02d2 | ||
|
|
fb1a1f35ad | ||
|
|
71aadde901 | ||
|
|
f7bfd8c206 | ||
|
|
ff83e5271b | ||
|
|
ec6edb2864 | ||
|
|
4f4a6f60c5 | ||
|
|
354e13678b | ||
|
|
f1b8822e20 | ||
|
|
6bf83b1034 | ||
|
|
7b1e4ede2a | ||
|
|
a554820115 | ||
|
|
cb9f9ea5b3 | ||
|
|
4051553a56 | ||
|
|
01a213354b | ||
|
|
962abfb38e | ||
|
|
ada4cf0dfd | ||
|
|
aff1137c3d |
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@@ -1,3 +1,2 @@
|
||||
github: [jonasbark]
|
||||
open_collective: jonas-bark1
|
||||
custom: ["https://paypal.me/boni"]
|
||||
custom: ["https://paypal.me/boni", "https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200"]
|
||||
|
||||
19
.github/workflows/build.yml
vendored
19
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -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
|
||||
|
||||
18
README.md
18
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -20,8 +20,12 @@ class KeyPressSimulator {
|
||||
return _platform.requestAccess(onlyOpenPrefPane: onlyOpenPrefPane);
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClick(Offset position) {
|
||||
return _platform.simulateMouseClick(position);
|
||||
Future<void> simulateMouseClickDown(Offset position) {
|
||||
return _platform.simulateMouseClick(position, keyDown: true);
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClickUp(Offset position) {
|
||||
return _platform.simulateMouseClick(position, keyDown: false);
|
||||
}
|
||||
|
||||
/// Simulate key down.
|
||||
|
||||
@@ -62,6 +62,7 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
let x: Double = args["x"] as! Double
|
||||
let y: Double = args["y"] as! Double
|
||||
let keyDown: Bool = args["keyDown"] as! Bool
|
||||
|
||||
let point = CGPoint(x: x, y: y)
|
||||
|
||||
@@ -72,19 +73,21 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
mouseButton: .left)
|
||||
move?.post(tap: .cghidEventTap)*/
|
||||
|
||||
// Mouse down
|
||||
let mouseDown = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .leftMouseDown,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
mouseDown?.post(tap: .cghidEventTap)
|
||||
|
||||
// Mouse up
|
||||
let mouseUp = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .leftMouseUp,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
mouseUp?.post(tap: .cghidEventTap)
|
||||
if (keyDown) {
|
||||
// Mouse down
|
||||
let mouseDown = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .leftMouseDown,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
mouseDown?.post(tap: .cghidEventTap)
|
||||
} else {
|
||||
// Mouse up
|
||||
let mouseUp = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .leftMouseUp,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
mouseUp?.post(tap: .cghidEventTap)
|
||||
}
|
||||
result(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,11 @@ class MethodChannelKeyPressSimulator extends KeyPressSimulatorPlatform {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> simulateMouseClick(Offset position) async {
|
||||
final Map<String, double> arguments = {
|
||||
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) async {
|
||||
final Map<String, Object?> arguments = {
|
||||
'x': position.dx,
|
||||
'y': position.dy,
|
||||
'keyDown': keyDown,
|
||||
};
|
||||
await methodChannel.invokeMethod('simulateMouseClick', arguments);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ abstract class KeyPressSimulatorPlatform extends PlatformInterface {
|
||||
throw UnimplementedError('simulateKeyPress() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClick(Offset position) {
|
||||
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) {
|
||||
throw UnimplementedError('simulateKeyPress() has not been implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
// This must be included before many other Windows headers.
|
||||
#include <windows.h>
|
||||
#include <psapi.h>
|
||||
#include <string.h>
|
||||
#include <flutter_windows.h>
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
@@ -16,6 +19,16 @@ using flutter::EncodableValue;
|
||||
|
||||
namespace keypress_simulator_windows {
|
||||
|
||||
// Forward declarations
|
||||
struct FindWindowData {
|
||||
std::string targetProcessName;
|
||||
std::string targetWindowTitle;
|
||||
HWND foundWindow;
|
||||
};
|
||||
|
||||
BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam);
|
||||
HWND FindTargetWindow(const std::string& processName, const std::string& windowTitle);
|
||||
|
||||
// static
|
||||
void KeypressSimulatorWindowsPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarWindows* registrar) {
|
||||
@@ -54,33 +67,43 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
modifiers.push_back(key_modifier);
|
||||
}
|
||||
|
||||
INPUT input[6];
|
||||
// List of compatible training apps to look for
|
||||
std::vector<std::string> compatibleApps = {
|
||||
"MyWhooshHD.exe",
|
||||
"indieVelo.exe",
|
||||
"biketerra.exe"
|
||||
};
|
||||
|
||||
for (int32_t i = 0; i < modifiers.size(); i++) {
|
||||
if (modifiers[i].compare("shiftModifier") == 0) {
|
||||
input[i].ki.wVk = VK_SHIFT;
|
||||
} else if (modifiers[i].compare("controlModifier") == 0) {
|
||||
input[i].ki.wVk = VK_CONTROL;
|
||||
} else if (modifiers[i].compare("altModifier") == 0) {
|
||||
input[i].ki.wVk = VK_MENU;
|
||||
} else if (modifiers[i].compare("metaModifier") == 0) {
|
||||
input[i].ki.wVk = VK_LWIN;
|
||||
// Try to find and focus a compatible app
|
||||
HWND targetWindow = NULL;
|
||||
for (const std::string& processName : compatibleApps) {
|
||||
targetWindow = FindTargetWindow(processName, "");
|
||||
if (targetWindow != NULL) {
|
||||
// Only focus the window if it's not already in the foreground
|
||||
if (GetForegroundWindow() != targetWindow) {
|
||||
SetForegroundWindow(targetWindow);
|
||||
Sleep(50); // Brief delay to ensure window is focused
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
input[i].ki.dwFlags = keyDown ? 0 : KEYEVENTF_KEYUP;
|
||||
input[i].type = INPUT_KEYBOARD;
|
||||
}
|
||||
|
||||
/*int keyIndex = static_cast<int>(modifiers.size());
|
||||
input[keyIndex].ki.wVk = static_cast<WORD>(keyCode);
|
||||
input[keyIndex].ki.dwFlags = keyDown ? 0 : KEYEVENTF_KEYUP;
|
||||
input[keyIndex].type = INPUT_KEYBOARD;*/
|
||||
WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
|
||||
|
||||
// Send key sequence to system
|
||||
//SendInput(static_cast<UINT>(std::size(input)), input, sizeof(INPUT));
|
||||
INPUT in = {0};
|
||||
in.type = INPUT_KEYBOARD;
|
||||
in.ki.wVk = 0; // when using SCANCODE, set VK=0
|
||||
in.ki.wScan = sc;
|
||||
in.ki.dwFlags = KEYEVENTF_SCANCODE | (keyDown ? 0 : KEYEVENTF_KEYUP);
|
||||
if (keyCode == VK_LEFT || keyCode == VK_RIGHT || keyCode == VK_UP || keyCode == VK_DOWN ||
|
||||
keyCode == VK_INSERT || keyCode == VK_DELETE || keyCode == VK_HOME || keyCode == VK_END ||
|
||||
keyCode == VK_PRIOR || keyCode == VK_NEXT) {
|
||||
in.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
|
||||
}
|
||||
SendInput(1, &in, sizeof(INPUT));
|
||||
|
||||
BYTE byteValue = static_cast<BYTE>(keyCode);
|
||||
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);
|
||||
/*BYTE byteValue = static_cast<BYTE>(keyCode);
|
||||
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);*/
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
}
|
||||
@@ -93,6 +116,7 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
|
||||
bool keyDown = std::get<bool>(args.at(EncodableValue("keyDown")));
|
||||
auto it_x = args.find(EncodableValue("x"));
|
||||
if (it_x != args.end() && std::holds_alternative<double>(it_x->second)) {
|
||||
x = std::get<double>(it_x->second);
|
||||
@@ -103,24 +127,97 @@ void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
y = std::get<double>(it_y->second);
|
||||
}
|
||||
|
||||
// Get the monitor containing the target point and its DPI
|
||||
const POINT target_point = {static_cast<LONG>(x), static_cast<LONG>(y)};
|
||||
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||
double scale_factor = dpi / 96.0;
|
||||
|
||||
// Scale the coordinates according to the DPI scaling
|
||||
int scaled_x = static_cast<int>(x * scale_factor);
|
||||
int scaled_y = static_cast<int>(y * scale_factor);
|
||||
|
||||
// Move the mouse to the specified coordinates
|
||||
SetCursorPos(static_cast<int>(x), static_cast<int>(y));
|
||||
SetCursorPos(scaled_x, scaled_y);
|
||||
|
||||
// Prepare input for mouse down and up
|
||||
INPUT input = {0};
|
||||
input.type = INPUT_MOUSE;
|
||||
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
if (keyDown) {
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
|
||||
// Mouse left button up
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
} else {
|
||||
// Mouse left button up
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
|
||||
SendInput(1, &input, sizeof(INPUT));
|
||||
}
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
}
|
||||
|
||||
BOOL CALLBACK EnumWindowsCallback(HWND hwnd, LPARAM lParam) {
|
||||
FindWindowData* data = reinterpret_cast<FindWindowData*>(lParam);
|
||||
|
||||
// Check if window is visible and not minimized
|
||||
if (!IsWindowVisible(hwnd) || IsIconic(hwnd)) {
|
||||
return TRUE; // Continue enumeration
|
||||
}
|
||||
|
||||
// Get window title
|
||||
char windowTitle[256];
|
||||
GetWindowTextA(hwnd, windowTitle, sizeof(windowTitle));
|
||||
|
||||
// Get process name
|
||||
DWORD processId;
|
||||
GetWindowThreadProcessId(hwnd, &processId);
|
||||
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
|
||||
char processName[MAX_PATH];
|
||||
if (hProcess) {
|
||||
DWORD size = sizeof(processName);
|
||||
if (QueryFullProcessImageNameA(hProcess, 0, processName, &size)) {
|
||||
// Extract just the filename from the full path
|
||||
char* filename = strrchr(processName, '\\');
|
||||
if (filename) {
|
||||
filename++; // Skip the backslash
|
||||
} else {
|
||||
filename = processName;
|
||||
}
|
||||
|
||||
// Check if this matches our target
|
||||
if (!data->targetProcessName.empty() &&
|
||||
_stricmp(filename, data->targetProcessName.c_str()) == 0) {
|
||||
data->foundWindow = hwnd;
|
||||
return FALSE; // Stop enumeration
|
||||
}
|
||||
}
|
||||
CloseHandle(hProcess);
|
||||
}
|
||||
|
||||
// Check window title if process name didn't match
|
||||
if (!data->targetWindowTitle.empty() &&
|
||||
_stricmp(windowTitle, data->targetWindowTitle.c_str()) == 0) {
|
||||
data->foundWindow = hwnd;
|
||||
return FALSE; // Stop enumeration
|
||||
}
|
||||
|
||||
return TRUE; // Continue enumeration
|
||||
}
|
||||
|
||||
HWND FindTargetWindow(const std::string& processName, const std::string& windowTitle) {
|
||||
FindWindowData data;
|
||||
data.targetProcessName = processName;
|
||||
data.targetWindowTitle = windowTitle;
|
||||
data.foundWindow = NULL;
|
||||
|
||||
EnumWindows(EnumWindowsCallback, reinterpret_cast<LPARAM>(&data));
|
||||
return data.foundWindow;
|
||||
}
|
||||
|
||||
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
|
||||
@@ -30,6 +30,8 @@ class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
|
||||
|
||||
// Called when a method is called on this plugin's channel from Dart.
|
||||
void HandleMethodCall(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
|
||||
23
launch.json
Normal file
23
launch.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "swiftcontrol",
|
||||
"request": "launch",
|
||||
"type": "dart"
|
||||
},
|
||||
{
|
||||
"name": "swiftcontrol (profile mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "profile"
|
||||
},
|
||||
{
|
||||
"name": "swiftcontrol (release mode)",
|
||||
"request": "launch",
|
||||
"type": "dart",
|
||||
"flutterMode": "release"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class BleUuid {
|
||||
}
|
||||
|
||||
class Constants {
|
||||
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc
|
||||
static const ZWIFT_MANUFACTURER_ID = 2378; // Zwift, Inc => 0x094A
|
||||
|
||||
// Zwift Play = RC1
|
||||
static const RC1_LEFT_SIDE = 0x03;
|
||||
@@ -22,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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
5
lib/bluetooth/devices/zwift_clickv2.dart
Normal file
5
lib/bluetooth/devices/zwift_clickv2.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
|
||||
class ZwiftClickV2 extends ZwiftRide {
|
||||
ZwiftClickV2(super.scanResult);
|
||||
}
|
||||
@@ -12,8 +12,8 @@ class ClickNotification extends BaseNotification {
|
||||
ClickNotification(Uint8List message) {
|
||||
final status = ClickKeyPadStatus.fromBuffer(message);
|
||||
buttonsClicked = [
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.shiftUpLeft,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButton.shiftDownRight,
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButton.shiftDownLeft,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class SwiftPlayApp extends StatelessWidget {
|
||||
title: 'SwiftControl',
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
themeMode: ThemeMode.system,
|
||||
themeMode: ThemeMode.light,
|
||||
home: const RequirementsPage(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/pages/touch_area.dart';
|
||||
import 'package:swift_control/widgets/keymap_explanation.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
@@ -57,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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ abstract class BaseActions {
|
||||
this.supportedApp = supportedApp;
|
||||
}
|
||||
|
||||
Future<String> performAction(ZwiftButton action);
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
}
|
||||
|
||||
class StubActions extends BaseActions {
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action) {
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
return Future.value(action.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class DesktopActions extends BaseActions {
|
||||
// Track keys that are currently held down in long press mode
|
||||
final Set<ZwiftButton> _heldKeys = <ZwiftButton>{};
|
||||
|
||||
@override
|
||||
Future<String> performAction(ZwiftButton action) async {
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ('Supported app is not set');
|
||||
}
|
||||
@@ -14,14 +17,60 @@ class DesktopActions extends BaseActions {
|
||||
return ('Keymap entry not found for action: $action');
|
||||
}
|
||||
|
||||
if (keyPair.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key pressed: ${keyPair.logicalKey?.keyLabel}';
|
||||
// Handle long press mode
|
||||
if (keyPair.isLongPress) {
|
||||
if (isKeyDown && !isKeyUp) {
|
||||
// Key press: start long press
|
||||
if (!_heldKeys.contains(action)) {
|
||||
_heldKeys.add(action);
|
||||
if (keyPair.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
return 'Long press started: ${keyPair.logicalKey?.keyLabel}';
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
return 'Long Mouse click started at: $point';
|
||||
}
|
||||
}
|
||||
} else if (isKeyUp && !isKeyDown) {
|
||||
// Key release: end long press
|
||||
if (_heldKeys.contains(action)) {
|
||||
_heldKeys.remove(action);
|
||||
if (keyPair.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Long press ended: ${keyPair.logicalKey?.keyLabel}';
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Long Mouse click ended at: $point';
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore other combinations in long press mode
|
||||
return 'Long press active';
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateMouseClick(point);
|
||||
return 'Mouse clicked at: $point';
|
||||
// Handle regular key press mode (existing behavior)
|
||||
if (keyPair.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
|
||||
return 'Key pressed: ${keyPair.logicalKey?.keyLabel}';
|
||||
} else {
|
||||
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
|
||||
await keyPressSimulator.simulateMouseClickDown(point);
|
||||
await keyPressSimulator.simulateMouseClickUp(point);
|
||||
return 'Mouse clicked at: $point';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release all held keys (useful for cleanup)
|
||||
Future<void> releaseAllHeldKeys() async {
|
||||
for (final action in _heldKeys.toList()) {
|
||||
final keyPair = supportedApp?.keymap.getKeyPair(action);
|
||||
if (keyPair?.physicalKey != null) {
|
||||
await keyPressSimulator.simulateKeyUp(keyPair!.physicalKey);
|
||||
}
|
||||
}
|
||||
_heldKeys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
49
lib/utils/keymap/apps/biketerra.dart
Normal file
49
lib/utils/keymap/apps/biketerra.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
|
||||
class Biketerra extends SupportedApp {
|
||||
Biketerra()
|
||||
: super(
|
||||
name: 'Biketerra',
|
||||
packageName: "biketerra",
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyS,
|
||||
logicalKey: LogicalKeyboardKey.keyS,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyW,
|
||||
logicalKey: LogicalKeyboardKey.keyW,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowRight,
|
||||
logicalKey: LogicalKeyboardKey.arrowRight,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.arrowLeft,
|
||||
logicalKey: LogicalKeyboardKey.arrowLeft,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyU,
|
||||
logicalKey: LogicalKeyboardKey.keyU,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
extension WindowSize on WindowEvent {
|
||||
int get width => right - left;
|
||||
int get height => bottom - top;
|
||||
}
|
||||
@@ -42,14 +42,26 @@ class CustomApp extends SupportedApp {
|
||||
ZwiftButton zwiftButton, {
|
||||
required PhysicalKeyboardKey physicalKey,
|
||||
required LogicalKeyboardKey? logicalKey,
|
||||
bool isLongPress = false,
|
||||
Offset? touchPosition,
|
||||
}) {
|
||||
// set the key for the zwift button
|
||||
final keyPair = keymap.getKeyPair(zwiftButton);
|
||||
if (keyPair != null) {
|
||||
keyPair.physicalKey = physicalKey;
|
||||
keyPair.logicalKey = logicalKey;
|
||||
keyPair.isLongPress = isLongPress;
|
||||
keyPair.touchPosition = touchPosition ?? Offset.zero;
|
||||
} else {
|
||||
keymap.keyPairs.add(KeyPair(buttons: [zwiftButton], physicalKey: physicalKey, logicalKey: logicalKey));
|
||||
keymap.keyPairs.add(
|
||||
KeyPair(
|
||||
buttons: [zwiftButton],
|
||||
physicalKey: physicalKey,
|
||||
logicalKey: logicalKey,
|
||||
isLongPress: isLongPress,
|
||||
touchPosition: touchPosition ?? Offset.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
148
lib/widgets/keymap_explanation.dart
Normal file
148
lib/widgets/keymap_explanation.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
92
pubspec.lock
92
pubspec.lock
@@ -28,10 +28,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
version: "2.13.0"
|
||||
bluez:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -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"
|
||||
|
||||
10
pubspec.yaml
10
pubspec.yaml
@@ -1,7 +1,7 @@
|
||||
name: swift_control
|
||||
description: "SwiftControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 2.0.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
75
test/long_press_test.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
void main() {
|
||||
group('Long Press KeyPair Tests', () {
|
||||
test('KeyPair should encode and decode isLongPress property', () {
|
||||
// Create a KeyPair with long press enabled
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
isLongPress: true,
|
||||
);
|
||||
|
||||
// Encode the KeyPair
|
||||
final encoded = keyPair.encode();
|
||||
|
||||
// Decode the KeyPair
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
// Verify the decoded KeyPair has the correct properties
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, true);
|
||||
expect(decoded.buttons, equals([ZwiftButton.a]));
|
||||
expect(decoded.physicalKey, equals(PhysicalKeyboardKey.keyA));
|
||||
expect(decoded.logicalKey, equals(LogicalKeyboardKey.keyA));
|
||||
});
|
||||
|
||||
test('KeyPair should default isLongPress to false when not specified in decode', () {
|
||||
// Create a legacy encoded KeyPair without isLongPress property
|
||||
const legacyEncoded = '''
|
||||
{
|
||||
"actions": ["a"],
|
||||
"logicalKey": "97",
|
||||
"physicalKey": "458752",
|
||||
"touchPosition": {"x": 0.0, "y": 0.0}
|
||||
}
|
||||
''';
|
||||
|
||||
// Decode the legacy KeyPair
|
||||
final decoded = KeyPair.decode(legacyEncoded);
|
||||
|
||||
// Verify the decoded KeyPair defaults isLongPress to false
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, false);
|
||||
});
|
||||
|
||||
test('KeyPair constructor should default isLongPress to false', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
);
|
||||
|
||||
expect(keyPair.isLongPress, false);
|
||||
});
|
||||
|
||||
test('KeyPair should correctly encode isLongPress false', () {
|
||||
final keyPair = KeyPair(
|
||||
buttons: [ZwiftButton.a],
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
isLongPress: false,
|
||||
);
|
||||
|
||||
final encoded = keyPair.encode();
|
||||
final decoded = KeyPair.decode(encoded);
|
||||
|
||||
expect(decoded, isNotNull);
|
||||
expect(decoded!.isLongPress, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user