Compare commits

...

16 Commits

Author SHA1 Message Date
Jonas Bark
a14d21f8e4 update readme 2025-04-06 13:56:33 +02:00
Jonas Bark
8de715a153 Windows: implement mouse touch 2025-04-06 13:52:00 +02:00
Jonas Bark
e9ebe832de Desktop: add mouse click support 2025-04-06 13:30:59 +02:00
Jonas Bark
2c8feccea1 update changelog and readme 2025-04-06 12:39:49 +02:00
Jonas Bark
36083e654f Android: fix touch alignment 2025-04-06 12:34:16 +02:00
Jonas Bark
8790b1938a Android: support media keys 2025-04-06 11:59:29 +02:00
Jonas Bark
7cd48ce3c4 allow media keys for keyboard mapping 2025-04-06 11:29:41 +02:00
Jonas Bark
4300f1005d show battery level of connected devices 2025-04-06 11:18:20 +02:00
Jonas Bark
9dec9c370c Android: custom touch mapping for media actions, add keymap for training peaks 2025-04-06 11:10:40 +02:00
Jonas Bark
e9f460279a Android: custom touch mapping for all actions 2025-04-05 17:25:46 +02:00
Jonas Bark
06b322e575 Windows & macOS: custom keyboard mapping for all actions 2025-04-05 13:53:23 +02:00
Jonas Bark
80d8d8c0cd some refactoring, UI adjustments 2025-04-05 11:40:07 +02:00
Jonas Bark
4450db3be9 more logging 2025-04-02 21:31:27 +02:00
Jonas Bark
b875489ad3 more logging 2025-04-02 18:29:41 +02:00
Jonas Bark
ece3f3822f increase connection timeout, logging 2025-04-02 13:34:40 +02:00
Jonas Bark
0780cdc80b increase connection timeout, logging 2025-04-02 13:30:25 +02:00
74 changed files with 1832 additions and 629 deletions

View File

@@ -1,3 +1,11 @@
#### 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

View File

@@ -19,8 +19,8 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
- MyWhoosh
- indieVelo / Training Peaks
- 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
@@ -33,16 +33,20 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
- Windows (make sure you have installed the "[Microsoft Visual C++ Runtime libraries](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170)")
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
## Troubleshooting
Your Zwift device is found but connection does not work properly? You may need to update the firmware in Zwift Companion app.
## How does it work?
The app connects to your Zwift device automatically.
- 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 macOS or Windows a keyboard or mouse click is used to trigger the action.
- there are predefined Keymaps for MyWhoosh and indieVelo / Training Peaks
- 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.
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/boni)
## TODO
- implement more actions for Play + Ride

View 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/

View 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

View File

@@ -0,0 +1,7 @@
## 0.2.0
* feat: Convert to federated plugin
## 0.1.0
* First release.

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

View File

@@ -0,0 +1 @@
../../README-ZH.md

View File

@@ -0,0 +1 @@
../../README.md

View File

@@ -0,0 +1 @@
include: package:mostly_reasonable_lints/flutter.yaml

View File

@@ -0,0 +1 @@
export 'src/keypress_simulator.dart';

View File

@@ -0,0 +1,54 @@
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> simulateMouseClick(Offset position) {
return _platform.simulateMouseClick(position);
}
/// 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;

View 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

View 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/

View File

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

View File

@@ -0,0 +1,3 @@
## 0.2.0
* First release.

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

View File

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

View File

@@ -0,0 +1 @@
include: package:mostly_reasonable_lints/flutter.yaml

View File

@@ -0,0 +1,114 @@
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 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)*/
// Mouse down
let mouseDown = CGEvent(mouseEventSource: nil,
mouseType: .leftMouseDown,
mouseCursorPosition: point,
mouseButton: .left)
mouseDown?.post(tap: .cghidEventTap)
// Mouse up
let mouseUp = CGEvent(mouseEventSource: nil,
mouseType: .leftMouseUp,
mouseCursorPosition: point,
mouseButton: .left)
mouseUp?.post(tap: .cghidEventTap)
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!
}
}

View File

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

View File

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

View 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/

View File

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

View File

@@ -0,0 +1,3 @@
## 0.2.0
* First release.

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

View File

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

View File

@@ -0,0 +1 @@
include: package:mostly_reasonable_lints/flutter.yaml

View File

