Compare commits

...

39 Commits

Author SHA1 Message Date
Jonas Bark
7f24c27201 update readme 2025-04-06 16:21:14 +02:00
Jonas Bark
51c5e34220 long pressing a button now repeats the action every 250ms until it's released 2025-04-06 14:57:50 +02:00
Jonas Bark
10c2cc64a2 don't build on Readme updates 2025-04-06 14:02:30 +02:00
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
Jonas Bark
1c0e027abb Android: make sure the touch reassignment page is fullscreen 2025-04-02 12:20:56 +02:00
Jonas Bark
0dcb666bbd update changelog 2025-04-01 21:21:58 +02:00
Jonas Bark
00ebca7a01 Merge branch 'windows' 2025-04-01 21:20:25 +02:00
Jonas Bark
f9c8820303 version++ 2025-04-01 21:20:20 +02:00
Jonas Bark
3d414edda9 Zwift Ride: cleanup, fix button 2025-04-01 21:17:46 +02:00
Jonas Bark
0e3f6f1d5e Windows: implement keybd_event 2025-04-01 19:54:41 +02:00
Jonas Bark
c4b0ef38c0 Zwift Ride: only connect to the left controller 2025-04-01 19:38:52 +02:00
Jonas Bark
2e800bb2de add hint to install runtime libraries for windows 2025-04-01 11:48:38 +02:00
Jonas Bark
505b970497 show the connected device type if the bluetooth name is null 2025-04-01 09:43:08 +02:00
Jonas Bark
986bfd481c show the connected device type if the bluetooth name is null 2025-04-01 09:42:40 +02:00
Jonas Bark
ab8d480a01 change SendInput to use keybd_event instead 2025-04-01 09:39:41 +02:00
Jonas Bark
7e19b76403 integrate keypress_simulator to make changes 2025-04-01 09:25:05 +02:00
Jonas Bark
cbc2f103ac run connection sequentially - may fix issues on Windows 2025-04-01 09:21:31 +02:00
Jonas Bark
6a7922cf17 version++ 2025-03-31 21:00:37 +02:00
Jonas Bark
8e23de2718 version update check => internet permission is required 2025-03-31 20:58:27 +02:00
Jonas Bark
11aec5fba1 Zwift Ride: add buttonPowerDown to shift gears
Zwift Play: Fix buttonShift assignment
Android: fix Media next button
2025-03-31 20:20:47 +02:00
Jonas Bark
c98f213e2e add some comments 2025-03-30 21:34:29 +02:00
Jonas Bark
8c11cfcad6 remove location requirement again 2025-03-30 21:21:31 +02:00
Jonas Bark
5fe88ffc6a version++ 2025-03-30 21:18:38 +02:00
Jonas Bark
7a8c7a4ee1 fix #6 for real this time 2025-03-30 21:18:18 +02:00
104 changed files with 2939 additions and 710 deletions

View File

@@ -4,6 +4,12 @@ on:
push:
branches:
- main
paths:
- '.github/workflows/**'
- 'lib/**'
- 'accessibility/**'
- 'keypress_simulator/**'
- 'pubspec.yaml'
jobs:
build:

View File

