mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
106 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 | ||
|
|
7f24c27201 | ||
|
|
51c5e34220 | ||
|
|
10c2cc64a2 | ||
|
|
a14d21f8e4 | ||
|
|
8de715a153 | ||
|
|
e9ebe832de | ||
|
|
2c8feccea1 | ||
|
|
36083e654f | ||
|
|
8790b1938a | ||
|
|
7cd48ce3c4 | ||
|
|
4300f1005d | ||
|
|
9dec9c370c | ||
|
|
e9f460279a | ||
|
|
06b322e575 | ||
|
|
80d8d8c0cd | ||
|
|
4450db3be9 | ||
|
|
b875489ad3 | ||
|
|
ece3f3822f | ||
|
|
0780cdc80b | ||
|
|
1c0e027abb | ||
|
|
0dcb666bbd | ||
|
|
00ebca7a01 | ||
|
|
f9c8820303 | ||
|
|
3d414edda9 | ||
|
|
0e3f6f1d5e | ||
|
|
c4b0ef38c0 | ||
|
|
2e800bb2de | ||
|
|
505b970497 | ||
|
|
986bfd481c | ||
|
|
ab8d480a01 | ||
|
|
7e19b76403 | ||
|
|
cbc2f103ac | ||
|
|
6a7922cf17 | ||
|
|
8e23de2718 | ||
|
|
11aec5fba1 | ||
|
|
c98f213e2e | ||
|
|
8c11cfcad6 | ||
|
|
5fe88ffc6a | ||
|
|
7a8c7a4ee1 | ||
|
|
3343325195 | ||
|
|
edda16dc06 |
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"]
|
||||
|
||||
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
@@ -4,6 +4,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- 'lib/**'
|
||||
- 'accessibility/**'
|
||||
- 'keypress_simulator/**'
|
||||
- 'pubspec.yaml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -173,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
|
||||
|
||||
76
CHANGELOG.md
76
CHANGELOG.md
@@ -1,4 +1,78 @@
|
||||
### 1.1.0 (2025-03-30)
|
||||
### 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)
|
||||
- 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
|
||||
|
||||
### 1.1.10 (2025-04-03)
|
||||
- Add more troubleshooting during connection
|
||||
|
||||
### 1.1.8 (2025-04-02)
|
||||
- Android: make sure the touch reassignment page is fullscreen
|
||||
|
||||
### 1.1.7 (2025-04-01)
|
||||
- Zwift Ride: fix connection issues by connecting only to the left controller
|
||||
- Windows: connect sequentially to fix (finally?) fix connection issues
|
||||
- Windows: change the way keyboard is simulated, should fix glitches
|
||||
|
||||
### 1.1.6 (2025-03-31)
|
||||
- Zwift Ride: add buttonPowerDown to shift gears
|
||||
- Zwift Play: Fix buttonShift assignment
|
||||
- Android: fix action to go to next song
|
||||
- App now checks if you run the latest available version
|
||||
|
||||
### 1.1.5 (2025-03-30)
|
||||
- fix bluetooth connection #6, also add missing entitlement on macOS
|
||||
|
||||
### 1.1.3 (2025-03-30)
|
||||
- Windows: fix custom keyboard profile recreation after restart, also warn when choosing MyWhoosh profile (may fix #7)
|
||||
- Zwift Ride: button map adjustments to prevent double shifting
|
||||
- potential fix for #6
|
||||
|
||||
35
README.md
35
README.md
@@ -4,7 +4,12 @@
|
||||
|
||||
## Description
|
||||
|
||||
With SwiftControl you can control your favorite trainer app using your Zwift Click, Zwift Ride or Zwift Play devices. Primarily useful to perform virtual gear shifting.
|
||||
With SwiftControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
|
||||
- Virtual Gear shifting
|
||||
- Steering / turning
|
||||
- adjust workout intensity
|
||||
- control music on your device
|
||||
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
|
||||
@@ -18,31 +23,41 @@ 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 the gear shifting touch points in the app
|
||||
- Desktop: you can customize the keyboard shortcuts in the app
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
## 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 macOS or Windows a keyboard click is used to trigger the action. Typically + and - keys are used to shift gears, while MyWhoosh uses K and I keys.
|
||||
- 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, 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
|
||||
|
||||
## Donate
|
||||
Please consider donating to support the development of this app.
|
||||
Please consider donating to support the development of this app :)
|
||||
|
||||
[](https://paypal.me/boni)
|
||||
|
||||
## TODO
|
||||
- implement more actions for Play + Ride
|
||||
|
||||
@@ -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,18 +7,16 @@ 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
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
|
||||
|
||||
/** AccessibilityPlugin */
|
||||
class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility {
|
||||
class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
/// The MethodChannel that will the communication between Flutter and native Android
|
||||
///
|
||||
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
|
||||
@@ -38,14 +36,6 @@ class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility {
|
||||
Observable.fromService = eventHandler
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
if (call.method == "getPlatformVersion") {
|
||||
result.success("Android ${android.os.Build.VERSION.RELEASE}")
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
}
|
||||
@@ -61,20 +51,27 @@ class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, 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))
|
||||
}
|
||||
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.NEXT -> audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,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) {
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- to check if you have the latest version -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:label="SwiftControl"
|
||||
android:name="${applicationName}"
|
||||
|
||||
12
keypress_simulator/.clang-format
Normal file
12
keypress_simulator/.clang-format
Normal file
@@ -0,0 +1,12 @@
|
||||
# Defines the Chromium style for automatic reformatting.
|
||||
# http://clang.llvm.org/docs/ClangFormatStyleOptions.html
|
||||
BasedOnStyle: Chromium
|
||||
# This defaults to 'Auto'. Explicitly set it for a while, so that
|
||||
# 'vector<vector<int> >' in existing files gets formatted to
|
||||
# 'vector<vector<int>>'. ('Auto' means that clang-format will only use
|
||||
# 'int>>' if the file already contains at least one such instance.)
|
||||
Standard: Cpp11
|
||||
SortIncludes: true
|
||||
---
|
||||
Language: ObjC
|
||||
ColumnLimit: 100
|
||||
1
keypress_simulator/.github/FUNDING.yml
vendored
Normal file
1
keypress_simulator/.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
liberapay: lijy91
|
||||
50
keypress_simulator/.github/workflows/build.yml
vendored
Normal file
50
keypress_simulator/.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.16.8"
|
||||
channel: "stable"
|
||||
- uses: bluefireteam/melos-action@v2
|
||||
- working-directory: ./packages/keypress_simulator/example
|
||||
run: |
|
||||
melos bs
|
||||
flutter build macos --release
|
||||
|
||||
build-web:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.16.8"
|
||||
channel: "stable"
|
||||
- uses: bluefireteam/melos-action@v2
|
||||
- working-directory: ./packages/keypress_simulator/example
|
||||
run: |
|
||||
melos bs
|
||||
flutter build web --release
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.16.8"
|
||||
channel: "stable"
|
||||
- uses: bluefireteam/melos-action@v2
|
||||
- working-directory: ./packages/keypress_simulator/example
|
||||
run: |
|
||||
melos bs
|
||||
flutter build windows --release
|
||||
31
keypress_simulator/.github/workflows/lint.yml
vendored
Normal file
31
keypress_simulator/.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.16.8"
|
||||
channel: "stable"
|
||||
- uses: bluefireteam/melos-action@v2
|
||||
- run: melos run analyze
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.16.8"
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- uses: bluefireteam/melos-action@v2
|
||||
- run: melos run format-check
|
||||
20
keypress_simulator/.github/workflows/test.yml
vendored
Normal file
20
keypress_simulator/.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: "3.16.8"
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- uses: bluefireteam/melos-action@v2
|
||||
- run: melos run test --no-select
|
||||
6
keypress_simulator/.gitignore
vendored
Normal file
6
keypress_simulator/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.dart_tool/
|
||||
.idea/
|
||||
|
||||
*.iml
|
||||
pubspec_overrides.yaml
|
||||
pubspec.lock
|
||||
21
keypress_simulator/LICENSE
Normal file
21
keypress_simulator/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 LiJianying <lijy91@foxmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
93
keypress_simulator/README-ZH.md
Normal file
93
keypress_simulator/README-ZH.md
Normal file
@@ -0,0 +1,93 @@
|
||||
> **🚀 快速发布您的应用**: 试试 [Fastforge](https://fastforge.dev) - 构建、打包和分发您的 Flutter 应用最简单的方式。
|
||||
|
||||
# keypress_simulator
|
||||
|
||||
[![pub version][pub-image]][pub-url] [![][discord-image]][discord-url]
|
||||
|
||||
[pub-image]: https://img.shields.io/pub/v/keypress_simulator.svg
|
||||
[pub-url]: https://pub.dev/packages/keypress_simulator
|
||||
[discord-image]: https://img.shields.io/discord/884679008049037342.svg
|
||||
[discord-url]: https://discord.gg/zPa6EZ2jqb
|
||||
|
||||
这个插件允许 Flutter 桌面应用模拟按键操作。
|
||||
|
||||
---
|
||||
|
||||
[English](./README.md) | 简体中文
|
||||
|
||||
---
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
|
||||
- [平台支持](#%E5%B9%B3%E5%8F%B0%E6%94%AF%E6%8C%81)
|
||||
- [快速开始](#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B)
|
||||
- [安装](#%E5%AE%89%E8%A3%85)
|
||||
- [用法](#%E7%94%A8%E6%B3%95)
|
||||
- [谁在用使用它?](#%E8%B0%81%E5%9C%A8%E7%94%A8%E4%BD%BF%E7%94%A8%E5%AE%83)
|
||||
- [许可证](#%E8%AE%B8%E5%8F%AF%E8%AF%81)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## 平台支持
|
||||
|
||||
| Linux | macOS | Windows |
|
||||
| :---: | :---: | :-----: |
|
||||
| ➖ | ✔️ | ✔️ |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装
|
||||
|
||||
将此添加到你的软件包的 pubspec.yaml 文件:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
keypress_simulator: ^0.2.0
|
||||
```
|
||||
|
||||
### 用法
|
||||
|
||||
```dart
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
|
||||
// 1. Simulate pressing ⌘ + C
|
||||
|
||||
// 1.1 Simulate key down
|
||||
await keyPressSimulator.simulateKeyDown(
|
||||
PhysicalKeyboardKey.keyC,
|
||||
[ModifierKey.metaModifier],
|
||||
);
|
||||
|
||||
// 1.2 Simulate key up
|
||||
await keyPressSimulator.simulateKeyUp(
|
||||
PhysicalKeyboardKey.keyC,
|
||||
[ModifierKey.metaModifier],
|
||||
);
|
||||
|
||||
// 2. Simulate long pressing ⌘ + space
|
||||
|
||||
// 2.1. Simulate key down
|
||||
await keyPressSimulator.simulateKeyDown(
|
||||
PhysicalKeyboardKey.space,
|
||||
[ModifierKey.metaModifier],
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
|
||||
// 2.2. Simulate key up
|
||||
await keyPressSimulator.simulateKeyUp(
|
||||
PhysicalKeyboardKey.space,
|
||||
[ModifierKey.metaModifier],
|
||||
);
|
||||
```
|
||||
|
||||
> 请看这个插件的示例应用,以了解完整的例子。
|
||||
|
||||
## 谁在用使用它?
|
||||
|
||||
- [Biyi (比译)](https://biyidev.com/) - 一个便捷的翻译和词典应用程序。
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](./LICENSE)
|
||||
93
keypress_simulator/README.md
Normal file
93
keypress_simulator/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
> **🚀 Ship Your App Faster**: Try [Fastforge](https://fastforge.dev) - The simplest way to build, package and distribute your Flutter apps.
|
||||
|
||||
# keypress_simulator
|
||||
|
||||
[![pub version][pub-image]][pub-url] [![][discord-image]][discord-url]
|
||||
|
||||
[pub-image]: https://img.shields.io/pub/v/keypress_simulator.svg
|
||||
[pub-url]: https://pub.dev/packages/keypress_simulator
|
||||
[discord-image]: https://img.shields.io/discord/884679008049037342.svg
|
||||
[discord-url]: https://discord.gg/zPa6EZ2jqb
|
||||
|
||||
This plugin allows Flutter desktop apps to simulate key presses.
|
||||
|
||||
---
|
||||
|
||||
English | [简体中文](./README-ZH.md)
|
||||
|
||||
---
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
|
||||
- [Platform Support](#platform-support)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Who's using it?](#whos-using-it)
|
||||
- [License](#license)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Linux | macOS | Windows |
|
||||
| :---: | :---: | :-----: |
|
||||
| ➖ | ✔️ | ✔️ |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
Add this to your package's pubspec.yaml file:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
keypress_simulator: ^0.2.0
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```dart
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
|
||||
// 1. Simulate pressing ⌘ + C
|
||||
|
||||
// 1.1 Simulate key down
|
||||
await keyPressSimulator.simulateKeyDown(
|
||||
PhysicalKeyboardKey.keyC,
|
||||
[ModifierKey.metaModifier],
|
||||
);
|
||||
|
||||
// 1.2 Simulate key up
|
||||
await keyPressSimulator.simulateKeyUp(
|
||||
PhysicalKeyboardKey.keyC,
|
||||
[ModifierKey.metaModifier],
|
||||
);
|
||||
|
||||
// 2. Simulate long pressing ⌘ + space
|
||||
|
||||
// 2.1. Simulate key down
|
||||
await keyPressSimulator.simulateKeyDown(
|
||||
PhysicalKeyboardKey.space,
|
||||
[ModifierKey.metaModifier],
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(seconds: 5));
|
||||
|
||||
// 2.2. Simulate key up
|
||||
await keyPressSimulator.simulateKeyUp(
|
||||
PhysicalKeyboardKey.space,
|
||||
[ModifierKey.metaModifier],
|
||||
);
|
||||
```
|
||||
|
||||
> Please see the example app of this plugin for a full example.
|
||||
|
||||
## Who's using it?
|
||||
|
||||
- [Biyi (比译)](https://biyidev.com/) - A convenient translation and dictionary app.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
35
keypress_simulator/melos.yaml
Normal file
35
keypress_simulator/melos.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
name: keypress_simulator_workspace
|
||||
repository: https://github.com/leanflutter/keypress_simulator
|
||||
|
||||
packages:
|
||||
- examples/**
|
||||
- packages/**
|
||||
|
||||
command:
|
||||
bootstrap:
|
||||
# Uses the pubspec_overrides.yaml instead of having Melos modifying the lock file.
|
||||
usePubspecOverrides: true
|
||||
|
||||
scripts:
|
||||
analyze:
|
||||
exec: flutter analyze --fatal-infos
|
||||
description: Run `flutter analyze` for all packages.
|
||||
|
||||
test:
|
||||
exec: flutter test
|
||||
description: Run `flutter test` for a specific package.
|
||||
packageFilters:
|
||||
dirExists:
|
||||
- test
|
||||
|
||||
format:
|
||||
exec: dart format . --fix
|
||||
description: Run `dart format` for all packages.
|
||||
|
||||
format-check:
|
||||
exec: dart format . --fix --set-exit-if-changed
|
||||
description: Run `dart format` checks for all packages.
|
||||
|
||||
fix:
|
||||
exec: dart fix . --apply
|
||||
description: Run `dart fix` for all packages.
|
||||
29
keypress_simulator/packages/keypress_simulator/.gitignore
vendored
Normal file
29
keypress_simulator/packages/keypress_simulator/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
10
keypress_simulator/packages/keypress_simulator/.metadata
Normal file
10
keypress_simulator/packages/keypress_simulator/.metadata
Normal file
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: db747aa1331bd95bc9b3874c842261ca2d302cd5
|
||||
channel: stable
|
||||
|
||||
project_type: plugin
|
||||
@@ -0,0 +1,7 @@
|
||||
## 0.2.0
|
||||
|
||||
* feat: Convert to federated plugin
|
||||
|
||||
## 0.1.0
|
||||
|
||||
* First release.
|
||||
21
keypress_simulator/packages/keypress_simulator/LICENSE
Normal file
21
keypress_simulator/packages/keypress_simulator/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 LiJianying <lijy91@foxmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
1
keypress_simulator/packages/keypress_simulator/README-ZH.md
Symbolic link
1
keypress_simulator/packages/keypress_simulator/README-ZH.md
Symbolic link
@@ -0,0 +1 @@
|
||||
../../README-ZH.md
|
||||
1
keypress_simulator/packages/keypress_simulator/README.md
Symbolic link
1
keypress_simulator/packages/keypress_simulator/README.md
Symbolic link
@@ -0,0 +1 @@
|
||||
../../README.md
|
||||
@@ -0,0 +1 @@
|
||||
include: package:mostly_reasonable_lints/flutter.yaml
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/keypress_simulator.dart';
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:keypress_simulator_platform_interface/keypress_simulator_platform_interface.dart';
|
||||
|
||||
class KeyPressSimulator {
|
||||
KeyPressSimulator._();
|
||||
|
||||
/// The shared instance of [KeyPressSimulator].
|
||||
static final KeyPressSimulator instance = KeyPressSimulator._();
|
||||
|
||||
KeyPressSimulatorPlatform get _platform => KeyPressSimulatorPlatform.instance;
|
||||
|
||||
Future<bool> isAccessAllowed() {
|
||||
return _platform.isAccessAllowed();
|
||||
}
|
||||
|
||||
Future<void> requestAccess({bool onlyOpenPrefPane = false}) {
|
||||
return _platform.requestAccess(onlyOpenPrefPane: onlyOpenPrefPane);
|
||||
}
|
||||
|
||||
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.
|
||||
Future<void> simulateKeyDown(PhysicalKeyboardKey? key, [List<ModifierKey> modifiers = const []]) {
|
||||
return _platform.simulateKeyPress(key: key, modifiers: modifiers, keyDown: true);
|
||||
}
|
||||
|
||||
/// Simulate key up.
|
||||
Future<void> simulateKeyUp(PhysicalKeyboardKey? key, [List<ModifierKey> modifiers = const []]) {
|
||||
return _platform.simulateKeyPress(key: key, modifiers: modifiers, keyDown: false);
|
||||
}
|
||||
|
||||
@Deprecated('Please use simulateKeyDown & simulateKeyUp methods.')
|
||||
Future<void> simulateCtrlCKeyPress() async {
|
||||
const key = PhysicalKeyboardKey.keyC;
|
||||
final modifiers = Platform.isMacOS ? [ModifierKey.metaModifier] : [ModifierKey.controlModifier];
|
||||
await simulateKeyDown(key, modifiers);
|
||||
await simulateKeyUp(key, modifiers);
|
||||
}
|
||||
|
||||
@Deprecated('Please use simulateKeyDown & simulateKeyUp methods.')
|
||||
Future<void> simulateCtrlVKeyPress() async {
|
||||
const key = PhysicalKeyboardKey.keyV;
|
||||
final modifiers = Platform.isMacOS ? [ModifierKey.metaModifier] : [ModifierKey.controlModifier];
|
||||
await simulateKeyDown(key, modifiers);
|
||||
await simulateKeyUp(key, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
final keyPressSimulator = KeyPressSimulator.instance;
|
||||
36
keypress_simulator/packages/keypress_simulator/pubspec.yaml
Normal file
36
keypress_simulator/packages/keypress_simulator/pubspec.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: keypress_simulator
|
||||
description: This plugin allows Flutter desktop apps to simulate key presses.
|
||||
version: 0.2.0
|
||||
homepage: https://github.com/leanflutter/keypress_simulator
|
||||
|
||||
platforms:
|
||||
macos:
|
||||
windows:
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
flutter: ">=3.3.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
keypress_simulator_macos:
|
||||
path: ../keypress_simulator_macos
|
||||
keypress_simulator_platform_interface:
|
||||
path: ../keypress_simulator_platform_interface
|
||||
keypress_simulator_windows:
|
||||
path: ../keypress_simulator_windows
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mostly_reasonable_lints: ^0.1.1
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
macos:
|
||||
default_package: keypress_simulator_macos
|
||||
windows:
|
||||
default_package: keypress_simulator_windows
|
||||
|
||||
29
keypress_simulator/packages/keypress_simulator_macos/.gitignore
vendored
Normal file
29
keypress_simulator/packages/keypress_simulator_macos/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "67457e669f79e9f8d13d7a68fe09775fefbb79f4"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
- platform: macos
|
||||
create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.2.0
|
||||
|
||||
* First release.
|
||||
21
keypress_simulator/packages/keypress_simulator_macos/LICENSE
Normal file
21
keypress_simulator/packages/keypress_simulator_macos/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 LiJianying <lijy91@foxmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,12 @@
|
||||
# keypress_simulator_macos
|
||||
|
||||
[![pub version][pub-image]][pub-url]
|
||||
|
||||
[pub-image]: https://img.shields.io/pub/v/keypress_simulator_macos.svg
|
||||
[pub-url]: https://pub.dev/packages/keypress_simulator_macos
|
||||
|
||||
The macOS implementation of [keypress_simulator](https://pub.dev/packages/keypress_simulator).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
@@ -0,0 +1 @@
|
||||
include: package:mostly_reasonable_lints/flutter.yaml
|
||||
@@ -0,0 +1,117 @@
|
||||
import Cocoa
|
||||
import FlutterMacOS
|
||||
|
||||
public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: "dev.leanflutter.plugins/keypress_simulator", binaryMessenger: registrar.messenger)
|
||||
let instance = KeypressSimulatorMacosPlugin()
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "isAccessAllowed":
|
||||
isAccessAllowed(call, result: result)
|
||||
break
|
||||
case "requestAccess":
|
||||
requestAccess(call, result: result)
|
||||
break
|
||||
case "simulateKeyPress":
|
||||
simulateKeyPress(call, result: result)
|
||||
break
|
||||
case "simulateMouseClick":
|
||||
simulateMouseClick(call, result: result)
|
||||
break
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
public func isAccessAllowed(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
result(AXIsProcessTrusted())
|
||||
}
|
||||
|
||||
public func requestAccess(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args:[String: Any] = call.arguments as! [String: Any]
|
||||
let onlyOpenPrefPane: Bool = args["onlyOpenPrefPane"] as! Bool
|
||||
|
||||
if (!onlyOpenPrefPane) {
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] as CFDictionary
|
||||
AXIsProcessTrustedWithOptions(options)
|
||||
} else {
|
||||
let prefpaneUrl = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
|
||||
NSWorkspace.shared.open(prefpaneUrl)
|
||||
}
|
||||
result(true)
|
||||
}
|
||||
|
||||
public func simulateKeyPress(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args:[String: Any] = call.arguments as! [String: Any]
|
||||
|
||||
let keyCode: Int? = args["keyCode"] as? Int
|
||||
let modifiers: Array<String> = args["modifiers"] as! Array<String>
|
||||
let keyDown: Bool = args["keyDown"] as! Bool
|
||||
|
||||
let event = _createKeyPressEvent(keyCode, modifiers, keyDown);
|
||||
event.post(tap: .cghidEventTap);
|
||||
result(true)
|
||||
}
|
||||
|
||||
|
||||
public func simulateMouseClick(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args:[String: Any] = call.arguments as! [String: Any]
|
||||
|
||||
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)
|
||||
|
||||
// Move mouse to the point
|
||||
/*let move = CGEvent(mouseEventSource: nil,
|
||||
mouseType: .mouseMoved,
|
||||
mouseCursorPosition: point,
|
||||
mouseButton: .left)
|
||||
move?.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)
|
||||
}
|
||||
|
||||
public func _createKeyPressEvent(_ keyCode: Int?, _ modifiers: Array<String>, _ keyDown: Bool) -> CGEvent {
|
||||
let virtualKey: CGKeyCode = CGKeyCode(UInt32(keyCode ?? 0))
|
||||
var flags: CGEventFlags = CGEventFlags()
|
||||
|
||||
if (modifiers.contains("shiftModifier")) {
|
||||
flags.insert(CGEventFlags.maskShift)
|
||||
}
|
||||
if (modifiers.contains("controlModifier")) {
|
||||
flags.insert(CGEventFlags.maskControl)
|
||||
}
|
||||
if (modifiers.contains("altModifier")) {
|
||||
flags.insert(CGEventFlags.maskAlternate)
|
||||
}
|
||||
if (modifiers.contains("metaModifier")) {
|
||||
flags.insert(CGEventFlags.maskCommand)
|
||||
}
|
||||
if (modifiers.contains("functionModifier")) {
|
||||
flags.insert(CGEventFlags.maskSecondaryFn)
|
||||
}
|
||||
let eventKeyPress = CGEvent(keyboardEventSource: nil, virtualKey: virtualKey, keyDown: keyDown);
|
||||
eventKeyPress!.flags = flags
|
||||
return eventKeyPress!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint keypress_simulator_macos.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'keypress_simulator_macos'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter plugin project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter plugin project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'FlutterMacOS'
|
||||
|
||||
s.platform = :osx, '10.11'
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
||||
s.swift_version = '5.0'
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
name: keypress_simulator_macos
|
||||
description: macOS implementation of the keypress_simulator plugin.
|
||||
version: 0.2.0
|
||||
repository: https://github.com/leanflutter/keypress_simulator/tree/main/packages/keypress_simulator_macos
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
keypress_simulator_platform_interface:
|
||||
path: ../keypress_simulator_platform_interface
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mostly_reasonable_lints: ^0.1.1
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
implements: keypress_simulator
|
||||
platforms:
|
||||
macos:
|
||||
pluginClass: KeypressSimulatorMacosPlugin
|
||||
|
||||
29
keypress_simulator/packages/keypress_simulator_platform_interface/.gitignore
vendored
Normal file
29
keypress_simulator/packages/keypress_simulator_platform_interface/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
||||
@@ -0,0 +1,27 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "67457e669f79e9f8d13d7a68fe09775fefbb79f4"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.2.0
|
||||
|
||||
* First release.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 LiJianying <lijy91@foxmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
# keypress_simulator_platform_interface
|
||||
|
||||
[![pub version][pub-image]][pub-url]
|
||||
|
||||
[pub-image]: https://img.shields.io/pub/v/keypress_simulator_platform_interface.svg
|
||||
[pub-url]: https://pub.dev/packages/keypress_simulator_platform_interface
|
||||
|
||||
A common platform interface for the [keypress_simulator](https://pub.dev/packages/keypress_simulator) plugin.
|
||||
|
||||
## Usage
|
||||
|
||||
To implement a new platform-specific implementation of keypress_simulator, extend `KeyPressSimulatorPlatform` with an implementation that performs the platform-specific behavior, and when you register your plugin, set the default `KeyPressSimulatorPlatform` by calling `KeyPressSimulatorPlatform.instance = MyPlatformKeyPressSimulator()`.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
@@ -0,0 +1 @@
|
||||
include: package:mostly_reasonable_lints/flutter.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
library keypress_simulator_platform_interface;
|
||||
|
||||
export 'src/keypress_simulator_method_channel.dart';
|
||||
export 'src/keypress_simulator_platform_interface.dart';
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:keypress_simulator_platform_interface/src/keypress_simulator_platform_interface.dart';
|
||||
import 'package:uni_platform/uni_platform.dart';
|
||||
|
||||
/// An implementation of [KeyPressSimulatorPlatform] that uses method channels.
|
||||
class MethodChannelKeyPressSimulator extends KeyPressSimulatorPlatform {
|
||||
/// The method channel used to interact with the native platform.
|
||||
@visibleForTesting
|
||||
final methodChannel = const MethodChannel(
|
||||
'dev.leanflutter.plugins/keypress_simulator',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<bool> isAccessAllowed() async {
|
||||
if (UniPlatform.isMacOS) {
|
||||
return await methodChannel.invokeMethod('isAccessAllowed');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestAccess({
|
||||
bool onlyOpenPrefPane = false,
|
||||
}) async {
|
||||
if (UniPlatform.isMacOS) {
|
||||
final Map<String, dynamic> arguments = {
|
||||
'onlyOpenPrefPane': onlyOpenPrefPane,
|
||||
};
|
||||
await methodChannel.invokeMethod('requestAccess', arguments);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> simulateKeyPress({
|
||||
KeyboardKey? key,
|
||||
List<ModifierKey> modifiers = const [],
|
||||
bool keyDown = true,
|
||||
}) async {
|
||||
PhysicalKeyboardKey? physicalKey = key is PhysicalKeyboardKey ? key : null;
|
||||
if (key is LogicalKeyboardKey) {
|
||||
physicalKey = key.physicalKey;
|
||||
}
|
||||
if (key != null && physicalKey == null) {
|
||||
throw UnsupportedError('Unsupported key: $key.');
|
||||
}
|
||||
final Map<Object?, Object?> arguments = {
|
||||
'keyCode': physicalKey?.keyCode,
|
||||
'modifiers': modifiers.map((e) => e.name).toList(),
|
||||
'keyDown': keyDown,
|
||||
}..removeWhere((key, value) => value == null);
|
||||
await methodChannel.invokeMethod('simulateKeyPress', arguments);
|
||||
}
|
||||
|
||||
@override
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:keypress_simulator_platform_interface/src/keypress_simulator_method_channel.dart';
|
||||
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
||||
|
||||
abstract class KeyPressSimulatorPlatform extends PlatformInterface {
|
||||
/// Constructs a KeyPressSimulatorPlatform.
|
||||
KeyPressSimulatorPlatform() : super(token: _token);
|
||||
|
||||
static final Object _token = Object();
|
||||
|
||||
static KeyPressSimulatorPlatform _instance = MethodChannelKeyPressSimulator();
|
||||
|
||||
/// The default instance of [KeyPressSimulatorPlatform] to use.
|
||||
///
|
||||
/// Defaults to [MethodChannelKeyPressSimulator].
|
||||
static KeyPressSimulatorPlatform get instance => _instance;
|
||||
|
||||
/// Platform-specific implementations should set this with their own
|
||||
/// platform-specific class that extends [KeyPressSimulatorPlatform] when
|
||||
/// they register themselves.
|
||||
static set instance(KeyPressSimulatorPlatform instance) {
|
||||
PlatformInterface.verifyToken(instance, _token);
|
||||
_instance = instance;
|
||||
}
|
||||
|
||||
Future<bool> isAccessAllowed() {
|
||||
throw UnimplementedError('isAccessAllowed() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<void> requestAccess({
|
||||
bool onlyOpenPrefPane = false,
|
||||
}) {
|
||||
throw UnimplementedError('requestAccess() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<void> simulateKeyPress({
|
||||
KeyboardKey? key,
|
||||
List<ModifierKey> modifiers = const [],
|
||||
bool keyDown = true,
|
||||
}) {
|
||||
throw UnimplementedError('simulateKeyPress() has not been implemented.');
|
||||
}
|
||||
|
||||
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) {
|
||||
throw UnimplementedError('simulateKeyPress() has not been implemented.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
name: keypress_simulator_platform_interface
|
||||
description: A common platform interface for the keypress_simulator plugin.
|
||||
version: 0.2.0
|
||||
homepage: https://github.com/leanflutter/keypress_simulator/blob/main/packages/keypress_simulator_platform_interface
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
dependencies:
|
||||
collection: ^1.18.0
|
||||
flutter:
|
||||
sdk: flutter
|
||||
plugin_platform_interface: ^2.1.8
|
||||
uni_platform: ^0.1.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mostly_reasonable_lints: ^0.1.1
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:keypress_simulator_platform_interface/src/keypress_simulator_method_channel.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
MethodChannelKeyPressSimulator platform = MethodChannelKeyPressSimulator();
|
||||
const MethodChannel channel = MethodChannel(
|
||||
'dev.leanflutter.plugins/keypress_simulator',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
channel,
|
||||
(MethodCall methodCall) async {
|
||||
if (methodCall.method == 'isAccessAllowed') return true;
|
||||
return '42';
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('isAccessAllowed', () async {
|
||||
expect(await platform.isAccessAllowed(), true);
|
||||
});
|
||||
}
|
||||
29
keypress_simulator/packages/keypress_simulator_windows/.gitignore
vendored
Normal file
29
keypress_simulator/packages/keypress_simulator_windows/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
||||
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "67457e669f79e9f8d13d7a68fe09775fefbb79f4"
|
||||
channel: "stable"
|
||||
|
||||
project_type: plugin
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
- platform: windows
|
||||
create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.2.0
|
||||
|
||||
* First release.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022-2024 LiJianying <lijy91@foxmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,12 @@
|
||||
# keypress_simulator_windows
|
||||
|
||||
[![pub version][pub-image]][pub-url]
|
||||
|
||||
[pub-image]: https://img.shields.io/pub/v/keypress_simulator_windows.svg
|
||||
[pub-url]: https://pub.dev/packages/keypress_simulator_windows
|
||||
|
||||
The Windows implementation of [keypress_simulator](https://pub.dev/packages/keypress_simulator).
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
@@ -0,0 +1 @@
|
||||
include: package:mostly_reasonable_lints/flutter.yaml
|
||||
@@ -0,0 +1,26 @@
|
||||
name: keypress_simulator_windows
|
||||
description: Windows implementation of the keypress_simulator plugin.
|
||||
version: 0.2.0
|
||||
repository: https://github.com/leanflutter/keypress_simulator/tree/main/packages/keypress_simulator_windows
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
keypress_simulator_platform_interface:
|
||||
path: ../keypress_simulator_platform_interface
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
mostly_reasonable_lints: ^0.1.1
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
implements: keypress_simulator
|
||||
platforms:
|
||||
windows:
|
||||
pluginClass: KeypressSimulatorWindowsPluginCApi
|
||||
17
keypress_simulator/packages/keypress_simulator_windows/windows/.gitignore
vendored
Normal file
17
keypress_simulator/packages/keypress_simulator_windows/windows/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
flutter/
|
||||
|
||||
# Visual Studio user-specific files.
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Visual Studio build-related files.
|
||||
x64/
|
||||
x86/
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
@@ -0,0 +1,100 @@
|
||||
# The Flutter tooling requires that developers have a version of Visual Studio
|
||||
# installed that includes CMake 3.14 or later. You should not increase this
|
||||
# version, as doing so will cause the plugin to fail to compile for some
|
||||
# customers of the plugin.
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
|
||||
# Project-level configuration.
|
||||
set(PROJECT_NAME "keypress_simulator_windows")
|
||||
project(${PROJECT_NAME} LANGUAGES CXX)
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(VERSION 3.14...3.25)
|
||||
|
||||
# This value is used when generating builds using this plugin, so it must
|
||||
# not be changed
|
||||
set(PLUGIN_NAME "keypress_simulator_windows_plugin")
|
||||
|
||||
# Any new source files that you add to the plugin should be added here.
|
||||
list(APPEND PLUGIN_SOURCES
|
||||
"keypress_simulator_windows_plugin.cpp"
|
||||
"keypress_simulator_windows_plugin.h"
|
||||
)
|
||||
|
||||
# Define the plugin library target. Its name must not be changed (see comment
|
||||
# on PLUGIN_NAME above).
|
||||
add_library(${PLUGIN_NAME} SHARED
|
||||
"include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h"
|
||||
"keypress_simulator_windows_plugin_c_api.cpp"
|
||||
${PLUGIN_SOURCES}
|
||||
)
|
||||
|
||||
# Apply a standard set of build settings that are configured in the
|
||||
# application-level CMakeLists.txt. This can be removed for plugins that want
|
||||
# full control over build settings.
|
||||
apply_standard_settings(${PLUGIN_NAME})
|
||||
|
||||
# Symbols are hidden by default to reduce the chance of accidental conflicts
|
||||
# between plugins. This should not be removed; any symbols that should be
|
||||
# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro.
|
||||
set_target_properties(${PLUGIN_NAME} PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden)
|
||||
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
|
||||
|
||||
# Source include directories and library dependencies. Add any plugin-specific
|
||||
# dependencies here.
|
||||
target_include_directories(${PLUGIN_NAME} INTERFACE
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin)
|
||||
|
||||
# List of absolute paths to libraries that should be bundled with the plugin.
|
||||
# This list could contain prebuilt libraries, or libraries created by an
|
||||
# external build triggered from this build file.
|
||||
set(keypress_simulator_windows_bundled_libraries
|
||||
""
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
# === Tests ===
|
||||
# These unit tests can be run from a terminal after building the example, or
|
||||
# from Visual Studio after opening the generated solution file.
|
||||
|
||||
# Only enable test builds when building the example (which sets this variable)
|
||||
# so that plugin clients aren't building the tests.
|
||||
if (${include_${PROJECT_NAME}_tests})
|
||||
set(TEST_RUNNER "${PROJECT_NAME}_test")
|
||||
enable_testing()
|
||||
|
||||
# Add the Google Test dependency.
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
googletest
|
||||
URL https://github.com/google/googletest/archive/release-1.11.0.zip
|
||||
)
|
||||
# Prevent overriding the parent project's compiler/linker settings
|
||||
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
|
||||
# Disable install commands for gtest so it doesn't end up in the bundle.
|
||||
set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE)
|
||||
FetchContent_MakeAvailable(googletest)
|
||||
|
||||
# The plugin's C API is not very useful for unit testing, so build the sources
|
||||
# directly into the test binary rather than using the DLL.
|
||||
add_executable(${TEST_RUNNER}
|
||||
test/keypress_simulator_windows_plugin_test.cpp
|
||||
${PLUGIN_SOURCES}
|
||||
)
|
||||
apply_standard_settings(${TEST_RUNNER})
|
||||
target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin)
|
||||
target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock)
|
||||
# flutter_wrapper_plugin has link dependencies on the Flutter DLL.
|
||||
add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${FLUTTER_LIBRARY}" $<TARGET_FILE_DIR:${TEST_RUNNER}>
|
||||
)
|
||||
|
||||
# Enable automatic test discovery.
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(${TEST_RUNNER})
|
||||
endif()
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_
|
||||
#define FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_
|
||||
|
||||
#include <flutter_plugin_registrar.h>
|
||||
|
||||
#ifdef FLUTTER_PLUGIN_IMPL
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
|
||||
#endif
|
||||
|
||||
#if defined(__cplusplus)
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
FLUTTER_PLUGIN_EXPORT void KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar);
|
||||
|
||||
#if defined(__cplusplus)
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
#endif // FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_
|
||||
@@ -0,0 +1,233 @@
|
||||
#include "keypress_simulator_windows_plugin.h"
|
||||
|
||||
// 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>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
|
||||
using flutter::EncodableList;
|
||||
using flutter::EncodableMap;
|
||||
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) {
|
||||
auto channel =
|
||||
std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
registrar->messenger(), "dev.leanflutter.plugins/keypress_simulator",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
auto plugin = std::make_unique<KeypressSimulatorWindowsPlugin>();
|
||||
|
||||
channel->SetMethodCallHandler(
|
||||
[plugin_pointer = plugin.get()](const auto& call, auto result) {
|
||||
plugin_pointer->HandleMethodCall(call, std::move(result));
|
||||
});
|
||||
|
||||
registrar->AddPlugin(std::move(plugin));
|
||||
}
|
||||
|
||||
KeypressSimulatorWindowsPlugin::KeypressSimulatorWindowsPlugin() {}
|
||||
|
||||
KeypressSimulatorWindowsPlugin::~KeypressSimulatorWindowsPlugin() {}
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
|
||||
|
||||
UINT keyCode = std::get<int>(args.at(EncodableValue("keyCode")));
|
||||
std::vector<std::string> modifiers;
|
||||
bool keyDown = std::get<bool>(args.at(EncodableValue("keyDown")));
|
||||
|
||||
EncodableList key_modifier_list =
|
||||
std::get<EncodableList>(args.at(EncodableValue("modifiers")));
|
||||
for (flutter::EncodableValue key_modifier_value : key_modifier_list) {
|
||||
std::string key_modifier = std::get<std::string>(key_modifier_value);
|
||||
modifiers.push_back(key_modifier);
|
||||
}
|
||||
|
||||
// List of compatible training apps to look for
|
||||
std::vector<std::string> compatibleApps = {
|
||||
"MyWhooshHD.exe",
|
||||
"indieVelo.exe",
|
||||
"biketerra.exe"
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
WORD sc = (WORD)MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
|
||||
|
||||
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);*/
|
||||
|
||||
result->Success(flutter::EncodableValue(true));
|
||||
}
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||
|
||||
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
|
||||
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);
|
||||
}
|
||||
|
||||
auto it_y = args.find(EncodableValue("y"));
|
||||
if (it_y != args.end() && std::holds_alternative<double>(it_y->second)) {
|
||||
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(scaled_x, scaled_y);
|
||||
|
||||
// Prepare input for mouse down and up
|
||||
INPUT input = {0};
|
||||
input.type = INPUT_MOUSE;
|
||||
|
||||
if (keyDown) {
|
||||
// Mouse left button down
|
||||
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
|
||||
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) {
|
||||
if (method_call.method_name().compare("simulateKeyPress") == 0) {
|
||||
SimulateKeyPress(method_call, std::move(result));
|
||||
} else if (method_call.method_name().compare("simulateMouseClick") == 0) {
|
||||
SimulateMouseClick(method_call, std::move(result));
|
||||
} else {
|
||||
result->NotImplemented();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace keypress_simulator_windows
|
||||
@@ -0,0 +1,43 @@
|
||||
#ifndef FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_
|
||||
#define FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace keypress_simulator_windows {
|
||||
|
||||
class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
|
||||
public:
|
||||
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar);
|
||||
|
||||
KeypressSimulatorWindowsPlugin();
|
||||
|
||||
virtual ~KeypressSimulatorWindowsPlugin();
|
||||
|
||||
// Disallow copy and assign.
|
||||
KeypressSimulatorWindowsPlugin(const KeypressSimulatorWindowsPlugin&) =
|
||||
delete;
|
||||
KeypressSimulatorWindowsPlugin& operator=(
|
||||
const KeypressSimulatorWindowsPlugin&) = delete;
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
|
||||
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
|
||||
void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
|
||||
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,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||
};
|
||||
|
||||
} // namespace keypress_simulator_windows
|
||||
|
||||
#endif // FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_
|
||||
@@ -0,0 +1,12 @@
|
||||
#include "include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h"
|
||||
|
||||
#include <flutter/plugin_registrar_windows.h>
|
||||
|
||||
#include "keypress_simulator_windows_plugin.h"
|
||||
|
||||
void KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar(
|
||||
FlutterDesktopPluginRegistrarRef registrar) {
|
||||
keypress_simulator_windows::KeypressSimulatorWindowsPlugin::RegisterWithRegistrar(
|
||||
flutter::PluginRegistrarManager::GetInstance()
|
||||
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#include <flutter/method_call.h>
|
||||
#include <flutter/method_result_functions.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
#include <gtest/gtest.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
|
||||
#include "keypress_simulator_windows_plugin.h"
|
||||
|
||||
namespace keypress_simulator_windows {
|
||||
namespace test {
|
||||
|
||||
namespace {
|
||||
|
||||
using flutter::EncodableMap;
|
||||
using flutter::EncodableValue;
|
||||
using flutter::MethodCall;
|
||||
using flutter::MethodResultFunctions;
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(KeypressSimulatorWindowsPlugin, GetPlatformVersion) {
|
||||
KeypressSimulatorWindowsPlugin plugin;
|
||||
// Save the reply value from the success callback.
|
||||
std::string result_string;
|
||||
plugin.HandleMethodCall(
|
||||
MethodCall("getPlatformVersion", std::make_unique<EncodableValue>()),
|
||||
std::make_unique<MethodResultFunctions<>>(
|
||||
[&result_string](const EncodableValue* result) {
|
||||
result_string = std::get<std::string>(*result);
|
||||
},
|
||||
nullptr, nullptr));
|
||||
|
||||
// Since the exact string varies by host, just ensure that it's a string
|
||||
// with the expected format.
|
||||
EXPECT_TRUE(result_string.rfind("Windows ", 0) == 0);
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
} // namespace keypress_simulator_windows
|
||||
9
keypress_simulator/pubspec.yaml
Normal file
9
keypress_simulator/pubspec.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
name: keypress_simulator_workspace
|
||||
homepage: https://github.com/leanflutter/keypress_simulator
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
dev_dependencies:
|
||||
melos: ^3.1.0
|
||||
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:
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/requirements/android.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
@@ -15,11 +14,14 @@ class Connection {
|
||||
final devices = <BaseDevice>[];
|
||||
var androidNotificationsSetup = false;
|
||||
|
||||
final _connectionQueue = <BaseDevice>[];
|
||||
var _handlingConnectionQueue = false;
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
|
||||
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;
|
||||
|
||||
@@ -31,10 +33,16 @@ class Connection {
|
||||
UniversalBle.onScanResult = (result) {
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
|
||||
_lastScanResult.add(result);
|
||||
_actionStreams.add(LogNotification('Found new devices: ${result.name}'));
|
||||
final scanResult = BaseDevice.fromScanResult(result);
|
||||
|
||||
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}'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -52,12 +60,13 @@ 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) {
|
||||
UniversalBle.getSystemDevices(
|
||||
withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID],
|
||||
).then((devices) {
|
||||
).then((devices) async {
|
||||
final baseDevices = devices.mapNotNull(BaseDevice.fromScanResult).toList();
|
||||
if (baseDevices.isNotEmpty) {
|
||||
_addDevices(baseDevices);
|
||||
@@ -81,30 +90,62 @@ class Connection {
|
||||
final newDevices = dev.where((device) => !devices.contains(device)).toList();
|
||||
devices.addAll(newDevices);
|
||||
|
||||
for (final device in newDevices) {
|
||||
_connect(device).then((_) {});
|
||||
}
|
||||
_connectionQueue.addAll(newDevices);
|
||||
_handleConnectionQueue();
|
||||
|
||||
hasDevices.value = devices.isNotEmpty;
|
||||
if (devices.isNotEmpty && !androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
|
||||
androidNotificationsSetup = true;
|
||||
actionHandler.init(null);
|
||||
NotificationRequirement.setup().catchError((e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnectionQueue() {
|
||||
// windows apparently has issues when connecting to multiple devices at once, so don't
|
||||
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue) {
|
||||
_handlingConnectionQueue = true;
|
||||
final device = _connectionQueue.removeAt(0);
|
||||
_actionStreams.add(LogNotification('Connecting to: ${device.device.name ?? device.runtimeType}'));
|
||||
_connect(device)
|
||||
.then((_) {
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(LogNotification('Connection finished: ${device.device.name ?? device.runtimeType}'));
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
})
|
||||
.catchError((e) {
|
||||
_handlingConnectionQueue = false;
|
||||
_actionStreams.add(LogNotification('Connection failed: ${device.device.name ?? device.runtimeType} - $e'));
|
||||
if (_connectionQueue.isNotEmpty) {
|
||||
_handleConnectionQueue();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect(BaseDevice bleDevice) async {
|
||||
try {
|
||||
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;
|
||||
|
||||
@@ -112,11 +153,12 @@ class Connection {
|
||||
|
||||
_streamSubscriptions[bleDevice] = actionSubscription;
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
_actionStreams.add(LogNotification("$e\n$backtrace"));
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,4 +176,8 @@ class Connection {
|
||||
hasDevices.value = false;
|
||||
devices.clear();
|
||||
}
|
||||
|
||||
void signalChange(BaseDevice baseDevice) {
|
||||
_connectionStreams.add(baseDevice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,18 @@ 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';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../utils/crypto/encryption_utils.dart';
|
||||
import '../../utils/keymap/buttons.dart';
|
||||
import '../messages/notification.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
@@ -24,20 +29,27 @@ 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;
|
||||
|
||||
static BaseDevice? fromScanResult(BleDevice scanResult) {
|
||||
// Use the name first, probably safest method on all platforms
|
||||
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
|
||||
final device = switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
//'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,
|
||||
};
|
||||
|
||||
// otherwise use the manufacturer data, which doesn't exist on Web and "System Devices"
|
||||
if (device == null) {
|
||||
if (device != null) {
|
||||
return device;
|
||||
} else {
|
||||
// otherwise use the manufacturer data to identify the device
|
||||
final manufacturerData = scanResult.manufacturerDataList;
|
||||
final data = manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
|
||||
|
||||
@@ -50,8 +62,10 @@ abstract class BaseDevice {
|
||||
DeviceType.click => ZwiftClick(scanResult),
|
||||
DeviceType.playRight => ZwiftPlay(scanResult),
|
||||
DeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
DeviceType.rideRight => ZwiftRide(scanResult),
|
||||
DeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
|
||||
DeviceType.clickV2Left => ZwiftClickV2(scanResult),
|
||||
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -72,10 +86,16 @@ abstract class BaseDevice {
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
|
||||
|
||||
int? batteryLevel;
|
||||
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
|
||||
|
||||
Future<void> connect() async {
|
||||
await UniversalBle.connect(device.deviceId, connectionTimeout: const Duration(seconds: 3));
|
||||
actionStream.listen((message) {
|
||||
print("Received message: $message");
|
||||
});
|
||||
|
||||
await UniversalBle.connect(device.deviceId);
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
//await UniversalBle.requestMtu(device.deviceId, 256);
|
||||
@@ -89,7 +109,9 @@ abstract class BaseDevice {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
|
||||
|
||||
if (customService == null) {
|
||||
throw Exception('Custom service $customServiceId not found for device $this ${device.name ?? device.rawName}');
|
||||
throw Exception(
|
||||
'Custom service $customServiceId not found for device $this ${device.name ?? device.rawName}.\nYou may need to update the firmware in Zwift Companion app.\nWe found: ${services.joinToString(transform: (s) => s.uuid)}',
|
||||
);
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
@@ -98,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,
|
||||
);
|
||||
|
||||
@@ -106,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -163,15 +175,15 @@ abstract class BaseDevice {
|
||||
//print("Empty RideOn response - unencrypted mode");
|
||||
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
|
||||
_processData(bytes);
|
||||
} else if (bytes[0] == Constants.DISCONNECT_MESSAGE_TYPE) {
|
||||
//print("Disconnect message");
|
||||
} else {
|
||||
//print("Unprocessed - Data Type: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
print("Error processing data: $e");
|
||||
print("Stack Trace: $stackTrace");
|
||||
actionStreamInternal.add(LogNotification(e.toString()));
|
||||
if (e is SingleLineException) {
|
||||
actionStreamInternal.add(LogNotification(e.message));
|
||||
} else {
|
||||
actionStreamInternal.add(LogNotification("$e\n$stackTrace"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +203,15 @@ abstract class BaseDevice {
|
||||
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
|
||||
final payload = bytes.sublist(4);
|
||||
|
||||
if (zapEncryption.encryptionKeyBytes == null) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final data = zapEncryption.decrypt(counter, payload);
|
||||
type = data[0];
|
||||
message = data.sublist(1);
|
||||
@@ -204,15 +225,105 @@ abstract class BaseDevice {
|
||||
//print("Empty Message"); // expected when nothing happening
|
||||
break;
|
||||
case Constants.BATTERY_LEVEL_TYPE:
|
||||
//print("Battery level update: $message");
|
||||
if (batteryLevel != message[1]) {
|
||||
batteryLevel = message[1];
|
||||
connection.signalChange(this);
|
||||
}
|
||||
break;
|
||||
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE: // untested
|
||||
processClickNotification(message);
|
||||
processClickNotification(message)
|
||||
.then((buttonsClicked) async {
|
||||
if (buttonsClicked == null) {
|
||||
// ignore, no changes
|
||||
} else if (buttonsClicked.isEmpty) {
|
||||
actionStreamInternal.add(LogNotification('Buttons released'));
|
||||
_longPressTimer?.cancel();
|
||||
|
||||
// Handle release events for long press keys
|
||||
final buttonsReleased = _previouslyPressedButtons.toList();
|
||||
if (buttonsReleased.isNotEmpty) {
|
||||
await _performRelease(buttonsReleased);
|
||||
}
|
||||
_previouslyPressedButtons.clear();
|
||||
} else {
|
||||
// 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) {
|
||||
actionStreamInternal.add(LogNotification(e.toString()));
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void processClickNotification(Uint8List message);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../messages/click_notification.dart';
|
||||
|
||||
@@ -10,17 +10,17 @@ class ZwiftClick extends BaseDevice {
|
||||
ClickNotification? _lastClickNotification;
|
||||
|
||||
@override
|
||||
void processClickNotification(Uint8List message) {
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
final ClickNotification clickNotification = ClickNotification(message);
|
||||
if (_lastClickNotification == null || _lastClickNotification != clickNotification) {
|
||||
_lastClickNotification = clickNotification;
|
||||
actionStreamInternal.add(clickNotification);
|
||||
|
||||
if (clickNotification.buttonUp) {
|
||||
actionHandler.increaseGear();
|
||||
} else if (clickNotification.buttonDown) {
|
||||
actionHandler.decreaseGear();
|
||||
if (clickNotification.buttonsClicked.isNotEmpty) {
|
||||
actionStreamInternal.add(clickNotification);
|
||||
}
|
||||
return clickNotification.buttonsClicked;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/play_notification.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../ble.dart';
|
||||
|
||||
class ZwiftPlay extends BaseDevice {
|
||||
@@ -15,30 +16,18 @@ class ZwiftPlay extends BaseDevice {
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY;
|
||||
|
||||
@override
|
||||
void processClickNotification(Uint8List message) {
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
final PlayNotification clickNotification = PlayNotification(message);
|
||||
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
|
||||
_lastControllerNotification = clickNotification;
|
||||
actionStreamInternal.add(clickNotification);
|
||||
|
||||
if ((clickNotification.rightPad && clickNotification.buttonShift) ||
|
||||
(clickNotification.rightPad && clickNotification.analogLR.abs() == 100)) {
|
||||
actionHandler.increaseGear();
|
||||
} else if ((!clickNotification.rightPad && clickNotification.buttonShift) ||
|
||||
(!clickNotification.rightPad && clickNotification.analogLR.abs() == 100)) {
|
||||
actionHandler.decreaseGear();
|
||||
}
|
||||
if (clickNotification.rightPad) {
|
||||
if (clickNotification.buttonA) {
|
||||
actionHandler.controlMedia(MediaAction.next);
|
||||
} else if (clickNotification.buttonY) {
|
||||
actionHandler.controlMedia(MediaAction.volumeUp);
|
||||
} else if (clickNotification.buttonB) {
|
||||
actionHandler.controlMedia(MediaAction.volumeDown);
|
||||
} else if (clickNotification.buttonZ) {
|
||||
actionHandler.controlMedia(MediaAction.playPause);
|
||||
}
|
||||
if (clickNotification.buttonsClicked.isNotEmpty) {
|
||||
actionStreamInternal.add(clickNotification);
|
||||
}
|
||||
|
||||
return clickNotification.buttonsClicked;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../ble.dart';
|
||||
|
||||
@@ -18,29 +18,17 @@ class ZwiftRide extends BaseDevice {
|
||||
RideNotification? _lastControllerNotification;
|
||||
|
||||
@override
|
||||
void processClickNotification(Uint8List message) {
|
||||
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
|
||||
final RideNotification clickNotification = RideNotification(message);
|
||||
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
|
||||
_lastControllerNotification = clickNotification;
|
||||
actionStreamInternal.add(clickNotification);
|
||||
|
||||
if (clickNotification.buttonShiftDownLeft || clickNotification.buttonShiftUpLeft || clickNotification.buttonZ) {
|
||||
actionHandler.decreaseGear();
|
||||
} else if (clickNotification.buttonShiftUpRight ||
|
||||
clickNotification.buttonShiftDownRight ||
|
||||
clickNotification.buttonOnOffLeft) {
|
||||
// TODO remove buttonZ once the assignment is fixed for real
|
||||
actionHandler.increaseGear();
|
||||
if (clickNotification.buttonsClicked.isNotEmpty) {
|
||||
actionStreamInternal.add(clickNotification);
|
||||
}
|
||||
/*if (clickNotification.buttonA) {
|
||||
actionHandler.controlMedia(MediaAction.next);
|
||||
} else if (clickNotification.buttonY) {
|
||||
actionHandler.controlMedia(MediaAction.volumeUp);
|
||||
} else if (clickNotification.buttonB) {
|
||||
actionHandler.controlMedia(MediaAction.volumeDown);
|
||||
} else if (clickNotification.buttonZ) {
|
||||
actionHandler.controlMedia(MediaAction.playPause);
|
||||
}*/
|
||||
return clickNotification.buttonsClicked;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../protocol/zwift.pb.dart';
|
||||
import 'notification.dart';
|
||||
|
||||
class ClickNotification extends BaseNotification {
|
||||
static const int BTN_PRESSED = 0;
|
||||
|
||||
bool buttonUp = false;
|
||||
bool buttonDown = false;
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
|
||||
ClickNotification(Uint8List message) {
|
||||
final status = ClickKeyPadStatus.fromBuffer(message);
|
||||
buttonUp = status.buttonPlus.value == BTN_PRESSED;
|
||||
buttonDown = status.buttonMinus.value == BTN_PRESSED;
|
||||
buttonsClicked = [
|
||||
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.shiftUpRight,
|
||||
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButton.shiftDownLeft,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Click: {buttonUp: $buttonUp, buttonDown: $buttonDown}';
|
||||
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name)}';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -25,9 +27,8 @@ class ClickNotification extends BaseNotification {
|
||||
identical(this, other) ||
|
||||
other is ClickNotification &&
|
||||
runtimeType == other.runtimeType &&
|
||||
buttonUp == other.buttonUp &&
|
||||
buttonDown == other.buttonDown;
|
||||
buttonsClicked.contentEquals(other.buttonsClicked);
|
||||
|
||||
@override
|
||||
int get hashCode => buttonUp.hashCode ^ buttonDown.hashCode;
|
||||
int get hashCode => buttonsClicked.hashCode;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class PlayNotification extends BaseNotification {
|
||||
static const int BTN_PRESSED = 0;
|
||||
|
||||
late bool rightPad, buttonY, buttonZ, buttonA, buttonB, buttonOn, buttonShift;
|
||||
late int analogLR, analogUD;
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
|
||||
PlayNotification(Uint8List message) {
|
||||
final status = PlayKeyPadStatus.fromBuffer(message);
|
||||
|
||||
rightPad = status.rightPad.value == BTN_PRESSED;
|
||||
buttonY = status.buttonYUp.value == BTN_PRESSED;
|
||||
buttonZ = status.buttonZLeft.value == BTN_PRESSED;
|
||||
buttonA = status.buttonARight.value == BTN_PRESSED;
|
||||
buttonB = status.buttonBDown.value == BTN_PRESSED;
|
||||
buttonOn = status.buttonOn.value == BTN_PRESSED;
|
||||
buttonShift = status.buttonShift.value == BTN_PRESSED;
|
||||
analogLR = status.analogLR;
|
||||
analogUD = status.analogUD;
|
||||
buttonsClicked = [
|
||||
if (status.rightPad == PlayButtonStatus.ON) ...[
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButton.y,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButton.z,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButton.a,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButton.b,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButton.onOffRight,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButton.sideButtonRight,
|
||||
if (status.analogLR.abs() == 100) ZwiftButton.paddleRight,
|
||||
],
|
||||
if (status.rightPad == PlayButtonStatus.OFF) ...[
|
||||
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButton.navigationUp,
|
||||
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButton.navigationLeft,
|
||||
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButton.navigationRight,
|
||||
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButton.navigationDown,
|
||||
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButton.onOffLeft,
|
||||
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButton.sideButtonLeft,
|
||||
if (status.analogLR.abs() == 100) ZwiftButton.paddleLeft,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final allTrueParameters = [
|
||||
//if (rightPad) 'rightPad',
|
||||
if (buttonY) 'buttonY',
|
||||
if (buttonZ) 'buttonZ',
|
||||
if (buttonA) 'buttonA',
|
||||
if (buttonB) 'buttonB',
|
||||
if (buttonOn) 'buttonOn',
|
||||
if (buttonShift) 'buttonShift',
|
||||
];
|
||||
return '${rightPad ? 'Right' : 'Left'}: {$allTrueParameters, analogLR: $analogLR, analogUD: $analogUD}';
|
||||
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name)}';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -42,25 +43,8 @@ class PlayNotification extends BaseNotification {
|
||||
identical(this, other) ||
|
||||
other is PlayNotification &&
|
||||
runtimeType == other.runtimeType &&
|
||||
rightPad == other.rightPad &&
|
||||
buttonY == other.buttonY &&
|
||||
buttonZ == other.buttonZ &&
|
||||
buttonA == other.buttonA &&
|
||||
buttonB == other.buttonB &&
|
||||
buttonOn == other.buttonOn &&
|
||||
buttonShift == other.buttonShift &&
|
||||
analogLR == other.analogLR &&
|
||||
analogUD == other.analogUD;
|
||||
buttonsClicked.contentEquals(other.buttonsClicked);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
rightPad.hashCode ^
|
||||
buttonY.hashCode ^
|
||||
buttonZ.hashCode ^
|
||||
buttonA.hashCode ^
|
||||
buttonB.hashCode ^
|
||||
buttonOn.hashCode ^
|
||||
buttonShift.hashCode ^
|
||||
analogLR.hashCode ^
|
||||
analogUD.hashCode;
|
||||
int get hashCode => buttonsClicked.hashCode;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
enum _RideButtonMask {
|
||||
LEFT_BTN(0x00001),
|
||||
@@ -30,67 +32,47 @@ enum _RideButtonMask {
|
||||
}
|
||||
|
||||
class RideNotification extends BaseNotification {
|
||||
static const int BTN_PRESSED = 0;
|
||||
|
||||
late bool buttonLeft, buttonRight, buttonUp, buttonDown;
|
||||
late bool buttonA, buttonB, buttonY, buttonZ;
|
||||
late bool buttonShiftUpLeft, buttonShiftDownLeft;
|
||||
late bool buttonShiftUpRight, buttonShiftDownRight;
|
||||
late bool buttonPowerUpLeft, buttonPowerDownLeft;
|
||||
late bool buttonOnOffLeft, buttonOnOffRight;
|
||||
|
||||
int analogLR = 0, analogUD = 0;
|
||||
late List<ZwiftButton> buttonsClicked;
|
||||
|
||||
RideNotification(Uint8List message) {
|
||||
final status = RideKeyPadStatus.fromBuffer(message);
|
||||
|
||||
buttonLeft = status.buttonMap & _RideButtonMask.LEFT_BTN.mask == BTN_PRESSED;
|
||||
buttonRight = status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == BTN_PRESSED;
|
||||
buttonUp = status.buttonMap & _RideButtonMask.UP_BTN.mask == BTN_PRESSED;
|
||||
buttonDown = status.buttonMap & _RideButtonMask.DOWN_BTN.mask == BTN_PRESSED;
|
||||
buttonA = status.buttonMap & _RideButtonMask.A_BTN.mask == BTN_PRESSED;
|
||||
buttonB = status.buttonMap & _RideButtonMask.B_BTN.mask == BTN_PRESSED;
|
||||
buttonY = status.buttonMap & _RideButtonMask.Y_BTN.mask == BTN_PRESSED;
|
||||
buttonZ = status.buttonMap & _RideButtonMask.Z_BTN.mask == BTN_PRESSED;
|
||||
buttonShiftUpLeft = status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == BTN_PRESSED;
|
||||
buttonShiftDownLeft = status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == BTN_PRESSED;
|
||||
buttonShiftUpRight = status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == BTN_PRESSED;
|
||||
buttonShiftDownRight = status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == BTN_PRESSED;
|
||||
buttonPowerUpLeft = status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == BTN_PRESSED;
|
||||
buttonPowerDownLeft = status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == BTN_PRESSED;
|
||||
buttonOnOffLeft = status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == BTN_PRESSED;
|
||||
buttonOnOffRight = status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == BTN_PRESSED;
|
||||
buttonsClicked = [
|
||||
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationLeft,
|
||||
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationRight,
|
||||
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationUp,
|
||||
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationDown,
|
||||
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.a,
|
||||
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.b,
|
||||
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.y,
|
||||
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.z,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftDownLeft,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
|
||||
ZwiftButton.shiftDownRight,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.powerUpLeft,
|
||||
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.powerUpRight,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffLeft,
|
||||
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffRight,
|
||||
];
|
||||
|
||||
for (final analogue in status.analogButtons.groupStatus) {
|
||||
if (analogue.location == RideAnalogLocation.LEFT || analogue.location == RideAnalogLocation.RIGHT) {
|
||||
analogLR = analogue.analogValue;
|
||||
} else if (analogue.location == RideAnalogLocation.DOWN || analogue.location == RideAnalogLocation.UP) {
|
||||
analogUD = analogue.analogValue;
|
||||
if (analogue.analogValue.abs() == 100) {
|
||||
if (analogue.location == RideAnalogLocation.LEFT) {
|
||||
buttonsClicked.add(ZwiftButton.paddleLeft);
|
||||
} else if (analogue.location == RideAnalogLocation.RIGHT) {
|
||||
buttonsClicked.add(ZwiftButton.paddleRight);
|
||||
} else if (analogue.location == RideAnalogLocation.DOWN || analogue.location == RideAnalogLocation.UP) {
|
||||
// TODO what is this even?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final allTrueParameters = [
|
||||
if (buttonLeft) 'buttonLeft',
|
||||
if (buttonRight) 'buttonRight',
|
||||
if (buttonUp) 'buttonUp',
|
||||
if (buttonDown) 'buttonDown',
|
||||
if (buttonA) 'buttonA',
|
||||
if (buttonB) 'buttonB',
|
||||
if (buttonY) 'buttonY',
|
||||
if (buttonZ) 'buttonZ',
|
||||
if (buttonShiftUpLeft) 'buttonShiftUpLeft',
|
||||
if (buttonShiftDownLeft) 'buttonShiftDownLeft',
|
||||
if (buttonShiftUpRight) 'buttonShiftUpRight',
|
||||
if (buttonShiftDownRight) 'buttonShiftDownRight',
|
||||
if (buttonPowerUpLeft) 'buttonPowerUpLeft',
|
||||
if (buttonPowerDownLeft) 'buttonPowerDownLeft',
|
||||
if (buttonOnOffLeft) 'buttonOnOffLeft',
|
||||
if (buttonOnOffRight) 'buttonOnOffRight',
|
||||
];
|
||||
return '{$allTrueParameters, analogLR: $analogLR, analogUD: $analogUD}';
|
||||
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name)}';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -98,43 +80,8 @@ class RideNotification extends BaseNotification {
|
||||
identical(this, other) ||
|
||||
other is RideNotification &&
|
||||
runtimeType == other.runtimeType &&
|
||||
buttonLeft == other.buttonLeft &&
|
||||
buttonRight == other.buttonRight &&
|
||||
buttonUp == other.buttonUp &&
|
||||
buttonDown == other.buttonDown &&
|
||||
buttonA == other.buttonA &&
|
||||
buttonB == other.buttonB &&
|
||||
buttonY == other.buttonY &&
|
||||
buttonZ == other.buttonZ &&
|
||||
buttonShiftUpLeft == other.buttonShiftUpLeft &&
|
||||
buttonShiftDownLeft == other.buttonShiftDownLeft &&
|
||||
buttonShiftUpRight == other.buttonShiftUpRight &&
|
||||
buttonShiftDownRight == other.buttonShiftDownRight &&
|
||||
buttonPowerUpLeft == other.buttonPowerUpLeft &&
|
||||
buttonPowerDownLeft == other.buttonPowerDownLeft &&
|
||||
buttonOnOffLeft == other.buttonOnOffLeft &&
|
||||
buttonOnOffRight == other.buttonOnOffRight &&
|
||||
analogLR == other.analogLR &&
|
||||
analogUD == other.analogUD;
|
||||
buttonsClicked.contentEquals(other.buttonsClicked);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
buttonLeft.hashCode ^
|
||||
buttonRight.hashCode ^
|
||||
buttonUp.hashCode ^
|
||||
buttonDown.hashCode ^
|
||||
buttonA.hashCode ^
|
||||
buttonB.hashCode ^
|
||||
buttonY.hashCode ^
|
||||
buttonZ.hashCode ^
|
||||
buttonShiftUpLeft.hashCode ^
|
||||
buttonShiftDownLeft.hashCode ^
|
||||
buttonShiftUpRight.hashCode ^
|
||||
buttonShiftDownRight.hashCode ^
|
||||
buttonPowerUpLeft.hashCode ^
|
||||
buttonPowerDownLeft.hashCode ^
|
||||
buttonOnOffLeft.hashCode ^
|
||||
buttonOnOffRight.hashCode ^
|
||||
analogLR.hashCode ^
|
||||
analogUD.hashCode;
|
||||
int get hashCode => buttonsClicked.hashCode;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage {
|
||||
PlayButtonStatus? buttonZLeft,
|
||||
PlayButtonStatus? buttonARight,
|
||||
PlayButtonStatus? buttonBDown,
|
||||
PlayButtonStatus? buttonOn,
|
||||
PlayButtonStatus? buttonShift,
|
||||
PlayButtonStatus? buttonOn,
|
||||
$core.int? analogLR,
|
||||
$core.int? analogUD,
|
||||
}) {
|
||||
@@ -46,12 +46,12 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage {
|
||||
if (buttonBDown != null) {
|
||||
$result.buttonBDown = buttonBDown;
|
||||
}
|
||||
if (buttonOn != null) {
|
||||
$result.buttonOn = buttonOn;
|
||||
}
|
||||
if (buttonShift != null) {
|
||||
$result.buttonShift = buttonShift;
|
||||
}
|
||||
if (buttonOn != null) {
|
||||
$result.buttonOn = buttonOn;
|
||||
}
|
||||
if (analogLR != null) {
|
||||
$result.analogLR = analogLR;
|
||||
}
|
||||
@@ -70,8 +70,8 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage {
|
||||
..e<PlayButtonStatus>(3, _omitFieldNames ? '' : 'ButtonZLeft', $pb.PbFieldType.OE, protoName: 'Button_Z_Left', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
|
||||
..e<PlayButtonStatus>(4, _omitFieldNames ? '' : 'ButtonARight', $pb.PbFieldType.OE, protoName: 'Button_A_Right', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
|
||||
..e<PlayButtonStatus>(5, _omitFieldNames ? '' : 'ButtonBDown', $pb.PbFieldType.OE, protoName: 'Button_B_Down', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
|
||||
..e<PlayButtonStatus>(6, _omitFieldNames ? '' : 'ButtonOn', $pb.PbFieldType.OE, protoName: 'Button_On', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
|
||||
..e<PlayButtonStatus>(7, _omitFieldNames ? '' : 'ButtonShift', $pb.PbFieldType.OE, protoName: 'Button_Shift', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
|
||||
..e<PlayButtonStatus>(6, _omitFieldNames ? '' : 'ButtonShift', $pb.PbFieldType.OE, protoName: 'Button_Shift', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
|
||||
..e<PlayButtonStatus>(7, _omitFieldNames ? '' : 'ButtonOn', $pb.PbFieldType.OE, protoName: 'Button_On', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
|
||||
..a<$core.int>(8, _omitFieldNames ? '' : 'AnalogLR', $pb.PbFieldType.OS3, protoName: 'Analog_LR')
|
||||
..a<$core.int>(9, _omitFieldNames ? '' : 'AnalogUD', $pb.PbFieldType.OS3, protoName: 'Analog_UD')
|
||||
..hasRequiredFields = false
|
||||
@@ -144,22 +144,22 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage {
|
||||
void clearButtonBDown() => clearField(5);
|
||||
|
||||
@$pb.TagNumber(6)
|
||||
PlayButtonStatus get buttonOn => $_getN(5);
|
||||
PlayButtonStatus get buttonShift => $_getN(5);
|
||||
@$pb.TagNumber(6)
|
||||
set buttonOn(PlayButtonStatus v) { setField(6, v); }
|
||||
set buttonShift(PlayButtonStatus v) { setField(6, v); }
|
||||
@$pb.TagNumber(6)
|
||||
$core.bool hasButtonOn() => $_has(5);
|
||||
$core.bool hasButtonShift() => $_has(5);
|
||||
@$pb.TagNumber(6)
|
||||
void clearButtonOn() => clearField(6);
|
||||
void clearButtonShift() => clearField(6);
|
||||
|
||||
@$pb.TagNumber(7)
|
||||
PlayButtonStatus get buttonShift => $_getN(6);
|
||||
PlayButtonStatus get buttonOn => $_getN(6);
|
||||
@$pb.TagNumber(7)
|
||||
set buttonShift(PlayButtonStatus v) { setField(7, v); }
|
||||
set buttonOn(PlayButtonStatus v) { setField(7, v); }
|
||||
@$pb.TagNumber(7)
|
||||
$core.bool hasButtonShift() => $_has(6);
|
||||
$core.bool hasButtonOn() => $_has(6);
|
||||
@$pb.TagNumber(7)
|
||||
void clearButtonShift() => clearField(7);
|
||||
void clearButtonOn() => clearField(7);
|
||||
|
||||
@$pb.TagNumber(8)
|
||||
$core.int get analogLR => $_getIZ(7);
|
||||
|
||||
@@ -82,8 +82,8 @@ const PlayKeyPadStatus$json = {
|
||||
{'1': 'Button_Z_Left', '3': 3, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonZLeft'},
|
||||
{'1': 'Button_A_Right', '3': 4, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonARight'},
|
||||
{'1': 'Button_B_Down', '3': 5, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonBDown'},
|
||||
{'1': 'Button_On', '3': 6, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonOn'},
|
||||
{'1': 'Button_Shift', '3': 7, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonShift'},
|
||||
{'1': 'Button_Shift', '3': 6, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonShift'},
|
||||
{'1': 'Button_On', '3': 7, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonOn'},
|
||||
{'1': 'Analog_LR', '3': 8, '4': 1, '5': 17, '10': 'AnalogLR'},
|
||||
{'1': 'Analog_UD', '3': 9, '4': 1, '5': 17, '10': 'AnalogUD'},
|
||||
],
|
||||
@@ -97,9 +97,9 @@ final $typed_data.Uint8List playKeyPadStatusDescriptor = $convert.base64Decode(
|
||||
'4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0dXNSC0J1dHRvblpMZWZ0EkQKDkJ1dHRvbl9B'
|
||||
'X1JpZ2h0GAQgASgOMh4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0dXNSDEJ1dHRvbkFSaW'
|
||||
'dodBJCCg1CdXR0b25fQl9Eb3duGAUgASgOMh4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0'
|
||||
'dXNSC0J1dHRvbkJEb3duEjsKCUJ1dHRvbl9PbhgGIAEoDjIeLmRlLmpvbmFzYmFyay5QbGF5Qn'
|
||||
'V0dG9uU3RhdHVzUghCdXR0b25PbhJBCgxCdXR0b25fU2hpZnQYByABKA4yHi5kZS5qb25hc2Jh'
|
||||
'cmsuUGxheUJ1dHRvblN0YXR1c1ILQnV0dG9uU2hpZnQSGwoJQW5hbG9nX0xSGAggASgRUghBbm'
|
||||
'dXNSC0J1dHRvbkJEb3duEkEKDEJ1dHRvbl9TaGlmdBgGIAEoDjIeLmRlLmpvbmFzYmFyay5QbG'
|
||||
'F5QnV0dG9uU3RhdHVzUgtCdXR0b25TaGlmdBI7CglCdXR0b25fT24YByABKA4yHi5kZS5qb25h'
|
||||
'c2JhcmsuUGxheUJ1dHRvblN0YXR1c1IIQnV0dG9uT24SGwoJQW5hbG9nX0xSGAggASgRUghBbm'
|
||||
'Fsb2dMUhIbCglBbmFsb2dfVUQYCSABKBFSCEFuYWxvZ1VE');
|
||||
|
||||
@$core.Deprecated('Use playCommandParametersDescriptor instead')
|
||||
|
||||
@@ -16,8 +16,8 @@ message PlayKeyPadStatus {
|
||||
optional PlayButtonStatus Button_Z_Left = 3;
|
||||
optional PlayButtonStatus Button_A_Right = 4;
|
||||
optional PlayButtonStatus Button_B_Down = 5;
|
||||
optional PlayButtonStatus Button_On = 6;
|
||||
optional PlayButtonStatus Button_Shift = 7;
|
||||
optional PlayButtonStatus Button_Shift = 6;
|
||||
optional PlayButtonStatus Button_On = 7;
|
||||
optional sint32 Analog_LR = 8;
|
||||
optional sint32 Analog_UD = 9;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:swift_control/theme.dart';
|
||||
import 'package:swift_control/utils/actions/android.dart';
|
||||
import 'package:swift_control/utils/actions/desktop.dart';
|
||||
import 'package:swift_control/utils/settings/settings.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'bluetooth/connection.dart';
|
||||
import 'utils/actions/base_actions.dart';
|
||||
@@ -19,13 +20,16 @@ final accessibilityHandler = Accessibility();
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
final settings = Settings();
|
||||
|
||||
void main() {
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if (kIsWeb) {
|
||||
actionHandler = StubActions();
|
||||
} else if (Platform.isAndroid) {
|
||||
actionHandler = AndroidActions();
|
||||
} else {
|
||||
actionHandler = DesktopActions();
|
||||
// Must add this line.
|
||||
await windowManager.ensureInitialized();
|
||||
}
|
||||
|
||||
runApp(const SwiftPlayApp());
|
||||
@@ -41,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,9 +6,13 @@ 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';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/apps/supported_app.dart';
|
||||
import '../widgets/menu.dart';
|
||||
|
||||
class DevicePage extends StatefulWidget {
|
||||
@@ -20,6 +24,7 @@ class DevicePage extends StatefulWidget {
|
||||
|
||||
class _DevicePageState extends State<DevicePage> {
|
||||
late StreamSubscription<BaseDevice> _connectionStateSubscription;
|
||||
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -33,6 +38,7 @@ class _DevicePageState extends State<DevicePage> {
|
||||
@override
|
||||
void dispose() {
|
||||
_connectionStateSubscription.cancel();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -48,11 +54,11 @@ class _DevicePageState extends State<DevicePage> {
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('SwiftControl'),
|
||||
title: AppTitle(),
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: Padding(
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -60,40 +66,97 @@ class _DevicePageState extends State<DevicePage> {
|
||||
children: [
|
||||
Text(
|
||||
'Devices:\n${connection.devices.joinToString(separator: '\n', transform: (it) {
|
||||
return "${it.device.name}: ${it.isConnected ? 'Connected' : 'Not connected'}";
|
||||
return "${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}";
|
||||
})}',
|
||||
),
|
||||
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
|
||||
if (!kIsWeb && (Platform.isAndroid || kDebugMode)) ...[
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder:
|
||||
(_) => TouchAreaSetupPage(
|
||||
onSave: (gearUp, gearDown) {
|
||||
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
if (!kIsWeb)
|
||||
Column(
|
||||
spacing: 12,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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)',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
initialSelection: actionHandler.supportedApp,
|
||||
hintText: 'Select your Keymap',
|
||||
),
|
||||
|
||||
final convertedGearUp =
|
||||
gearUp.translate(touchAreaSize / 2, touchAreaSize / 2) * devicePixelRatio;
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (actionHandler.supportedApp! is! CustomApp) {
|
||||
final customApp = CustomApp();
|
||||
|
||||
final convertedGearDown =
|
||||
gearDown.translate(touchAreaSize / 2, touchAreaSize / 2) * devicePixelRatio;
|
||||
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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
print("Gear Up Position: $gearUp - converted: $convertedGearUp");
|
||||
print("Gear Down Position: $gearDown - converted: $convertedGearDown");
|
||||
|
||||
actionHandler.updateTouchPositions(convertedGearUp, convertedGearDown);
|
||||
settings.updateTouchPositions(convertedGearUp, convertedGearDown);
|
||||
},
|
||||
),
|
||||
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 touch areas (optional)'),
|
||||
],
|
||||
),
|
||||
],
|
||||
Expanded(child: LogViewer()),
|
||||
SizedBox(height: 800, child: LogViewer()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,9 +4,9 @@ import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/requirements/multi.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/menu.dart';
|
||||
import 'package:swift_control/widgets/title.dart';
|
||||
|
||||
import 'device.dart';
|
||||
|
||||
@@ -65,7 +65,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('SwiftControl'),
|
||||
title: AppTitle(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: buildMenuButtons(),
|
||||
),
|
||||
@@ -86,7 +86,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
}
|
||||
: null,
|
||||
onStepTapped: (step) {
|
||||
if (_requirements[step].status && _requirements[step] is! KeymapRequirement) {
|
||||
if (_requirements[step].status) {
|
||||
return;
|
||||
}
|
||||
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
|
||||
|
||||
@@ -30,7 +30,13 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// must be called from a button
|
||||
if (!kIsWeb) {
|
||||
connection.performScanning();
|
||||
Future.delayed(Duration(seconds: 1))
|
||||
.then((_) {
|
||||
return connection.performScanning();
|
||||
})
|
||||
.catchError((e) {
|
||||
print(e);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -70,7 +76,7 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
}
|
||||
},
|
||||
),
|
||||
if (kDebugMode) LogViewer(),
|
||||
if (kDebugMode) SizedBox(height: 500, child: LogViewer()),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
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';
|
||||
|
||||
final touchAreaSize = 32.0;
|
||||
import '../bluetooth/messages/click_notification.dart';
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
import '../bluetooth/messages/play_notification.dart';
|
||||
import '../bluetooth/messages/ride_notification.dart';
|
||||
import '../utils/keymap/apps/custom_app.dart';
|
||||
import '../utils/keymap/buttons.dart';
|
||||
import '../utils/keymap/keymap.dart';
|
||||
import '../widgets/custom_keymap_selector.dart';
|
||||
|
||||
final touchAreaSize = 42.0;
|
||||
|
||||
class TouchAreaSetupPage extends StatefulWidget {
|
||||
final void Function(Offset gearUp, Offset gearDown) onSave;
|
||||
|
||||
const TouchAreaSetupPage({required this.onSave, super.key});
|
||||
const TouchAreaSetupPage({super.key});
|
||||
|
||||
@override
|
||||
State<TouchAreaSetupPage> createState() => _TouchAreaSetupPageState();
|
||||
@@ -17,8 +30,8 @@ class TouchAreaSetupPage extends StatefulWidget {
|
||||
|
||||
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
File? _backgroundImage;
|
||||
Offset _gearUpPos = const Offset(200, 300);
|
||||
Offset _gearDownPos = const Offset(100, 300);
|
||||
late StreamSubscription<BaseNotification> _actionSubscription;
|
||||
ZwiftButton? _pressedButton;
|
||||
|
||||
Future<void> _pickScreenshot() async {
|
||||
final picker = ImagePicker();
|
||||
@@ -31,33 +44,66 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
}
|
||||
|
||||
void _saveAndClose() {
|
||||
widget.onSave(_gearUpPos, _gearDownPos);
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_actionSubscription.cancel();
|
||||
// Exit full screen
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
windowManager.setFullScreen(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
|
||||
if (actionHandler.gearUpTouchPosition != null) {
|
||||
_gearUpPos = actionHandler.gearUpTouchPosition!;
|
||||
_gearUpPos = Offset(
|
||||
_gearUpPos.dx / devicePixelRatio - touchAreaSize / 2,
|
||||
_gearUpPos.dy / devicePixelRatio - touchAreaSize / 2,
|
||||
);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []);
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
windowManager.setFullScreen(true);
|
||||
}
|
||||
_actionSubscription = connection.actionStream.listen((data) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (data is ClickNotification) {
|
||||
_pressedButton = data.buttonsClicked.singleOrNull;
|
||||
}
|
||||
if (data is PlayNotification) {
|
||||
_pressedButton = data.buttonsClicked.singleOrNull;
|
||||
}
|
||||
if (data is RideNotification) {
|
||||
_pressedButton = data.buttonsClicked.singleOrNull;
|
||||
}
|
||||
|
||||
if (actionHandler.gearDownTouchPosition != null) {
|
||||
_gearDownPos = actionHandler.gearDownTouchPosition!;
|
||||
_gearDownPos = Offset(
|
||||
_gearDownPos.dx / devicePixelRatio - touchAreaSize / 2,
|
||||
_gearDownPos.dy / devicePixelRatio - touchAreaSize / 2,
|
||||
);
|
||||
if (_pressedButton != null) {
|
||||
if (actionHandler.supportedApp!.keymap.getKeyPair(_pressedButton!) == null) {
|
||||
final KeyPair keyPair;
|
||||
actionHandler.supportedApp!.keymap.keyPairs.add(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,81 +111,209 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
required Offset position,
|
||||
required void Function(Offset newPosition) onPositionChanged,
|
||||
required Color color,
|
||||
required KeyPair keyPair,
|
||||
required String label,
|
||||
}) {
|
||||
return Positioned(
|
||||
left: position.dx,
|
||||
top: position.dy,
|
||||
child: Draggable(
|
||||
feedback: Material(color: Colors.transparent, child: _TouchDot(color: Colors.yellow, label: label)),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDraggableCanceled: (_, offset) {
|
||||
setState(() => onPositionChanged(offset));
|
||||
child: PopupMenuButton<PhysicalKeyboardKey>(
|
||||
tooltip: 'Drag or click for special keys',
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem<PhysicalKeyboardKey>(
|
||||
value: null,
|
||||
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),
|
||||
);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
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;
|
||||
keyPair.logicalKey = null;
|
||||
setState(() {});
|
||||
},
|
||||
child: _TouchDot(color: color, label: label),
|
||||
child: Container(
|
||||
color: kDebugMode && false ? Colors.yellow : null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Draggable(
|
||||
feedback: Material(
|
||||
color: Colors.transparent,
|
||||
child: _TouchDot(color: Colors.yellow, label: label, keyPair: keyPair),
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
onDraggableCanceled: (_, offset) {
|
||||
setState(() => onPositionChanged(offset));
|
||||
},
|
||||
child: _TouchDot(color: color, label: label, keyPair: keyPair),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = Platform.isWindows || Platform.isLinux || Platform.isMacOS;
|
||||
final devicePixelRatio = isDesktop ? 1.0 : MediaQuery.devicePixelRatioOf(context);
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
if (_backgroundImage != null)
|
||||
Positioned.fill(child: Opacity(opacity: 0.5, child: Image.file(_backgroundImage!, fit: BoxFit.cover)))
|
||||
Positioned.fill(child: Opacity(opacity: 0.5, child: Image.file(_backgroundImage!, fit: BoxFit.contain)))
|
||||
else
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('''1. Create an in-game screenshot of your app (e.g. within MyWhoosh)
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text('''1. Create an in-game screenshot of your app (e.g. within MyWhoosh)
|
||||
2. Load the screenshot with the button below
|
||||
3. Make sure the app is in the correct orientation (portrait or landscape)
|
||||
4. Drag the touch areas to the correct position where the gear up / down buttons are located
|
||||
4. Press a button on your Zwift device to create a touch area
|
||||
5. Drag the touch areas to the desired position on the screenshot
|
||||
5. Save and close this screen'''),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_pickScreenshot();
|
||||
},
|
||||
child: Text('Load in-game screenshot for placement'),
|
||||
),
|
||||
],
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_pickScreenshot();
|
||||
},
|
||||
child: Text('Load in-game screenshot for placement'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Touch Areas
|
||||
_buildDraggableArea(
|
||||
position: _gearUpPos,
|
||||
onPositionChanged: (newPos) => _gearUpPos = newPos,
|
||||
color: Colors.green,
|
||||
label: "Gear ↑",
|
||||
),
|
||||
_buildDraggableArea(
|
||||
position: _gearDownPos,
|
||||
onPositionChanged: (newPos) => _gearDownPos = newPos,
|
||||
color: Colors.red,
|
||||
label: "Gear ↓",
|
||||
),
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 170,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
_gearDownPos = Offset(100, 300);
|
||||
_gearUpPos = Offset(200, 300);
|
||||
...?actionHandler.supportedApp?.keymap.keyPairs.map(
|
||||
(keyPair) => _buildDraggableArea(
|
||||
position: Offset(
|
||||
keyPair.touchPosition.dx / devicePixelRatio - touchAreaSize / 2,
|
||||
keyPair.touchPosition.dy / devicePixelRatio - touchAreaSize / 2 - (isDesktop ? touchAreaSize * 1.5 : 0),
|
||||
),
|
||||
keyPair: keyPair,
|
||||
onPositionChanged: (newPos) {
|
||||
final converted =
|
||||
newPos.translate(touchAreaSize / 2, touchAreaSize / 2 + (isDesktop ? touchAreaSize * 1.5 : 0)) *
|
||||
devicePixelRatio;
|
||||
keyPair.touchPosition = converted;
|
||||
setState(() {});
|
||||
},
|
||||
label: const Icon(Icons.lock_reset),
|
||||
color: Colors.red,
|
||||
label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n'),
|
||||
),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 20,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _saveAndClose,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text("Save & Close"),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
actionHandler.supportedApp?.keymap.reset();
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.lock_reset),
|
||||
label: Text('Reset'),
|
||||
),
|
||||
ElevatedButton.icon(onPressed: _saveAndClose, icon: const Icon(Icons.save), label: const Text("Save")),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -151,12 +325,14 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
class _TouchDot extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
final KeyPair keyPair;
|
||||
|
||||
const _TouchDot({required this.color, required this.label});
|
||||
const _TouchDot({required this.color, required this.label, required this.keyPair});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: touchAreaSize,
|
||||
@@ -164,10 +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.touch_app_outlined,
|
||||
),
|
||||
),
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(label, style: TextStyle(color: Colors.black, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,50 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
import 'package:swift_control/utils/keymap/keymap.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/custom_app.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import '../keymap/apps/supported_app.dart';
|
||||
import '../single_line_exception.dart';
|
||||
|
||||
class AndroidActions extends BaseActions {
|
||||
static const MYWHOOSH_APP_PACKAGE = "com.mywhoosh.whooshgame";
|
||||
static const TRAININGPEAKS_APP_PACKAGE = "com.indieVelo.client";
|
||||
static const validPackageNames = [MYWHOOSH_APP_PACKAGE, TRAININGPEAKS_APP_PACKAGE];
|
||||
|
||||
WindowEvent? windowInfo;
|
||||
Offset? _gearUpTouchPosition;
|
||||
Offset? _gearDownTouchPosition;
|
||||
|
||||
@override
|
||||
Offset? get gearUpTouchPosition => _gearUpTouchPosition;
|
||||
|
||||
@override
|
||||
Offset? get gearDownTouchPosition => _gearDownTouchPosition;
|
||||
|
||||
@override
|
||||
void init(Keymap? keymap) {
|
||||
void init(SupportedApp? supportedApp) {
|
||||
super.init(supportedApp);
|
||||
streamEvents().listen((windowEvent) {
|
||||
if (validPackageNames.contains(windowEvent.packageName)) {
|
||||
if (supportedApp != null) {
|
||||
windowInfo = windowEvent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void decreaseGear() {
|
||||
if (_gearDownTouchPosition == null) {
|
||||
if (windowInfo == null) {
|
||||
throw Exception("Increasing gear: No window info");
|
||||
}
|
||||
final point = switch (windowInfo!.packageName) {
|
||||
MYWHOOSH_APP_PACKAGE => Offset(windowInfo!.windowWidth * 0.80, windowInfo!.windowHeight * 0.94),
|
||||
TRAININGPEAKS_APP_PACKAGE => Offset(windowInfo!.windowWidth / 2 * 1.15, windowInfo!.windowHeight * 0.74),
|
||||
_ => throw UnimplementedError("Decreasing gear not supported for ${windowInfo!.packageName}"),
|
||||
};
|
||||
|
||||
accessibilityHandler.performTouch(point.dx, point.dy);
|
||||
} else {
|
||||
accessibilityHandler.performTouch(_gearDownTouchPosition!.dx, _gearDownTouchPosition!.dy);
|
||||
Future<String> performAction(ZwiftButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ("Could not perform ${button.name}: No keymap set");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void increaseGear() {
|
||||
if (_gearUpTouchPosition == null) {
|
||||
if (windowInfo == null) {
|
||||
throw Exception("Increasing gear: No window info");
|
||||
if (supportedApp is CustomApp) {
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(button);
|
||||
if (keyPair != null && keyPair.isSpecialKey) {
|
||||
await accessibilityHandler.controlMedia(switch (keyPair.physicalKey) {
|
||||
PhysicalKeyboardKey.mediaTrackNext => MediaAction.next,
|
||||
PhysicalKeyboardKey.mediaPlayPause => MediaAction.playPause,
|
||||
PhysicalKeyboardKey.audioVolumeUp => MediaAction.volumeUp,
|
||||
PhysicalKeyboardKey.audioVolumeDown => MediaAction.volumeDown,
|
||||
_ => throw SingleLineException("No action for key: ${keyPair.physicalKey}"),
|
||||
});
|
||||
return "Key pressed: ${keyPair.toString()}";
|
||||
}
|
||||
final point = switch (windowInfo!.packageName) {
|
||||
MYWHOOSH_APP_PACKAGE => Offset(windowInfo!.windowWidth * 0.98, windowInfo!.windowHeight * 0.94),
|
||||
TRAININGPEAKS_APP_PACKAGE => Offset(windowInfo!.windowWidth / 2 * 1.32, windowInfo!.windowHeight * 0.74),
|
||||
_ => throw UnimplementedError("Increasing gear not supported for ${windowInfo!.packageName}"),
|
||||
};
|
||||
|
||||
accessibilityHandler.performTouch(point.dx, point.dy);
|
||||
} else {
|
||||
accessibilityHandler.performTouch(_gearUpTouchPosition!.dx, _gearUpTouchPosition!.dy);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void controlMedia(MediaAction action) {
|
||||
accessibilityHandler.controlMedia(action);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateTouchPositions(Offset gearUp, Offset gearDown) {
|
||||
_gearUpTouchPosition = gearUp;
|
||||
_gearDownTouchPosition = gearDown;
|
||||
final point = supportedApp!.resolveTouchPosition(action: button, windowInfo: windowInfo);
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
import 'dart:ui';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
|
||||
import '../keymap/keymap.dart';
|
||||
import '../keymap/apps/supported_app.dart';
|
||||
|
||||
abstract class BaseActions {
|
||||
Keymap? get keymap => null;
|
||||
Offset? get gearUpTouchPosition => null;
|
||||
Offset? get gearDownTouchPosition => null;
|
||||
SupportedApp? supportedApp;
|
||||
|
||||
void init(Keymap? keymap) {}
|
||||
void increaseGear();
|
||||
void decreaseGear();
|
||||
|
||||
void controlMedia(MediaAction action) {
|
||||
throw UnimplementedError();
|
||||
void init(SupportedApp? supportedApp) {
|
||||
this.supportedApp = supportedApp;
|
||||
}
|
||||
|
||||
void updateTouchPositions(Offset gearUp, Offset gearDown) {}
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false});
|
||||
}
|
||||
|
||||
class StubActions extends BaseActions {
|
||||
@override
|
||||
void decreaseGear() {
|
||||
print('Decrease gear');
|
||||
}
|
||||
|
||||
@override
|
||||
void increaseGear() {
|
||||
print('Increase gear');
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) {
|
||||
return Future.value(action.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,76 @@
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:swift_control/utils/actions/base_actions.dart';
|
||||
|
||||
import '../keymap/keymap.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class DesktopActions extends BaseActions {
|
||||
Keymap? _keymap;
|
||||
// Track keys that are currently held down in long press mode
|
||||
final Set<ZwiftButton> _heldKeys = <ZwiftButton>{};
|
||||
|
||||
@override
|
||||
Keymap? get keymap => _keymap;
|
||||
Future<String> performAction(ZwiftButton action, {bool isKeyDown = true, bool isKeyUp = false}) async {
|
||||
if (supportedApp == null) {
|
||||
return ('Supported app is not set');
|
||||
}
|
||||
|
||||
@override
|
||||
void init(Keymap? keymap) {
|
||||
_keymap = keymap;
|
||||
final keyPair = supportedApp!.keymap.getKeyPair(action);
|
||||
if (keyPair == null) {
|
||||
return ('Keymap entry not found for action: $action');
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> decreaseGear() async {
|
||||
if (keymap == null) {
|
||||
throw Exception('Keymap is not set');
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
await keyPressSimulator.simulateKeyDown(_keymap!.decrease?.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(_keymap!.decrease?.physicalKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> increaseGear() async {
|
||||
if (keymap == null) {
|
||||
throw Exception('Keymap is not set');
|
||||
}
|
||||
await keyPressSimulator.simulateKeyDown(_keymap!.increase?.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(_keymap!.increase?.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;
|
||||
}
|
||||
67
lib/utils/keymap/apps/custom_app.dart
Normal file
67
lib/utils/keymap/apps/custom_app.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
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 '../../single_line_exception.dart';
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
|
||||
class CustomApp extends SupportedApp {
|
||||
CustomApp() : super(name: 'Custom', packageName: "custom", keymap: Keymap.custom);
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
final keyPair = keymap.getKeyPair(action);
|
||||
if (keyPair == null || keyPair.touchPosition == Offset.zero) {
|
||||
throw SingleLineException("No key pair found for action: $action");
|
||||
}
|
||||
return keyPair.touchPosition;
|
||||
}
|
||||
|
||||
List<String> encodeKeymap() {
|
||||
// encode to save in preferences
|
||||
return keymap.keyPairs.map((e) => e.encode()).toList();
|
||||
}
|
||||
|
||||
void decodeKeymap(List<String> data) {
|
||||
// decode from preferences
|
||||
|
||||
if (data.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final keyPairs = data.map((e) => KeyPair.decode(e)).whereNotNull().toList();
|
||||
if (keyPairs.isEmpty) {
|
||||
return;
|
||||
}
|
||||
keymap.keyPairs = keyPairs;
|
||||
}
|
||||
|
||||
void setKey(
|
||||
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,
|
||||
isLongPress: isLongPress,
|
||||
touchPosition: touchPosition ?? Offset.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
lib/utils/keymap/apps/my_whoosh.dart
Normal file
96
lib/utils/keymap/apps/my_whoosh.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
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';
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
|
||||
class MyWhoosh extends SupportedApp {
|
||||
MyWhoosh()
|
||||
: super(
|
||||
name: 'MyWhoosh',
|
||||
packageName: "com.mywhoosh.whooshgame",
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftDown).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyI,
|
||||
logicalKey: LogicalKeyboardKey.keyI,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyK,
|
||||
logicalKey: LogicalKeyboardKey.keyK,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateRight).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyD,
|
||||
logicalKey: LogicalKeyboardKey.keyD,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.navigateLeft).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.toggleUi).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.keyH,
|
||||
logicalKey: LogicalKeyboardKey.keyH,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
final superPosition = super.resolveTouchPosition(action: action, windowInfo: windowInfo);
|
||||
if (superPosition != Offset.zero) {
|
||||
return superPosition;
|
||||
}
|
||||
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.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;
|
||||
}
|
||||
37
lib/utils/keymap/apps/supported_app.dart
Normal file
37
lib/utils/keymap/apps/supported_app.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
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';
|
||||
import '../buttons.dart';
|
||||
import '../keymap.dart';
|
||||
import 'custom_app.dart';
|
||||
import 'my_whoosh.dart';
|
||||
|
||||
abstract class SupportedApp {
|
||||
final String packageName;
|
||||
final String name;
|
||||
final Keymap keymap;
|
||||
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
if (this is CustomApp) {
|
||||
final keyPair = keymap.getKeyPair(action);
|
||||
if (keyPair == null || keyPair.touchPosition == Offset.zero) {
|
||||
throw SingleLineException("No key pair found for action: $action");
|
||||
}
|
||||
return keyPair.touchPosition;
|
||||
}
|
||||
return Offset.zero;
|
||||
}
|
||||
|
||||
const SupportedApp({required this.name, required this.packageName, required this.keymap});
|
||||
|
||||
static final List<SupportedApp> supportedApps = [MyWhoosh(), TrainingPeaks(), Biketerra(), CustomApp()];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return runtimeType.toString();
|
||||
}
|
||||
}
|
||||
73
lib/utils/keymap/apps/training_peaks.dart
Normal file
73
lib/utils/keymap/apps/training_peaks.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
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';
|
||||
|
||||
import '../keymap.dart';
|
||||
|
||||
class TrainingPeaks extends SupportedApp {
|
||||
TrainingPeaks()
|
||||
: super(
|
||||
name: 'IndieVelo / TrainingPeaks',
|
||||
packageName: "com.indieVelo.client",
|
||||
keymap: Keymap(
|
||||
keyPairs: [
|
||||
// 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.numpadSubtract,
|
||||
logicalKey: LogicalKeyboardKey.numpadSubtract,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.numpadAdd,
|
||||
logicalKey: LogicalKeyboardKey.numpadAdd,
|
||||
),
|
||||
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.keyH,
|
||||
logicalKey: LogicalKeyboardKey.keyH,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.increaseResistance).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.pageUp,
|
||||
logicalKey: LogicalKeyboardKey.pageUp,
|
||||
),
|
||||
KeyPair(
|
||||
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.decreaseResistance).toList(),
|
||||
physicalKey: PhysicalKeyboardKey.pageDown,
|
||||
logicalKey: LogicalKeyboardKey.pageDown,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Offset resolveTouchPosition({required ZwiftButton action, required WindowEvent? windowInfo}) {
|
||||
final superPosition = super.resolveTouchPosition(action: action, windowInfo: windowInfo);
|
||||
if (superPosition != Offset.zero) {
|
||||
return superPosition;
|
||||
}
|
||||
if (windowInfo == null) {
|
||||
throw SingleLineException("Window size not known - open $this first");
|
||||
}
|
||||
return switch (action.action) {
|
||||
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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
52
lib/utils/keymap/buttons.dart
Normal file
52
lib/utils/keymap/buttons.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
enum InGameAction {
|
||||
shiftUp,
|
||||
shiftDown,
|
||||
navigateLeft,
|
||||
navigateRight,
|
||||
toggleUi,
|
||||
increaseResistance,
|
||||
decreaseResistance;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
enum ZwiftButton {
|
||||
// left controller
|
||||
navigationUp._(InGameAction.increaseResistance),
|
||||
navigationDown._(InGameAction.decreaseResistance),
|
||||
navigationLeft._(InGameAction.navigateLeft),
|
||||
navigationRight._(InGameAction.navigateRight),
|
||||
onOffLeft._(InGameAction.toggleUi),
|
||||
sideButtonLeft._(InGameAction.shiftDown),
|
||||
paddleLeft._(InGameAction.shiftDown),
|
||||
|
||||
// zwift ride only
|
||||
shiftUpLeft._(InGameAction.shiftDown),
|
||||
shiftDownLeft._(InGameAction.shiftDown),
|
||||
powerUpLeft._(InGameAction.shiftDown),
|
||||
|
||||
// right controller
|
||||
a._(null),
|
||||
b._(null),
|
||||
z._(null),
|
||||
y._(null),
|
||||
onOffRight._(InGameAction.toggleUi),
|
||||
sideButtonRight._(InGameAction.shiftUp),
|
||||
paddleRight._(InGameAction.shiftUp),
|
||||
|
||||
// zwift ride only
|
||||
shiftUpRight._(InGameAction.shiftUp),
|
||||
shiftDownRight._(InGameAction.shiftUp),
|
||||
powerUpRight._(InGameAction.shiftUp);
|
||||
|
||||
final InGameAction? action;
|
||||
const ZwiftButton._(this.action);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,105 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:swift_control/utils/keymap/buttons.dart';
|
||||
|
||||
class Keymap {
|
||||
static Keymap myWhoosh = Keymap(
|
||||
'MyWhoosh',
|
||||
increase: KeyPair(physicalKey: PhysicalKeyboardKey.keyK, logicalKey: LogicalKeyboardKey.keyK),
|
||||
decrease: KeyPair(physicalKey: PhysicalKeyboardKey.keyI, logicalKey: LogicalKeyboardKey.keyI),
|
||||
);
|
||||
static Keymap custom = Keymap('Custom', increase: null, decrease: null);
|
||||
static Keymap custom = Keymap(keyPairs: []);
|
||||
|
||||
static List<Keymap> values = [myWhoosh, custom];
|
||||
List<KeyPair> keyPairs;
|
||||
|
||||
KeyPair? increase;
|
||||
KeyPair? decrease;
|
||||
final String name;
|
||||
|
||||
Keymap(this.name, {required this.increase, required this.decrease});
|
||||
Keymap({required this.keyPairs});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (increase == null && decrease == null) {
|
||||
return name;
|
||||
}
|
||||
return "$name: ${increase?.logicalKey.keyLabel} + ${decrease?.logicalKey.keyLabel}";
|
||||
return keyPairs.joinToString(
|
||||
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()}' : ''}${k.isLongPress ? '\nLong Press: Enabled' : ''}''',
|
||||
);
|
||||
}
|
||||
|
||||
List<String> encode() {
|
||||
// encode to save in preferences
|
||||
return [
|
||||
name,
|
||||
increase?.logicalKey.keyId.toString() ?? '',
|
||||
increase?.physicalKey.usbHidUsage.toString() ?? '',
|
||||
decrease?.logicalKey.keyId.toString() ?? '',
|
||||
decrease?.physicalKey.usbHidUsage.toString() ?? '',
|
||||
];
|
||||
PhysicalKeyboardKey? getPhysicalKey(ZwiftButton action) {
|
||||
// get the key pair by in game action
|
||||
return keyPairs.firstOrNullWhere((element) => element.buttons.contains(action))?.physicalKey;
|
||||
}
|
||||
|
||||
static Keymap decode(List<String> data) {
|
||||
// decode from preferences
|
||||
KeyPair? getKeyPair(ZwiftButton action) {
|
||||
// get the key pair by in game action
|
||||
return keyPairs.firstOrNullWhere((element) => element.buttons.contains(action));
|
||||
}
|
||||
|
||||
if (data.length < 4) {
|
||||
return custom;
|
||||
}
|
||||
final name = data[0];
|
||||
final keymap = values.firstWhere((element) => element.name == name, orElse: () => custom);
|
||||
|
||||
if (keymap.name != custom.name) {
|
||||
return keymap;
|
||||
}
|
||||
|
||||
if (data.sublist(1).all((e) => e.isNotEmpty)) {
|
||||
keymap.increase = KeyPair(
|
||||
physicalKey: PhysicalKeyboardKey(int.parse(data[2])),
|
||||
logicalKey: LogicalKeyboardKey(int.parse(data[1])),
|
||||
);
|
||||
keymap.decrease = KeyPair(
|
||||
physicalKey: PhysicalKeyboardKey(int.parse(data[4])),
|
||||
logicalKey: LogicalKeyboardKey(int.parse(data[3])),
|
||||
);
|
||||
}
|
||||
return keymap;
|
||||
void reset() {
|
||||
keyPairs = [];
|
||||
}
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
final PhysicalKeyboardKey physicalKey;
|
||||
final LogicalKeyboardKey logicalKey;
|
||||
final List<ZwiftButton> buttons;
|
||||
PhysicalKeyboardKey? physicalKey;
|
||||
LogicalKeyboardKey? logicalKey;
|
||||
Offset touchPosition;
|
||||
bool isLongPress;
|
||||
|
||||
KeyPair({required this.physicalKey, required this.logicalKey});
|
||||
KeyPair({
|
||||
required this.buttons,
|
||||
required this.physicalKey,
|
||||
required this.logicalKey,
|
||||
this.touchPosition = Offset.zero,
|
||||
this.isLongPress = false,
|
||||
});
|
||||
|
||||
bool get isSpecialKey =>
|
||||
physicalKey == PhysicalKeyboardKey.mediaPlayPause ||
|
||||
physicalKey == PhysicalKeyboardKey.mediaTrackNext ||
|
||||
physicalKey == PhysicalKeyboardKey.mediaTrackPrevious ||
|
||||
physicalKey == PhysicalKeyboardKey.mediaStop ||
|
||||
physicalKey == PhysicalKeyboardKey.audioVolumeUp ||
|
||||
physicalKey == PhysicalKeyboardKey.audioVolumeDown;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return logicalKey?.keyLabel ??
|
||||
switch (physicalKey) {
|
||||
PhysicalKeyboardKey.mediaPlayPause => 'Play/Pause',
|
||||
PhysicalKeyboardKey.mediaTrackNext => 'Next Track',
|
||||
PhysicalKeyboardKey.mediaTrackPrevious => 'Previous Track',
|
||||
PhysicalKeyboardKey.mediaStop => 'Stop',
|
||||
PhysicalKeyboardKey.audioVolumeUp => 'Volume Up',
|
||||
PhysicalKeyboardKey.audioVolumeDown => 'Volume Down',
|
||||
_ => 'Not assigned',
|
||||
};
|
||||
}
|
||||
|
||||
String encode() {
|
||||
// encode to save in preferences
|
||||
return jsonEncode({
|
||||
'actions': buttons.map((e) => e.name).toList(),
|
||||
'logicalKey': logicalKey?.keyId.toString() ?? '0',
|
||||
'physicalKey': physicalKey?.usbHidUsage.toString() ?? '0',
|
||||
'touchPosition': {'x': touchPosition.dx, 'y': touchPosition.dy},
|
||||
'isLongPress': isLongPress,
|
||||
});
|
||||
}
|
||||
|
||||
static KeyPair? decode(String data) {
|
||||
// decode from preferences
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded['actions'] == null || decoded['logicalKey'] == null || decoded['physicalKey'] == null) {
|
||||
return null;
|
||||
}
|
||||
return KeyPair(
|
||||
buttons:
|
||||
decoded['actions']
|
||||
.map<ZwiftButton>((e) => ZwiftButton.values.firstWhere((element) => element.name == e))
|
||||
.toList(),
|
||||
logicalKey: int.parse(decoded['logicalKey']) != 0 ? LogicalKeyboardKey(int.parse(decoded['logicalKey'])) : null,
|
||||
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');
|
||||
|
||||
@@ -50,7 +65,7 @@ class BluetoothConnectRequirement extends PlatformRequirement {
|
||||
}
|
||||
|
||||
class NotificationRequirement extends PlatformRequirement {
|
||||
NotificationRequirement() : super('Allow adding persistent Notification');
|
||||
NotificationRequirement() : super('Allow adding persistent Notification (keeps app alive)');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:keypress_simulator/keypress_simulator.dart';
|
||||
import 'package:swift_control/pages/scan.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
import 'package:swift_control/widgets/custom_keymap_selector.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../keymap/keymap.dart';
|
||||
|
||||
class KeyboardRequirement extends PlatformRequirement {
|
||||
KeyboardRequirement() : super('Keyboard access');
|
||||
|
||||
@@ -25,46 +18,6 @@ class KeyboardRequirement extends PlatformRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
class KeymapRequirement extends PlatformRequirement {
|
||||
KeymapRequirement() : super('Select your Keymap / App');
|
||||
|
||||
@override
|
||||
Future<void> call() async {}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
status = actionHandler.keymap != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
final controller = TextEditingController(text: actionHandler.keymap?.name);
|
||||
return DropdownMenu<Keymap>(
|
||||
controller: controller,
|
||||
dropdownMenuEntries:
|
||||
Keymap.values.map((key) => DropdownMenuEntry<Keymap>(value: key, label: key.toString())).toList(),
|
||||
onSelected: (keymap) async {
|
||||
if (keymap!.name == Keymap.custom.name) {
|
||||
keymap = await showCustomKeymapDialog(context, keymap: keymap);
|
||||
} else if (keymap.name == Keymap.myWhoosh.name && (!kIsWeb && Platform.isWindows)) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Use a Custom Keymap if you experience any issues on Windows')));
|
||||
}
|
||||
controller.text = keymap?.name ?? '';
|
||||
if (keymap == null) {
|
||||
return;
|
||||
}
|
||||
actionHandler.init(keymap);
|
||||
settings.setKeymap(keymap);
|
||||
onUpdate();
|
||||
},
|
||||
initialSelection: actionHandler.keymap,
|
||||
hintText: 'Keymap',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BluetoothTurnedOn extends PlatformRequirement {
|
||||
BluetoothTurnedOn() : super('Bluetooth turned on');
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -25,16 +26,22 @@ Future<List<PlatformRequirement>> getRequirements() async {
|
||||
if (kIsWeb) {
|
||||
list = [BluetoothTurnedOn(), BluetoothScanning()];
|
||||
} else if (Platform.isMacOS) {
|
||||
list = [BluetoothTurnedOn(), KeyboardRequirement(), KeymapRequirement(), BluetoothScanning()];
|
||||
list = [BluetoothTurnedOn(), KeyboardRequirement(), BluetoothScanning()];
|
||||
} else if (Platform.isWindows) {
|
||||
list = [BluetoothTurnedOn(), KeyboardRequirement(), KeymapRequirement(), BluetoothScanning()];
|
||||
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 {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:swift_control/utils/keymap/apps/supported_app.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../keymap/keymap.dart';
|
||||
import '../keymap/apps/custom_app.dart';
|
||||
|
||||
class Settings {
|
||||
late final SharedPreferences _prefs;
|
||||
@@ -12,32 +12,30 @@ class Settings {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
|
||||
try {
|
||||
final keymapSetting = _prefs.getStringList("keymap");
|
||||
if (keymapSetting != null) {
|
||||
actionHandler.init(Keymap.decode(keymapSetting));
|
||||
final appSetting = _prefs.getStringList("customapp");
|
||||
if (appSetting != null) {
|
||||
final customApp = CustomApp();
|
||||
customApp.decodeKeymap(appSetting);
|
||||
}
|
||||
|
||||
final gearUpX = _prefs.getDouble("gearUpX");
|
||||
final gearUpY = _prefs.getDouble("gearUpY");
|
||||
final gearDownX = _prefs.getDouble("gearDownX");
|
||||
final gearDownY = _prefs.getDouble("gearDownY");
|
||||
if (gearUpX != null && gearUpY != null && gearDownX != null && gearDownY != null) {
|
||||
actionHandler.updateTouchPositions(Offset(gearUpX, gearUpY), Offset(gearDownX, gearDownY));
|
||||
final appName = _prefs.getString('app');
|
||||
if (appName == null) {
|
||||
return;
|
||||
}
|
||||
final app = SupportedApp.supportedApps.firstOrNullWhere((e) => e.name == appName);
|
||||
|
||||
actionHandler.init(app);
|
||||
} catch (e) {
|
||||
// couldn't decode, reset
|
||||
await _prefs.clear();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void setKeymap(Keymap keymap) {
|
||||
_prefs.setStringList("keymap", keymap.encode());
|
||||
}
|
||||
|
||||
void updateTouchPositions(Offset gearUp, Offset gearDown) {
|
||||
_prefs.setDouble("gearUpX", gearUp.dx);
|
||||
_prefs.setDouble("gearUpY", gearUp.dy);
|
||||
_prefs.setDouble("gearDownX", gearDown.dx);
|
||||
_prefs.setDouble("gearDownY", gearDown.dy);
|
||||
void setApp(SupportedApp app) {
|
||||
if (app is CustomApp) {
|
||||
_prefs.setStringList("customapp", app.encodeKeymap());
|
||||
}
|
||||
_prefs.setString('app', app.name);
|
||||
}
|
||||
}
|
||||
|
||||
10
lib/utils/single_line_exception.dart
Normal file
10
lib/utils/single_line_exception.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
class SingleLineException implements Exception {
|
||||
final String message;
|
||||
|
||||
SingleLineException(this.message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user