@@ -0,0 +1,4 @@
library keypress_simulator_platform_interface;
export 'src/keypress_simulator_method_channel.dart';
export 'src/keypress_simulator_platform_interface.dart';

View File

@@ -0,0 +1,63 @@
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) async {
final Map<String, double> arguments = {
'x': position.dx,
'y': position.dy,
};
await methodChannel.invokeMethod('simulateMouseClick', arguments);
}
}

View File

@@ -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) {
throw UnimplementedError('simulateKeyPress() has not been implemented.');
}
}

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ environment:
dependencies:
flutter:
sdk: flutter
keypress_simulator_platform_interface: ^0.2.0
keypress_simulator_platform_interface:
path: ../keypress_simulator_platform_interface
dev_dependencies:
flutter_test:

View File

@@ -85,11 +85,49 @@ void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
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;
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);
}
// Move the mouse to the specified coordinates
SetCursorPos(static_cast<int>(x), static_cast<int>(y));
// Prepare input for mouse down and up
INPUT input = {0};
input.type = INPUT_MOUSE;
// Mouse left button down
input.mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
SendInput(1, &input, sizeof(INPUT));
// Mouse left button up
input.mi.dwFlags = MOUSEEVENTF_LEFTUP;
SendInput(1, &input, sizeof(INPUT));
result->Success(flutter::EncodableValue(true));
}
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();
}

View File

@@ -26,6 +26,10 @@ class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
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,

View File

@@ -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';
@@ -34,8 +33,10 @@ class Connection {
UniversalBle.onScanResult = (result) {
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
_lastScanResult.add(result);
_actionStreams.add(LogNotification('Found new device: ${result.name}'));
final scanResult = BaseDevice.fromScanResult(result);
_actionStreams.add(
LogNotification('Found new device: ${result.name ?? scanResult?.runtimeType ?? result.deviceId}'),
);
if (scanResult != null) {
_addDevices([scanResult]);
}
@@ -90,7 +91,6 @@ class Connection {
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()));
});
@@ -138,11 +138,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;
}
}
@@ -160,4 +161,8 @@ class Connection {
hasDevices.value = false;
devices.clear();
}
void signalChange(BaseDevice baseDevice) {
_connectionStreams.add(baseDevice);
}
}

View File

@@ -7,8 +7,10 @@ import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/zwift_click.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/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';
@@ -75,10 +77,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);
@@ -92,7 +100,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(
@@ -166,15 +176,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"));
}
}
}
@@ -194,6 +204,11 @@ 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.'));
return;
}
final data = zapEncryption.decrypt(counter, payload);
type = data[0];
message = data.sublist(1);
@@ -207,15 +222,20 @@ 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((_) {}).catchError((e) {
actionStreamInternal.add(LogNotification(e.toString()));
});
break;
}
}
void processClickNotification(Uint8List message);
Future<void> processClickNotification(Uint8List message);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/main.dart';
import '../messages/click_notification.dart';
@@ -10,16 +11,18 @@ class ZwiftClick extends BaseDevice {
ClickNotification? _lastClickNotification;
@override
void processClickNotification(Uint8List message) {
Future<void> processClickNotification(Uint8List message) async {
final ClickNotification clickNotification = ClickNotification(message);
if (_lastClickNotification == null || _lastClickNotification != clickNotification) {
_lastClickNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if (clickNotification.buttonsClicked.isNotEmpty) {
actionStreamInternal.add(clickNotification);
}
if (clickNotification.buttonUp) {
actionHandler.increaseGear();
} else if (clickNotification.buttonDown) {
actionHandler.decreaseGear();
final buttons = clickNotification.buttonsClicked;
for (final action in buttons) {
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
}
}
}

View File

@@ -1,10 +1,10 @@
import 'package:accessibility/accessibility.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/messages/play_notification.dart';
import '../../main.dart';
import '../ble.dart';
import '../messages/notification.dart';
class ZwiftPlay extends BaseDevice {
ZwiftPlay(super.scanResult);
@@ -15,29 +15,19 @@ class ZwiftPlay extends BaseDevice {
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY;
@override
void processClickNotification(Uint8List message) {
Future<void> 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.buttonsClicked.isNotEmpty) {
actionStreamInternal.add(clickNotification);
}
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);
}
final buttons = clickNotification.buttonsClicked;
for (final action in buttons) {
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
}
}
}

View File

@@ -5,6 +5,7 @@ import 'package:swift_control/bluetooth/messages/ride_notification.dart';
import 'package:swift_control/main.dart';
import '../ble.dart';
import '../messages/notification.dart';
class ZwiftRide extends BaseDevice {
ZwiftRide(super.scanResult);
@@ -18,32 +19,19 @@ class ZwiftRide extends BaseDevice {
RideNotification? _lastControllerNotification;
@override
void processClickNotification(Uint8List message) {
Future<void> 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.buttonOnOffLeft ||
clickNotification.buttonPowerUpLeft) {
actionHandler.decreaseGear();
} else if (clickNotification.buttonShiftUpRight ||
clickNotification.buttonShiftDownRight ||
clickNotification.buttonOnOffRight ||
clickNotification.buttonPowerUpRight) {
actionHandler.increaseGear();
if (clickNotification.buttonsClicked.isNotEmpty) {
actionStreamInternal.add(clickNotification);
}
final buttons = clickNotification.buttonsClicked;
for (final action in buttons) {
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
}
/*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);
}*/
}
}
}

View File

@@ -1,21 +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 {
bool buttonUp = false;
bool buttonDown = false;
late List<ZwiftButton> buttonsClicked;
ClickNotification(Uint8List message) {
final status = ClickKeyPadStatus.fromBuffer(message);
buttonUp = status.buttonPlus == PlayButtonStatus.ON;
buttonDown = status.buttonMinus == PlayButtonStatus.ON;
buttonsClicked = [
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.shiftUpLeft,
if (status.buttonMinus == PlayButtonStatus.ON) ZwiftButton.shiftDownRight,
];
}
@override
String toString() {
return 'Click: {buttonUp: $buttonUp, buttonDown: $buttonDown}';
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name)}';
}
@override
@@ -23,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;
}