@@ -1,5 +1,30 @@
### 1.1.4 (2025-03-30)
- another potential fix for #6
#### 2.0.1 (2025-04-06)
- long pressing a button will trigger the action again every 250ms
#### 2.0.0 (2025-04-06)
- You can now customize the actions (touches, mouse clicks or keyboard keys) for all buttons on all supported Zwift devices
- now shows the battery level of the connected devices
- add more troubleshooting information
### 1.1.10 (2025-04-03)
- Add more troubleshooting during connection
### 1.1.8 (2025-04-02)
- Android: make sure the touch reassignment page is fullscreen
### 1.1.7 (2025-04-01)
- Zwift Ride: fix connection issues by connecting only to the left controller
- Windows: connect sequentially to fix (finally?) fix connection issues
- Windows: change the way keyboard is simulated, should fix glitches
### 1.1.6 (2025-03-31)
- Zwift Ride: add buttonPowerDown to shift gears
- Zwift Play: Fix buttonShift assignment
- Android: fix action to go to next song
- App now checks if you run the latest available version
### 1.1.5 (2025-03-30)
- fix bluetooth connection #6, also add missing entitlement on macOS
### 1.1.3 (2025-03-30)
- Windows: fix custom keyboard profile recreation after restart, also warn when choosing MyWhoosh profile (may fix #7)

View File

@@ -4,7 +4,12 @@
## Description
With SwiftControl you can control your favorite trainer app using your Zwift Click, Zwift Ride or Zwift Play devices. Primarily useful to perform virtual gear shifting.
With SwiftControl you can control your favorite trainer app using your Zwift Click, Zwift Ride or Zwift Play devices. Here's what you can do with it, depending on your configuration:
- Virtual Gear shifting
- Steering / turning
- adjust workout intensity
- control music on your device
- more? If you can do it via keyboard, mouse or touch, you can do it with SwiftControl
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
@@ -19,8 +24,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
@@ -30,19 +35,23 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
## Supported Platforms
- Android
- macOS
- Windows
- 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.
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

@@ -11,14 +11,11 @@ import android.os.Bundle
import android.provider.Settings
import androidx.core.content.ContextCompat.startActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
/** AccessibilityPlugin */
class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility {
class AccessibilityPlugin: FlutterPlugin, Accessibility {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
@@ -38,14 +35,6 @@ class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility {
Observable.fromService = eventHandler
}
override fun onMethodCall(call: MethodCall, result: Result) {
if (call.method == "getPlatformVersion") {
result.success("Android ${android.os.Build.VERSION.RELEASE}")
} else {
result.notImplemented()
}
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
@@ -72,7 +61,10 @@ class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility {
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
}
MediaAction.NEXT -> audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
MediaAction.NEXT -> {
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
}
MediaAction.VOLUME_DOWN -> audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI)
MediaAction.VOLUME_UP -> audioService.adjustVolume(android.media.AudioManager.ADJUST_RAISE, android.media.AudioManager.FLAG_SHOW_UI)
}

View File

@@ -10,7 +10,7 @@
<!-- legacy for Android 11 or lower -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
@@ -19,6 +19,9 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- to check if you have the latest version -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="SwiftControl"
android:name="${applicationName}"

View File

@@ -0,0 +1,12 @@
# Defines the Chromium style for automatic reformatting.
# http://clang.llvm.org/docs/ClangFormatStyleOptions.html
BasedOnStyle: Chromium
# This defaults to 'Auto'. Explicitly set it for a while, so that
# 'vector<vector<int> >' in existing files gets formatted to
# 'vector<vector<int>>'. ('Auto' means that clang-format will only use
# 'int>>' if the file already contains at least one such instance.)
Standard: Cpp11
SortIncludes: true
---
Language: ObjC
ColumnLimit: 100

View File

@@ -0,0 +1 @@
liberapay: lijy91

View File

@@ -0,0 +1,50 @@
name: build
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
jobs:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.16.8"
channel: "stable"
- uses: bluefireteam/melos-action@v2
- working-directory: ./packages/keypress_simulator/example
run: |
melos bs
flutter build macos --release
build-web:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.16.8"
channel: "stable"
- uses: bluefireteam/melos-action@v2
- working-directory: ./packages/keypress_simulator/example
run: |
melos bs
flutter build web --release
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.16.8"
channel: "stable"
- uses: bluefireteam/melos-action@v2
- working-directory: ./packages/keypress_simulator/example
run: |
melos bs
flutter build windows --release

View File

@@ -0,0 +1,31 @@
name: lint
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.16.8"
channel: "stable"
- uses: bluefireteam/melos-action@v2
- run: melos run analyze
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.16.8"
channel: "stable"
cache: true
- uses: bluefireteam/melos-action@v2
- run: melos run format-check

View File

@@ -0,0 +1,20 @@
name: test
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.16.8"
channel: "stable"
cache: true
- uses: bluefireteam/melos-action@v2
- run: melos run test --no-select

6
keypress_simulator/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.dart_tool/
.idea/
*.iml
pubspec_overrides.yaml
pubspec.lock

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,93 @@
> **🚀 快速发布您的应用**: 试试 [Fastforge](https://fastforge.dev) - 构建、打包和分发您的 Flutter 应用最简单的方式。
# keypress_simulator
[![pub version][pub-image]][pub-url] [![][discord-image]][discord-url]
[pub-image]: https://img.shields.io/pub/v/keypress_simulator.svg
[pub-url]: https://pub.dev/packages/keypress_simulator
[discord-image]: https://img.shields.io/discord/884679008049037342.svg
[discord-url]: https://discord.gg/zPa6EZ2jqb
这个插件允许 Flutter 桌面应用模拟按键操作。
---
[English](./README.md) | 简体中文
---
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [平台支持](#%E5%B9%B3%E5%8F%B0%E6%94%AF%E6%8C%81)
- [快速开始](#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B)
- [安装](#%E5%AE%89%E8%A3%85)
- [用法](#%E7%94%A8%E6%B3%95)
- [谁在用使用它?](#%E8%B0%81%E5%9C%A8%E7%94%A8%E4%BD%BF%E7%94%A8%E5%AE%83)
- [许可证](#%E8%AE%B8%E5%8F%AF%E8%AF%81)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## 平台支持
| Linux | macOS | Windows |
| :---: | :---: | :-----: |
| | ✔️ | ✔️ |
## 快速开始
### 安装
将此添加到你的软件包的 pubspec.yaml 文件:
```yaml
dependencies:
keypress_simulator: ^0.2.0
```
### 用法
```dart
import 'package:keypress_simulator/keypress_simulator.dart';
// 1. Simulate pressing ⌘ + C
// 1.1 Simulate key down
await keyPressSimulator.simulateKeyDown(
PhysicalKeyboardKey.keyC,
[ModifierKey.metaModifier],
);
// 1.2 Simulate key up
await keyPressSimulator.simulateKeyUp(
PhysicalKeyboardKey.keyC,
[ModifierKey.metaModifier],
);
// 2. Simulate long pressing ⌘ + space
// 2.1. Simulate key down
await keyPressSimulator.simulateKeyDown(
PhysicalKeyboardKey.space,
[ModifierKey.metaModifier],
);
await Future.delayed(const Duration(seconds: 5));
// 2.2. Simulate key up
await keyPressSimulator.simulateKeyUp(
PhysicalKeyboardKey.space,
[ModifierKey.metaModifier],
);
```
> 请看这个插件的示例应用,以了解完整的例子。
## 谁在用使用它?
- [Biyi (比译)](https://biyidev.com/) - 一个便捷的翻译和词典应用程序。
## 许可证
[MIT](./LICENSE)

View File

@@ -0,0 +1,93 @@
> **🚀 Ship Your App Faster**: Try [Fastforge](https://fastforge.dev) - The simplest way to build, package and distribute your Flutter apps.
# keypress_simulator
[![pub version][pub-image]][pub-url] [![][discord-image]][discord-url]
[pub-image]: https://img.shields.io/pub/v/keypress_simulator.svg
[pub-url]: https://pub.dev/packages/keypress_simulator
[discord-image]: https://img.shields.io/discord/884679008049037342.svg
[discord-url]: https://discord.gg/zPa6EZ2jqb
This plugin allows Flutter desktop apps to simulate key presses.
---
English | [简体中文](./README-ZH.md)
---
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Platform Support](#platform-support)
- [Quick Start](#quick-start)
- [Installation](#installation)
- [Usage](#usage)
- [Who's using it?](#whos-using-it)
- [License](#license)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Platform Support
| Linux | macOS | Windows |
| :---: | :---: | :-----: |
| | ✔️ | ✔️ |
## Quick Start
### Installation
Add this to your package's pubspec.yaml file:
```yaml
dependencies:
keypress_simulator: ^0.2.0
```
### Usage
```dart
import 'package:keypress_simulator/keypress_simulator.dart';
// 1. Simulate pressing ⌘ + C
// 1.1 Simulate key down
await keyPressSimulator.simulateKeyDown(
PhysicalKeyboardKey.keyC,
[ModifierKey.metaModifier],
);
// 1.2 Simulate key up
await keyPressSimulator.simulateKeyUp(
PhysicalKeyboardKey.keyC,
[ModifierKey.metaModifier],
);
// 2. Simulate long pressing ⌘ + space
// 2.1. Simulate key down
await keyPressSimulator.simulateKeyDown(
PhysicalKeyboardKey.space,
[ModifierKey.metaModifier],
);
await Future.delayed(const Duration(seconds: 5));
// 2.2. Simulate key up
await keyPressSimulator.simulateKeyUp(
PhysicalKeyboardKey.space,
[ModifierKey.metaModifier],
);
```
> Please see the example app of this plugin for a full example.
## Who's using it?
- [Biyi (比译)](https://biyidev.com/) - A convenient translation and dictionary app.
## License
[MIT](./LICENSE)

View File

@@ -0,0 +1,35 @@
name: keypress_simulator_workspace
repository: https://github.com/leanflutter/keypress_simulator
packages:
- examples/**
- packages/**
command:
bootstrap:
# Uses the pubspec_overrides.yaml instead of having Melos modifying the lock file.
usePubspecOverrides: true
scripts:
analyze:
exec: flutter analyze --fatal-infos
description: Run `flutter analyze` for all packages.
test:
exec: flutter test
description: Run `flutter test` for a specific package.
packageFilters:
dirExists:
- test
format:
exec: dart format . --fix
description: Run `dart format` for all packages.
format-check:
exec: dart format . --fix --set-exit-if-changed
description: Run `dart format` checks for all packages.
fix:
exec: dart fix . --apply
description: Run `dart fix` for all packages.

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

@@ -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: windows
create_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
base_revision: 67457e669f79e9f8d13d7a68fe09775fefbb79f4
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

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_windows
[![pub version][pub-image]][pub-url]
[pub-image]: https://img.shields.io/pub/v/keypress_simulator_windows.svg
[pub-url]: https://pub.dev/packages/keypress_simulator_windows
The Windows implementation of [keypress_simulator](https://pub.dev/packages/keypress_simulator).
## License
[MIT](./LICENSE)

View File

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

View File

@@ -0,0 +1,26 @@
name: keypress_simulator_windows
description: Windows implementation of the keypress_simulator plugin.
version: 0.2.0
repository: https://github.com/leanflutter/keypress_simulator/tree/main/packages/keypress_simulator_windows
environment:
sdk: '>=3.0.0 <4.0.0'
flutter: '>=3.3.0'
dependencies:
flutter:
sdk: flutter
keypress_simulator_platform_interface:
path: ../keypress_simulator_platform_interface
dev_dependencies:
flutter_test:
sdk: flutter
mostly_reasonable_lints: ^0.1.1
flutter:
plugin:
implements: keypress_simulator
platforms:
windows:
pluginClass: KeypressSimulatorWindowsPluginCApi

View File

@@ -0,0 +1,17 @@
flutter/
# Visual Studio user-specific files.
*.suo
*.user
*.userosscache
*.sln.docstates
# Visual Studio build-related files.
x64/
x86/
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/

View File

@@ -0,0 +1,100 @@
# The Flutter tooling requires that developers have a version of Visual Studio
# installed that includes CMake 3.14 or later. You should not increase this
# version, as doing so will cause the plugin to fail to compile for some
# customers of the plugin.
cmake_minimum_required(VERSION 3.14)
# Project-level configuration.
set(PROJECT_NAME "keypress_simulator_windows")
project(${PROJECT_NAME} LANGUAGES CXX)
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(VERSION 3.14...3.25)
# This value is used when generating builds using this plugin, so it must
# not be changed
set(PLUGIN_NAME "keypress_simulator_windows_plugin")
# Any new source files that you add to the plugin should be added here.
list(APPEND PLUGIN_SOURCES
"keypress_simulator_windows_plugin.cpp"
"keypress_simulator_windows_plugin.h"
)
# Define the plugin library target. Its name must not be changed (see comment
# on PLUGIN_NAME above).
add_library(${PLUGIN_NAME} SHARED
"include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h"
"keypress_simulator_windows_plugin_c_api.cpp"
${PLUGIN_SOURCES}
)
# Apply a standard set of build settings that are configured in the
# application-level CMakeLists.txt. This can be removed for plugins that want
# full control over build settings.
apply_standard_settings(${PLUGIN_NAME})
# Symbols are hidden by default to reduce the chance of accidental conflicts
# between plugins. This should not be removed; any symbols that should be
# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro.
set_target_properties(${PLUGIN_NAME} PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
# Source include directories and library dependencies. Add any plugin-specific
# dependencies here.
target_include_directories(${PLUGIN_NAME} INTERFACE
"${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin)
# List of absolute paths to libraries that should be bundled with the plugin.
# This list could contain prebuilt libraries, or libraries created by an
# external build triggered from this build file.
set(keypress_simulator_windows_bundled_libraries
""
PARENT_SCOPE
)
# === Tests ===
# These unit tests can be run from a terminal after building the example, or
# from Visual Studio after opening the generated solution file.
# Only enable test builds when building the example (which sets this variable)
# so that plugin clients aren't building the tests.
if (${include_${PROJECT_NAME}_tests})
set(TEST_RUNNER "${PROJECT_NAME}_test")
enable_testing()
# Add the Google Test dependency.
include(FetchContent)
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/release-1.11.0.zip
)
# Prevent overriding the parent project's compiler/linker settings
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
# Disable install commands for gtest so it doesn't end up in the bundle.
set(INSTALL_GTEST OFF CACHE BOOL "Disable installation of googletest" FORCE)
FetchContent_MakeAvailable(googletest)
# The plugin's C API is not very useful for unit testing, so build the sources
# directly into the test binary rather than using the DLL.
add_executable(${TEST_RUNNER}
test/keypress_simulator_windows_plugin_test.cpp
${PLUGIN_SOURCES}
)
apply_standard_settings(${TEST_RUNNER})
target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(${TEST_RUNNER} PRIVATE flutter_wrapper_plugin)
target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock)
# flutter_wrapper_plugin has link dependencies on the Flutter DLL.
add_custom_command(TARGET ${TEST_RUNNER} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${FLUTTER_LIBRARY}" $<TARGET_FILE_DIR:${TEST_RUNNER}>
)
# Enable automatic test discovery.
include(GoogleTest)
gtest_discover_tests(${TEST_RUNNER})
endif()

View File

@@ -0,0 +1,23 @@
#ifndef FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_
#define FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_
#include <flutter_plugin_registrar.h>
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
#else
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
#endif
#if defined(__cplusplus)
extern "C" {
#endif
FLUTTER_PLUGIN_EXPORT void KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar);
#if defined(__cplusplus)
} // extern "C"
#endif
#endif // FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_C_API_H_

View File

@@ -0,0 +1,136 @@
#include "keypress_simulator_windows_plugin.h"
// This must be included before many other Windows headers.
#include <windows.h>
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>
#include <memory>
#include <sstream>
using flutter::EncodableList;
using flutter::EncodableMap;
using flutter::EncodableValue;
namespace keypress_simulator_windows {
// static
void KeypressSimulatorWindowsPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarWindows* registrar) {
auto channel =
std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
registrar->messenger(), "dev.leanflutter.plugins/keypress_simulator",
&flutter::StandardMethodCodec::GetInstance());
auto plugin = std::make_unique<KeypressSimulatorWindowsPlugin>();
channel->SetMethodCallHandler(
[plugin_pointer = plugin.get()](const auto& call, auto result) {
plugin_pointer->HandleMethodCall(call, std::move(result));
});
registrar->AddPlugin(std::move(plugin));
}
KeypressSimulatorWindowsPlugin::KeypressSimulatorWindowsPlugin() {}
KeypressSimulatorWindowsPlugin::~KeypressSimulatorWindowsPlugin() {}
void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
UINT keyCode = std::get<int>(args.at(EncodableValue("keyCode")));
std::vector<std::string> modifiers;
bool keyDown = std::get<bool>(args.at(EncodableValue("keyDown")));
EncodableList key_modifier_list =
std::get<EncodableList>(args.at(EncodableValue("modifiers")));
for (flutter::EncodableValue key_modifier_value : key_modifier_list) {
std::string key_modifier = std::get<std::string>(key_modifier_value);
modifiers.push_back(key_modifier);
}
INPUT input[6];
for (int32_t i = 0; i < modifiers.size(); i++) {
if (modifiers[i].compare("shiftModifier") == 0) {
input[i].ki.wVk = VK_SHIFT;
} else if (modifiers[i].compare("controlModifier") == 0) {
input[i].ki.wVk = VK_CONTROL;
} else if (modifiers[i].compare("altModifier") == 0) {
input[i].ki.wVk = VK_MENU;
} else if (modifiers[i].compare("metaModifier") == 0) {
input[i].ki.wVk = VK_LWIN;
}
input[i].ki.dwFlags = keyDown ? 0 : KEYEVENTF_KEYUP;
input[i].type = INPUT_KEYBOARD;
}
/*int keyIndex = static_cast<int>(modifiers.size());
input[keyIndex].ki.wVk = static_cast<WORD>(keyCode);
input[keyIndex].ki.dwFlags = keyDown ? 0 : KEYEVENTF_KEYUP;
input[keyIndex].type = INPUT_KEYBOARD;*/
// Send key sequence to system
//SendInput(static_cast<UINT>(std::size(input)), input, sizeof(INPUT));
BYTE byteValue = static_cast<BYTE>(keyCode);
keybd_event(byteValue, 0x45, keyDown ? 0 : KEYEVENTF_KEYUP, 0);
result->Success(flutter::EncodableValue(true));
}
void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
double x = 0;
double y = 0;
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();
}
}
} // namespace keypress_simulator_windows

View File

@@ -0,0 +1,41 @@
#ifndef FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_
#define FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <memory>
namespace keypress_simulator_windows {
class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
public:
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar);
KeypressSimulatorWindowsPlugin();
virtual ~KeypressSimulatorWindowsPlugin();
// Disallow copy and assign.
KeypressSimulatorWindowsPlugin(const KeypressSimulatorWindowsPlugin&) =
delete;
KeypressSimulatorWindowsPlugin& operator=(
const KeypressSimulatorWindowsPlugin&) = delete;
void KeypressSimulatorWindowsPlugin::SimulateKeyPress(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
void KeypressSimulatorWindowsPlugin::SimulateMouseClick(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
// Called when a method is called on this plugin's channel from Dart.
void HandleMethodCall(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
};
} // namespace keypress_simulator_windows
#endif // FLUTTER_PLUGIN_KEYPRESS_SIMULATOR_WINDOWS_PLUGIN_H_

View File

@@ -0,0 +1,12 @@
#include "include/keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h"
#include <flutter/plugin_registrar_windows.h>
#include "keypress_simulator_windows_plugin.h"
void KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar) {
keypress_simulator_windows::KeypressSimulatorWindowsPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarManager::GetInstance()
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
}

View File

@@ -0,0 +1,43 @@
#include <flutter/method_call.h>
#include <flutter/method_result_functions.h>
#include <flutter/standard_method_codec.h>
#include <gtest/gtest.h>
#include <windows.h>
#include <memory>
#include <string>
#include <variant>
#include "keypress_simulator_windows_plugin.h"
namespace keypress_simulator_windows {
namespace test {
namespace {
using flutter::EncodableMap;
using flutter::EncodableValue;
using flutter::MethodCall;
using flutter::MethodResultFunctions;
} // namespace
TEST(KeypressSimulatorWindowsPlugin, GetPlatformVersion) {
KeypressSimulatorWindowsPlugin plugin;
// Save the reply value from the success callback.
std::string result_string;
plugin.HandleMethodCall(
MethodCall("getPlatformVersion", std::make_unique<EncodableValue>()),
std::make_unique<MethodResultFunctions<>>(
[&result_string](const EncodableValue* result) {
result_string = std::get<std::string>(*result);
},
nullptr, nullptr));
// Since the exact string varies by host, just ensure that it's a string
// with the expected format.
EXPECT_TRUE(result_string.rfind("Windows ", 0) == 0);
}
} // namespace test
} // namespace keypress_simulator_windows

View File

@@ -0,0 +1,9 @@
name: keypress_simulator_workspace
homepage: https://github.com/leanflutter/keypress_simulator
publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dev_dependencies:
melos: ^3.1.0

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';
@@ -15,6 +14,9 @@ class Connection {
final devices = <BaseDevice>[];
var androidNotificationsSetup = false;
final _connectionQueue = <BaseDevice>[];
var _handlingConnectionQueue = false;
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
Stream<BaseNotification> get actionStream => _actionStreams.stream;
@@ -31,8 +33,10 @@ class Connection {
UniversalBle.onScanResult = (result) {
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
_lastScanResult.add(result);
_actionStreams.add(LogNotification('Found new devices: ${result.name}'));
final scanResult = BaseDevice.fromScanResult(result);
_actionStreams.add(
LogNotification('Found new device: ${result.name ?? scanResult?.runtimeType ?? result.deviceId}'),
);
if (scanResult != null) {
_addDevices([scanResult]);
}
@@ -57,7 +61,7 @@ class Connection {
if (!kIsWeb && !Platform.isWindows) {
UniversalBle.getSystemDevices(
withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID],
).then((devices) {
).then((devices) async {
final baseDevices = devices.mapNotNull(BaseDevice.fromScanResult).toList();
if (baseDevices.isNotEmpty) {
_addDevices(baseDevices);
@@ -81,20 +85,42 @@ class Connection {
final newDevices = dev.where((device) => !devices.contains(device)).toList();
devices.addAll(newDevices);
for (final device in newDevices) {
_connect(device).then((_) {});
}
_connectionQueue.addAll(newDevices);
_handleConnectionQueue();
hasDevices.value = devices.isNotEmpty;
if (devices.isNotEmpty && !androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
androidNotificationsSetup = true;
actionHandler.init(null);
NotificationRequirement.setup().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
void _handleConnectionQueue() {
// windows apparently has issues when connecting to multiple devices at once, so don't
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue) {
_handlingConnectionQueue = true;
final device = _connectionQueue.removeAt(0);
_actionStreams.add(LogNotification('Connecting to: ${device.device.name ?? device.runtimeType}'));
_connect(device)
.then((_) {
_handlingConnectionQueue = false;
_actionStreams.add(LogNotification('Connection finished: ${device.device.name ?? device.runtimeType}'));
if (_connectionQueue.isNotEmpty) {
_handleConnectionQueue();
}
})
.catchError((e) {
_handlingConnectionQueue = false;
_actionStreams.add(LogNotification('Connection failed: ${device.device.name ?? device.runtimeType} - $e'));
if (_connectionQueue.isNotEmpty) {
_handleConnectionQueue();
}
});
}
}
Future<void> _connect(BaseDevice bleDevice) async {
try {
final actionSubscription = bleDevice.actionStream.listen((data) {
@@ -112,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;
}
}
@@ -134,4 +161,8 @@ class Connection {
hasDevices.value = false;
devices.clear();
}
void signalChange(BaseDevice baseDevice) {
_connectionStreams.add(baseDevice);
}
}

View File

@@ -7,11 +7,14 @@ 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';
import '../../utils/keymap/buttons.dart';
import '../messages/notification.dart';
abstract class BaseDevice {
@@ -24,20 +27,25 @@ abstract class BaseDevice {
bool supportsEncryption = true;
Timer? _longPressTimer;
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
static BaseDevice? fromScanResult(BleDevice scanResult) {
// Use the name first, probably safest method on all platforms
// Use the name first as the "System Devices" and Web (android sometimes Windows) don't have manufacturer data
final device = switch (scanResult.name) {
'Zwift Ride' => ZwiftRide(scanResult),
//'Zwift Ride' => ZwiftRide(scanResult), special case for Zwift Ride: we must only connect to the left controller
// https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/
'Zwift Play' => ZwiftPlay(scanResult),
'Zwift Click' => ZwiftClick(scanResult),
_ => null,
};
// otherwise use the manufacturer data, which doesn't exist on Web and "System Devices"
if (device == null) {
if (device != null) {
return device;
} else {
// otherwise use the manufacturer data to identify the device
final manufacturerData = scanResult.manufacturerDataList;
final data = manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
@@ -50,7 +58,7 @@ abstract class BaseDevice {
DeviceType.click => ZwiftClick(scanResult),
DeviceType.playRight => ZwiftPlay(scanResult),
DeviceType.playLeft => ZwiftPlay(scanResult),
DeviceType.rideRight => ZwiftRide(scanResult),
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
DeviceType.rideLeft => ZwiftRide(scanResult),
_ => null,
};
@@ -72,10 +80,16 @@ abstract class BaseDevice {
BleDevice get device => scanResult;
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
int? batteryLevel;
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
Future<void> connect() async {
await UniversalBle.connect(device.deviceId, connectionTimeout: const Duration(seconds: 3));
actionStream.listen((message) {
print("Received message: $message");
});
await UniversalBle.connect(device.deviceId);
if (!kIsWeb && Platform.isAndroid) {
//await UniversalBle.requestMtu(device.deviceId, 256);
@@ -89,7 +103,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(
@@ -163,15 +179,15 @@ abstract class BaseDevice {
//print("Empty RideOn response - unencrypted mode");
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
_processData(bytes);
} else if (bytes[0] == Constants.DISCONNECT_MESSAGE_TYPE) {
//print("Disconnect message");
} else {
//print("Unprocessed - Data Type: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
}
} catch (e, stackTrace) {
print("Error processing data: $e");
print("Stack Trace: $stackTrace");
actionStreamInternal.add(LogNotification(e.toString()));
if (e is SingleLineException) {
actionStreamInternal.add(LogNotification(e.message));
} else {
actionStreamInternal.add(LogNotification("$e\n$stackTrace"));
}
}
}
@@ -191,6 +207,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);
@@ -204,15 +225,39 @@ abstract class BaseDevice {
//print("Empty Message"); // expected when nothing happening
break;
case Constants.BATTERY_LEVEL_TYPE:
//print("Battery level update: $message");
if (batteryLevel != message[1]) {
batteryLevel = message[1];
connection.signalChange(this);
}
break;
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE: // untested
processClickNotification(message);
processClickNotification(message)
.then((buttonsClicked) async {
if (buttonsClicked == null) {
// ignore, no changes
} else if (buttonsClicked.isEmpty) {
actionStreamInternal.add(LogNotification('Buttons released'));
_longPressTimer?.cancel();
} else {
_longPressTimer?.cancel();
_longPressTimer = Timer.periodic(const Duration(milliseconds: 250), (timer) async {
for (final action in buttonsClicked) {
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
}
});
for (final action in buttonsClicked) {
actionStreamInternal.add(LogNotification(await actionHandler.performAction(action)));
}
}
})
.catchError((e) {
actionStreamInternal.add(LogNotification(e.toString()));
});
break;
}
}
void processClickNotification(Uint8List message);
Future<List<ZwiftButton>?> processClickNotification(Uint8List message);
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../messages/click_notification.dart';
@@ -10,17 +10,17 @@ class ZwiftClick extends BaseDevice {
ClickNotification? _lastClickNotification;
@override
void processClickNotification(Uint8List message) {
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
final ClickNotification clickNotification = ClickNotification(message);
if (_lastClickNotification == null || _lastClickNotification != clickNotification) {
_lastClickNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if (clickNotification.buttonUp) {
actionHandler.increaseGear();
} else if (clickNotification.buttonDown) {
actionHandler.decreaseGear();
if (clickNotification.buttonsClicked.isNotEmpty) {
actionStreamInternal.add(clickNotification);
}
return clickNotification.buttonsClicked;
} else {
return null;
}
}
}

View File

@@ -1,9 +1,10 @@
import 'package:accessibility/accessibility.dart';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/messages/play_notification.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../../main.dart';
import '../ble.dart';
class ZwiftPlay extends BaseDevice {
@@ -15,30 +16,18 @@ class ZwiftPlay extends BaseDevice {
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY;
@override
void processClickNotification(Uint8List message) {
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
final PlayNotification clickNotification = PlayNotification(message);
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
_lastControllerNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if ((clickNotification.rightPad && clickNotification.buttonShift) ||
(clickNotification.rightPad && clickNotification.analogLR.abs() == 100)) {
actionHandler.increaseGear();
} else if ((!clickNotification.rightPad && clickNotification.buttonShift) ||
(!clickNotification.rightPad && clickNotification.analogLR.abs() == 100)) {
actionHandler.decreaseGear();
}
if (clickNotification.rightPad) {
if (clickNotification.buttonA) {
actionHandler.controlMedia(MediaAction.next);
} else if (clickNotification.buttonY) {
actionHandler.controlMedia(MediaAction.volumeUp);
} else if (clickNotification.buttonB) {
actionHandler.controlMedia(MediaAction.volumeDown);
} else if (clickNotification.buttonZ) {
actionHandler.controlMedia(MediaAction.playPause);
}
if (clickNotification.buttonsClicked.isNotEmpty) {
actionStreamInternal.add(clickNotification);
}
return clickNotification.buttonsClicked;
} else {
return null;
}
}
}

View File

@@ -2,7 +2,7 @@ import 'dart:typed_data';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../ble.dart';
@@ -18,29 +18,17 @@ class ZwiftRide extends BaseDevice {
RideNotification? _lastControllerNotification;
@override
void processClickNotification(Uint8List message) {
Future<List<ZwiftButton>?> processClickNotification(Uint8List message) async {
final RideNotification clickNotification = RideNotification(message);
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
_lastControllerNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if (clickNotification.buttonShiftDownLeft || clickNotification.buttonShiftUpLeft || clickNotification.buttonZ) {
actionHandler.decreaseGear();
} else if (clickNotification.buttonShiftUpRight ||
clickNotification.buttonShiftDownRight ||
clickNotification.buttonOnOffLeft) {
// TODO remove buttonZ once the assignment is fixed for real
actionHandler.increaseGear();
if (clickNotification.buttonsClicked.isNotEmpty) {
actionStreamInternal.add(clickNotification);
}
/*if (clickNotification.buttonA) {
actionHandler.controlMedia(MediaAction.next);
} else if (clickNotification.buttonY) {
actionHandler.controlMedia(MediaAction.volumeUp);
} else if (clickNotification.buttonB) {
actionHandler.controlMedia(MediaAction.volumeDown);
} else if (clickNotification.buttonZ) {
actionHandler.controlMedia(MediaAction.playPause);
}*/
return clickNotification.buttonsClicked;
} else {
return null;
}
}
}

View File

@@ -1,23 +1,25 @@
import 'dart:typed_data';
import 'package:dartx/dartx.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import '../protocol/zwift.pb.dart';
import 'notification.dart';
class ClickNotification extends BaseNotification {
static const int BTN_PRESSED = 0;
bool buttonUp = false;
bool buttonDown = false;
late List<ZwiftButton> buttonsClicked;
ClickNotification(Uint8List message) {
final status = ClickKeyPadStatus.fromBuffer(message);
buttonUp = status.buttonPlus.value == BTN_PRESSED;
buttonDown = status.buttonMinus.value == BTN_PRESSED;
buttonsClicked = [
if (status.buttonPlus == PlayButtonStatus.ON) ZwiftButton.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
@@ -25,9 +27,8 @@ class ClickNotification extends BaseNotification {
identical(this, other) ||
other is ClickNotification &&
runtimeType == other.runtimeType &&
buttonUp == other.buttonUp &&
buttonDown == other.buttonDown;
buttonsClicked.contentEquals(other.buttonsClicked);
@override
int get hashCode => buttonUp.hashCode ^ buttonDown.hashCode;
int get hashCode => buttonsClicked.hashCode;
}

View File

@@ -1,40 +1,41 @@
import 'dart:typed_data';
import 'package:dartx/dartx.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
class PlayNotification extends BaseNotification {
static const int BTN_PRESSED = 0;
late bool rightPad, buttonY, buttonZ, buttonA, buttonB, buttonOn, buttonShift;
late int analogLR, analogUD;
late List<ZwiftButton> buttonsClicked;
PlayNotification(Uint8List message) {
final status = PlayKeyPadStatus.fromBuffer(message);
rightPad = status.rightPad.value == BTN_PRESSED;
buttonY = status.buttonYUp.value == BTN_PRESSED;
buttonZ = status.buttonZLeft.value == BTN_PRESSED;
buttonA = status.buttonARight.value == BTN_PRESSED;
buttonB = status.buttonBDown.value == BTN_PRESSED;
buttonOn = status.buttonOn.value == BTN_PRESSED;
buttonShift = status.buttonShift.value == BTN_PRESSED;
analogLR = status.analogLR;
analogUD = status.analogUD;
buttonsClicked = [
if (status.rightPad == PlayButtonStatus.ON) ...[
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButton.y,
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButton.z,
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButton.a,
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButton.b,
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButton.onOffRight,
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButton.sideButtonRight,
if (status.analogLR.abs() == 100) ZwiftButton.paddleRight,
],
if (status.rightPad == PlayButtonStatus.OFF) ...[
if (status.buttonYUp == PlayButtonStatus.ON) ZwiftButton.navigationUp,
if (status.buttonZLeft == PlayButtonStatus.ON) ZwiftButton.navigationLeft,
if (status.buttonARight == PlayButtonStatus.ON) ZwiftButton.navigationRight,
if (status.buttonBDown == PlayButtonStatus.ON) ZwiftButton.navigationDown,
if (status.buttonOn == PlayButtonStatus.ON) ZwiftButton.onOffLeft,
if (status.buttonShift == PlayButtonStatus.ON) ZwiftButton.sideButtonLeft,
if (status.analogLR.abs() == 100) ZwiftButton.paddleLeft,
],
];
}
@override
String toString() {
final allTrueParameters = [
//if (rightPad) 'rightPad',
if (buttonY) 'buttonY',
if (buttonZ) 'buttonZ',
if (buttonA) 'buttonA',
if (buttonB) 'buttonB',
if (buttonOn) 'buttonOn',
if (buttonShift) 'buttonShift',
];
return '${rightPad ? 'Right' : 'Left'}: {$allTrueParameters, analogLR: $analogLR, analogUD: $analogUD}';
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name)}';
}
@override
@@ -42,25 +43,8 @@ class PlayNotification extends BaseNotification {
identical(this, other) ||
other is PlayNotification &&
runtimeType == other.runtimeType &&
rightPad == other.rightPad &&
buttonY == other.buttonY &&
buttonZ == other.buttonZ &&
buttonA == other.buttonA &&
buttonB == other.buttonB &&
buttonOn == other.buttonOn &&
buttonShift == other.buttonShift &&
analogLR == other.analogLR &&
analogUD == other.analogUD;
buttonsClicked.contentEquals(other.buttonsClicked);
@override
int get hashCode =>
rightPad.hashCode ^
buttonY.hashCode ^
buttonZ.hashCode ^
buttonA.hashCode ^
buttonB.hashCode ^
buttonOn.hashCode ^
buttonShift.hashCode ^
analogLR.hashCode ^
analogUD.hashCode;
int get hashCode => buttonsClicked.hashCode;
}

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, buttonPowerDownLeft;
late bool buttonOnOffLeft, buttonOnOffRight;
int analogLR = 0, analogUD = 0;
late List<ZwiftButton> buttonsClicked;
RideNotification(Uint8List message) {
final status = RideKeyPadStatus.fromBuffer(message);
buttonLeft = status.buttonMap & _RideButtonMask.LEFT_BTN.mask == BTN_PRESSED;
buttonRight = status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == BTN_PRESSED;
buttonUp = status.buttonMap & _RideButtonMask.UP_BTN.mask == BTN_PRESSED;
buttonDown = status.buttonMap & _RideButtonMask.DOWN_BTN.mask == BTN_PRESSED;
buttonA = status.buttonMap & _RideButtonMask.A_BTN.mask == BTN_PRESSED;
buttonB = status.buttonMap & _RideButtonMask.B_BTN.mask == BTN_PRESSED;
buttonY = status.buttonMap & _RideButtonMask.Y_BTN.mask == BTN_PRESSED;
buttonZ = status.buttonMap & _RideButtonMask.Z_BTN.mask == BTN_PRESSED;
buttonShiftUpLeft = status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == BTN_PRESSED;
buttonShiftDownLeft = status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == BTN_PRESSED;
buttonShiftUpRight = status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == BTN_PRESSED;
buttonShiftDownRight = status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == BTN_PRESSED;
buttonPowerUpLeft = status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == BTN_PRESSED;
buttonPowerDownLeft = status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == BTN_PRESSED;
buttonOnOffLeft = status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == BTN_PRESSED;
buttonOnOffRight = status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == BTN_PRESSED;
buttonsClicked = [
if (status.buttonMap & _RideButtonMask.LEFT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationLeft,
if (status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationRight,
if (status.buttonMap & _RideButtonMask.UP_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationUp,
if (status.buttonMap & _RideButtonMask.DOWN_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.navigationDown,
if (status.buttonMap & _RideButtonMask.A_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.a,
if (status.buttonMap & _RideButtonMask.B_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.b,
if (status.buttonMap & _RideButtonMask.Y_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.y,
if (status.buttonMap & _RideButtonMask.Z_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.z,
if (status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftUpLeft,
if (status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftDownLeft,
if (status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.shiftUpRight,
if (status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == PlayButtonStatus.ON.value)
ZwiftButton.shiftDownRight,
if (status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.powerUpLeft,
if (status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.powerUpRight,
if (status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffLeft,
if (status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == PlayButtonStatus.ON.value) ZwiftButton.onOffRight,
];
for (final analogue in status.analogButtons.groupStatus) {
if (analogue.location == RideAnalogLocation.LEFT || analogue.location == RideAnalogLocation.RIGHT) {
analogLR = analogue.analogValue;
} else if (analogue.location == RideAnalogLocation.DOWN || analogue.location == RideAnalogLocation.UP) {
analogUD = analogue.analogValue;
if (analogue.analogValue.abs() == 100) {
if (analogue.location == RideAnalogLocation.LEFT) {
buttonsClicked.add(ZwiftButton.paddleLeft);
} else if (analogue.location == RideAnalogLocation.RIGHT) {
buttonsClicked.add(ZwiftButton.paddleRight);
} else if (analogue.location == RideAnalogLocation.DOWN || analogue.location == RideAnalogLocation.UP) {
// TODO what is this even?
}
}
}
}
@override
String toString() {
final allTrueParameters = [
if (buttonLeft) 'buttonLeft',
if (buttonRight) 'buttonRight',
if (buttonUp) 'buttonUp',
if (buttonDown) 'buttonDown',
if (buttonA) 'buttonA',
if (buttonB) 'buttonB',
if (buttonY) 'buttonY',
if (buttonZ) 'buttonZ',
if (buttonShiftUpLeft) 'buttonShiftUpLeft',
if (buttonShiftDownLeft) 'buttonShiftDownLeft',
if (buttonShiftUpRight) 'buttonShiftUpRight',
if (buttonShiftDownRight) 'buttonShiftDownRight',
if (buttonPowerUpLeft) 'buttonPowerUpLeft',
if (buttonPowerDownLeft) 'buttonPowerDownLeft',
if (buttonOnOffLeft) 'buttonOnOffLeft',
if (buttonOnOffRight) 'buttonOnOffRight',
];
return '{$allTrueParameters, analogLR: $analogLR, analogUD: $analogUD}';
return 'Buttons: ${buttonsClicked.joinToString(transform: (e) => e.name)}';
}
@override
@@ -98,43 +80,8 @@ class RideNotification extends BaseNotification {
identical(this, other) ||
other is RideNotification &&
runtimeType == other.runtimeType &&
buttonLeft == other.buttonLeft &&
buttonRight == other.buttonRight &&
buttonUp == other.buttonUp &&
buttonDown == other.buttonDown &&
buttonA == other.buttonA &&
buttonB == other.buttonB &&
buttonY == other.buttonY &&
buttonZ == other.buttonZ &&
buttonShiftUpLeft == other.buttonShiftUpLeft &&
buttonShiftDownLeft == other.buttonShiftDownLeft &&
buttonShiftUpRight == other.buttonShiftUpRight &&
buttonShiftDownRight == other.buttonShiftDownRight &&
buttonPowerUpLeft == other.buttonPowerUpLeft &&
buttonPowerDownLeft == other.buttonPowerDownLeft &&
buttonOnOffLeft == other.buttonOnOffLeft &&
buttonOnOffRight == other.buttonOnOffRight &&
analogLR == other.analogLR &&
analogUD == other.analogUD;
buttonsClicked.contentEquals(other.buttonsClicked);
@override
int get hashCode =>
buttonLeft.hashCode ^
buttonRight.hashCode ^
buttonUp.hashCode ^
buttonDown.hashCode ^
buttonA.hashCode ^
buttonB.hashCode ^
buttonY.hashCode ^
buttonZ.hashCode ^
buttonShiftUpLeft.hashCode ^
buttonShiftDownLeft.hashCode ^
buttonShiftUpRight.hashCode ^
buttonShiftDownRight.hashCode ^
buttonPowerUpLeft.hashCode ^
buttonPowerDownLeft.hashCode ^
buttonOnOffLeft.hashCode ^
buttonOnOffRight.hashCode ^
analogLR.hashCode ^
analogUD.hashCode;
int get hashCode => buttonsClicked.hashCode;
}

View File

@@ -25,8 +25,8 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage {
PlayButtonStatus? buttonZLeft,
PlayButtonStatus? buttonARight,
PlayButtonStatus? buttonBDown,
PlayButtonStatus? buttonOn,
PlayButtonStatus? buttonShift,
PlayButtonStatus? buttonOn,
$core.int? analogLR,
$core.int? analogUD,
}) {
@@ -46,12 +46,12 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage {
if (buttonBDown != null) {
$result.buttonBDown = buttonBDown;
}
if (buttonOn != null) {
$result.buttonOn = buttonOn;
}
if (buttonShift != null) {
$result.buttonShift = buttonShift;
}
if (buttonOn != null) {
$result.buttonOn = buttonOn;
}
if (analogLR != null) {
$result.analogLR = analogLR;
}
@@ -70,8 +70,8 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage {
..e<PlayButtonStatus>(3, _omitFieldNames ? '' : 'ButtonZLeft', $pb.PbFieldType.OE, protoName: 'Button_Z_Left', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
..e<PlayButtonStatus>(4, _omitFieldNames ? '' : 'ButtonARight', $pb.PbFieldType.OE, protoName: 'Button_A_Right', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
..e<PlayButtonStatus>(5, _omitFieldNames ? '' : 'ButtonBDown', $pb.PbFieldType.OE, protoName: 'Button_B_Down', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
..e<PlayButtonStatus>(6, _omitFieldNames ? '' : 'ButtonOn', $pb.PbFieldType.OE, protoName: 'Button_On', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
..e<PlayButtonStatus>(7, _omitFieldNames ? '' : 'ButtonShift', $pb.PbFieldType.OE, protoName: 'Button_Shift', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
..e<PlayButtonStatus>(6, _omitFieldNames ? '' : 'ButtonShift', $pb.PbFieldType.OE, protoName: 'Button_Shift', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
..e<PlayButtonStatus>(7, _omitFieldNames ? '' : 'ButtonOn', $pb.PbFieldType.OE, protoName: 'Button_On', defaultOrMaker: PlayButtonStatus.ON, valueOf: PlayButtonStatus.valueOf, enumValues: PlayButtonStatus.values)
..a<$core.int>(8, _omitFieldNames ? '' : 'AnalogLR', $pb.PbFieldType.OS3, protoName: 'Analog_LR')
..a<$core.int>(9, _omitFieldNames ? '' : 'AnalogUD', $pb.PbFieldType.OS3, protoName: 'Analog_UD')
..hasRequiredFields = false
@@ -144,22 +144,22 @@ class PlayKeyPadStatus extends $pb.GeneratedMessage {
void clearButtonBDown() => clearField(5);
@$pb.TagNumber(6)
PlayButtonStatus get buttonOn => $_getN(5);
PlayButtonStatus get buttonShift => $_getN(5);
@$pb.TagNumber(6)
set buttonOn(PlayButtonStatus v) { setField(6, v); }
set buttonShift(PlayButtonStatus v) { setField(6, v); }
@$pb.TagNumber(6)
$core.bool hasButtonOn() => $_has(5);
$core.bool hasButtonShift() => $_has(5);
@$pb.TagNumber(6)
void clearButtonOn() => clearField(6);
void clearButtonShift() => clearField(6);
@$pb.TagNumber(7)
PlayButtonStatus get buttonShift => $_getN(6);
PlayButtonStatus get buttonOn => $_getN(6);
@$pb.TagNumber(7)
set buttonShift(PlayButtonStatus v) { setField(7, v); }
set buttonOn(PlayButtonStatus v) { setField(7, v); }
@$pb.TagNumber(7)
$core.bool hasButtonShift() => $_has(6);
$core.bool hasButtonOn() => $_has(6);
@$pb.TagNumber(7)
void clearButtonShift() => clearField(7);
void clearButtonOn() => clearField(7);
@$pb.TagNumber(8)
$core.int get analogLR => $_getIZ(7);

View File

@@ -82,8 +82,8 @@ const PlayKeyPadStatus$json = {
{'1': 'Button_Z_Left', '3': 3, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonZLeft'},
{'1': 'Button_A_Right', '3': 4, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonARight'},
{'1': 'Button_B_Down', '3': 5, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonBDown'},
{'1': 'Button_On', '3': 6, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonOn'},
{'1': 'Button_Shift', '3': 7, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonShift'},
{'1': 'Button_Shift', '3': 6, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonShift'},
{'1': 'Button_On', '3': 7, '4': 1, '5': 14, '6': '.de.jonasbark.PlayButtonStatus', '10': 'ButtonOn'},
{'1': 'Analog_LR', '3': 8, '4': 1, '5': 17, '10': 'AnalogLR'},
{'1': 'Analog_UD', '3': 9, '4': 1, '5': 17, '10': 'AnalogUD'},
],
@@ -97,9 +97,9 @@ final $typed_data.Uint8List playKeyPadStatusDescriptor = $convert.base64Decode(
'4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0dXNSC0J1dHRvblpMZWZ0EkQKDkJ1dHRvbl9B'
'X1JpZ2h0GAQgASgOMh4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0dXNSDEJ1dHRvbkFSaW'
'dodBJCCg1CdXR0b25fQl9Eb3duGAUgASgOMh4uZGUuam9uYXNiYXJrLlBsYXlCdXR0b25TdGF0'
'dXNSC0J1dHRvbkJEb3duEjsKCUJ1dHRvbl9PbhgGIAEoDjIeLmRlLmpvbmFzYmFyay5QbGF5Qn'
'V0dG9uU3RhdHVzUghCdXR0b25PbhJBCgxCdXR0b25fU2hpZnQYByABKA4yHi5kZS5qb25hc2Jh'
'cmsuUGxheUJ1dHRvblN0YXR1c1ILQnV0dG9uU2hpZnQSGwoJQW5hbG9nX0xSGAggASgRUghBbm'
'dXNSC0J1dHRvbkJEb3duEkEKDEJ1dHRvbl9TaGlmdBgGIAEoDjIeLmRlLmpvbmFzYmFyay5QbG'
'F5QnV0dG9uU3RhdHVzUgtCdXR0b25TaGlmdBI7CglCdXR0b25fT24YByABKA4yHi5kZS5qb25h'
'c2JhcmsuUGxheUJ1dHRvblN0YXR1c1IIQnV0dG9uT24SGwoJQW5hbG9nX0xSGAggASgRUghBbm'
'Fsb2dMUhIbCglBbmFsb2dfVUQYCSABKBFSCEFuYWxvZ1VE');
@$core.Deprecated('Use playCommandParametersDescriptor instead')

View File

@@ -16,8 +16,8 @@ message PlayKeyPadStatus {
optional PlayButtonStatus Button_Z_Left = 3;
optional PlayButtonStatus Button_A_Right = 4;
optional PlayButtonStatus Button_B_Down = 5;
optional PlayButtonStatus Button_On = 6;
optional PlayButtonStatus Button_Shift = 7;
optional PlayButtonStatus Button_Shift = 6;
optional PlayButtonStatus Button_On = 7;
optional sint32 Analog_LR = 8;
optional sint32 Analog_UD = 9;
}

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

@@ -7,8 +7,11 @@ import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/touch_area.dart';
import 'package:swift_control/widgets/logviewer.dart';
import 'package:swift_control/widgets/title.dart';
import '../bluetooth/devices/base_device.dart';
import '../utils/keymap/apps/custom_app.dart';
import '../utils/keymap/apps/supported_app.dart';
import '../widgets/menu.dart';
class DevicePage extends StatefulWidget {
@@ -20,6 +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() {
@@ -33,6 +37,7 @@ class _DevicePageState extends State<DevicePage> {
@override
void dispose() {
_connectionStateSubscription.cancel();
controller.dispose();
super.dispose();
}
@@ -48,7 +53,7 @@ class _DevicePageState extends State<DevicePage> {
},
child: Scaffold(
appBar: AppBar(
title: Text('SwiftControl'),
title: AppTitle(),
actions: buildMenuButtons(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
@@ -60,39 +65,84 @@ class _DevicePageState extends State<DevicePage> {
children: [
Text(
'Devices:\n${connection.devices.joinToString(separator: '\n', transform: (it) {
return "${it.device.name}: ${it.isConnected ? 'Connected' : 'Not connected'}";
return "${it.device.name ?? it.runtimeType}: ${it.isConnected ? 'Connected' : 'Not connected'}${it.batteryLevel != null ? ' - Battery Level: ${it.batteryLevel}%' : ''}";
})}',
),
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
if (!kIsWeb && (Platform.isAndroid || kDebugMode)) ...[
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder:
(_) => TouchAreaSetupPage(
onSave: (gearUp, gearDown) {
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
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,9 +4,9 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/menu.dart';
import 'package:swift_control/widgets/title.dart';
import 'device.dart';
@@ -65,7 +65,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('SwiftControl'),
title: AppTitle(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: buildMenuButtons(),
),
@@ -86,7 +86,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
: null,
onStepTapped: (step) {
if (_requirements[step].status && _requirements[step] is! KeymapRequirement) {
if (_requirements[step].status) {
return;
}
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;

View File

@@ -30,7 +30,9 @@ class _ScanWidgetState extends State<ScanWidget> {
WidgetsBinding.instance.addPostFrameCallback((_) {
// must be called from a button
if (!kIsWeb) {
connection.performScanning();
Future.delayed(Duration(seconds: 1)).then((_) {
connection.performScanning();
});
}
});
}
@@ -70,7 +72,7 @@ class _ScanWidgetState extends State<ScanWidget> {
}
},
),
if (kDebugMode) LogViewer(),
if (kDebugMode) SizedBox(height: 500, child: LogViewer()),
],
),
);

View File

@@ -1,15 +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();
@@ -17,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();
@@ -31,33 +43,55 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
}
void _saveAndClose() {
widget.onSave(_gearUpPos, _gearDownPos);
Navigator.of(context).pop();
Navigator.of(context).pop(true);
}
@override
void dispose() {
super.dispose();
_actionSubscription.cancel();
// Exit full screen
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
windowManager.setFullScreen(false);
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
if (actionHandler.gearUpTouchPosition != null) {
_gearUpPos = actionHandler.gearUpTouchPosition!;
_gearUpPos = Offset(
_gearUpPos.dx / devicePixelRatio - touchAreaSize / 2,
_gearUpPos.dy / devicePixelRatio - touchAreaSize / 2,
);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []);
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
windowManager.setFullScreen(true);
}
_actionSubscription = connection.actionStream.listen((data) {
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(() {});
});
}
@@ -65,81 +99,141 @@ 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: Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Text('''1. Create an in-game screenshot of your app (e.g. within MyWhoosh)
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Text('''1. Create an in-game screenshot of your app (e.g. within MyWhoosh)
2. Load the screenshot with the button below
3. Make sure the app is in the correct orientation (portrait or landscape)
4. Drag the touch areas to the correct position where the gear up / down buttons are located
4. Press a button on your Zwift device to create a touch area
5. Drag the touch areas to the desired position on the screenshot
5. Save and close this screen'''),
ElevatedButton(
onPressed: () {
_pickScreenshot();
},
child: Text('Load in-game screenshot for placement'),
),
],
ElevatedButton(
onPressed: () {
_pickScreenshot();
},
child: Text('Load in-game screenshot for placement'),
),
],
),
),
),
// Touch Areas
_buildDraggableArea(
position: _gearUpPos,
onPositionChanged: (newPos) => _gearUpPos = newPos,
color: Colors.green,
label: "Gear ↑",
),
_buildDraggableArea(
position: _gearDownPos,
onPositionChanged: (newPos) => _gearDownPos = newPos,
color: Colors.red,
label: "Gear ↓",
),
Positioned(
top: 40,
right: 170,
child: ElevatedButton.icon(
onPressed: () {
_gearDownPos = Offset(100, 300);
_gearUpPos = Offset(200, 300);
...?actionHandler.supportedApp?.keymap.keyPairs.map(
(keyPair) => _buildDraggableArea(
position: Offset(
keyPair.touchPosition.dx / devicePixelRatio - touchAreaSize / 2,
keyPair.touchPosition.dy / devicePixelRatio - touchAreaSize / 2 - (isDesktop ? touchAreaSize * 1.5 : 0),
),
keyPair: keyPair,
onPositionChanged: (newPos) {
final converted =
newPos.translate(touchAreaSize / 2, touchAreaSize / 2 + (isDesktop ? touchAreaSize * 1.5 : 0)) *
devicePixelRatio;
keyPair.touchPosition = converted;
setState(() {});
},
label: const Icon(Icons.lock_reset),
color: Colors.red,
label: keyPair.buttons.joinToString(transform: (e) => e.name, separator: '\n'),
),
),
Positioned(
top: 40,
right: 20,
child: ElevatedButton.icon(
onPressed: _saveAndClose,
icon: const Icon(Icons.save),
label: const Text("Save & Close"),
child: Row(
spacing: 8,
children: [
ElevatedButton.icon(
onPressed: () {
actionHandler.supportedApp?.keymap.reset();
setState(() {});
},
icon: const Icon(Icons.lock_reset),
label: Text('Reset'),
),
ElevatedButton.icon(onPressed: _saveAndClose, icon: const Icon(Icons.save), label: const Text("Save")),
],
),
),
],
@@ -151,12 +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,
@@ -166,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

@@ -19,21 +19,6 @@ class AccessibilityRequirement extends PlatformRequirement {
}
}
class LocationRequirement extends PlatformRequirement {
LocationRequirement() : super('Allow Location Permission (req. by Samsung devices');
@override
Future<void> call() async {
await Permission.locationWhenInUse.request();
}
@override
Future<void> getStatus() async {
final state = await Permission.locationWhenInUse.status;
status = state.isGranted;
}
}
class BluetoothScanRequirement extends PlatformRequirement {
BluetoothScanRequirement() : super('Allow Bluetooth Scan');
@@ -65,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,14 +25,13 @@ 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(),
AccessibilityRequirement(),
LocationRequirement(),
NotificationRequirement(),
BluetoothScanRequirement(),
BluetoothConnectRequirement(),

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()),
],

100
lib/widgets/title.dart Normal file
View File

@@ -0,0 +1,100 @@
import 'dart:convert';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:url_launcher/url_launcher_string.dart';
String? _latestVersionUrlValue;
PackageInfo? _packageInfoValue;
class AppTitle extends StatefulWidget {
const AppTitle({super.key});
@override
State<AppTitle> createState() => _AppTitleState();
}
class _AppTitleState extends State<AppTitle> {
Future<String?> getLatestVersionUrlIfNewer() async {
final response = await http.get(Uri.parse('https://api.github.com/repos/jonasbark/swiftcontrol/releases/latest'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final latestVersion = data['tag_name'].split('+').first;
final currentVersion = 'v${_packageInfoValue!.version}';
if (latestVersion != null && latestVersion != currentVersion) {
final assets = data['assets'] as List;
if (Platform.isAndroid) {
final apkUrl = assets.firstOrNullWhere((asset) => asset['name'].endsWith('.apk'))['browser_download_url'];
return apkUrl;
} else if (Platform.isMacOS) {
final dmgUrl =
assets.firstOrNullWhere((asset) => asset['name'].endsWith('.macos.zip'))['browser_download_url'];
return dmgUrl;
} else if (Platform.isWindows) {
final appImageUrl =
assets.firstOrNullWhere((asset) => asset['name'].endsWith('.windows.zip'))['browser_download_url'];
return appImageUrl;
}
}
}
return null;
}
@override
void initState() {
super.initState();
if (_packageInfoValue == null) {
PackageInfo.fromPlatform().then((value) {
setState(() {
_packageInfoValue = value;
});
_loadLatestVersionUrl();
});
} else {
_loadLatestVersionUrl();
}
}
void _loadLatestVersionUrl() async {
if (_latestVersionUrlValue == null && !kIsWeb) {
final url = await getLatestVersionUrlIfNewer();
if (url != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('New version available: ${url.split("/").takeLast(2).first.split('%').first}'),
duration: Duration(seconds: 1337),
action: SnackBarAction(
label: 'Download',
onPressed: () {
launchUrlString(url);
},
),
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SwiftControl'),
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

@@ -8,15 +8,21 @@ import Foundation
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

@@ -6,6 +6,10 @@ PODS:
- FlutterMacOS (1.0.0)
- keypress_simulator_macos (0.0.1):
- FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -14,15 +18,20 @@ 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`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- 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:
@@ -33,21 +42,30 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
keypress_simulator_macos:
: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
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
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

@@ -12,5 +12,7 @@
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More