mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edda16dc06 | ||
|
|
7a3d120123 | ||
|
|
92419c9182 | ||
|
|
68bb5bf371 | ||
|
|
b0d8bfcadd | ||
|
|
a58ad1daf6 | ||
|
|
657c6056c4 | ||
|
|
84daba8902 | ||
|
|
3e37f8a269 | ||
|
|
28d178c4be | ||
|
|
f560cd5930 | ||
|
|
dbf24c6cd3 | ||
|
|
0a4989ca47 | ||
|
|
507dbf5d0f | ||
|
|
536f36f4e7 | ||
|
|
c523ba2287 | ||
|
|
a3f1cbb3b1 | ||
|
|
561bb2f0f4 | ||
|
|
dbbd1b5f2c | ||
|
|
7f6ec2f732 | ||
|
|
e8649203cf | ||
|
|
765b0c3d6d | ||
|
|
248c40731c | ||
|
|
cde01a4863 | ||
|
|
1ee8a0188b | ||
|
|
32e8fc9bf5 | ||
|
|
3c87d895c5 | ||
|
|
82dd8a9b48 | ||
|
|
0996506fd1 | ||
|
|
3ee38ee1e2 | ||
|
|
cf9401d81c | ||
|
|
dbed5cc247 | ||
|
|
625a77fd74 | ||
|
|
3e56c32376 | ||
|
|
56ecad07cd | ||
|
|
de8ef5f10b | ||
|
|
022df97b89 | ||
|
|
662480ef4c |
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
@@ -71,6 +71,11 @@ jobs:
|
||||
env:
|
||||
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
|
||||
|
||||
- name: Decode Keystore
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
|
||||
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
|
||||
|
||||
#6 Building APK
|
||||
- name: Build APK
|
||||
run: flutter build apk --release
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,6 +12,8 @@
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
android/keystore.properties
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
29
CHANGELOG.md
Normal file
29
CHANGELOG.md
Normal file
@@ -0,0 +1,29 @@
|
||||
### 1.1.3 (2025-03-30)
|
||||
- Windows: fix custom keyboard profile recreation after restart, also warn when choosing MyWhoosh profile (may fix #7)
|
||||
- Zwift Ride: button map adjustments to prevent double shifting
|
||||
- potential fix for #6
|
||||
|
||||
### 1.1.1 (2025-03-30)
|
||||
- potential fix for Bluetooth device detection
|
||||
|
||||
### 1.1.0 (2025-03-30)
|
||||
- Windows & macOS: allow setting custom keymap and store the setting
|
||||
- Android: allow customizing the touch area, so it can work with any device without guesswork where the buttons are (#4)
|
||||
- Zwift Ride: update Zwift Ride decoding based on Feedback from @JayyajGH (#3)
|
||||
|
||||
### 1.0.6 (2025-03-29)
|
||||
- Another potential keyboard fix for Windows
|
||||
- Zwift Play: actually also use the dedicated shift buttons
|
||||
|
||||
### 1.0.5 (2025-03-29)
|
||||
- Zwift Ride: remap the shifter buttons to the correct values
|
||||
|
||||
### 1.0.0+4 (2025-03-29)
|
||||
- Zwift Ride: attempt to fix button parsing
|
||||
- Android: fix missing permissions
|
||||
- Windows: potential fix for key press issues
|
||||
|
||||
### 1.0.0+3 (2025-03-29)
|
||||
|
||||
- Windows: fix connection by using a different Bluetooth stack (issue #1)
|
||||
- Android: fix non-working touch propagation (issue #2)
|
||||
@@ -18,7 +18,9 @@ Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- indieVelo / Training Peaks
|
||||
- let me know if you know others that can benefit
|
||||
- any other:
|
||||
- Android: you can customize the gear shifting touch points in the app
|
||||
- Desktop: you can customize the keyboard shortcuts in the app
|
||||
|
||||
## Supported Devices
|
||||
- Zwift Click
|
||||
@@ -43,6 +45,4 @@ Please consider donating to support the development of this app.
|
||||
[](https://paypal.me/boni)
|
||||
|
||||
## TODO
|
||||
- test Zwift Ride
|
||||
- confirm that Windows release works
|
||||
- implement more actions for Play + Ride
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED
|
||||
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
|
||||
|
||||
|
||||
@@ -26,11 +25,13 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
Observable.toService = null
|
||||
}
|
||||
|
||||
private val ignorePackages = listOf("com.android.systemui", "com.android.launcher", "com.android.settings")
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent) {
|
||||
if (event.packageName == null || rootInActiveWindow == null) {
|
||||
return
|
||||
}
|
||||
if (event.contentChangeTypes == CONTENT_CHANGE_TYPE_PANE_DISAPPEARED) {
|
||||
if (event.eventType != TYPE_WINDOW_STATE_CHANGED || event.packageName in ignorePackages) {
|
||||
// we're not interested
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.*
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +8,11 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
|
||||
android {
|
||||
namespace = "de.jonasbark.swift_play"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -33,11 +41,18 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("config") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file("../${keystoreProperties["storeFile"] as String}")
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a report to help us improve
|
||||
title: "fix: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps To Reproduce**
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected Behavior**
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional Context**
|
||||
|
||||
Add any other context about the problem here.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Build System
|
||||
about: Changes that affect the build system or external dependencies
|
||||
title: "build: "
|
||||
labels: build
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Describe what changes need to be done to the build system and why.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] The build system is passing
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Chore
|
||||
about: Other changes that don't modify src or test files
|
||||
title: "chore: "
|
||||
labels: chore
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Clearly describe what change is needed and why. If this changes code then please use another issue type.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] No functional changes to the code
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Continuous Integration
|
||||
about: Changes to the CI configuration files and scripts
|
||||
title: "ci: "
|
||||
labels: ci
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Describe what changes need to be done to the ci/cd system and why.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] The ci system is passing
|
||||
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Documentation
|
||||
about: Improve the documentation so all collaborators have a common understanding
|
||||
title: "docs: "
|
||||
labels: documentation
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Clearly describe what documentation you are looking to add or improve.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] Requirements go here
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: A new feature to be added to the project
|
||||
title: "feat: "
|
||||
labels: feature
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Clearly describe what you are looking to add. The more context the better.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] Checklist of requirements to be fulfilled
|
||||
|
||||
**Additional Context**
|
||||
|
||||
Add any other context or screenshots about the feature request go here.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Performance Update
|
||||
about: A code change that improves performance
|
||||
title: "perf: "
|
||||
labels: performance
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] There is no drop in test coverage.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Refactor
|
||||
about: A code change that neither fixes a bug nor adds a feature
|
||||
title: "refactor: "
|
||||
labels: refactor
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] There is no drop in test coverage.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: Revert Commit
|
||||
about: Reverts a previous commit
|
||||
title: "revert: "
|
||||
labels: revert
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Provide a link to a PR/Commit that you are looking to revert and why.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] Change has been reverted
|
||||
- [ ] No change in test coverage has happened
|
||||
- [ ] A new ticket is created for any follow on work that needs to happen
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Style Changes
|
||||
about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc)
|
||||
title: "style: "
|
||||
labels: style
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
Clearly describe what you are looking to change and why.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] There is no drop in test coverage.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Test
|
||||
about: Adding missing tests or correcting existing tests
|
||||
title: "test: "
|
||||
labels: test
|
||||
---
|
||||
|
||||
**Description**
|
||||
|
||||
List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- [ ] There is no drop in test coverage.
|
||||
@@ -1,27 +0,0 @@
|
||||
<!--
|
||||
Thanks for contributing!
|
||||
|
||||
Provide a description of your changes below and a general summary in the title
|
||||
|
||||
Please look at the following checklist to ensure that your PR can be accepted quickly:
|
||||
-->
|
||||
|
||||
## Status
|
||||
|
||||
**READY/IN DEVELOPMENT/HOLD**
|
||||
|
||||
## Description
|
||||
|
||||
<!--- Describe your changes in detail -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!--- Put an `x` in all the boxes that apply: -->
|
||||
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
|
||||
- [ ] 🧹 Code refactor
|
||||
- [ ] ✅ Build configuration change
|
||||
- [ ] 📝 Documentation
|
||||
- [ ] 🗑️ Chore
|
||||
21
flutter_blue_plus_windows/.github/cspell.json
vendored
21
flutter_blue_plus_windows/.github/cspell.json
vendored
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"version": "0.2",
|
||||
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
|
||||
"dictionaries": ["vgv_allowed", "vgv_forbidden"],
|
||||
"dictionaryDefinitions": [
|
||||
{
|
||||
"name": "vgv_allowed",
|
||||
"path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt",
|
||||
"description": "Allowed VGV Spellings"
|
||||
},
|
||||
{
|
||||
"name": "vgv_forbidden",
|
||||
"path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt",
|
||||
"description": "Forbidden VGV Spellings"
|
||||
}
|
||||
],
|
||||
"useGitignore": true,
|
||||
"words": [
|
||||
"flutter_blue_plus_windows"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
version: 2
|
||||
enable-beta-ecosystems: true
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "pub"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
12
flutter_blue_plus_windows/.gitignore
vendored
12
flutter_blue_plus_windows/.gitignore
vendored
@@ -1,12 +0,0 @@
|
||||
# See https://www.dartlang.org/guides/libraries/private-files
|
||||
|
||||
# Files and directories created by pub
|
||||
.dart_tool/
|
||||
.packages
|
||||
build/
|
||||
pubspec.lock
|
||||
/.idea/
|
||||
/.flutter-plugins
|
||||
/.flutter-plugins-dependencies
|
||||
|
||||
interanl_example
|
||||
@@ -1,106 +0,0 @@
|
||||
## 1.26.1
|
||||
* Add setOptions.
|
||||
|
||||
## 1.26.0
|
||||
* Set `flutter_blue_plus` upperbound <1.35.0 (due to api changes)
|
||||
|
||||
## 1.25.0
|
||||
* Ensure tracing connection when reconnection occurs after force disconnection.
|
||||
|
||||
## 1.24.22
|
||||
* Upgrade FBP version to `>=1.32.4 <=1.40.0` #24.
|
||||
|
||||
## 1.24.21
|
||||
* fix: startScan() doesn't return correct ScanResult #25
|
||||
|
||||
## 1.24.20
|
||||
* Downgrade FBP version to `>=1.32.4 <=1.33.6` due to the breaking changes.
|
||||
* After upgrade process, the dependencies will be returned to `>=1.34.4 <1.40.0` #24.
|
||||
|
||||
## 1.24.19
|
||||
* Fix a bug with `onValueReceived` of emitting write packet #22.
|
||||
|
||||
## 1.24.18
|
||||
* Add implementation of `BluetoothDeviceWindow.fromId()` #21.
|
||||
|
||||
## 1.24.15
|
||||
* Fix a bug w.r.t. company ID in manufacturer data. (@betto-a #18)
|
||||
|
||||
## 1.24.14
|
||||
* Implement cancelOnDisconnect (@jefflongo #16)
|
||||
|
||||
## 1.24.12
|
||||
* Fix minor bug w.r.t. `characteristic.isNotifying`.
|
||||
|
||||
## 1.24.11
|
||||
* Fix breaking changes of FBP w.r.t. `systemDevices(List withServices)`.
|
||||
|
||||
## 1.24.10
|
||||
* Add support for `cancelWhenScanComplete`
|
||||
|
||||
## 1.24.9
|
||||
* Implement scan filter (including `withServices`, `withRemoteIds`, `withNames`).
|
||||
|
||||
## 1.24.8
|
||||
* Keep manufacturer data when scanning.
|
||||
|
||||
## 1.24.7
|
||||
* Keep service uuids when scanning.
|
||||
|
||||
## 1.24.0
|
||||
* Update `README.md`.
|
||||
|
||||
## 1.23.6
|
||||
* Add unimplemented notification for `read` or `write`.
|
||||
|
||||
## 1.14.0
|
||||
* Remove dependencies `ffi` and `win32` to avoid compile error for web
|
||||
|
||||
## 1.9.5
|
||||
* Apply `flutter blue plus` to `1.28.13`.
|
||||
|
||||
## 1.9.0
|
||||
* Apply a breaking changes `Guid` in `Flutter blue plus` packages.
|
||||
* Use `uuid128` instead of `toString()`.
|
||||
|
||||
## 1.8.10
|
||||
* Fix `Guid` bug related with `Flutter blue plus` packages.
|
||||
|
||||
## 1.8.0
|
||||
* Fix bug with Guid converted from string due to starting/ending with '{ }' in `WinBLE`
|
||||
|
||||
## 1.7.0
|
||||
* Apply `flutter blue plus 1.28.5` (there is several breaking changes.).
|
||||
|
||||
## 1.6.6
|
||||
* Add cache for storing characteristics.
|
||||
|
||||
## 1.6.0
|
||||
* Apply `Flutter blue plus 1.26.0`, (there is a breaking change with `connect()`).
|
||||
|
||||
## 1.5.7
|
||||
* Remove connection by OS when performing `startScan`.
|
||||
|
||||
## 1.5.3
|
||||
* Write logs when connection state stream is started/terminated.
|
||||
|
||||
## 1.5.2
|
||||
* Fix a bug of features added in `1.5.1`
|
||||
|
||||
## 1.5.1
|
||||
* Remove device from connected device list when device is disconnected.
|
||||
|
||||
## 1.5.0
|
||||
* Split functionality of `disconnect` / `removeBond`.
|
||||
|
||||
## 1.4.0
|
||||
* Implement `Subscribe/Unsubscribe Characteristic`.
|
||||
|
||||
## 1.1.0
|
||||
* Implement `Read/Write Characteristic`.
|
||||
|
||||
## 1.0.5
|
||||
* Change `rxdart` version to `0.27.7`.
|
||||
|
||||
## 1.0.0
|
||||
* Initial release (using Github action).
|
||||
@@ -1,7 +0,0 @@
|
||||
Copyright 2023 Himchan Park
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,54 +0,0 @@
|
||||
[](https://pub.dartlang.org/packages/flutter_blue_plus_windows)
|
||||
|
||||
## Flutter Blue Plus Windows
|
||||
|
||||
This project is a wrapper library for `Flutter Blue Plus` and `Win_ble`.
|
||||
It allows `Flutter_blue_plus` to operate on Windows.
|
||||
|
||||
With minimal effort, you can use Flutter Blue Plus on Windows.
|
||||
|
||||
## Usage
|
||||
Only you need to do is change the import statement.
|
||||
|
||||
```dart
|
||||
// instead of import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
|
||||
// Alternatively, you can hide FlutterBluePlus when importing the FBP statement
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart' hide FlutterBluePlus;
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
|
||||
```
|
||||
|
||||
### Scan devices
|
||||
```dart
|
||||
final scannedDevices = <ScanResult>{};
|
||||
|
||||
const timeout = Duration(seconds: 3);
|
||||
FlutterBluePlus.startScan(timeout: timeout);
|
||||
|
||||
final sub = FlutterBluePlus.scanResults.expand((e)=>e).listen(scannedDevices.add);
|
||||
|
||||
await Future.delayed(timeout);
|
||||
sub.cancel();
|
||||
scannedDevices.forEach(print);
|
||||
```
|
||||
|
||||
### Connect a device
|
||||
```dart
|
||||
final scannedDevice = scannedDevices
|
||||
.where((scanResult) => scanResult.device.platformName == DEVICE_NAME)
|
||||
.firstOrNull;
|
||||
final device = scannedDevice?.device;
|
||||
device?.connect();
|
||||
```
|
||||
|
||||
### Disconnect the device
|
||||
```dart
|
||||
device?.disconnect();
|
||||
```
|
||||
|
||||
Check out the usage of Flutter Blue Plus on [Flutter Blue Plus](https://pub.dev/packages/flutter_blue_plus)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
library flutter_blue_plus_windows;
|
||||
|
||||
export 'package:flutter_blue_plus/flutter_blue_plus.dart' hide FlutterBluePlus;
|
||||
export 'package:win_ble/win_ble.dart';
|
||||
export 'package:win_ble/win_file.dart';
|
||||
|
||||
export 'src/flutter_blue_plus_windows.dart';
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:win_ble/win_ble.dart';
|
||||
|
||||
extension BluetoothAdapterStateExtension on BleState {
|
||||
BluetoothAdapterState toAdapterState() {
|
||||
switch(this){
|
||||
case BleState.On:
|
||||
return BluetoothAdapterState.on;
|
||||
case BleState.Off:
|
||||
return BluetoothAdapterState.off;
|
||||
case BleState.Unknown:
|
||||
return BluetoothAdapterState.unknown;
|
||||
case BleState.Disabled:
|
||||
return BluetoothAdapterState.unavailable;
|
||||
case BleState.Unsupported:
|
||||
return BluetoothAdapterState.unauthorized;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
|
||||
extension BluetoothCharacteristicExtension on BluetoothCharacteristic {
|
||||
BmBluetoothCharacteristic toProto() {
|
||||
return BmBluetoothCharacteristic(
|
||||
remoteId: DeviceIdentifier(remoteId.str),
|
||||
serviceUuid: serviceUuid,
|
||||
characteristicUuid: characteristicUuid,
|
||||
descriptors: [for (final d in descriptors) d.toProto()],
|
||||
properties: properties.toProto(),
|
||||
primaryServiceUuid: null, // TODO: API changes
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
|
||||
|
||||
extension BluetoothDescriptorExtension on BluetoothDescriptor {
|
||||
BmBluetoothDescriptor toProto() {
|
||||
return BmBluetoothDescriptor(
|
||||
remoteId: DeviceIdentifier(remoteId.str),
|
||||
serviceUuid: serviceUuid,
|
||||
characteristicUuid: characteristicUuid,
|
||||
descriptorUuid: descriptorUuid,
|
||||
primaryServiceUuid: null, // TODO: API changes
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
|
||||
extension BluetoothServiceExtension on BluetoothService {
|
||||
BmBluetoothService toProto() {
|
||||
return BmBluetoothService(
|
||||
serviceUuid: serviceUuid,
|
||||
remoteId: DeviceIdentifier(remoteId.str),
|
||||
characteristics: [for (final c in characteristics) c.toProto()],
|
||||
primaryServiceUuid: null, // TODO: API changes
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
|
||||
|
||||
extension CharacteristicPropertiesExtension on CharacteristicProperties {
|
||||
BmCharacteristicProperties toProto() {
|
||||
return BmCharacteristicProperties(
|
||||
broadcast: broadcast,
|
||||
read: read,
|
||||
writeWithoutResponse: writeWithoutResponse,
|
||||
write: write,
|
||||
notify: notify,
|
||||
indicate: indicate,
|
||||
authenticatedSignedWrites: authenticatedSignedWrites,
|
||||
extendedProperties: extendedProperties,
|
||||
notifyEncryptionRequired: notifyEncryptionRequired,
|
||||
indicateEncryptionRequired: indicateEncryptionRequired,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export 'bluetooth_adapter_state_extension.dart';
|
||||
export 'bluetooth_characteristic_extension.dart';
|
||||
export 'bluetooth_descriptor_extension.dart';
|
||||
export 'bluetooth_service_extension.dart';
|
||||
export 'characteristic_properties_extension.dart';
|
||||
@@ -1,3 +0,0 @@
|
||||
export 'extension/extension.dart';
|
||||
export 'windows/windows.dart';
|
||||
export 'wrapper/wrapper.dart';
|
||||
@@ -1,160 +0,0 @@
|
||||
part of 'windows.dart';
|
||||
|
||||
class BluetoothCharacteristicWindows extends BluetoothCharacteristic {
|
||||
final DeviceIdentifier remoteId;
|
||||
final Guid serviceUuid;
|
||||
final Guid? secondaryServiceUuid;
|
||||
final Guid characteristicUuid;
|
||||
final List<BluetoothDescriptor> descriptors;
|
||||
|
||||
final Properties propertiesWinBle;
|
||||
|
||||
BluetoothCharacteristicWindows({
|
||||
required this.remoteId,
|
||||
required this.serviceUuid,
|
||||
required this.characteristicUuid,
|
||||
required this.descriptors,
|
||||
required this.propertiesWinBle,
|
||||
this.secondaryServiceUuid,
|
||||
}) : super.fromProto(
|
||||
BmBluetoothCharacteristic(
|
||||
remoteId: DeviceIdentifier(remoteId.str),
|
||||
serviceUuid: serviceUuid,
|
||||
characteristicUuid: characteristicUuid,
|
||||
descriptors: [
|
||||
for (final descriptor in descriptors)
|
||||
BmBluetoothDescriptor(
|
||||
remoteId: DeviceIdentifier(descriptor.remoteId.str),
|
||||
serviceUuid: descriptor.serviceUuid,
|
||||
characteristicUuid: descriptor.characteristicUuid,
|
||||
descriptorUuid: descriptor.uuid,
|
||||
primaryServiceUuid: null, // TODO: API changes
|
||||
),
|
||||
],
|
||||
properties: BmCharacteristicProperties(
|
||||
broadcast: propertiesWinBle.broadcast ?? false,
|
||||
read: propertiesWinBle.read ?? false,
|
||||
writeWithoutResponse: propertiesWinBle.writeWithoutResponse ?? false,
|
||||
write: propertiesWinBle.write ?? false,
|
||||
notify: propertiesWinBle.notify ?? false,
|
||||
indicate: propertiesWinBle.indicate ?? false,
|
||||
authenticatedSignedWrites: propertiesWinBle.authenticatedSignedWrites ?? false,
|
||||
// TODO: implementation missing
|
||||
extendedProperties: false,
|
||||
// TODO: implementation missing
|
||||
notifyEncryptionRequired: false,
|
||||
// TODO: implementation missing
|
||||
indicateEncryptionRequired: false,
|
||||
),
|
||||
primaryServiceUuid: null, // TODO: API changes
|
||||
),
|
||||
);
|
||||
|
||||
String get _address => remoteId.str.toLowerCase();
|
||||
|
||||
String get _key => "$serviceUuid:$characteristicUuid";
|
||||
|
||||
FBP.BluetoothDevice get device =>
|
||||
FlutterBluePlusWindows.connectedDevices.firstWhere((device) => device.remoteId == remoteId);
|
||||
|
||||
/// this variable is updated:
|
||||
/// - anytime `read()` is called
|
||||
/// - anytime `write()` is called
|
||||
/// - anytime a notification arrives (if subscribed)
|
||||
List<int> get lastValue => FlutterBluePlusWindows._lastChrs[remoteId]?[_key] ?? [];
|
||||
|
||||
/// this stream emits values:
|
||||
/// - anytime `read()` is called
|
||||
/// - anytime `write()` is called
|
||||
/// - anytime a notification arrives (if subscribed)
|
||||
/// - and when first listened to, it re-emits the last value for convenience
|
||||
Stream<List<int>> get lastValueStream => _mergeStreams(
|
||||
[
|
||||
WinBle.characteristicValueStreamOf(
|
||||
address: _address,
|
||||
serviceId: serviceUuid.str128,
|
||||
characteristicId: characteristicUuid.str128,
|
||||
),
|
||||
FlutterBluePlusWindows._charReadWriteStream.where((e) => e.$1 == _key).map((e) => e.$2)
|
||||
],
|
||||
).map((p) => <int>[...p]).newStreamWithInitialValue(lastValue).asBroadcastStream();
|
||||
|
||||
/// this stream emits values:
|
||||
/// - anytime `read()` is called
|
||||
/// - anytime a notification arrives (if subscribed)
|
||||
Stream<List<int>> get onValueReceived => _mergeStreams(
|
||||
[
|
||||
WinBle.characteristicValueStreamOf(
|
||||
address: _address,
|
||||
serviceId: serviceUuid.str128,
|
||||
characteristicId: characteristicUuid.str128,
|
||||
),
|
||||
FlutterBluePlusWindows._charReadStream.where((e) => e.$1 == _key).map((e) => e.$2)
|
||||
],
|
||||
).map((p) => <int>[...p]).asBroadcastStream();
|
||||
|
||||
// TODO: need to verify
|
||||
bool get isNotifying => FlutterBluePlusWindows._isNotifying[remoteId]?[_key] ?? false;
|
||||
|
||||
Future<List<int>> read({int timeout = 15}) async {
|
||||
final value = await WinBle.read(
|
||||
address: _address,
|
||||
serviceId: serviceUuid.str128,
|
||||
characteristicId: characteristicUuid.str128,
|
||||
);
|
||||
FlutterBluePlusWindows._charReadWriteStreamController.add((_key, value));
|
||||
FlutterBluePlusWindows._charReadStreamController.add((_key, value));
|
||||
FlutterBluePlusWindows._lastChrs[remoteId] ??= {};
|
||||
FlutterBluePlusWindows._lastChrs[remoteId]?[_key] = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> write(List<int> value,
|
||||
{bool allowLongWrite = false, bool withoutResponse = false, int timeout = 15}) async {
|
||||
await WinBle.write(
|
||||
address: _address,
|
||||
service: serviceUuid.str128,
|
||||
characteristic: characteristicUuid.str128,
|
||||
data: Uint8List.fromList(value),
|
||||
writeWithResponse: !withoutResponse, // propertiesWinBle.writeWithoutResponse ?? false,
|
||||
);
|
||||
FlutterBluePlusWindows._charReadWriteStreamController.add((_key, value));
|
||||
FlutterBluePlusWindows._lastChrs[remoteId] ??= {};
|
||||
FlutterBluePlusWindows._lastChrs[remoteId]?[_key] = value;
|
||||
}
|
||||
|
||||
// TODO: need to verify
|
||||
Future<bool> setNotifyValue(
|
||||
bool notify, {
|
||||
int timeout = 15, // TODO: missing implementation
|
||||
bool forceIndications = false, // TODO: missing implementation
|
||||
}) async {
|
||||
/// unSubscribeFromCharacteristic
|
||||
try {
|
||||
await WinBle.unSubscribeFromCharacteristic(
|
||||
address: _address,
|
||||
serviceId: serviceUuid.str128,
|
||||
characteristicId: characteristicUuid.str128,
|
||||
);
|
||||
} catch (e) {
|
||||
log('WinBle.unSubscribeFromCharacteristic was performed '
|
||||
'before setNotifyValue()');
|
||||
}
|
||||
|
||||
/// set notify
|
||||
try {
|
||||
if (notify) {
|
||||
await WinBle.subscribeToCharacteristic(
|
||||
address: _address,
|
||||
serviceId: serviceUuid.str128,
|
||||
characteristicId: characteristicUuid.str128,
|
||||
);
|
||||
}
|
||||
FlutterBluePlusWindows._isNotifying[remoteId] ??= {};
|
||||
FlutterBluePlusWindows._isNotifying[remoteId]?[_key] = notify;
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
// Bluetooth Device Page:
|
||||
// https://github.com/boskokg/flutter_blue_plus/blob/master/lib/src/bluetooth_device.dart
|
||||
|
||||
part of 'windows.dart';
|
||||
|
||||
class BluetoothDeviceWindows extends FBP.BluetoothDevice {
|
||||
BluetoothDeviceWindows({required super.remoteId});
|
||||
|
||||
// used for 'servicesStream' public api
|
||||
final _services = StreamController<List<BluetoothServiceWindows>>.broadcast();
|
||||
|
||||
// used for 'isDiscoveringServices' public api
|
||||
final _isDiscoveringServices = _StreamController(initialValue: false);
|
||||
|
||||
String get _address => remoteId.str.toLowerCase();
|
||||
|
||||
/// Create a device from an id
|
||||
/// - to connect, this device must have been discovered by your app in a previous scan
|
||||
/// - iOS uses 128-bit uuids the remoteId, e.g. e006b3a7-ef7b-4980-a668-1f8005f84383
|
||||
/// - Android uses 48-bit mac addresses as the remoteId, e.g. 06:E5:28:3B:FD:E0
|
||||
static FBP.BluetoothDevice fromId(String remoteId) {
|
||||
if (Platform.isWindows) {
|
||||
return BluetoothDeviceWindows(remoteId: DeviceIdentifier(remoteId.toUpperCase()));
|
||||
}
|
||||
return FBP.BluetoothDevice.fromId(remoteId);
|
||||
}
|
||||
|
||||
/// platform name
|
||||
/// - this name is kept track of by the platform
|
||||
/// - this name usually persist between app restarts
|
||||
/// - iOS: after you connect, iOS uses the GAP name characteristic (0x2A00)
|
||||
/// if it exists. Otherwise iOS use the advertised name.
|
||||
/// - Android: always uses the advertised name
|
||||
String get platformName => FlutterBluePlusWindows._platformNames[remoteId] ?? "";
|
||||
|
||||
/// Advertised Named
|
||||
/// - this is the name advertised by the device during scanning
|
||||
/// - it is only available after you scan with FlutterBluePlus
|
||||
/// - it is cleared when the app restarts.
|
||||
/// - not all devices advertise a name
|
||||
String get advName => FlutterBluePlusWindows._advNames[remoteId] ?? "";
|
||||
|
||||
// stream return whether or not we are currently discovering services
|
||||
@Deprecated("planed for removal (Jan 2024). It can be easily implemented yourself") // deprecated on Aug 2023
|
||||
Stream<bool> get isDiscoveringServices => _isDiscoveringServices.stream;
|
||||
|
||||
/// Get services
|
||||
/// - returns empty if discoverServices() has not been called
|
||||
/// or if your device does not have any services (rare)
|
||||
List<BluetoothServiceWindows> get servicesList => FlutterBluePlusWindows._knownServices[remoteId] ?? [];
|
||||
|
||||
/// Stream of bluetooth services offered by the remote device
|
||||
/// - this stream is only updated when you call discoverServices()
|
||||
@Deprecated("planed for removal (Jan 2024). It can be easily implemented yourself") // deprecated on Aug 2023
|
||||
Stream<List<BluetoothService>> get servicesStream {
|
||||
if (FlutterBluePlusWindows._knownServices[remoteId] != null) {
|
||||
return _services.stream.newStreamWithInitialValue(
|
||||
FlutterBluePlusWindows._knownServices[remoteId]!,
|
||||
);
|
||||
} else {
|
||||
return _services.stream;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a subscription to be canceled when the device is disconnected.
|
||||
/// This function simplifies cleanup, so you can prevent creating duplicate stream subscriptions.
|
||||
/// - this is an optional convenience function
|
||||
/// - prevents accidentally creating duplicate subscriptions on each reconnection.
|
||||
/// - [next] if true, the the stream will be canceled only on the *next* disconnection.
|
||||
/// This is useful if you setup your subscriptions before you connect.
|
||||
/// - [delayed] Note: This option is only meant for `connectionState` subscriptions.
|
||||
/// When `true`, we cancel after a small delay. This ensures the `connectionState`
|
||||
/// listener receives the `disconnected` event.
|
||||
void cancelWhenDisconnected(StreamSubscription subscription, {bool next = false, bool delayed = false}) {
|
||||
if (isConnected == false && next == false) {
|
||||
subscription.cancel(); // cancel immediately if already disconnected.
|
||||
} else if (delayed) {
|
||||
FlutterBluePlusWindows._delayedSubscriptions[remoteId] ??= [];
|
||||
FlutterBluePlusWindows._delayedSubscriptions[remoteId]!.add(subscription);
|
||||
} else {
|
||||
FlutterBluePlusWindows._deviceSubscriptions[remoteId] ??= [];
|
||||
FlutterBluePlusWindows._deviceSubscriptions[remoteId]!.add(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this device is currently connected to your app
|
||||
bool get isConnected {
|
||||
return FlutterBluePlusWindows.connectedDevices.contains(this);
|
||||
}
|
||||
|
||||
/// Returns true if this device is currently disconnected from your app
|
||||
bool get isDisconnected => isConnected == false;
|
||||
|
||||
Future<void> connect({
|
||||
Duration? timeout = const Duration(seconds: 35), // TODO: implementation missing
|
||||
bool autoConnect = false, // TODO: implementation missing
|
||||
int? mtu = 512, // TODO: implementation missing
|
||||
}) async {
|
||||
try {
|
||||
await WinBle.connect(_address);
|
||||
FlutterBluePlusWindows._deviceSet.add(this);
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect({
|
||||
int androidDelay = 2000, // TODO: implementation missing
|
||||
int timeout = 35, // TODO: implementation missing
|
||||
bool queue = true, // TODO: implementation missing
|
||||
}) async {
|
||||
try {
|
||||
await WinBle.disconnect(_address);
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
} finally {
|
||||
FlutterBluePlusWindows._deviceSet.remove(this);
|
||||
|
||||
FlutterBluePlusWindows._deviceSubscriptions[remoteId]?.forEach((s) => s.cancel());
|
||||
FlutterBluePlusWindows._deviceSubscriptions.remove(remoteId);
|
||||
// use delayed to update the stream before we cancel it
|
||||
Future.delayed(Duration.zero).then((_) {
|
||||
FlutterBluePlusWindows._delayedSubscriptions[remoteId]?.forEach((s) => s.cancel());
|
||||
FlutterBluePlusWindows._delayedSubscriptions.remove(remoteId);
|
||||
});
|
||||
|
||||
FlutterBluePlusWindows._lastChrs[remoteId]?.clear();
|
||||
FlutterBluePlusWindows._isNotifying[remoteId]?.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<BluetoothService>> discoverServices({
|
||||
bool subscribeToServicesChanged = true, // TODO: implementation missing
|
||||
int timeout = 15, // TODO: implementation missing
|
||||
}) async {
|
||||
List<BluetoothServiceWindows> result = List.from(FlutterBluePlusWindows._knownServices[remoteId] ?? []);
|
||||
|
||||
try {
|
||||
_isDiscoveringServices.add(true);
|
||||
|
||||
final response = await WinBle.discoverServices(_address);
|
||||
FlutterBluePlusWindows._characteristicCache[remoteId] ??= <String, List<BluetoothCharacteristic>>{};
|
||||
|
||||
for (final serviceId in response) {
|
||||
final characteristic = await WinBle.discoverCharacteristics(
|
||||
address: _address,
|
||||
serviceId: serviceId,
|
||||
);
|
||||
FlutterBluePlusWindows._characteristicCache[remoteId] ??= {};
|
||||
FlutterBluePlusWindows._characteristicCache[remoteId]?[serviceId] ??= [
|
||||
...characteristic.map(
|
||||
(e) => BluetoothCharacteristicWindows(
|
||||
remoteId: remoteId,
|
||||
serviceUuid: Guid(serviceId),
|
||||
characteristicUuid: Guid(e.uuid),
|
||||
descriptors: [],
|
||||
// TODO: implementation missing
|
||||
propertiesWinBle: e.properties,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
result = [
|
||||
...response.map(
|
||||
(p) => BluetoothServiceWindows(
|
||||
remoteId: remoteId,
|
||||
serviceUuid: Guid(p),
|
||||
// TODO: implementation missing
|
||||
isPrimary: true,
|
||||
// TODO: implementation missing
|
||||
characteristics: FlutterBluePlusWindows._characteristicCache[remoteId]![p]!,
|
||||
// TODO: implementation missing
|
||||
includedServices: [],
|
||||
),
|
||||
)
|
||||
];
|
||||
|
||||
FlutterBluePlusWindows._knownServices[remoteId] = result;
|
||||
|
||||
_services.add(result);
|
||||
} finally {
|
||||
_isDiscoveringServices.add(false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
DisconnectReason? get disconnectReason {
|
||||
// TODO: nothing to do
|
||||
return null;
|
||||
}
|
||||
|
||||
Stream<BluetoothConnectionState> get connectionState async* {
|
||||
await FlutterBluePlusWindows._initialize();
|
||||
|
||||
final map = FlutterBluePlusWindows._connectionStream.latestValue;
|
||||
|
||||
if (map[_address] != null) {
|
||||
yield map[_address]!.isConnected;
|
||||
}
|
||||
|
||||
yield* WinBle.connectionStreamOf(_address).map((e) => e.isConnected);
|
||||
}
|
||||
|
||||
Stream<int> get mtu async* {
|
||||
bool isEmitted = false;
|
||||
int retryCount = 0;
|
||||
while (!isEmitted) {
|
||||
if (retryCount > 3) throw "Device not found!";
|
||||
retryCount++;
|
||||
try {
|
||||
yield await WinBle.getMaxMtuSize(_address);
|
||||
isEmitted = true;
|
||||
} catch (e) {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> readRssi({int timeout = 15}) async {
|
||||
return FlutterBluePlusWindows._rssiMap[remoteId] ?? -100;
|
||||
}
|
||||
|
||||
Future<int> requestMtu(
|
||||
int desiredMtu, {
|
||||
double predelay = 0.35,
|
||||
int timeout = 15,
|
||||
}) async {
|
||||
// https://github.com/rohitsangwan01/win_ble/issues/8
|
||||
return await WinBle.getMaxMtuSize(_address);
|
||||
}
|
||||
|
||||
Future<void> requestConnectionPriority({
|
||||
required ConnectionPriority connectionPriorityRequest,
|
||||
}) async {
|
||||
// TODO: nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
/// Set the preferred connection (Android Only)
|
||||
/// - [txPhy] bitwise OR of all allowed phys for Tx, e.g. (Phy.le2m.mask | Phy.leCoded.mask)
|
||||
/// - [txPhy] bitwise OR of all allowed phys for Rx, e.g. (Phy.le2m.mask | Phy.leCoded.mask)
|
||||
/// - [option] preferred coding to use when transmitting on Phy.leCoded
|
||||
/// Please note that this is just a recommendation given to the system.
|
||||
Future<void> setPreferredPhy({
|
||||
required int txPhy,
|
||||
required int rxPhy,
|
||||
required PhyCoding option,
|
||||
}) async {
|
||||
// TODO: implementation missing
|
||||
}
|
||||
|
||||
Future<void> createBond({
|
||||
Uint8List? pin,
|
||||
int timeout = 90, // TODO: implementation missing
|
||||
}) async {
|
||||
try {
|
||||
await WinBle.pair(_address);
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeBond({
|
||||
int timeout = 30, // TODO: implementation missing
|
||||
}) async {
|
||||
try {
|
||||
await WinBle.unPair(_address);
|
||||
} catch (e) {
|
||||
log(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearGattCache() async {
|
||||
// TODO: implementation missing
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
(other is BluetoothDeviceWindows && runtimeType == other.runtimeType && remoteId == other.remoteId);
|
||||
|
||||
@override
|
||||
int get hashCode => remoteId.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BluetoothDevice{'
|
||||
'remoteId: $remoteId, '
|
||||
'platformName: $platformName, '
|
||||
'services: ${FlutterBluePlusWindows._knownServices[remoteId]}'
|
||||
'}';
|
||||
}
|
||||
|
||||
@Deprecated('Use createBond() instead')
|
||||
Future<void> pair() async => await createBond();
|
||||
|
||||
@Deprecated('Use remoteId instead')
|
||||
DeviceIdentifier get id => remoteId;
|
||||
|
||||
@Deprecated('Use localName instead')
|
||||
String get name => localName;
|
||||
|
||||
@Deprecated('Use connectionState instead')
|
||||
Stream<BluetoothConnectionState> get state => connectionState;
|
||||
|
||||
@Deprecated('Use servicesStream instead')
|
||||
Stream<List<BluetoothService>> get services => servicesStream;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
part of 'windows.dart';
|
||||
|
||||
class BluetoothServiceWindows extends BluetoothService {
|
||||
final DeviceIdentifier remoteId;
|
||||
final Guid serviceUuid;
|
||||
final bool isPrimary;
|
||||
final List<BluetoothCharacteristic> characteristics;
|
||||
final List<BluetoothService> includedServices;
|
||||
|
||||
BluetoothServiceWindows({
|
||||
required this.remoteId,
|
||||
required this.serviceUuid,
|
||||
required this.isPrimary,
|
||||
required this.characteristics,
|
||||
required this.includedServices,
|
||||
}) : super.fromProto(
|
||||
BmBluetoothService(
|
||||
remoteId: DeviceIdentifier(remoteId.str),
|
||||
serviceUuid: serviceUuid,
|
||||
characteristics: [for (final c in characteristics) c.toProto()],
|
||||
primaryServiceUuid: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
part of 'windows.dart';
|
||||
|
||||
class FlutterBluePlusWindows {
|
||||
static bool _initialized = false;
|
||||
|
||||
static BluetoothAdapterState _state = BluetoothAdapterState.unknown;
|
||||
|
||||
// stream used for the isScanning public api
|
||||
static final _isScanning = _StreamController(initialValue: false);
|
||||
|
||||
// we always keep track of these device variables
|
||||
static final _platformNames = <DeviceIdentifier, String>{};
|
||||
static final _advNames = <DeviceIdentifier, String>{};
|
||||
static final _rssiMap = <DeviceIdentifier, int?>{};
|
||||
static final _knownServices = <DeviceIdentifier, List<BluetoothServiceWindows>>{};
|
||||
static final Map<DeviceIdentifier, Map<String, List<int>>> _lastChrs = {};
|
||||
static final Map<DeviceIdentifier, Map<String, bool>> _isNotifying = {};
|
||||
static final Map<DeviceIdentifier, Map<String, List<BluetoothCharacteristic>>> _characteristicCache = {};
|
||||
static final Map<DeviceIdentifier, List<StreamSubscription>> _deviceSubscriptions = {};
|
||||
static final Map<DeviceIdentifier, List<StreamSubscription>> _delayedSubscriptions = {};
|
||||
static final List<StreamSubscription> _scanSubscriptions = [];
|
||||
|
||||
// stream used for the scanResults public api
|
||||
static final _scanResultsList = _StreamController(initialValue: <ScanResult>[]);
|
||||
|
||||
// the subscription to the scan results stream
|
||||
static StreamSubscription<BleDevice?>? _scanSubscription;
|
||||
|
||||
// timeout for scanning that can be cancelled by stopScan
|
||||
static Timer? _scanTimeout;
|
||||
|
||||
static List<BluetoothDeviceWindows> get _devices => [..._deviceSet];
|
||||
|
||||
static final _deviceSet = <BluetoothDeviceWindows>{};
|
||||
static final _removedDeviceTracer = <BluetoothDeviceWindows, StreamSubscription>{};
|
||||
|
||||
// static final _unhandledDeviceSet = <BluetoothDeviceWindows>{};
|
||||
|
||||
/// Flutter blue plus windows
|
||||
static final _charReadWriteStreamController = StreamController<(String, List<int>)>();
|
||||
static final _charReadStreamController = StreamController<(String, List<int>)>();
|
||||
|
||||
static final _charReadWriteStream = _charReadWriteStreamController.stream.asBroadcastStream();
|
||||
static final _charReadStream = _charReadStreamController.stream.asBroadcastStream();
|
||||
|
||||
/// Flutter blue plus windows
|
||||
static final _connectionStream = _StreamController(initialValue: <String, bool>{});
|
||||
|
||||
static Future<void> _initialize() async {
|
||||
if (_initialized) return;
|
||||
await WinBle.initialize(
|
||||
serverPath: await WinServer.path(),
|
||||
enableLog: false,
|
||||
);
|
||||
|
||||
WinBle.connectionStream.listen(
|
||||
(event) {
|
||||
log('$event - event');
|
||||
if (event['device'] == null) return;
|
||||
if (event['connected'] == null) return;
|
||||
|
||||
final map = _connectionStream.latestValue;
|
||||
map[event['device']] = event['connected'];
|
||||
|
||||
log('$map - map');
|
||||
_connectionStream.add(map);
|
||||
|
||||
if (!event['connected']) {
|
||||
final removingDevices = [
|
||||
..._deviceSet.where(
|
||||
(device) => device._address == event['device'],
|
||||
),
|
||||
];
|
||||
for (final device in removingDevices) {
|
||||
_deviceSet.remove(device);
|
||||
if (!_removedDeviceTracer.keys.contains(device)) {
|
||||
_removedDeviceTracer[device] = Stream.periodic(const Duration(seconds: 10), (_) => device).listen(
|
||||
(event) {
|
||||
if(event.isConnected) {
|
||||
_removedDeviceTracer[device]?.cancel();
|
||||
_removedDeviceTracer.remove(device);
|
||||
return;
|
||||
}
|
||||
event.connect();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_deviceSubscriptions[device.remoteId]?.forEach((s) => s.cancel());
|
||||
_deviceSubscriptions.remove(device.remoteId);
|
||||
// use delayed to update the stream before we cancel it
|
||||
Future.delayed(Duration.zero).then((_) {
|
||||
_delayedSubscriptions[device.remoteId]?.forEach((s) => s.cancel());
|
||||
_delayedSubscriptions.remove(device.remoteId);
|
||||
});
|
||||
|
||||
_lastChrs[device.remoteId]?.clear();
|
||||
_isNotifying[device.remoteId]?.clear();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static Future<bool> get isSupported async {
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<String> get adapterName async {
|
||||
return 'Windows';
|
||||
}
|
||||
|
||||
static Stream<bool> get isScanning => _isScanning.stream;
|
||||
|
||||
static bool get isScanningNow => _isScanning.latestValue;
|
||||
|
||||
static Future<void> turnOn({int timeout = 10}) async {
|
||||
await _initialize();
|
||||
await WinBle.updateBluetoothState(true);
|
||||
}
|
||||
|
||||
// TODO: compare with original lib
|
||||
static Stream<List<ScanResult>> get scanResults => _scanResultsList.stream;
|
||||
|
||||
static Stream<BluetoothAdapterState> get adapterState async* {
|
||||
await _initialize();
|
||||
yield _state;
|
||||
yield* WinBle.bleState.asBroadcastStream().map(
|
||||
(s) {
|
||||
_state = s.toAdapterState();
|
||||
return _state;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Start a scan, and return a stream of results
|
||||
/// Note: scan filters use an "or" behavior. i.e. if you set `withServices` & `withNames` we
|
||||
/// return all the advertisments that match any of the specified services *or* any of the specified names.
|
||||
/// - [withServices] filter by advertised services
|
||||
/// - [withRemoteIds] filter for known remoteIds (iOS: 128-bit guid, android: 48-bit mac address)
|
||||
/// - [withNames] filter by advertised names (exact match)
|
||||
/// - [withKeywords] filter by advertised names (matches any substring)
|
||||
/// - [withMsd] filter by manfacture specific data
|
||||
/// - [withServiceData] filter by service data
|
||||
/// - [timeout] calls stopScan after a specified duration
|
||||
/// - [removeIfGone] if true, remove devices after they've stopped advertising for X duration
|
||||
/// - [continuousUpdates] If `true`, we continually update 'lastSeen' & 'rssi' by processing
|
||||
/// duplicate advertisements. This takes more power. You typically should not use this option.
|
||||
/// - [continuousDivisor] Useful to help performance. If divisor is 3, then two-thirds of advertisements are
|
||||
/// ignored, and one-third are processed. This reduces main-thread usage caused by the platform channel.
|
||||
/// The scan counting is per-device so you always get the 1st advertisement from each device.
|
||||
/// If divisor is 1, all advertisements are returned. This argument only matters for `continuousUpdates` mode.
|
||||
/// - [oneByOne] if `true`, we will stream every advertistment one by one, possibly including duplicates.
|
||||
/// If `false`, we deduplicate the advertisements, and return a list of devices.
|
||||
/// - [androidLegacy] Android only. If `true`, scan on 1M phy only.
|
||||
/// If `false`, scan on all supported phys. How the radio cycles through all the supported phys is purely
|
||||
/// dependent on the your Bluetooth stack implementation.
|
||||
/// - [androidScanMode] choose the android scan mode to use when scanning
|
||||
/// - [androidUsesFineLocation] request `ACCESS_FINE_LOCATION` permission at runtime
|
||||
static Future<void> startScan({
|
||||
List<Guid> withServices = const [],
|
||||
List<String> withRemoteIds = const [],
|
||||
List<String> withNames = const [],
|
||||
//TODO: implementation missing
|
||||
List<String> withKeywords = const [],
|
||||
//TODO: implementation missing
|
||||
List<MsdFilter> withMsd = const [],
|
||||
List<ServiceDataFilter> withServiceData = const [],
|
||||
Duration? timeout,
|
||||
Duration? removeIfGone,
|
||||
bool continuousUpdates = false,
|
||||
int continuousDivisor = 1,
|
||||
bool oneByOne = false,
|
||||
bool androidLegacy = false,
|
||||
AndroidScanMode androidScanMode = AndroidScanMode.lowLatency,
|
||||
bool androidUsesFineLocation = false,
|
||||
}) async {
|
||||
await _initialize();
|
||||
|
||||
// stop existing scan
|
||||
if (_isScanning.latestValue == true) {
|
||||
await stopScan();
|
||||
}
|
||||
|
||||
// push to stream
|
||||
_isScanning.add(true);
|
||||
|
||||
// Start timer *after* stream is being listened to, to make sure the
|
||||
// timeout does not fire before _buffer is set
|
||||
if (timeout != null) {
|
||||
_scanTimeout = Timer(timeout, stopScan);
|
||||
}
|
||||
|
||||
/// remove connection by OS.
|
||||
/// The reason why we add this logic is
|
||||
/// to avoid uncontrollable devices and to make consistency.
|
||||
|
||||
/// add WinBle scanning
|
||||
WinBle.startScanning();
|
||||
|
||||
// check every 250ms for gone devices?
|
||||
late Stream<BleDevice?> outputStream;
|
||||
if (removeIfGone != null) {
|
||||
outputStream = _mergeStreams([WinBle.scanStream, Stream.periodic(Duration(milliseconds: 250))]);
|
||||
} else {
|
||||
outputStream = WinBle.scanStream;
|
||||
}
|
||||
|
||||
final output = <ScanResult>[];
|
||||
|
||||
// listen & push to `scanResults` stream
|
||||
_scanSubscription = outputStream.listen(
|
||||
(BleDevice? winBleDevice) {
|
||||
// print(winBleDevice?.serviceUuids);
|
||||
if (winBleDevice == null) {
|
||||
// if null, this is just a periodic update for removing old results
|
||||
output.removeWhere((elm) => DateTime.now().difference(elm.timeStamp) > removeIfGone!);
|
||||
|
||||
// push to stream
|
||||
_scanResultsList.add(List.from(output));
|
||||
} else {
|
||||
final remoteId = DeviceIdentifier(winBleDevice.address.toUpperCase());
|
||||
final scanResult = output.where((sr) => sr.device.remoteId == remoteId).firstOrNull;
|
||||
final deviceName = winBleDevice.name.isNotEmpty ? winBleDevice.name : scanResult?.device.platformName ?? '';
|
||||
final serviceUuids = winBleDevice.serviceUuids.isNotEmpty
|
||||
? [...winBleDevice.serviceUuids.map((e) => Guid((e as String).replaceAll(RegExp(r'[{}]'), '')))]
|
||||
: scanResult?.advertisementData.serviceUuids ?? [];
|
||||
|
||||
final manufacturerData = winBleDevice.manufacturerData.isNotEmpty
|
||||
? {
|
||||
if (winBleDevice.manufacturerData.length >= 2)
|
||||
winBleDevice.manufacturerData[0] + (winBleDevice.manufacturerData[1] << 8):
|
||||
winBleDevice.manufacturerData.sublist(2),
|
||||
}
|
||||
: scanResult?.advertisementData.manufacturerData ?? {};
|
||||
|
||||
final rssi = int.tryParse(winBleDevice.rssi) ?? -100;
|
||||
|
||||
FlutterBluePlusWindows._platformNames[remoteId] = deviceName;
|
||||
FlutterBluePlusWindows._advNames[remoteId] = deviceName;
|
||||
FlutterBluePlusWindows._rssiMap[remoteId] = rssi;
|
||||
|
||||
final device = BluetoothDeviceWindows(remoteId: remoteId);
|
||||
|
||||
String hex(int value) => value.toRadixString(16).padLeft(2, '0');
|
||||
String hexToId(Iterable<int> values) => values.map((e) => hex(e)).join();
|
||||
|
||||
final sr = ScanResult(
|
||||
device: device,
|
||||
advertisementData: AdvertisementData(
|
||||
advName: deviceName,
|
||||
txPowerLevel: winBleDevice.adStructures?.where((e) => e.type == 10).singleOrNull?.data.firstOrNull,
|
||||
//TODO: Should verify
|
||||
connectable: !winBleDevice.advType.contains('Non'),
|
||||
manufacturerData: manufacturerData,
|
||||
serviceData: {
|
||||
for (final advStructures in winBleDevice.adStructures ?? <AdStructure>[])
|
||||
if (advStructures.type == 0x16 && advStructures.data.length >= 2)
|
||||
Guid(hexToId(advStructures.data.sublist(0, 2).reversed)): advStructures.data.sublist(2),
|
||||
for (final advStructures in winBleDevice.adStructures ?? <AdStructure>[])
|
||||
if (advStructures.type == 0x20 && advStructures.data.length >= 4)
|
||||
Guid(hexToId(advStructures.data.sublist(0, 4).reversed)): advStructures.data.sublist(4),
|
||||
for (final advStructures in winBleDevice.adStructures ?? <AdStructure>[])
|
||||
if (advStructures.type == 0x21 && advStructures.data.length >= 16)
|
||||
Guid(hexToId(advStructures.data.sublist(0, 16).reversed)): advStructures.data.sublist(16),
|
||||
},
|
||||
serviceUuids: serviceUuids,
|
||||
appearance: null,
|
||||
),
|
||||
rssi: rssi,
|
||||
timeStamp: DateTime.now(),
|
||||
);
|
||||
|
||||
// filter with services
|
||||
final isFilteredWithServices =
|
||||
withServices.isNotEmpty && serviceUuids.where((service) => withServices.contains(service)).isEmpty;
|
||||
|
||||
// filter with remote ids
|
||||
final isFilteredWithRemoteIds = withRemoteIds.isNotEmpty && !withRemoteIds.contains(remoteId);
|
||||
|
||||
// filter with names
|
||||
final isFilteredWithNames = withNames.isNotEmpty && !withNames.contains(deviceName);
|
||||
|
||||
if (isFilteredWithServices || isFilteredWithRemoteIds || isFilteredWithNames) {
|
||||
_scanResultsList.add(List.from(output));
|
||||
return;
|
||||
}
|
||||
|
||||
// add result to output
|
||||
if (oneByOne) {
|
||||
output
|
||||
..clear()
|
||||
..add(sr);
|
||||
} else {
|
||||
output.addOrUpdate(sr);
|
||||
}
|
||||
|
||||
// push to stream
|
||||
_scanResultsList.add(List.from(output));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static List<FBP.BluetoothDevice> get connectedDevices {
|
||||
return _devices;
|
||||
}
|
||||
|
||||
static Future<List<BluetoothDeviceWindows>> get bondedDevices async {
|
||||
return _devices;
|
||||
}
|
||||
|
||||
/// Stops a scan for Bluetooth Low Energy devices
|
||||
static Future<void> stopScan() async {
|
||||
await _initialize();
|
||||
WinBle.stopScanning();
|
||||
_scanSubscription?.cancel();
|
||||
_scanTimeout?.cancel();
|
||||
_isScanning.add(false);
|
||||
|
||||
for (var subscription in _scanSubscriptions) {
|
||||
subscription.cancel();
|
||||
}
|
||||
|
||||
_scanResultsList.latestValue = [];
|
||||
}
|
||||
|
||||
/// Register a subscription to be canceled when scanning is complete.
|
||||
/// This function simplifies cleanup, so you can prevent creating duplicate stream subscriptions.
|
||||
/// - this is an optional convenience function
|
||||
/// - prevents accidentally creating duplicate subscriptions before each scan
|
||||
static void cancelWhenScanComplete(StreamSubscription subscription) {
|
||||
_scanSubscriptions.add(subscription);
|
||||
}
|
||||
|
||||
/// Sets the internal FlutterBlue log level
|
||||
static Future<void> setLogLevel(LogLevel level, {color = true}) async {
|
||||
// Nothing to implement
|
||||
return;
|
||||
}
|
||||
|
||||
static Future<void> turnOff({int timeout = 10}) async {
|
||||
await _initialize();
|
||||
await WinBle.updateBluetoothState(false);
|
||||
}
|
||||
|
||||
// TODO: need to test
|
||||
static Future<bool> get isOn async {
|
||||
await _initialize();
|
||||
return await WinBle.bleState.asBroadcastStream().first == BleState.On;
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
part of 'windows.dart';
|
||||
|
||||
String _hexEncode(List<int> numbers) {
|
||||
return numbers
|
||||
.map((n) => (n & 0xFF).toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
|
||||
List<int> _hexDecode(String hex) {
|
||||
List<int> numbers = [];
|
||||
for (int i = 0; i < hex.length; i += 2) {
|
||||
String hexPart = hex.substring(i, i + 2);
|
||||
int num = int.parse(hexPart, radix: 16);
|
||||
numbers.add(num);
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
int _compareAsciiLowerCase(String a, String b) {
|
||||
const int upperCaseA = 0x41;
|
||||
const int upperCaseZ = 0x5a;
|
||||
const int asciiCaseBit = 0x20;
|
||||
var defaultResult = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (i >= b.length) return 1;
|
||||
var aChar = a.codeUnitAt(i);
|
||||
var bChar = b.codeUnitAt(i);
|
||||
if (aChar == bChar) continue;
|
||||
var aLowerCase = aChar;
|
||||
var bLowerCase = bChar;
|
||||
// Upper case if ASCII letters.
|
||||
if (upperCaseA <= bChar && bChar <= upperCaseZ) {
|
||||
bLowerCase += asciiCaseBit;
|
||||
}
|
||||
if (upperCaseA <= aChar && aChar <= upperCaseZ) {
|
||||
aLowerCase += asciiCaseBit;
|
||||
}
|
||||
if (aLowerCase != bLowerCase) return (aLowerCase - bLowerCase).sign;
|
||||
if (defaultResult == 0) defaultResult = aChar - bChar;
|
||||
}
|
||||
if (b.length > a.length) return -1;
|
||||
return defaultResult.sign;
|
||||
}
|
||||
|
||||
// This is a reimplementation of BehaviorSubject from RxDart library.
|
||||
// It is essentially a stream but:
|
||||
// 1. we cache the latestValue of the stream
|
||||
// 2. the "latestValue" is re-emitted whenever the stream is listened to
|
||||
class _StreamController<T> {
|
||||
T latestValue;
|
||||
|
||||
final StreamController<T> _controller = StreamController<T>.broadcast();
|
||||
|
||||
_StreamController({required T initialValue})
|
||||
: this.latestValue = initialValue;
|
||||
|
||||
Stream<T> get stream => _controller.stream;
|
||||
|
||||
T get value => latestValue;
|
||||
|
||||
void add(T newValue) {
|
||||
latestValue = newValue;
|
||||
_controller.add(newValue);
|
||||
}
|
||||
|
||||
void listen(Function(T) onData,
|
||||
{Function? onError, void Function()? onDone, bool? cancelOnError}) {
|
||||
onData(latestValue);
|
||||
_controller.stream.listen(onData,
|
||||
onError: onError, onDone: onDone, cancelOnError: cancelOnError);
|
||||
}
|
||||
|
||||
Future<void> close() {
|
||||
return _controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
// imediately starts listening to a broadcast stream and
|
||||
// buffering it in a new single-subscription stream
|
||||
class _BufferStream<T> {
|
||||
final Stream<T> _inputStream;
|
||||
late final StreamSubscription? _subscription;
|
||||
late final StreamController<T> _controller;
|
||||
late bool hasReceivedValue = false;
|
||||
|
||||
_BufferStream.listen(this._inputStream) {
|
||||
_controller = StreamController<T>(
|
||||
onCancel: () {
|
||||
_subscription?.cancel();
|
||||
},
|
||||
onPause: () {
|
||||
_subscription?.pause();
|
||||
},
|
||||
onResume: () {
|
||||
_subscription?.resume();
|
||||
},
|
||||
onListen: () {}, // inputStream is already listened to
|
||||
);
|
||||
|
||||
// immediately start listening to the inputStream
|
||||
_subscription = _inputStream.listen(
|
||||
(data) {
|
||||
hasReceivedValue = true;
|
||||
_controller.add(data);
|
||||
},
|
||||
onError: (e) {
|
||||
_controller.addError(e);
|
||||
},
|
||||
onDone: () {
|
||||
_controller.close();
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
void close() {
|
||||
_subscription?.cancel();
|
||||
_controller.close();
|
||||
}
|
||||
|
||||
Stream<T> get stream async* {
|
||||
yield* _controller.stream;
|
||||
}
|
||||
}
|
||||
|
||||
// helper for 'doOnDone' method for streams.
|
||||
class _OnDoneTransformer<T> extends StreamTransformerBase<T, T> {
|
||||
final Function onDone;
|
||||
|
||||
_OnDoneTransformer({required this.onDone});
|
||||
|
||||
@override
|
||||
Stream<T> bind(Stream<T> stream) {
|
||||
if (stream.isBroadcast) {
|
||||
return _bindBroadcast(stream);
|
||||
}
|
||||
return _bindSingleSubscription(stream);
|
||||
}
|
||||
|
||||
Stream<T> _bindSingleSubscription(Stream<T> stream) {
|
||||
StreamController<T>? controller;
|
||||
StreamSubscription<T>? subscription;
|
||||
|
||||
controller = StreamController<T>(
|
||||
onListen: () {
|
||||
subscription = stream.listen(
|
||||
controller?.add,
|
||||
onError: controller?.addError,
|
||||
onDone: () {
|
||||
onDone();
|
||||
controller?.close();
|
||||
},
|
||||
);
|
||||
},
|
||||
onPause: ([Future<dynamic>? resumeSignal]) {
|
||||
subscription?.pause(resumeSignal);
|
||||
},
|
||||
onResume: () {
|
||||
subscription?.resume();
|
||||
},
|
||||
onCancel: () {
|
||||
return subscription?.cancel();
|
||||
},
|
||||
sync: true,
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Stream<T> _bindBroadcast(Stream<T> stream) {
|
||||
StreamController<T>? controller;
|
||||
StreamSubscription<T>? subscription;
|
||||
|
||||
controller = StreamController<T>.broadcast(
|
||||
onListen: () {
|
||||
subscription = stream
|
||||
.listen(controller?.add, onError: controller?.addError, onDone: () {
|
||||
onDone();
|
||||
controller?.close();
|
||||
});
|
||||
},
|
||||
onCancel: () {
|
||||
subscription?.cancel();
|
||||
},
|
||||
sync: true,
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
}
|
||||
|
||||
// helper for 'doOnCancel' method for streams.
|
||||
class _OnCancelTransformer<T> extends StreamTransformerBase<T, T> {
|
||||
final Function onCancel;
|
||||
|
||||
_OnCancelTransformer({required this.onCancel});
|
||||
|
||||
@override
|
||||
Stream<T> bind(Stream<T> stream) {
|
||||
if (stream.isBroadcast) {
|
||||
return _bindBroadcast(stream);
|
||||
}
|
||||
|
||||
return _bindSingleSubscription(stream);
|
||||
}
|
||||
|
||||
Stream<T> _bindSingleSubscription(Stream<T> stream) {
|
||||
StreamController<T>? controller;
|
||||
StreamSubscription<T>? subscription;
|
||||
|
||||
controller = StreamController<T>(
|
||||
onListen: () {
|
||||
subscription = stream.listen(
|
||||
controller?.add,
|
||||
onError: (Object error) {
|
||||
controller?.addError(error);
|
||||
controller?.close();
|
||||
},
|
||||
onDone: controller?.close,
|
||||
);
|
||||
},
|
||||
onPause: ([Future<dynamic>? resumeSignal]) {
|
||||
subscription?.pause(resumeSignal);
|
||||
},
|
||||
onResume: () {
|
||||
subscription?.resume();
|
||||
},
|
||||
onCancel: () {
|
||||
onCancel();
|
||||
return subscription?.cancel();
|
||||
},
|
||||
sync: true,
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Stream<T> _bindBroadcast(Stream<T> stream) {
|
||||
StreamController<T>? controller;
|
||||
StreamSubscription<T>? subscription;
|
||||
|
||||
controller = StreamController<T>.broadcast(
|
||||
onListen: () {
|
||||
subscription = stream.listen(
|
||||
controller?.add,
|
||||
onError: (Object error) {
|
||||
controller?.addError(error);
|
||||
controller?.close();
|
||||
},
|
||||
onDone: controller?.close,
|
||||
);
|
||||
},
|
||||
onCancel: () {
|
||||
onCancel();
|
||||
subscription?.cancel();
|
||||
},
|
||||
sync: true,
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for 'newStreamWithInitialValue' method for streams.
|
||||
class _NewStreamWithInitialValueTransformer<T>
|
||||
extends StreamTransformerBase<T, T> {
|
||||
final T initialValue;
|
||||
|
||||
_NewStreamWithInitialValueTransformer(this.initialValue);
|
||||
|
||||
@override
|
||||
Stream<T> bind(Stream<T> stream) {
|
||||
return _bindSingleSubscription(stream);
|
||||
}
|
||||
|
||||
Stream<T> _bindSingleSubscription(Stream<T> stream) {
|
||||
StreamController<T>? controller;
|
||||
StreamSubscription<T>? subscription;
|
||||
|
||||
controller = StreamController<T>(
|
||||
onListen: () {
|
||||
// Emit the initial value
|
||||
controller?.add(initialValue);
|
||||
|
||||
subscription = stream.listen(
|
||||
controller?.add,
|
||||
onError: (Object error) {
|
||||
controller?.addError(error);
|
||||
controller?.close();
|
||||
},
|
||||
onDone: controller?.close,
|
||||
);
|
||||
},
|
||||
onPause: ([Future<dynamic>? resumeSignal]) {
|
||||
subscription?.pause(resumeSignal);
|
||||
},
|
||||
onResume: () {
|
||||
subscription?.resume();
|
||||
},
|
||||
onCancel: () {
|
||||
return subscription?.cancel();
|
||||
},
|
||||
sync: true,
|
||||
);
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
}
|
||||
|
||||
extension _StreamDoOnDone<T> on Stream<T> {
|
||||
// ignore: unused_element
|
||||
Stream<T> doOnDone(void Function() onDone) {
|
||||
return transform(_OnDoneTransformer(onDone: onDone));
|
||||
}
|
||||
}
|
||||
|
||||
extension _StreamDoOnCancel<T> on Stream<T> {
|
||||
// ignore: unused_element
|
||||
Stream<T> doOnCancel(void Function() onCancel) {
|
||||
return transform(_OnCancelTransformer(onCancel: onCancel));
|
||||
}
|
||||
}
|
||||
|
||||
extension _StreamNewStreamWithInitialValue<T> on Stream<T> {
|
||||
Stream<T> newStreamWithInitialValue(T initialValue) {
|
||||
return transform(_NewStreamWithInitialValueTransformer(initialValue));
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: unused_element
|
||||
Stream<T> _mergeStreams<T>(List<Stream<T>> streams) {
|
||||
StreamController<T> controller = StreamController<T>();
|
||||
List<StreamSubscription<T>> subscriptions = [];
|
||||
|
||||
void handleData(T data) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
void handleError(Object error, StackTrace stackTrace) {
|
||||
if (!controller.isClosed) {
|
||||
controller.addError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void handleDone() {
|
||||
if (subscriptions.every((s) => s.isPaused)) {
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
|
||||
void subscribeToStream(Stream<T> stream) {
|
||||
final s =
|
||||
stream.listen(handleData, onError: handleError, onDone: handleDone);
|
||||
subscriptions.add(s);
|
||||
}
|
||||
|
||||
streams.forEach(subscribeToStream);
|
||||
|
||||
controller.onCancel = () async {
|
||||
await Future.wait(subscriptions.map((s) => s.cancel()));
|
||||
};
|
||||
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
// dart is single threaded, but still has task switching.
|
||||
// this mutex lets a single task through at a time.
|
||||
class _Mutex {
|
||||
final StreamController _controller = StreamController.broadcast();
|
||||
int current = 0;
|
||||
int issued = 0;
|
||||
|
||||
Future<void> take() async {
|
||||
int mine = issued;
|
||||
issued++;
|
||||
// tasks are executed in the same order they call take()
|
||||
while (mine != current) {
|
||||
await _controller.stream.first; // wait
|
||||
}
|
||||
}
|
||||
|
||||
void give() {
|
||||
current++;
|
||||
_controller.add(null); // release waiting tasks
|
||||
}
|
||||
}
|
||||
|
||||
// Create mutexes in a parrallel-safe way,
|
||||
class _MutexFactory {
|
||||
static final _Mutex _global = _Mutex();
|
||||
static final Map<String, _Mutex> _all = {};
|
||||
|
||||
static Future<_Mutex> getMutexForKey(String key) async {
|
||||
_Mutex? value;
|
||||
await _global.take();
|
||||
{
|
||||
_all[key] ??= _Mutex();
|
||||
value = _all[key];
|
||||
}
|
||||
_global.give();
|
||||
return value!;
|
||||
}
|
||||
}
|
||||
|
||||
String _black(String s) {
|
||||
// Use ANSI escape codes
|
||||
return '\x1B[1;30m$s\x1B[0m';
|
||||
}
|
||||
|
||||
// ignore: unused_element
|
||||
String _green(String s) {
|
||||
// Use ANSI escape codes
|
||||
return '\x1B[1;32m$s\x1B[0m';
|
||||
}
|
||||
|
||||
String _magenta(String s) {
|
||||
// Use ANSI escape codes
|
||||
return '\x1B[1;35m$s\x1B[0m';
|
||||
}
|
||||
|
||||
String _brown(String s) {
|
||||
// Use ANSI escape codes
|
||||
return '\x1B[1;33m$s\x1B[0m';
|
||||
}
|
||||
|
||||
extension Boolean2ConnectionState on bool {
|
||||
BluetoothConnectionState get isConnected {
|
||||
if (this) return BluetoothConnectionState.connected;
|
||||
return BluetoothConnectionState.disconnected;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as FBP;
|
||||
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
|
||||
part 'bluetooth_characteristic_windows.dart';
|
||||
part 'bluetooth_device_windows.dart';
|
||||
part 'bluetooth_service_windows.dart';
|
||||
part 'flutter_blue_plus_windows.dart';
|
||||
part 'util.dart';
|
||||
@@ -1,141 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as FBP;
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
|
||||
class FlutterBluePlus {
|
||||
static Future<void> startScan({
|
||||
List<Guid> withServices = const [],
|
||||
List<String> withRemoteIds = const [],
|
||||
List<String> withNames = const [],
|
||||
List<String> withKeywords = const [],
|
||||
List<MsdFilter> withMsd = const [],
|
||||
List<ServiceDataFilter> withServiceData = const [],
|
||||
Duration? timeout,
|
||||
Duration? removeIfGone,
|
||||
bool continuousUpdates = false,
|
||||
int continuousDivisor = 1,
|
||||
bool oneByOne = false,
|
||||
bool androidLegacy = false,
|
||||
AndroidScanMode androidScanMode = AndroidScanMode.lowLatency,
|
||||
bool androidUsesFineLocation = false,
|
||||
List<Guid> webOptionalServices = const [],
|
||||
}) async {
|
||||
if (!kIsWeb && Platform.isWindows) {
|
||||
return await FlutterBluePlusWindows.startScan(
|
||||
withServices: withServices,
|
||||
withRemoteIds: withRemoteIds,
|
||||
withNames: withNames,
|
||||
withKeywords: withKeywords,
|
||||
withMsd: withMsd,
|
||||
withServiceData: withServiceData,
|
||||
timeout: timeout,
|
||||
removeIfGone: removeIfGone,
|
||||
continuousUpdates: continuousUpdates,
|
||||
continuousDivisor: continuousDivisor,
|
||||
oneByOne: oneByOne,
|
||||
androidLegacy: androidLegacy,
|
||||
androidScanMode: androidScanMode,
|
||||
androidUsesFineLocation: androidUsesFineLocation,
|
||||
);
|
||||
}
|
||||
|
||||
return await FBP.FlutterBluePlus.startScan(
|
||||
withServices: withServices,
|
||||
withRemoteIds: withRemoteIds,
|
||||
withNames: withNames,
|
||||
withKeywords: withKeywords,
|
||||
withMsd: withMsd,
|
||||
withServiceData: withServiceData,
|
||||
timeout: timeout,
|
||||
removeIfGone: removeIfGone,
|
||||
continuousUpdates: continuousUpdates,
|
||||
continuousDivisor: continuousDivisor,
|
||||
oneByOne: oneByOne,
|
||||
androidLegacy: androidLegacy,
|
||||
androidScanMode: androidScanMode,
|
||||
androidUsesFineLocation: androidUsesFineLocation,
|
||||
webOptionalServices: webOptionalServices,
|
||||
);
|
||||
}
|
||||
|
||||
static Stream<BluetoothAdapterState> get adapterState {
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.adapterState;
|
||||
return FBP.FlutterBluePlus.adapterState;
|
||||
}
|
||||
|
||||
static Stream<List<ScanResult>> get scanResults {
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.scanResults;
|
||||
return FBP.FlutterBluePlus.scanResults;
|
||||
}
|
||||
|
||||
static bool get isScanningNow {
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.isScanningNow;
|
||||
return FBP.FlutterBluePlus.isScanningNow;
|
||||
}
|
||||
|
||||
static Stream<bool> get isScanning {
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.isScanning;
|
||||
return FBP.FlutterBluePlus.isScanning;
|
||||
}
|
||||
|
||||
static Future<void> stopScan() async {
|
||||
if (!kIsWeb && Platform.isWindows) return await FlutterBluePlusWindows.stopScan();
|
||||
return await FBP.FlutterBluePlus.stopScan();
|
||||
}
|
||||
|
||||
static Future<void> setLogLevel(LogLevel level, {color = true}) async {
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.setLogLevel(level, color: color);
|
||||
return FBP.FlutterBluePlus.setLogLevel(level, color: color);
|
||||
}
|
||||
|
||||
/// TODO: need to verify
|
||||
static LogLevel get logLevel => FBP.FlutterBluePlus.logLevel;
|
||||
|
||||
static Future<void> setOptions({bool restoreState = false, bool showPowerAlert = true}) async {
|
||||
if (!kIsWeb && Platform.isWindows) return;
|
||||
FBP.FlutterBluePlus.setOptions(restoreState: restoreState, showPowerAlert: showPowerAlert);
|
||||
}
|
||||
|
||||
static Future<bool> get isSupported async {
|
||||
if (!kIsWeb && Platform.isWindows) return await FlutterBluePlusWindows.isSupported;
|
||||
return await FBP.FlutterBluePlus.isSupported;
|
||||
}
|
||||
|
||||
static Future<String> get adapterName async {
|
||||
if (!kIsWeb && Platform.isWindows) return await FlutterBluePlusWindows.adapterName;
|
||||
return await FBP.FlutterBluePlus.adapterName;
|
||||
}
|
||||
|
||||
static Future<void> turnOn({int timeout = 60}) async {
|
||||
if (!kIsWeb && Platform.isWindows) return await FlutterBluePlusWindows.turnOn(timeout: timeout);
|
||||
return await FBP.FlutterBluePlus.turnOn(timeout: timeout);
|
||||
}
|
||||
|
||||
static List<FBP.BluetoothDevice> get connectedDevices {
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.connectedDevices;
|
||||
return FBP.FlutterBluePlus.connectedDevices;
|
||||
}
|
||||
|
||||
static Future<List<FBP.BluetoothDevice>> systemDevices(List<Guid> withServices) async {
|
||||
//TODO: connected devices => system devices
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.connectedDevices;
|
||||
return await FBP.FlutterBluePlus.systemDevices(withServices);
|
||||
}
|
||||
|
||||
static Future<PhySupport> getPhySupport() {
|
||||
return FBP.FlutterBluePlus.getPhySupport();
|
||||
}
|
||||
|
||||
static Future<List<FBP.BluetoothDevice>> get bondedDevices async {
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.connectedDevices;
|
||||
return await FBP.FlutterBluePlus.bondedDevices;
|
||||
}
|
||||
|
||||
static void cancelWhenScanComplete(StreamSubscription subscription) {
|
||||
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.cancelWhenScanComplete(subscription);
|
||||
return FBP.FlutterBluePlus.cancelWhenScanComplete(subscription);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export 'flutter_blue_plus_wrapper.dart';
|
||||
@@ -1,13 +0,0 @@
|
||||
name: flutter_blue_plus_windows
|
||||
description: Flutter blue plus for Windows
|
||||
version: 1.26.1
|
||||
repository: https://github.com/chan150/flutter_blue_plus_windows
|
||||
#publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter_blue_plus: ">=1.32.4"
|
||||
win_ble: ">=1.1.1"
|
||||
stream_with_value: ">=0.5.0"
|
||||
@@ -1,13 +1,11 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
|
||||
class BleUuid {
|
||||
static final ZWIFT_CUSTOM_SERVICE_UUID = Guid("00000001-19CA-4651-86E5-FA29DCDD09D1");
|
||||
static final ZWIFT_RIDE_CUSTOM_SERVICE_UUID = Guid("0000fc82-0000-1000-8000-00805f9b34fb");
|
||||
static final ZWIFT_ASYNC_CHARACTERISTIC_UUID = Guid("00000002-19CA-4651-86E5-FA29DCDD09D1");
|
||||
static final ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = Guid("00000003-19CA-4651-86E5-FA29DCDD09D1");
|
||||
static final ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = Guid("00000004-19CA-4651-86E5-FA29DCDD09D1");
|
||||
static final ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
|
||||
static final ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb".toLowerCase();
|
||||
static final ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
|
||||
static final ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
|
||||
static final ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
|
||||
}
|
||||
|
||||
class Constants {
|
||||
@@ -17,6 +15,10 @@ class Constants {
|
||||
static const RC1_LEFT_SIDE = 0x03;
|
||||
static const RC1_RIGHT_SIDE = 0x02;
|
||||
|
||||
// Zwift Ride
|
||||
static const RIDE_RIGHT_SIDE = 0x07;
|
||||
static const RIDE_LEFT_SIDE = 0x08;
|
||||
|
||||
// Zwift Click = BC1
|
||||
static const BC1 = 0x09;
|
||||
|
||||
@@ -35,7 +37,7 @@ class Constants {
|
||||
// not figured out the protobuf type this really is, the content is just two varints.
|
||||
static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55;
|
||||
static const int PLAY_NOTIFICATION_MESSAGE_TYPE = 7;
|
||||
static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35;
|
||||
static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35; // 0x23
|
||||
|
||||
// see this if connected to Core then Zwift connects to it. just one byte
|
||||
static const DISCONNECT_MESSAGE_TYPE = 0xFE;
|
||||
@@ -43,9 +45,10 @@ class Constants {
|
||||
|
||||
enum DeviceType {
|
||||
click,
|
||||
ride,
|
||||
playLeft,
|
||||
playRight;
|
||||
playRight,
|
||||
rideRight,
|
||||
rideLeft;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -61,6 +64,10 @@ enum DeviceType {
|
||||
return DeviceType.playLeft;
|
||||
case Constants.RC1_RIGHT_SIDE:
|
||||
return DeviceType.playRight;
|
||||
case Constants.RIDE_RIGHT_SIDE:
|
||||
return DeviceType.rideRight;
|
||||
case Constants.RIDE_LEFT_SIDE:
|
||||
return DeviceType.rideLeft;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
137
lib/bluetooth/connection.dart
Normal file
137
lib/bluetooth/connection.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'dart:async';
|
||||
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';
|
||||
|
||||
import '../bluetooth/ble.dart';
|
||||
import 'devices/base_device.dart';
|
||||
import 'messages/notification.dart';
|
||||
|
||||
class Connection {
|
||||
final devices = <BaseDevice>[];
|
||||
var androidNotificationsSetup = false;
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
|
||||
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => _actionStreams.stream;
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<BleConnectionUpdate>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
Stream<BaseDevice> get connectionStream => _connectionStreams.stream;
|
||||
|
||||
final _lastScanResult = <BleDevice>[];
|
||||
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
|
||||
final ValueNotifier<bool> isScanning = ValueNotifier(false);
|
||||
|
||||
void initialize() {
|
||||
UniversalBle.onScanResult = (result) {
|
||||
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
|
||||
_lastScanResult.add(result);
|
||||
_actionStreams.add(LogNotification('Found new devices: ${result.name}'));
|
||||
final scanResult = BaseDevice.fromScanResult(result);
|
||||
if (scanResult != null) {
|
||||
_addDevices([scanResult]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
|
||||
final device = devices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
|
||||
if (device == null) {
|
||||
_actionStreams.add(LogNotification('Device not found: $deviceId'));
|
||||
return;
|
||||
} else {
|
||||
device.processCharacteristic(characteristicUuid, value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> performScanning() async {
|
||||
isScanning.value = true;
|
||||
|
||||
// does not work on web, may not work on Windows
|
||||
if (!kIsWeb && !Platform.isWindows) {
|
||||
UniversalBle.getSystemDevices(
|
||||
withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID],
|
||||
).then((devices) {
|
||||
final baseDevices = devices.mapNotNull(BaseDevice.fromScanResult).toList();
|
||||
if (baseDevices.isNotEmpty) {
|
||||
_addDevices(baseDevices);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await UniversalBle.startScan(
|
||||
scanFilter: ScanFilter(withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID]),
|
||||
platformConfig: PlatformConfig(web: WebOptions(optionalServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID])),
|
||||
);
|
||||
Future.delayed(Duration(seconds: 30)).then((_) {
|
||||
if (isScanning.value) {
|
||||
UniversalBle.stopScan();
|
||||
isScanning.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _addDevices(List<BaseDevice> dev) {
|
||||
final newDevices = dev.where((device) => !devices.contains(device)).toList();
|
||||
devices.addAll(newDevices);
|
||||
|
||||
for (final device in newDevices) {
|
||||
_connect(device).then((_) {});
|
||||
}
|
||||
|
||||
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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect(BaseDevice bleDevice) async {
|
||||
try {
|
||||
final actionSubscription = bleDevice.actionStream.listen((data) {
|
||||
_actionStreams.add(data);
|
||||
});
|
||||
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((
|
||||
state,
|
||||
) async {
|
||||
bleDevice.isConnected = state.isConnected;
|
||||
_connectionStreams.add(bleDevice);
|
||||
});
|
||||
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
|
||||
|
||||
await bleDevice.connect();
|
||||
|
||||
_streamSubscriptions[bleDevice] = actionSubscription;
|
||||
} catch (e, backtrace) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
UniversalBle.stopScan();
|
||||
isScanning.value = false;
|
||||
for (var device in devices) {
|
||||
_streamSubscriptions[device]?.cancel();
|
||||
_streamSubscriptions.remove(device);
|
||||
_connectionSubscriptions[device]?.cancel();
|
||||
_connectionSubscriptions.remove(device);
|
||||
UniversalBle.disconnect(device.device.deviceId);
|
||||
}
|
||||
_lastScanResult.clear();
|
||||
hasDevices.value = false;
|
||||
devices.clear();
|
||||
}
|
||||
}
|
||||
218
lib/bluetooth/devices/base_device.dart
Normal file
218
lib/bluetooth/devices/base_device.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/ble.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_play.dart';
|
||||
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
|
||||
import 'package:swift_control/utils/crypto/local_key_provider.dart';
|
||||
import 'package:swift_control/utils/crypto/zap_crypto.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../../utils/crypto/encryption_utils.dart';
|
||||
import '../messages/notification.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
BaseDevice(this.scanResult);
|
||||
|
||||
final zapEncryption = ZapCrypto(LocalKeyProvider());
|
||||
|
||||
bool isConnected = false;
|
||||
|
||||
bool supportsEncryption = true;
|
||||
|
||||
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
|
||||
final device = switch (scanResult.name) {
|
||||
'Zwift Ride' => ZwiftRide(scanResult),
|
||||
'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) {
|
||||
final manufacturerData = scanResult.manufacturerDataList;
|
||||
final data = manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
|
||||
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final type = DeviceType.fromManufacturerData(data.first);
|
||||
return switch (type) {
|
||||
DeviceType.click => ZwiftClick(scanResult),
|
||||
DeviceType.playRight => ZwiftPlay(scanResult),
|
||||
DeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
DeviceType.rideRight => ZwiftRide(scanResult),
|
||||
DeviceType.rideLeft => ZwiftRide(scanResult),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BaseDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return runtimeType.toString();
|
||||
}
|
||||
|
||||
BleDevice get device => scanResult;
|
||||
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
|
||||
|
||||
Future<void> connect() async {
|
||||
await UniversalBle.connect(device.deviceId, connectionTimeout: const Duration(seconds: 3));
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
//await UniversalBle.requestMtu(device.deviceId, 256);
|
||||
}
|
||||
|
||||
final services = await UniversalBle.discoverServices(device.deviceId);
|
||||
await _handleServices(services);
|
||||
}
|
||||
|
||||
Future<void> _handleServices(List<BleService> services) async {
|
||||
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}');
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
);
|
||||
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
final syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
|
||||
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
|
||||
throw Exception('Characteristics not found');
|
||||
}
|
||||
|
||||
await UniversalBle.setNotifiable(
|
||||
device.deviceId,
|
||||
customService.uuid,
|
||||
asyncCharacteristic.uuid,
|
||||
BleInputProperty.notification,
|
||||
);
|
||||
await UniversalBle.setNotifiable(
|
||||
device.deviceId,
|
||||
customService.uuid,
|
||||
syncTxCharacteristic.uuid,
|
||||
BleInputProperty.indication,
|
||||
);
|
||||
|
||||
await _setupHandshake(syncRxCharacteristic);
|
||||
}
|
||||
|
||||
Future<void> _setupHandshake(BleCharacteristic syncRxCharacteristic) async {
|
||||
if (supportsEncryption) {
|
||||
await UniversalBle.writeValue(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic.uuid,
|
||||
Uint8List.fromList([
|
||||
...Constants.RIDE_ON,
|
||||
...Constants.REQUEST_START,
|
||||
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
|
||||
]),
|
||||
BleOutputProperty.withoutResponse,
|
||||
);
|
||||
} else {
|
||||
await UniversalBle.writeValue(
|
||||
device.deviceId,
|
||||
customServiceId,
|
||||
syncRxCharacteristic.uuid,
|
||||
Constants.RIDE_ON,
|
||||
BleOutputProperty.withoutResponse,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (kDebugMode && false) {
|
||||
print('Received $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
print('Received $characteristic: ${String.fromCharCodes(bytes)}');
|
||||
}
|
||||
|
||||
if (bytes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (bytes.startsWith(startCommand)) {
|
||||
_processDevicePublicKeyResponse(bytes);
|
||||
} else if (bytes.startsWith(Constants.RIDE_ON)) {
|
||||
//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()));
|
||||
}
|
||||
}
|
||||
|
||||
void _processDevicePublicKeyResponse(Uint8List bytes) {
|
||||
final devicePublicKeyBytes = bytes.sublist(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
|
||||
zapEncryption.initialise(devicePublicKeyBytes);
|
||||
if (kDebugMode) {
|
||||
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
|
||||
}
|
||||
}
|
||||
|
||||
void _processData(Uint8List bytes) {
|
||||
int type;
|
||||
Uint8List message;
|
||||
|
||||
if (supportsEncryption) {
|
||||
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
|
||||
final payload = bytes.sublist(4);
|
||||
|
||||
final data = zapEncryption.decrypt(counter, payload);
|
||||
type = data[0];
|
||||
message = data.sublist(1);
|
||||
} else {
|
||||
type = bytes[0];
|
||||
message = bytes.sublist(1);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Constants.EMPTY_MESSAGE_TYPE:
|
||||
//print("Empty Message"); // expected when nothing happening
|
||||
break;
|
||||
case Constants.BATTERY_LEVEL_TYPE:
|
||||
//print("Battery level update: $message");
|
||||
break;
|
||||
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE: // untested
|
||||
processClickNotification(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void processClickNotification(Uint8List message);
|
||||
}
|
||||
26
lib/bluetooth/devices/zwift_click.dart
Normal file
26
lib/bluetooth/devices/zwift_click.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
|
||||
import '../messages/click_notification.dart';
|
||||
|
||||
class ZwiftClick extends BaseDevice {
|
||||
ZwiftClick(super.scanResult);
|
||||
|
||||
ClickNotification? _lastClickNotification;
|
||||
|
||||
@override
|
||||
void processClickNotification(Uint8List message) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,31 @@
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:swift_control/utils/devices/zwift_click.dart';
|
||||
import 'package:swift_control/utils/messages/controller_notification.dart';
|
||||
import 'package:swift_control/bluetooth/devices/base_device.dart';
|
||||
import 'package:swift_control/bluetooth/messages/play_notification.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../ble.dart';
|
||||
|
||||
class ZwiftPlay extends ZwiftClick {
|
||||
class ZwiftPlay extends BaseDevice {
|
||||
ZwiftPlay(super.scanResult);
|
||||
|
||||
ControllerNotification? _lastControllerNotification;
|
||||
PlayNotification? _lastControllerNotification;
|
||||
|
||||
@override
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY;
|
||||
|
||||
@override
|
||||
void processClickNotification(Uint8List message) {
|
||||
final ControllerNotification clickNotification = ControllerNotification(message);
|
||||
final PlayNotification clickNotification = PlayNotification(message);
|
||||
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
|
||||
_lastControllerNotification = clickNotification;
|
||||
actionStreamInternal.add(clickNotification);
|
||||
|
||||
if (clickNotification.rightPad && clickNotification.analogLR.abs() == 100) {
|
||||
if ((clickNotification.rightPad && clickNotification.buttonShift) ||
|
||||
(clickNotification.rightPad && clickNotification.analogLR.abs() == 100)) {
|
||||
actionHandler.increaseGear();
|
||||
} else if (!clickNotification.rightPad && clickNotification.analogLR.abs() == 100) {
|
||||
} else if ((!clickNotification.rightPad && clickNotification.buttonShift) ||
|
||||
(!clickNotification.rightPad && clickNotification.analogLR.abs() == 100)) {
|
||||
actionHandler.decreaseGear();
|
||||
}
|
||||
if (clickNotification.rightPad) {
|
||||
46
lib/bluetooth/devices/zwift_ride.dart
Normal file
46
lib/bluetooth/devices/zwift_ride.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
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 '../ble.dart';
|
||||
|
||||
class ZwiftRide extends BaseDevice {
|
||||
ZwiftRide(super.scanResult);
|
||||
|
||||
@override
|
||||
String get customServiceId => BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID;
|
||||
|
||||
@override
|
||||
bool get supportsEncryption => false;
|
||||
|
||||
RideNotification? _lastControllerNotification;
|
||||
|
||||
@override
|
||||
void processClickNotification(Uint8List message) {
|
||||
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.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);
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/utils/messages/notification.dart';
|
||||
|
||||
import '../../protocol/zwift.pb.dart';
|
||||
import '../protocol/zwift.pb.dart';
|
||||
import 'notification.dart';
|
||||
|
||||
class ClickNotification extends BaseNotification {
|
||||
static const int BTN_PRESSED = 0;
|
||||
@@ -1,16 +1,15 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/utils/messages/notification.dart';
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
|
||||
|
||||
import '../../protocol/zwift.pb.dart';
|
||||
|
||||
class ControllerNotification extends BaseNotification {
|
||||
class PlayNotification extends BaseNotification {
|
||||
static const int BTN_PRESSED = 0;
|
||||
|
||||
late bool rightPad, buttonY, buttonZ, buttonA, buttonB, buttonOn, buttonShift;
|
||||
late int analogLR, analogUD;
|
||||
|
||||
ControllerNotification(Uint8List message) {
|
||||
PlayNotification(Uint8List message) {
|
||||
final status = PlayKeyPadStatus.fromBuffer(message);
|
||||
|
||||
rightPad = status.rightPad.value == BTN_PRESSED;
|
||||
@@ -41,7 +40,7 @@ class ControllerNotification extends BaseNotification {
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ControllerNotification &&
|
||||
other is PlayNotification &&
|
||||
runtimeType == other.runtimeType &&
|
||||
rightPad == other.rightPad &&
|
||||
buttonY == other.buttonY &&
|
||||
140
lib/bluetooth/messages/ride_notification.dart
Normal file
140
lib/bluetooth/messages/ride_notification.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swift_control/bluetooth/messages/notification.dart';
|
||||
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
|
||||
|
||||
enum _RideButtonMask {
|
||||
LEFT_BTN(0x00001),
|
||||
UP_BTN(0x00002),
|
||||
RIGHT_BTN(0x00004),
|
||||
DOWN_BTN(0x00008),
|
||||
|
||||
A_BTN(0x00010),
|
||||
B_BTN(0x00020),
|
||||
Y_BTN(0x00040),
|
||||
Z_BTN(0x00080),
|
||||
|
||||
SHFT_UP_L_BTN(0x00100),
|
||||
SHFT_DN_L_BTN(0x00200),
|
||||
SHFT_UP_R_BTN(0x01000),
|
||||
SHFT_DN_R_BTN(0x02000),
|
||||
|
||||
POWERUP_L_BTN(0x00400),
|
||||
POWERUP_R_BTN(0x04000),
|
||||
ONOFF_L_BTN(0x00800),
|
||||
ONOFF_R_BTN(0x08000);
|
||||
|
||||
final int mask;
|
||||
|
||||
const _RideButtonMask(this.mask);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
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;
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -1,18 +1,33 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:swift_control/pages/requirements.dart';
|
||||
import 'package:swift_control/theme.dart';
|
||||
import 'package:swift_control/utils/connection.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 'bluetooth/connection.dart';
|
||||
import 'utils/actions/base_actions.dart';
|
||||
|
||||
final connection = Connection();
|
||||
final actionHandler = ActionHandler();
|
||||
late final BaseActions actionHandler;
|
||||
final accessibilityHandler = Accessibility();
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
final settings = Settings();
|
||||
|
||||
void main() {
|
||||
if (kIsWeb) {
|
||||
actionHandler = StubActions();
|
||||
} else if (Platform.isAndroid) {
|
||||
actionHandler = AndroidActions();
|
||||
} else {
|
||||
actionHandler = DesktopActions();
|
||||
}
|
||||
|
||||
runApp(const SwiftPlayApp());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
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/devices/base_device.dart';
|
||||
import 'package:swift_control/pages/touch_area.dart';
|
||||
import 'package:swift_control/widgets/logviewer.dart';
|
||||
|
||||
import '../bluetooth/devices/base_device.dart';
|
||||
import '../widgets/menu.dart';
|
||||
|
||||
class DevicePage extends StatefulWidget {
|
||||
@@ -46,20 +49,50 @@ class _DevicePageState extends State<DevicePage> {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('SwiftControl'),
|
||||
actions: [MenuButton()],
|
||||
actions: buildMenuButtons(),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Text(
|
||||
'Devices:\n${connection.devices.joinToString(separator: '\n', transform: (it) {
|
||||
return "${it.device.platformName}: ${it.device.isConnected ? 'Connected' : 'Not connected'}";
|
||||
return "${it.device.name}: ${it.isConnected ? 'Connected' : 'Not connected'}";
|
||||
})}',
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Customize touch areas (optional)'),
|
||||
),
|
||||
],
|
||||
Expanded(child: LogViewer()),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
|
||||
@@ -28,14 +29,16 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
|
||||
// call after first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
// add more delay due tu CBManagerStateUnknown
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
settings.init().then((_) {
|
||||
if (!kIsWeb && Platform.isMacOS) {
|
||||
// add more delay due tu CBManagerStateUnknown
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
_reloadRequirements();
|
||||
});
|
||||
} else {
|
||||
_reloadRequirements();
|
||||
});
|
||||
} else {
|
||||
_reloadRequirements();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
connection.hasDevices.addListener(() {
|
||||
@@ -64,7 +67,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
appBar: AppBar(
|
||||
title: Text('SwiftControl'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
actions: [MenuButton()],
|
||||
actions: buildMenuButtons(),
|
||||
),
|
||||
body:
|
||||
_requirements.isEmpty
|
||||
@@ -83,7 +86,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
}
|
||||
: null,
|
||||
onStepTapped: (step) {
|
||||
if (_requirements[step].status) {
|
||||
if (_requirements[step].status && _requirements[step] is! KeymapRequirement) {
|
||||
return;
|
||||
}
|
||||
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
|
||||
@@ -100,16 +103,20 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
|
||||
.mapIndexed(
|
||||
(index, req) => Step(
|
||||
title: Text(req.name),
|
||||
content:
|
||||
(index == _currentStep
|
||||
? req.build(context, () {
|
||||
_reloadRequirements();
|
||||
})
|
||||
: null) ??
|
||||
ElevatedButton(
|
||||
onPressed: req.status ? null : () => _callRequirement(req),
|
||||
child: Text(req.name),
|
||||
),
|
||||
content: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
alignment: Alignment.centerLeft,
|
||||
child:
|
||||
(index == _currentStep
|
||||
? req.build(context, () {
|
||||
_reloadRequirements();
|
||||
})
|
||||
: null) ??
|
||||
ElevatedButton(
|
||||
onPressed: req.status ? null : () => _callRequirement(req),
|
||||
child: Text(req.name),
|
||||
),
|
||||
),
|
||||
state: req.status ? StepState.complete : StepState.indexed,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/ble.dart';
|
||||
import 'package:swift_control/widgets/small_progress_indicator.dart';
|
||||
|
||||
import '../widgets/logviewer.dart';
|
||||
@@ -17,73 +13,28 @@ class ScanWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ScanWidgetState extends State<ScanWidget> {
|
||||
bool _isScanning = false;
|
||||
late StreamSubscription<bool> _isScanningSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
connection.startScanning();
|
||||
connection.initialize();
|
||||
|
||||
_isScanningSubscription = FlutterBluePlus.isScanning.listen((state) {
|
||||
/*_isScanningSubscription = FlutterBluePlus.isScanning.listen((state) {
|
||||
_isScanning = state;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
});*/
|
||||
|
||||
// after the first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
onScanPressed();
|
||||
// must be called from a button
|
||||
if (!kIsWeb) {
|
||||
connection.performScanning();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isScanningSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future onScanPressed() async {
|
||||
try {
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: const Duration(seconds: 30),
|
||||
withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID],
|
||||
webOptionalServices: kIsWeb ? [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID] : [],
|
||||
);
|
||||
} catch (e, backtrace) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e'), duration: const Duration(seconds: 5)));
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future onStopPressed() async {
|
||||
try {
|
||||
FlutterBluePlus.stopScan();
|
||||
} catch (e, backtrace) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e'), duration: const Duration(seconds: 5)));
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildScanButton(BuildContext context) {
|
||||
if (FlutterBluePlus.isScanningNow) {
|
||||
return ElevatedButton(onPressed: onStopPressed, child: const Icon(Icons.stop));
|
||||
} else {
|
||||
return ElevatedButton(onPressed: onScanPressed, child: const Text("SCAN"));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -91,7 +42,36 @@ class _ScanWidgetState extends State<ScanWidget> {
|
||||
child: ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
shrinkWrap: true,
|
||||
children: [if (_isScanning) SmallProgressIndicator() else buildScanButton(context), LogViewer()],
|
||||
children: [
|
||||
ValueListenableBuilder(
|
||||
valueListenable: connection.isScanning,
|
||||
builder: (context, isScanning, widget) {
|
||||
if (isScanning) {
|
||||
return Column(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Text(
|
||||
'Scanning for devices... Make sure they are powered on and in range and not connected to another device.',
|
||||
),
|
||||
SmallProgressIndicator(),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
connection.performScanning();
|
||||
},
|
||||
child: const Text("SCAN"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (kDebugMode) LogViewer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
174
lib/pages/touch_area.dart
Normal file
174
lib/pages/touch_area.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
|
||||
final touchAreaSize = 32.0;
|
||||
|
||||
class TouchAreaSetupPage extends StatefulWidget {
|
||||
final void Function(Offset gearUp, Offset gearDown) onSave;
|
||||
|
||||
const TouchAreaSetupPage({required this.onSave, super.key});
|
||||
|
||||
@override
|
||||
State<TouchAreaSetupPage> createState() => _TouchAreaSetupPageState();
|
||||
}
|
||||
|
||||
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
File? _backgroundImage;
|
||||
Offset _gearUpPos = const Offset(200, 300);
|
||||
Offset _gearDownPos = const Offset(100, 300);
|
||||
|
||||
Future<void> _pickScreenshot() async {
|
||||
final picker = ImagePicker();
|
||||
final result = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
_backgroundImage = File(result.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _saveAndClose() {
|
||||
widget.onSave(_gearUpPos, _gearDownPos);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@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,
|
||||
);
|
||||
}
|
||||
|
||||
if (actionHandler.gearDownTouchPosition != null) {
|
||||
_gearDownPos = actionHandler.gearDownTouchPosition!;
|
||||
_gearDownPos = Offset(
|
||||
_gearDownPos.dx / devicePixelRatio - touchAreaSize / 2,
|
||||
_gearDownPos.dy / devicePixelRatio - touchAreaSize / 2,
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildDraggableArea({
|
||||
required Offset position,
|
||||
required void Function(Offset newPosition) onPositionChanged,
|
||||
required Color color,
|
||||
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: _TouchDot(color: color, label: label),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
if (_backgroundImage != null)
|
||||
Positioned.fill(child: Opacity(opacity: 0.5, child: Image.file(_backgroundImage!, fit: BoxFit.cover)))
|
||||
else
|
||||
Center(
|
||||
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
|
||||
5. Save and close this screen'''),
|
||||
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);
|
||||
setState(() {});
|
||||
},
|
||||
label: const Icon(Icons.lock_reset),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 40,
|
||||
right: 20,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _saveAndClose,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text("Save & Close"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TouchDot extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
const _TouchDot({required this.color, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: touchAreaSize,
|
||||
height: touchAreaSize,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.6),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black, width: 2),
|
||||
),
|
||||
),
|
||||
Text(label, style: TextStyle(color: Colors.black, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,21 +8,33 @@ import 'package:swift_control/utils/keymap/keymap.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) {
|
||||
streamEvents().listen((windowEvent) {
|
||||
windowInfo = windowEvent;
|
||||
if (validPackageNames.contains(windowEvent.packageName)) {
|
||||
windowInfo = windowEvent;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void decreaseGear() {
|
||||
if (windowInfo == null) {
|
||||
throw Exception("Decrease gear: No window info");
|
||||
} else {
|
||||
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),
|
||||
@@ -30,14 +42,17 @@ class AndroidActions extends BaseActions {
|
||||
};
|
||||
|
||||
accessibilityHandler.performTouch(point.dx, point.dy);
|
||||
} else {
|
||||
accessibilityHandler.performTouch(_gearDownTouchPosition!.dx, _gearDownTouchPosition!.dy);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void increaseGear() {
|
||||
if (windowInfo == null) {
|
||||
throw Exception("Increasing gear: No window info");
|
||||
} else {
|
||||
if (_gearUpTouchPosition == null) {
|
||||
if (windowInfo == null) {
|
||||
throw Exception("Increasing gear: No window info");
|
||||
}
|
||||
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),
|
||||
@@ -45,6 +60,8 @@ class AndroidActions extends BaseActions {
|
||||
};
|
||||
|
||||
accessibilityHandler.performTouch(point.dx, point.dy);
|
||||
} else {
|
||||
accessibilityHandler.performTouch(_gearUpTouchPosition!.dx, _gearUpTouchPosition!.dy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +69,10 @@ class AndroidActions extends BaseActions {
|
||||
void controlMedia(MediaAction action) {
|
||||
accessibilityHandler.controlMedia(action);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateTouchPositions(Offset gearUp, Offset gearDown) {
|
||||
_gearUpTouchPosition = gearUp;
|
||||
_gearDownTouchPosition = gearDown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../keymap/keymap.dart';
|
||||
import 'android.dart';
|
||||
import 'desktop.dart';
|
||||
|
||||
abstract class BaseActions {
|
||||
Keymap? get keymap => null;
|
||||
Offset? get gearUpTouchPosition => null;
|
||||
Offset? get gearDownTouchPosition => null;
|
||||
|
||||
void init(Keymap? keymap) {}
|
||||
void increaseGear();
|
||||
@@ -17,6 +16,8 @@ abstract class BaseActions {
|
||||
void controlMedia(MediaAction action) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
void updateTouchPositions(Offset gearUp, Offset gearDown) {}
|
||||
}
|
||||
|
||||
class StubActions extends BaseActions {
|
||||
@@ -30,35 +31,3 @@ class StubActions extends BaseActions {
|
||||
print('Increase gear');
|
||||
}
|
||||
}
|
||||
|
||||
class ActionHandler {
|
||||
late BaseActions actions;
|
||||
|
||||
ActionHandler() {
|
||||
if (kIsWeb) {
|
||||
actions = StubActions();
|
||||
} else if (Platform.isAndroid) {
|
||||
actions = AndroidActions();
|
||||
} else {
|
||||
actions = DesktopActions();
|
||||
}
|
||||
}
|
||||
|
||||
Keymap? get keymap => actions.keymap;
|
||||
|
||||
void init(Keymap? keymap) {
|
||||
actions.init(keymap);
|
||||
}
|
||||
|
||||
void increaseGear() {
|
||||
actions.increaseGear();
|
||||
}
|
||||
|
||||
void decreaseGear() {
|
||||
actions.decreaseGear();
|
||||
}
|
||||
|
||||
void controlMedia(MediaAction action) {
|
||||
actions.controlMedia(action);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,20 @@ class DesktopActions extends BaseActions {
|
||||
}
|
||||
|
||||
@override
|
||||
void decreaseGear() {
|
||||
Future<void> decreaseGear() async {
|
||||
if (keymap == null) {
|
||||
throw Exception('Keymap is not set');
|
||||
}
|
||||
keyPressSimulator.simulateKeyDown(_keymap!.decrease);
|
||||
await keyPressSimulator.simulateKeyDown(_keymap!.decrease?.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(_keymap!.decrease?.physicalKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void increaseGear() {
|
||||
Future<void> increaseGear() async {
|
||||
if (keymap == null) {
|
||||
throw Exception('Keymap is not set');
|
||||
}
|
||||
keyPressSimulator.simulateKeyDown(_keymap!.increase);
|
||||
await keyPressSimulator.simulateKeyDown(_keymap!.increase?.physicalKey);
|
||||
await keyPressSimulator.simulateKeyUp(_keymap!.increase?.physicalKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/devices/base_device.dart';
|
||||
import 'package:swift_control/utils/requirements/android.dart';
|
||||
|
||||
import 'messages/notification.dart';
|
||||
|
||||
class Connection {
|
||||
final devices = <BaseDevice>[];
|
||||
var androidNotificationsSetup = false;
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
|
||||
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => _actionStreams.stream;
|
||||
|
||||
final Map<BaseDevice, StreamSubscription<BluetoothConnectionState>> _connectionSubscriptions = {};
|
||||
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
|
||||
Stream<BaseDevice> get connectionStream => _connectionStreams.stream;
|
||||
|
||||
var _lastScanResult = <ScanResult>[];
|
||||
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
|
||||
late StreamSubscription<List<ScanResult>> _scanResultsSubscription;
|
||||
|
||||
void startScanning() {
|
||||
_scanResultsSubscription = FlutterBluePlus.scanResults.listen(
|
||||
(results) {
|
||||
if (!results.contentEquals(_lastScanResult)) {
|
||||
final diff = results
|
||||
.where((result) => !_lastScanResult.contains(result))
|
||||
.joinToString(
|
||||
transform: (result) => "${result.device.platformName} (${result.advertisementData.manufacturerData})",
|
||||
);
|
||||
_lastScanResult = results;
|
||||
if (diff.isNotEmpty) {
|
||||
_actionStreams.add(LogNotification('Found new devices: $diff'));
|
||||
}
|
||||
}
|
||||
final scanResults = results.mapNotNull(BaseDevice.fromScanResult).toList();
|
||||
_addDevices(scanResults);
|
||||
},
|
||||
onError: (e) {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _addDevices(List<BaseDevice> dev) {
|
||||
final newDevices = dev.where((device) => !devices.contains(device)).toList();
|
||||
devices.addAll(newDevices);
|
||||
|
||||
for (final device in newDevices) {
|
||||
_connect(device).then((_) {});
|
||||
}
|
||||
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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connect(BaseDevice bleDevice) async {
|
||||
try {
|
||||
await bleDevice.connect();
|
||||
|
||||
final actionSubscription = bleDevice.actionStream.listen((data) {
|
||||
_actionStreams.add(data);
|
||||
});
|
||||
_streamSubscriptions[bleDevice] = actionSubscription;
|
||||
|
||||
final connectionStateSubscription = bleDevice.device.connectionState.listen((state) async {
|
||||
_connectionStreams.add(bleDevice);
|
||||
});
|
||||
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
|
||||
} catch (e, backtrace) {
|
||||
if (e is FlutterBluePlusException && e.code == FbpErrorCode.connectionCanceled.index) {
|
||||
// ignore connections canceled by the user
|
||||
} else {
|
||||
_actionStreams.add(LogNotification(e.toString()));
|
||||
if (kDebugMode) {
|
||||
print(e);
|
||||
print("backtrace: $backtrace");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
FlutterBluePlus.stopScan();
|
||||
for (var device in devices) {
|
||||
device.device.disconnect();
|
||||
}
|
||||
devices.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
import 'package:swift_control/utils/ble.dart';
|
||||
import 'package:swift_control/utils/crypto/local_key_provider.dart';
|
||||
import 'package:swift_control/utils/devices/zwift_click.dart';
|
||||
import 'package:swift_control/utils/devices/zwift_play.dart';
|
||||
import 'package:swift_control/utils/devices/zwift_ride.dart';
|
||||
|
||||
import '../crypto/zap_crypto.dart';
|
||||
import '../messages/notification.dart';
|
||||
|
||||
abstract class BaseDevice {
|
||||
final ScanResult scanResult;
|
||||
final zapEncryption = ZapCrypto(LocalKeyProvider());
|
||||
|
||||
bool supportsEncryption = true;
|
||||
|
||||
BaseDevice(this.scanResult);
|
||||
|
||||
static BaseDevice? fromScanResult(ScanResult scanResult) {
|
||||
if (scanResult.device.platformName == 'Zwift Ride') {
|
||||
return ZwiftRide(scanResult);
|
||||
}
|
||||
if (kIsWeb) {
|
||||
// manufacturer data is not available on web
|
||||
if (scanResult.device.platformName == 'Zwift Play') {
|
||||
return ZwiftPlay(scanResult);
|
||||
} else if (scanResult.device.platformName == 'Zwift Click') {
|
||||
return ZwiftClick(scanResult);
|
||||
}
|
||||
}
|
||||
final manufacturerData = scanResult.advertisementData.manufacturerData;
|
||||
final data = manufacturerData[Constants.ZWIFT_MANUFACTURER_ID];
|
||||
if (data == null || data.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final type = DeviceType.fromManufacturerData(data.first);
|
||||
return switch (type) {
|
||||
DeviceType.click => ZwiftClick(scanResult),
|
||||
DeviceType.playRight => ZwiftPlay(scanResult),
|
||||
DeviceType.playLeft => ZwiftPlay(scanResult),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BaseDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
|
||||
|
||||
@override
|
||||
int get hashCode => scanResult.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return runtimeType.toString();
|
||||
}
|
||||
|
||||
BluetoothDevice get device => scanResult.device;
|
||||
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
|
||||
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
|
||||
|
||||
Future<void> connect() async {
|
||||
await device.connect(autoConnect: false).timeout(const Duration(seconds: 3));
|
||||
|
||||
var filteredStateStream = device.connectionState.where((s) => s == BluetoothConnectionState.connected);
|
||||
|
||||
// Start listening now, before invokeMethod, to ensure we don't miss the response
|
||||
Future<BluetoothConnectionState> futureState = filteredStateStream.first;
|
||||
|
||||
// wait for connection
|
||||
await futureState.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('Failed to connect in time.');
|
||||
},
|
||||
);
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
await device.requestMtu(256);
|
||||
}
|
||||
final services = await device.discoverServices();
|
||||
|
||||
if (device.isConnected) {
|
||||
await handleServices(services);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleServices(List<BluetoothService> services);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/devices/base_device.dart';
|
||||
import 'package:swift_control/utils/messages/notification.dart';
|
||||
|
||||
import '../ble.dart';
|
||||
import '../crypto/encryption_utils.dart';
|
||||
import '../messages/click_notification.dart';
|
||||
|
||||
class ZwiftClick extends BaseDevice {
|
||||
ZwiftClick(super.scanResult);
|
||||
|
||||
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
|
||||
Guid get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BluetoothService> services) async {
|
||||
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
|
||||
|
||||
if (customService == null) {
|
||||
throw Exception('Custom service not found');
|
||||
}
|
||||
|
||||
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
|
||||
);
|
||||
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
final syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
|
||||
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
|
||||
);
|
||||
|
||||
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
|
||||
throw Exception('Characteristics not found');
|
||||
}
|
||||
|
||||
if (!asyncCharacteristic.isNotifying) {
|
||||
await asyncCharacteristic.setNotifyValue(true);
|
||||
}
|
||||
final asyncSubscription = asyncCharacteristic.lastValueStream.listen((onData) {
|
||||
_processCharacteristic('async', Uint8List.fromList(onData));
|
||||
});
|
||||
device.cancelWhenDisconnected(asyncSubscription);
|
||||
|
||||
if (!syncTxCharacteristic.isNotifying) {
|
||||
await syncTxCharacteristic.setNotifyValue(true, forceIndications: !kIsWeb && Platform.isAndroid);
|
||||
}
|
||||
final syncSubscription = syncTxCharacteristic.lastValueStream.listen((onData) {
|
||||
_processCharacteristic('sync', Uint8List.fromList(onData));
|
||||
});
|
||||
device.cancelWhenDisconnected(syncSubscription);
|
||||
|
||||
await _setupHandshake(syncRxCharacteristic);
|
||||
}
|
||||
|
||||
Future<void> _setupHandshake(BluetoothCharacteristic syncRxCharacteristic) async {
|
||||
if (supportsEncryption) {
|
||||
await syncRxCharacteristic.write([
|
||||
...Constants.RIDE_ON,
|
||||
...Constants.REQUEST_START,
|
||||
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
|
||||
], withoutResponse: true);
|
||||
} else {
|
||||
await syncRxCharacteristic.write(Constants.RIDE_ON, withoutResponse: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _processCharacteristic(String tag, Uint8List bytes) {
|
||||
if (kDebugMode && false) {
|
||||
print('Received $tag: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
print('Received $tag: ${String.fromCharCodes(bytes)}');
|
||||
}
|
||||
|
||||
if (bytes.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (bytes.startsWith(startCommand)) {
|
||||
_processDevicePublicKeyResponse(bytes);
|
||||
} else if (bytes.startsWith(Constants.RIDE_ON)) {
|
||||
//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()));
|
||||
}
|
||||
}
|
||||
|
||||
ClickNotification? _lastClickNotification;
|
||||
|
||||
void _processData(Uint8List bytes) {
|
||||
int type;
|
||||
Uint8List message;
|
||||
|
||||
if (supportsEncryption) {
|
||||
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
|
||||
final payload = bytes.sublist(4);
|
||||
|
||||
final data = zapEncryption.decrypt(counter, payload);
|
||||
type = data[0];
|
||||
message = data.sublist(1);
|
||||
} else {
|
||||
type = bytes[0];
|
||||
message = bytes.sublist(1);
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case Constants.EMPTY_MESSAGE_TYPE:
|
||||
//print("Empty Message"); // expected when nothing happening
|
||||
break;
|
||||
case Constants.BATTERY_LEVEL_TYPE:
|
||||
//print("Battery level update: $message");
|
||||
break;
|
||||
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
|
||||
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE: // untested
|
||||
processClickNotification(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _processDevicePublicKeyResponse(Uint8List bytes) {
|
||||
final devicePublicKeyBytes = bytes.sublist(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
|
||||
zapEncryption.initialise(devicePublicKeyBytes);
|
||||
if (kDebugMode) {
|
||||
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
|
||||
}
|
||||
}
|
||||
|
||||
void processClickNotification(Uint8List message) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
|
||||
import 'package:swift_control/utils/devices/zwift_play.dart';
|
||||
|
||||
import '../ble.dart';
|
||||
|
||||
class ZwiftRide extends ZwiftPlay {
|
||||
ZwiftRide(super.scanResult);
|
||||
|
||||
@override
|
||||
Guid get customServiceId => BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID;
|
||||
|
||||
@override
|
||||
bool get supportsEncryption => false;
|
||||
}
|
||||
@@ -1,12 +1,77 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
enum Keymap {
|
||||
myWhoosh(increase: PhysicalKeyboardKey.keyK, decrease: PhysicalKeyboardKey.keyI),
|
||||
indieVelo(increase: PhysicalKeyboardKey(0x70030), decrease: PhysicalKeyboardKey(0x70038)),
|
||||
plusMinus(increase: PhysicalKeyboardKey(0x70030), decrease: PhysicalKeyboardKey(0x70038));
|
||||
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);
|
||||
|
||||
final PhysicalKeyboardKey increase;
|
||||
final PhysicalKeyboardKey decrease;
|
||||
static List<Keymap> values = [myWhoosh, custom];
|
||||
|
||||
const Keymap({required this.increase, required this.decrease});
|
||||
KeyPair? increase;
|
||||
KeyPair? decrease;
|
||||
final String name;
|
||||
|
||||
Keymap(this.name, {required this.increase, required this.decrease});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (increase == null && decrease == null) {
|
||||
return name;
|
||||
}
|
||||
return "$name: ${increase?.logicalKey.keyLabel} + ${decrease?.logicalKey.keyLabel}";
|
||||
}
|
||||
|
||||
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() ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
static Keymap? decode(List<String> data) {
|
||||
// decode from preferences
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
final PhysicalKeyboardKey physicalKey;
|
||||
final LogicalKeyboardKey logicalKey;
|
||||
|
||||
KeyPair({required this.physicalKey, required this.logicalKey});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:swift_control/utils/requirements/platform.dart';
|
||||
|
||||
@@ -18,6 +19,36 @@ class AccessibilityRequirement extends PlatformRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
class BluetoothScanRequirement extends PlatformRequirement {
|
||||
BluetoothScanRequirement() : super('Allow Bluetooth Scan');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
await Permission.bluetoothScan.request();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
final state = await Permission.bluetoothScan.status;
|
||||
status = state.isGranted || state.isLimited;
|
||||
}
|
||||
}
|
||||
|
||||
class BluetoothConnectRequirement extends PlatformRequirement {
|
||||
BluetoothConnectRequirement() : super('Allow Bluetooth Connections');
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
await Permission.bluetoothConnect.request();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
final state = await Permission.bluetoothConnect.status;
|
||||
status = state.isGranted || state.isLimited;
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationRequirement extends PlatformRequirement {
|
||||
NotificationRequirement() : super('Allow adding persistent Notification');
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.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';
|
||||
@@ -35,18 +38,29 @@ class KeymapRequirement extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Widget? build(BuildContext context, VoidCallback onUpdate) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: DropdownMenu<Keymap>(
|
||||
dropdownMenuEntries:
|
||||
Keymap.values.map((key) => DropdownMenuEntry<Keymap>(value: key, label: key.name.capitalize())).toList(),
|
||||
onSelected: (keymap) {
|
||||
actionHandler.init(keymap);
|
||||
onUpdate();
|
||||
},
|
||||
initialSelection: null,
|
||||
hintText: 'Keymap',
|
||||
),
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,13 +70,13 @@ class BluetoothTurnedOn extends PlatformRequirement {
|
||||
|
||||
@override
|
||||
Future<void> call() async {
|
||||
return FlutterBluePlus.turnOn();
|
||||
await UniversalBle.enableBluetooth();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStatus() async {
|
||||
final currentState = await FlutterBluePlus.adapterState.first;
|
||||
status = currentState != BluetoothAdapterState.off;
|
||||
final currentState = await UniversalBle.getBluetoothAvailabilityState();
|
||||
status = currentState == AvailabilityState.poweredOn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,14 @@ Future<List<PlatformRequirement>> getRequirements() async {
|
||||
} else if (Platform.isWindows) {
|
||||
list = [BluetoothTurnedOn(), KeyboardRequirement(), KeymapRequirement(), BluetoothScanning()];
|
||||
} else if (Platform.isAndroid) {
|
||||
list = [BluetoothTurnedOn(), AccessibilityRequirement(), NotificationRequirement(), BluetoothScanning()];
|
||||
list = [
|
||||
BluetoothTurnedOn(),
|
||||
AccessibilityRequirement(),
|
||||
NotificationRequirement(),
|
||||
BluetoothScanRequirement(),
|
||||
BluetoothConnectRequirement(),
|
||||
BluetoothScanning(),
|
||||
];
|
||||
} else {
|
||||
list = [UnsupportedPlatform()];
|
||||
}
|
||||
|
||||
43
lib/utils/settings/settings.dart
Normal file
43
lib/utils/settings/settings.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../keymap/keymap.dart';
|
||||
|
||||
class Settings {
|
||||
late final SharedPreferences _prefs;
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
|
||||
try {
|
||||
final keymapSetting = _prefs.getStringList("keymap");
|
||||
if (keymapSetting != null) {
|
||||
actionHandler.init(Keymap.decode(keymapSetting));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
} catch (e) {
|
||||
// couldn't decode, reset
|
||||
await _prefs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
97
lib/widgets/custom_keymap_selector.dart
Normal file
97
lib/widgets/custom_keymap_selector.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class GearHotkeyDialog extends StatefulWidget {
|
||||
final Keymap keymap;
|
||||
const GearHotkeyDialog({super.key, required this.keymap});
|
||||
|
||||
@override
|
||||
State<GearHotkeyDialog> createState() => _GearHotkeyDialogState();
|
||||
}
|
||||
|
||||
class _GearHotkeyDialogState extends State<GearHotkeyDialog> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
KeyDownEvent? _pressedKey;
|
||||
KeyDownEvent? _gearUpHotkey;
|
||||
KeyDownEvent? _gearDownHotkey;
|
||||
|
||||
String _mode = 'up'; // 'up' or 'down'
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _formatKey(KeyDownEvent? key) {
|
||||
return key?.logicalKey.keyLabel ?? 'Not set';
|
||||
}
|
||||
|
||||
@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"))],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import 'dart:async';
|
||||
import 'package:dartx/dartx.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../bluetooth/messages/notification.dart';
|
||||
import '../main.dart';
|
||||
import '../utils/messages/notification.dart';
|
||||
|
||||
class LogViewer extends StatefulWidget {
|
||||
const LogViewer({super.key});
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:swift_control/main.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
List<Widget> buildMenuButtons() {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
},
|
||||
child: Text('Donate ♥'),
|
||||
),
|
||||
const MenuButton(),
|
||||
SizedBox(width: 8),
|
||||
];
|
||||
}
|
||||
|
||||
class MenuButton extends StatelessWidget {
|
||||
const MenuButton({super.key});
|
||||
|
||||
@@ -9,18 +24,31 @@ class MenuButton extends StatelessWidget {
|
||||
return PopupMenuButton(
|
||||
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();
|
||||
});
|
||||
},
|
||||
),
|
||||
PopupMenuItem(child: PopupMenuDivider()),
|
||||
],
|
||||
PopupMenuItem(
|
||||
child: Text('Feedback'),
|
||||
onTap: () {
|
||||
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('Donate 🫶'),
|
||||
onTap: () {
|
||||
launchUrlString('https://paypal.me/boni');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Text('License'),
|
||||
onTap: () {
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_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) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import flutter_blue_plus_darwin
|
||||
import file_selector_macos
|
||||
import flutter_local_notifications
|
||||
import keypress_simulator_macos
|
||||
import path_provider_foundation
|
||||
import shared_preferences_foundation
|
||||
import universal_ble
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
PODS:
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- file_selector_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- keypress_simulator_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- universal_ble (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- 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`)
|
||||
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- 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`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
flutter_blue_plus_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
|
||||
file_selector_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
||||
flutter_local_notifications:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
keypress_simulator_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos
|
||||
path_provider_foundation:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||
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
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
flutter_blue_plus_darwin: 3ea4ec9133b377febcc8a70b28cd2d2dc9242bd9
|
||||
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
|
||||
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
|
||||
PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82
|
||||
|
||||
@@ -10,5 +10,7 @@
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
354
pubspec.lock
354
pubspec.lock
@@ -96,6 +96,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.4+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -136,6 +144,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_linux
|
||||
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+2"
|
||||
file_selector_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4+2"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_platform_interface
|
||||
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
file_selector_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+4"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -165,62 +213,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_blue_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_blue_plus
|
||||
sha256: "2d926dbef0fd6c58d4be8fca9eaaf1ba747c0ccb8373ddd5386665317e26eb61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.35.3"
|
||||
flutter_blue_plus_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_android
|
||||
sha256: c1d83f84b514e46345a8a58599c428f20b11e78379521e0d3b0611c7b7cbf2c1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_blue_plus_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_darwin
|
||||
sha256: "8d0a0f11f83b13dda173396b7e4028b4e8656bc8dbbc82c26a7e49aafc62644b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_blue_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_linux
|
||||
sha256: "1d367ed378b2bd6c3b9685fda7044e1d2f169884802b7dec7badb31a99a72660"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_blue_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_platform_interface
|
||||
sha256: "114f8e85a03a28a48d707a4df6cc9218e1f2005cf260c5e815e5585a00da5778"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_blue_plus_web:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
path: "packages/flutter_blue_plus_web"
|
||||
ref: HEAD
|
||||
resolved-ref: "5fc128ed6ff6b1bc038ec9f91a6d9789c2cebc53"
|
||||
url: "https://github.com/chipweinberger/flutter_blue_plus.git"
|
||||
source: git
|
||||
version: "3.0.0"
|
||||
flutter_blue_plus_windows:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: flutter_blue_plus_windows
|
||||
relative: true
|
||||
source: path
|
||||
version: "1.26.1"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -269,11 +261,27 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.27"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_bluetooth:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_web_bluetooth
|
||||
sha256: "1363831def5eed1e1064d1eca04e8ccb35446e8f758579c3c519e156b77926da"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -303,6 +311,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image_picker
|
||||
sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
image_picker_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.12+22"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_for_web
|
||||
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
image_picker_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.12+2"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_linux
|
||||
sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1+2"
|
||||
image_picker_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_macos
|
||||
sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1+2"
|
||||
image_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_platform_interface
|
||||
sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.10.1"
|
||||
image_picker_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_windows
|
||||
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1+1"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -375,6 +447,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -399,6 +479,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -407,30 +495,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.16"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -455,6 +519,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.6"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -503,14 +615,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
name: shared_preferences
|
||||
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
version: "2.5.3"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.8"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -540,14 +700,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_with_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_with_value
|
||||
sha256: "483d79bf604fdea5274e31207956b2f624f5f03a506cacf081b65cdfcfa647a6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -604,6 +756,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
universal_ble:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: universal_ble
|
||||
sha256: "35d210e93a5938c6a6d1fd3c710cf4ac90b1bdd1b11c8eb2beeb32600672e6e6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -692,14 +852,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win_ble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win_ble
|
||||
sha256: "2a867e13c4b355b101fc2c6e2ac85eeebf965db34eca46856f8b478e93b41e96"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
15
pubspec.yaml
15
pubspec.yaml
@@ -1,7 +1,7 @@
|
||||
name: swift_control
|
||||
description: "SwiftControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 1.0.0+2
|
||||
version: 1.1.3+0
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
@@ -12,23 +12,18 @@ dependencies:
|
||||
|
||||
url_launcher: ^6.3.1
|
||||
flutter_local_notifications: ^19.0.0
|
||||
flutter_blue_plus: ^1.35.3
|
||||
universal_ble: any
|
||||
protobuf: ^3.1.0
|
||||
permission_handler: ^11.4.0
|
||||
dartx: any
|
||||
image_picker: ^1.1.2
|
||||
pointycastle: any
|
||||
keypress_simulator: ^0.2.0
|
||||
shared_preferences: ^2.5.3
|
||||
flex_color_scheme: ^8.2.0
|
||||
flutter_blue_plus_windows:
|
||||
path: flutter_blue_plus_windows
|
||||
accessibility:
|
||||
path: accessibility
|
||||
|
||||
dependency_overrides:
|
||||
flutter_blue_plus_web:
|
||||
git:
|
||||
url: https://github.com/chipweinberger/flutter_blue_plus.git
|
||||
path: packages/flutter_blue_plus_web
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -6,12 +6,21 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <universal_ble/universal_ble_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("KeypressSimulatorWindowsPluginCApi"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
UniversalBlePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UniversalBlePluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
keypress_simulator_windows
|
||||
permission_handler_windows
|
||||
universal_ble
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user