View File

@@ -1,38 +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 {
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 == PlayButtonStatus.ON;
buttonY = status.buttonYUp == PlayButtonStatus.ON;
buttonZ = status.buttonZLeft == PlayButtonStatus.ON;
buttonA = status.buttonARight == PlayButtonStatus.ON;
buttonB = status.buttonBDown == PlayButtonStatus.ON;
buttonOn = status.buttonOn == PlayButtonStatus.ON;
buttonShift = status.buttonShift == PlayButtonStatus.ON;
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
@@ -40,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;
}

View File

@@ -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, buttonPowerUpRight;
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;
buttonPowerUpRight = 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 (buttonPowerUpRight) 'buttonPowerUpRight',
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 &&
buttonPowerUpRight == other.buttonPowerUpRight &&
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 ^
buttonPowerUpRight.hashCode ^
buttonOnOffLeft.hashCode ^
buttonOnOffRight.hashCode ^
analogLR.hashCode ^
analogUD.hashCode;
int get hashCode => buttonsClicked.hashCode;
}

View File

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

View File

@@ -10,6 +10,8 @@ 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 {
@@ -21,6 +23,7 @@ class DevicePage extends StatefulWidget {
class _DevicePageState extends State<DevicePage> {
late StreamSubscription<BaseDevice> _connectionStateSubscription;
final controller = TextEditingController(text: actionHandler.supportedApp?.name);
@override
void initState() {
@@ -34,6 +37,7 @@ class _DevicePageState extends State<DevicePage> {
@override
void dispose() {
_connectionStateSubscription.cancel();
controller.dispose();
super.dispose();
}
@@ -61,39 +65,84 @@ class _DevicePageState extends State<DevicePage> {
children: [
Text(
'Devices:\n${connection.devices.joinToString(separator: '\n', transform: (it) {
return "${it.device.name ?? it.runtimeType}: ${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);
final convertedGearUp =
gearUp.translate(touchAreaSize / 2, touchAreaSize / 2) * devicePixelRatio;
final convertedGearDown =
gearDown.translate(touchAreaSize / 2, touchAreaSize / 2) * devicePixelRatio;
print("Gear Up Position: $gearUp - converted: $convertedGearUp");
print("Gear Down Position: $gearDown - converted: $convertedGearDown");
actionHandler.updateTouchPositions(convertedGearUp, convertedGearDown);
settings.updateTouchPositions(convertedGearUp, convertedGearDown);
},
if (!kIsWeb)
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
DropdownMenu<SupportedApp>(
controller: controller,
dropdownMenuEntries:
SupportedApp.supportedApps
.map(
(app) => DropdownMenuEntry<SupportedApp>(
value: app,
label: app.name,
trailingIcon: IconButton(
icon: Icon(Icons.info_outline),
onPressed: () {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Text(app.name),
content: SelectableText(app.keymap.toString()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('OK'),
),
],
),
);
},
),
),
)
.toList(),
label: Text('Keymap'),
onSelected: (app) async {
if (app == null) {
return;
}
controller.text = app.name ?? '';
actionHandler.supportedApp = app;
settings.setApp(app);
setState(() {});
if (app is! CustomApp && !kIsWeb && (Platform.isMacOS || Platform.isWindows)) {
_snackBarMessengerKey.currentState!.showSnackBar(
SnackBar(
content: Text(
'Use Custom keymap if you experience any issues (e.g. wrong keyboard output)',
),
),
);
}
},
initialSelection: actionHandler.supportedApp,
hintText: 'Select your Keymap',
),
if (actionHandler.supportedApp is CustomApp)
ElevatedButton(
onPressed: () async {
final result = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => TouchAreaSetupPage()));
if (result == true && actionHandler.supportedApp is CustomApp) {
settings.setApp(actionHandler.supportedApp!);
}
setState(() {});
},
child: Text('Customize Keymap'),
),
);
},
child: Text('Customize touch areas (optional)'),
],
),
],
Expanded(child: LogViewer()),
],
),

View File

@@ -4,7 +4,6 @@ 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';
@@ -87,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;

View File

@@ -72,7 +72,7 @@ class _ScanWidgetState extends State<ScanWidget> {
}
},
),
if (kDebugMode) LogViewer(),
if (kDebugMode) SizedBox(height: 500, child: LogViewer()),
],
),
);

View File

@@ -1,16 +1,27 @@
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: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();
@@ -18,8 +29,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();
@@ -32,15 +43,18 @@ 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
@@ -48,25 +62,36 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []);
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,
);
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
windowManager.setFullScreen(true);
}
_actionSubscription = connection.actionStream.listen((data) {
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) {
actionHandler.supportedApp!.keymap.keyPairs.add(
KeyPair(
touchPosition: context.size!.center(Offset.zero),
buttons: [_pressedButton!],
physicalKey: null,
logicalKey: null,
),
);
setState(() {});
}
}
setState(() {});
});
}
@@ -74,29 +99,81 @@ 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: const Text('Set Keyboard shortcut'),
onTap: () async {
await showDialog<void>(
context: context,
builder:
(c) =>
HotKeyListenerDialog(customApp: actionHandler.supportedApp! as CustomApp, keyPair: keyPair),
);
setState(() {});
},
),
if (keyPair.physicalKey != null)
PopupMenuItem<PhysicalKeyboardKey>(
value: null,
child: const Text('Use as touch button'),
onTap: () {
keyPair.physicalKey = null;
keyPair.logicalKey = null;
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),
),
onDragUpdate: (details) {
print('Dragging: ${details.localPosition}');
},
childWhenDragging: const SizedBox.shrink(),
onDraggableCanceled: (_, offset) {
print('Drag canceled: ${offset}');
setState(() => onPositionChanged(offset));
},
child: _TouchDot(color: color, label: label, keyPair: keyPair),
),
],
),
),
),
);
}
@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: Padding(
@@ -108,7 +185,8 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
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: () {
@@ -121,37 +199,41 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
),
),
// Touch Areas
_buildDraggableArea(
position: _gearUpPos,
onPositionChanged: (newPos) => _gearUpPos = newPos,
color: Colors.red,
label: "Gear ↑",
),
_buildDraggableArea(
position: _gearDownPos,
onPositionChanged: (newPos) => _gearDownPos = newPos,
color: Colors.green,
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")),
],
),
),
],
@@ -163,12 +245,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,
@@ -178,8 +262,26 @@ class _TouchDot extends StatelessWidget {
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: 2),
),
child: Icon(
keyPair.isSpecialKey
? Icons.music_note_outlined
: keyPair.physicalKey != null
? Icons.keyboard_alt_outlined
: Icons.add,
),
),
Text(label, style: TextStyle(color: Colors.black, fontSize: 12)),
if (keyPair.physicalKey != null)
Text(switch (keyPair.physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => 'Media: Play/Pause',
PhysicalKeyboardKey.mediaStop => 'Media: Stop',
PhysicalKeyboardKey.mediaTrackPrevious => 'Media: Previous',
PhysicalKeyboardKey.mediaTrackNext => 'Media: Next',
PhysicalKeyboardKey.audioVolumeUp => 'Media: Volume Up',
PhysicalKeyboardKey.audioVolumeDown => 'Media: Volume Down',
_ => keyPair.logicalKey?.keyLabel ?? 'Unknown',
}, style: TextStyle(color: Colors.grey, fontSize: 12)),
],
);
}

View File

@@ -1,78 +1,47 @@
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) 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);
accessibilityHandler.performTouch(point.dx, point.dy);
return "Touch performed at: ${point.dx.toInt()}, ${point.dy.toInt()}";
}
}

View File

@@ -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);
}
class StubActions extends BaseActions {
@override
void decreaseGear() {
print('Decrease gear');
}
@override
void increaseGear() {
print('Increase gear');
Future<String> performAction(ZwiftButton action) {
return Future.value(action.name);
}
}

View File

@@ -1,34 +1,27 @@
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;
@override
Keymap? get keymap => _keymap;
@override
void init(Keymap? keymap) {
_keymap = keymap;
}
@override
Future<void> decreaseGear() async {
if (keymap == null) {
throw Exception('Keymap is not set');
Future<String> performAction(ZwiftButton action) async {
if (supportedApp == null) {
return ('Supported app is not set');
}
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');
final keyPair = supportedApp!.keymap.getKeyPair(action);
if (keyPair == null) {
return ('Keymap entry not found for action: $action');
}
if (keyPair.physicalKey != null) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey);
return 'Key pressed: ${keyPair.logicalKey?.keyLabel}';
} else {
final point = supportedApp!.resolveTouchPosition(action: action, windowInfo: null);
await keyPressSimulator.simulateMouseClick(point);
return 'Mouse clicked at: $point';
}
await keyPressSimulator.simulateKeyDown(_keymap!.increase?.physicalKey);
await keyPressSimulator.simulateKeyUp(_keymap!.increase?.physicalKey);
}
}

View File

@@ -0,0 +1,55 @@
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,
}) {
// set the key for the zwift button
final keyPair = keymap.getKeyPair(zwiftButton);
if (keyPair != null) {
keyPair.physicalKey = physicalKey;
keyPair.logicalKey = logicalKey;
} else {
keymap.keyPairs.add(KeyPair(buttons: [zwiftButton], physicalKey: physicalKey, logicalKey: logicalKey));
}
}
}

View File

@@ -0,0 +1,61 @@
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 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.keyK,
logicalKey: LogicalKeyboardKey.keyK,
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
physicalKey: PhysicalKeyboardKey.keyI,
logicalKey: LogicalKeyboardKey.keyI,
),
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");
}
return switch (action.action) {
InGameAction.shiftUp => Offset(windowInfo.windowWidth * 0.98, windowInfo.windowHeight * 0.94),
InGameAction.shiftDown => Offset(windowInfo.windowWidth * 0.80, windowInfo.windowHeight * 0.94),
_ => throw SingleLineException("Unsupported action for MyWhoosh: $action"),
};
}
}

View File

@@ -0,0 +1,36 @@
import 'dart:ui';
import 'package:accessibility/accessibility.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(), CustomApp()];
@override
String toString() {
return runtimeType.toString();
}
}

View File

@@ -0,0 +1,72 @@
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 '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.minus,
logicalKey: LogicalKeyboardKey.minus,
),
KeyPair(
buttons: ZwiftButton.values.filter((e) => e.action == InGameAction.shiftUp).toList(),
physicalKey: PhysicalKeyboardKey.equal,
logicalKey: LogicalKeyboardKey.equal,
),
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.windowWidth / 2 * 1.32, windowInfo.windowHeight * 0.74),
InGameAction.shiftDown => Offset(windowInfo.windowWidth / 2 * 1.15, windowInfo.windowHeight * 0.74),
_ => throw SingleLineException("Unsupported action for IndieVelo: $action"),
};
}
}

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

View File

@@ -1,77 +1,101 @@
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()}' : ''}''',
);
}
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 null;
}
final name = data[0];
final keymap = values.firstOrNullWhere((element) => element.name == name);
if (keymap == null) {
return null;
}
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;
} else {
return null;
}
void reset() {
keyPairs = [];
}
}
class KeyPair {
final PhysicalKeyboardKey physicalKey;
final LogicalKeyboardKey logicalKey;
final List<ZwiftButton> buttons;
PhysicalKeyboardKey? physicalKey;
LogicalKeyboardKey? logicalKey;
Offset touchPosition;
KeyPair({required this.physicalKey, required this.logicalKey});
KeyPair({
required this.buttons,
required this.physicalKey,
required this.logicalKey,
this.touchPosition = Offset.zero,
});
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},
});
}
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']),
);
}
}

View File

@@ -50,7 +50,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 {

View File

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

View File

@@ -25,9 +25,9 @@ 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) {
list = [
BluetoothTurnedOn(),

View File

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

View File

@@ -0,0 +1,10 @@
class SingleLineException implements Exception {
final String message;
SingleLineException(this.message);
@override
String toString() {
return message;
}
}

View File

@@ -1,97 +1,146 @@
import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/bluetooth/messages/click_notification.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/bluetooth/messages/play_notification.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 'package:swift_control/utils/keymap/keymap.dart';
Future<Keymap?> showCustomKeymapDialog(BuildContext context, {required Keymap keymap}) {
return showDialog<Keymap>(
context: context,
builder: (context) {
return GearHotkeyDialog(keymap: keymap);
},
);
}
import '../utils/keymap/apps/custom_app.dart';
class GearHotkeyDialog extends StatefulWidget {
final Keymap keymap;
const GearHotkeyDialog({super.key, required this.keymap});
class HotKeyListenerDialog extends StatefulWidget {
final CustomApp customApp;
final KeyPair? keyPair;
const HotKeyListenerDialog({super.key, required this.customApp, required this.keyPair});
@override
State<GearHotkeyDialog> createState() => _GearHotkeyDialogState();
State<HotKeyListenerDialog> createState() => _HotKeyListenerState();
}
class _GearHotkeyDialogState extends State<GearHotkeyDialog> {
class _HotKeyListenerState extends State<HotKeyListenerDialog> {
late StreamSubscription<BaseNotification> _actionSubscription;
final FocusNode _focusNode = FocusNode();
KeyDownEvent? _pressedKey;
KeyDownEvent? _gearUpHotkey;
KeyDownEvent? _gearDownHotkey;
String _mode = 'up'; // 'up' or 'down'
ZwiftButton? _pressedButton;
@override
void initState() {
super.initState();
_pressedButton = widget.keyPair?.buttons.firstOrNull;
_actionSubscription = connection.actionStream.listen((data) {
if (!mounted || widget.keyPair != null) {
return;
}
if (data is ClickNotification) {
setState(() {
_pressedButton = data.buttonsClicked.singleOrNull;
});
}
if (data is PlayNotification) {
setState(() {
_pressedButton = data.buttonsClicked.singleOrNull;
});
}
if (data is RideNotification) {
setState(() {
_pressedButton = data.buttonsClicked.singleOrNull;
});
}
});
_focusNode.requestFocus();
}
@override
void dispose() {
_actionSubscription.cancel();
_focusNode.dispose();
super.dispose();
}
void _onKey(KeyEvent event) {
setState(() {
if (event is KeyDownEvent) {
_pressedKey = event;
} else if (event is KeyUpEvent) {
if (_pressedKey != null) {
if (_mode == 'up') {
_gearUpHotkey = _pressedKey;
_mode = 'down';
} else {
_gearDownHotkey = _pressedKey;
widget.keymap.increase = KeyPair(
physicalKey: _gearUpHotkey!.physicalKey,
logicalKey: _gearUpHotkey!.logicalKey,
);
widget.keymap.decrease = KeyPair(
physicalKey: _gearDownHotkey!.physicalKey,
logicalKey: _gearDownHotkey!.logicalKey,
);
Navigator.of(context).pop(widget.keymap);
}
_pressedKey = null;
}
widget.customApp.setKey(
_pressedButton!,
physicalKey: _pressedKey!.physicalKey,
logicalKey: _pressedKey!.logicalKey,
);
}
});
}
String _formatKey(KeyDownEvent? key) {
return key?.logicalKey.keyLabel ?? 'Not set';
return key?.logicalKey.keyLabel ?? 'Waiting...';
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Set Gear Hotkeys'),
content: KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: _onKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Step 1: Press a hotkey for **Gear Up**."),
Text("Step 2: Press a hotkey for **Gear Down**."),
SizedBox(height: 20),
ListTile(
leading: Icon(Icons.arrow_upward),
title: Text("Gear Up Hotkey"),
subtitle: Text(_formatKey(_gearUpHotkey)),
),
ListTile(
leading: Icon(Icons.arrow_downward),
title: Text("Gear Down Hotkey"),
subtitle: Text(_formatKey(_gearDownHotkey)),
),
],
),
),
actions: [TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text("Cancel"))],
content:
_pressedButton == null
? Text('Press a button on your Zwift device')
: KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: _onKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: [
Text("Press a key on your keyboard to assign to ${_pressedButton.toString()}"),
Text(_formatKey(_pressedKey)),
PopupMenuButton<PhysicalKeyboardKey>(
tooltip: 'Drag or click for special keys',
itemBuilder:
(context) => [
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaPlayPause,
child: const Text('Media: Play/Pause'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaStop,
child: const Text('Media: Stop'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackPrevious,
child: const Text('Media: Previous'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.mediaTrackNext,
child: const Text('Media: Next'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeUp,
child: const Text('Media: Volume Up'),
),
PopupMenuItem<PhysicalKeyboardKey>(
value: PhysicalKeyboardKey.audioVolumeDown,
child: const Text('Media: Volume Down'),
),
],
onSelected: (key) {
widget.customApp.setKey(_pressedButton!, physicalKey: key, logicalKey: null);
Navigator.pop(context, key);
},
child: IgnorePointer(
child: ElevatedButton(onPressed: () {}, child: Text('Or choose special key')),
),
),
],
),
),
actions: [TextButton(onPressed: () => Navigator.of(context).pop(_pressedKey), child: Text("OK"))],
);
}
}

View File

@@ -14,9 +14,10 @@ class LogViewer extends StatefulWidget {
}
class _LogviewerState extends State<LogViewer> {
List<String> _actions = [];
List<({DateTime date, String entry})> _actions = [];
late StreamSubscription<BaseNotification> _actionSubscription;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
@@ -25,9 +26,15 @@ class _LogviewerState extends State<LogViewer> {
_actionSubscription = connection.actionStream.listen((data) {
if (mounted) {
setState(() {
_actions.add('${DateTime.now().toString().split(" ").last}: $data');
_actions = _actions.takeLast(30).toList();
_actions.add((date: DateTime.now(), entry: data.toString()));
_actions = _actions.takeLast(60).toList();
});
// scroll to the bottom
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 60),
curve: Curves.easeInOut,
);
}
});
}
@@ -35,6 +42,7 @@ class _LogviewerState extends State<LogViewer> {
@override
void dispose() {
_actionSubscription.cancel();
_scrollController.dispose();
super.dispose();
}
@@ -42,15 +50,38 @@ class _LogviewerState extends State<LogViewer> {
Widget build(BuildContext context) {
return Stack(
children: [
ListView(
shrinkWrap: true,
children:
_actions
.map(
(action) =>
Text(action, style: TextStyle(fontSize: 12, fontFeatures: [FontFeature.tabularFigures()])),
)
.toList(),
SelectionArea(
child: ListView(
controller: _scrollController,
children:
_actions
.map(
(action) => Text.rich(
TextSpan(
children: [
TextSpan(
text: action.date.toString().split(" ").last,
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: "monospace",
fontFamilyFallback: <String>["Courier"],
),
),
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
],
),
),
)
.toList(),
),
),
Align(
alignment: Alignment.topRight,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:url_launcher/url_launcher_string.dart';
List<Widget> buildMenuButtons() {
@@ -25,21 +26,15 @@ class MenuButton extends StatelessWidget {
itemBuilder:
(c) => [
if (kDebugMode) ...[
PopupMenuItem(
child: Text('Gear up'),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.increaseGear();
});
},
),
PopupMenuItem(
child: Text('Gear down'),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.decreaseGear();
});
},
...ZwiftButton.values.map(
(e) => PopupMenuItem(
child: Text(e.name),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.performAction(e);
});
},
),
),
PopupMenuItem(child: PopupMenuDivider()),
],

View File

@@ -83,11 +83,17 @@ class _AppTitleState extends State<AppTitle> {
@override
Widget build(BuildContext context) {
return Row(
spacing: 8,
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SwiftControl'),
if (_packageInfoValue != null) Text('v${_packageInfoValue!.version}') else SmallProgressIndicator(),
if (_packageInfoValue != null)
Text(
'v${_packageInfoValue!.version}',
style: TextStyle(fontFamily: "monospace", fontFamilyFallback: <String>["Courier"], fontSize: 12),
)
else
SmallProgressIndicator(),
],
);
}

View File

@@ -7,13 +7,21 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
}

View File

@@ -4,7 +4,9 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
screen_retriever_linux
url_launcher_linux
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -9,16 +9,20 @@ import file_selector_macos
import flutter_local_notifications
import keypress_simulator_macos
import package_info_plus
import screen_retriever_macos
import shared_preferences_foundation
import universal_ble
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}

View File

@@ -8,6 +8,8 @@ PODS:
- FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -16,6 +18,8 @@ PODS:
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- window_manager (0.2.0):
- FlutterMacOS
DEPENDENCIES:
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
@@ -23,9 +27,11 @@ DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`)
- keypress_simulator_macos (from `Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
EXTERNAL SOURCES:
file_selector_macos:
@@ -38,12 +44,16 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
screen_retriever_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
universal_ble:
:path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
window_manager:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
@@ -51,9 +61,11 @@ SPEC CHECKSUMS:
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82

View File

@@ -386,29 +386,26 @@ packages:
keypress_simulator:
dependency: "direct main"
description:
name: keypress_simulator
sha256: d5aa5ed472b6b396f41fd6dcee99f4afb2c7ac6202af622b4cec7955de6ed7f6
url: "https://pub.dev"
source: hosted
path: "keypress_simulator/packages/keypress_simulator"
relative: true
source: path
version: "0.2.0"
keypress_simulator_macos:
dependency: transitive
description:
name: keypress_simulator_macos
sha256: babb698b1331cff0301de839c7bc6b051d84f98ddb137cf9a6dda6c6caeb78ac
url: "https://pub.dev"
source: hosted
path: "keypress_simulator/packages/keypress_simulator_macos"
relative: true
source: path
version: "0.2.0"
keypress_simulator_platform_interface:
dependency: transitive
description:
name: keypress_simulator_platform_interface
sha256: "38c35fee6b107ff10cfb6bdb61e32eb0db17545ed64399a357794431212ca4b4"
url: "https://pub.dev"
source: hosted
path: "keypress_simulator/packages/keypress_simulator_platform_interface"
relative: true
source: path
version: "0.2.0"
keypress_simulator_windows:
dependency: "direct overridden"
dependency: transitive
description:
path: "keypress_simulator/packages/keypress_simulator_windows"
relative: true
@@ -630,6 +627,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
screen_retriever:
dependency: transitive
description:
name: screen_retriever
sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
screen_retriever_linux:
dependency: transitive
description:
name: screen_retriever_linux
sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18
url: "https://pub.dev"
source: hosted
version: "0.2.0"
screen_retriever_macos:
dependency: transitive
description:
name: screen_retriever_macos
sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
screen_retriever_platform_interface:
dependency: transitive
description:
name: screen_retriever_platform_interface
sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0
url: "https://pub.dev"
source: hosted
version: "0.2.0"
screen_retriever_windows:
dependency: transitive
description:
name: screen_retriever_windows
sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
shared_preferences:
dependency: "direct main"
description:
@@ -875,6 +912,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.12.0"
window_manager:
dependency: "direct main"
description:
name: window_manager
sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059"
url: "https://pub.dev"
source: hosted
version: "0.4.3"
xdg_directories:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: swift_control
description: "SwiftControl - Control your virtual riding"
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.1.8+0
version: 2.0.0+0
environment:
sdk: ^3.7.0
@@ -18,7 +18,9 @@ dependencies:
dartx: any
image_picker: ^1.1.2
pointycastle: any
keypress_simulator: any
window_manager: ^0.4.3
keypress_simulator:
path: keypress_simulator/packages/keypress_simulator
shared_preferences: ^2.5.3
flex_color_scheme: ^8.2.0
package_info_plus: ^8.3.0
@@ -26,10 +28,6 @@ dependencies:
path: accessibility
http: ^1.3.0
dependency_overrides:
keypress_simulator_windows:
path: keypress_simulator/packages/keypress_simulator_windows
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -9,8 +9,10 @@
#include <file_selector_windows/file_selector_windows.h>
#include <keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <universal_ble/universal_ble_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
@@ -19,8 +21,12 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("KeypressSimulatorWindowsPluginCApi"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
UniversalBlePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UniversalBlePluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}

View File

@@ -6,8 +6,10 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
keypress_simulator_windows
permission_handler_windows
screen_retriever_windows
universal_ble
url_launcher_windows
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST