mirror of
https://github.com/jonasbark/swiftcontrol.git
synced 2026-02-18 00:17:40 +01:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86addc00fd | ||
|
|
9cebea225c | ||
|
|
59bdb30321 | ||
|
|
d51fb7dfa2 | ||
|
|
b955c51a91 | ||
|
|
86ecd1ad20 | ||
|
|
c089b3bdbd | ||
|
|
9612b213aa | ||
|
|
83c9b52708 | ||
|
|
a7bde7c08a | ||
|
|
c8613b5975 | ||
|
|
87bb728601 | ||
|
|
e1f9d4fb08 | ||
|
|
14e6c1186c | ||
|
|
abeb142f0b | ||
|
|
d416756614 | ||
|
|
823eb9e9a4 | ||
|
|
6579092f4a | ||
|
|
c242c09025 | ||
|
|
89c9ed598c | ||
|
|
a3a592bd16 | ||
|
|
a161829913 | ||
|
|
b4473ad067 | ||
|
|
4752f99fcf | ||
|
|
6e757cf15c | ||
|
|
a87810db88 | ||
|
|
0ddb3e8081 | ||
|
|
e29aed8bcf | ||
|
|
d99a3257af | ||
|
|
a772b210cd | ||
|
|
860700ab91 | ||
|
|
ff5d90d468 | ||
|
|
43773310d5 | ||
|
|
2da65645b0 |
1
.github/workflows/patch.yml
vendored
1
.github/workflows/patch.yml
vendored
@@ -10,6 +10,7 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Patch iOS, Android & macOS
|
||||
if: false
|
||||
runs-on: macos-latest
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
## Instructions for using the MyWhoosh "Link" connection method
|
||||
*
|
||||
1) launch MyWhoosh on the device of your choice
|
||||
2) OPTIONAL: launch MyWhoosh Link, confirm the "Link" connection works at all. **close MyWhoosh Link** - very important!
|
||||
2) make sure the "MyWhoosh Link" app is not active at the same time as BikeControl
|
||||
3) open BikeControl, follow the on-screen instructions
|
||||
|
||||
Once you've confirmed the connection in BikeControl you won't have to do step 2 in the future. This is just to make sure the connection works in general.
|
||||
|
||||
And here's a video with a few explanations:
|
||||
Here's a video with a few explanations. Note it uses an older version, but the idea is the same.
|
||||
|
||||
[](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
|
||||
|
||||
## The MyWhoosh Link app itself works fine, but BikeControl doesn't connect
|
||||
*
|
||||
The MyWhoosh Link app must not run simultaneously with BikeControl. Make sure the MyWhoosh Link app is fully closed, then reopen BikeControl and try connecting again.
|
||||
|
||||
## MyWhoosh "Link" method never connects
|
||||
*
|
||||
The same network restrictions apply for BikeControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if a connection is possible at all.
|
||||
Here are some instructions that can help:
|
||||
This is a network/local-discovery problem. BikeControl needs the same kind of local network access as MyWhoosh Link.
|
||||
|
||||
[https://mywhoosh.com/troubleshoot/](https://mywhoosh.com/troubleshoot/)
|
||||
[https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/](https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/)
|
||||
Checklist:
|
||||
- Use the MyWhoosh Link app to confirm if "Link" works in general
|
||||
- Both devices are on the **same Wi‑Fi SSID**
|
||||
- Avoid “Guest” networks
|
||||
- Avoid “extenders/mesh guest mode” and networks with device isolation
|
||||
- If your router has it, disable:
|
||||
- “AP isolation / client isolation”
|
||||
- Try moving both devices to the same band:
|
||||
- Prefer **2.4 GHz** (often more reliable for local discovery than mixed/steering)
|
||||
- Temporarily disable:
|
||||
- VPNs
|
||||
- iCloud Private Relay (if enabled)
|
||||
- “Limit IP Address Tracking” (iOS Wi‑Fi option)
|
||||
- iOS Wi‑Fi settings for that network:
|
||||
- Turn off **Private Wi‑Fi Address**
|
||||
- Turn off **Limit IP Address Tracking**
|
||||
- Mesh networks: may work, but if it doesn’t, test with a simple router or phone hotspot.
|
||||
|
||||
In essence:
|
||||
- your two devices (phone, tablet) need to be on the same WiFi network
|
||||
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
|
||||
- Limit IP Address Tracking may need to be disabled
|
||||
- mesh networks may not work
|
||||
Official MyWhoosh troubleshooting links:
|
||||
- https://mywhoosh.com/troubleshoot/
|
||||
- https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
|
||||
|
||||
@@ -31,7 +31,7 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
## Supported Apps
|
||||
- MyWhoosh
|
||||
- Zwift
|
||||
- TrainingPeaks Virtual / indieVelo
|
||||
- TrainingPeaks Virtual
|
||||
- Biketerra.com
|
||||
- Rouvy
|
||||
- [OpenBikeControl](https://openbikecontrol.org) compatible apps
|
||||
@@ -51,6 +51,7 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
|
||||
- Wahoo Kickr Bike Shift
|
||||
- Wahoo Kickr Bike Pro
|
||||
- CYCPLUS BC2 Virtual Shifter
|
||||
- Thinkrider VS200 Virtual Shifter (beta)
|
||||
- Elite Sterzo Smart (for steering support)
|
||||
- Elite Square Smart Frame (beta)
|
||||
- Your Phone!
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
## Click / Ride device cannot be found
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
This means BikeControl does NOT see the device via Bluetooth.
|
||||
- Put the controller into pairing mode (LED should blink)
|
||||
- Ensure the controller is NOT connected to another app/device (e.g. Zwift)
|
||||
- Update controller firmware in Zwift Companion, if available
|
||||
- Reboot Bluetooth / reboot phone/PC
|
||||
|
||||
## Click / Ride device does not send any data
|
||||
*
|
||||
You may need to update the firmware in Zwift Companion app.
|
||||
|
||||
## My Click v2 disconnects after a minute
|
||||
## My Click v2 disconnects after a minute or buttons do not work
|
||||
*
|
||||
Check [this](https://github.com/jonasbark/swiftcontrol/issues/68) discussion.
|
||||
|
||||
To make your Click V2 work best you should connect it in the Zwift app once each day.
|
||||
To make your Click V2 work best you should connect it in the Zwift app once before a workout session.
|
||||
If you don't do that BikeControl will need to reconnect every minute.
|
||||
|
||||
1. Open Zwift app (not the Companion)
|
||||
2. Log in (subscription not required) and open the device connection screen
|
||||
3. Connect your Trainer, then connect the Click V2
|
||||
4. Optional: some users report that keeping the Click connected for more than a few seconds is more reliable.
|
||||
5. Close the Zwift app again and connect again in BikeControl
|
||||
2. Log in (subscription not required) → device connection screen
|
||||
3. Connect trainer, then connect Click v2
|
||||
4. Keep it connected for ~10–30 seconds
|
||||
5. Close Zwift completely, then connect in BikeControl
|
||||
|
||||
Details/updates: https://github.com/jonasbark/swiftcontrol/issues/68
|
||||
|
||||
## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
|
||||
*
|
||||
@@ -27,10 +32,6 @@ If you don't do that BikeControl will need to reconnect every minute.
|
||||
- grant accessibility permission for BikeControl
|
||||
- see [https://github.com/jonasbark/swiftcontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
|
||||
|
||||
## BikeControl crashes on Windows when searching for the device
|
||||
*
|
||||
You're probably running into [this](https://github.com/OpenBikeControl/bikecontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
|
||||
|
||||
|
||||
## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control
|
||||
*
|
||||
|
||||
@@ -1 +1 @@
|
||||
4.2.2
|
||||
4.3.0
|
||||
|
||||
@@ -216,6 +216,7 @@ interface Accessibility {
|
||||
fun controlMedia(action: MediaAction)
|
||||
fun isRunning(): Boolean
|
||||
fun ignoreHidDevices()
|
||||
fun setHandledKeys(keys: List<String>)
|
||||
|
||||
companion object {
|
||||
/** The codec used by Accessibility. */
|
||||
@@ -327,6 +328,24 @@ interface Accessibility {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$separatedMessageChannelSuffix", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { message, reply ->
|
||||
val args = message as List<Any?>
|
||||
val keysArg = args[0] as List<String>
|
||||
val wrapped: List<Any?> = try {
|
||||
api.setHandledKeys(keysArg)
|
||||
listOf(null)
|
||||
} catch (exception: Throwable) {
|
||||
AccessibilityApiPigeonUtils.wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,11 @@ class AccessibilityPlugin: FlutterPlugin, Accessibility {
|
||||
Observable.ignoreHidDevices = true
|
||||
}
|
||||
|
||||
override fun setHandledKeys(keys: List<String>) {
|
||||
// Clear and update the concurrent set
|
||||
Observable.handledKeys = keys.toSet()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class WindowEventListener : StreamEventsStreamHandler(), Receiver {
|
||||
|
||||
@@ -70,8 +70,9 @@ class AccessibilityService : AccessibilityService(), Listener {
|
||||
}
|
||||
|
||||
override fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event)) {
|
||||
// Handle media and volume keys from HID devices here
|
||||
val keyString = KeyEvent.keyCodeToString(event.keyCode)
|
||||
if (!Observable.ignoreHidDevices && isBleRemote(event) && Observable.handledKeys.contains(keyString)) {
|
||||
// Handle keys that have a keymap defined
|
||||
Log.d(
|
||||
"AccessibilityService",
|
||||
"onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}"
|
||||
|
||||
@@ -2,12 +2,15 @@ package de.jonasbark.accessibility
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.KeyEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object Observable {
|
||||
var toService: Listener? = null
|
||||
var fromServiceWindow: Receiver? = null
|
||||
var fromServiceKeys: Receiver? = null
|
||||
var ignoreHidDevices: Boolean = false
|
||||
// Use concurrent set for thread-safe access from AccessibilityService and plugin
|
||||
var handledKeys: Set<String> = ConcurrentHashMap.newKeySet()
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
|
||||
@@ -13,6 +13,8 @@ abstract class Accessibility {
|
||||
bool isRunning();
|
||||
|
||||
void ignoreHidDevices();
|
||||
|
||||
void setHandledKeys(List<String> keys);
|
||||
}
|
||||
|
||||
enum MediaAction { playPause, next, volumeUp, volumeDown }
|
||||
|
||||
@@ -353,6 +353,29 @@ class Accessibility {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setHandledKeys(List<String> keys) async {
|
||||
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$pigeonVar_messageChannelSuffix';
|
||||
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||
pigeonVar_channelName,
|
||||
pigeonChannelCodec,
|
||||
binaryMessenger: pigeonVar_binaryMessenger,
|
||||
);
|
||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[keys]);
|
||||
final List<Object?>? pigeonVar_replyList =
|
||||
await pigeonVar_sendFuture as List<Object?>?;
|
||||
if (pigeonVar_replyList == null) {
|
||||
throw _createConnectionError(pigeonVar_channelName);
|
||||
} else if (pigeonVar_replyList.length > 1) {
|
||||
throw PlatformException(
|
||||
code: pigeonVar_replyList[0]! as String,
|
||||
message: pigeonVar_replyList[1] as String?,
|
||||
details: pigeonVar_replyList[2],
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Stream<WindowEvent> streamEvents( {String instanceName = ''}) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import 'package:universal_ble/universal_ble.dart';
|
||||
import 'cycplus/cycplus_bc2.dart';
|
||||
import 'elite/elite_square.dart';
|
||||
import 'elite/elite_sterzo.dart';
|
||||
import 'thinkrider/thinkrider_vs200.dart';
|
||||
|
||||
abstract class BluetoothDevice extends BaseDevice {
|
||||
final BleDevice scanResult;
|
||||
@@ -55,6 +56,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
ShimanoDi2Constants.SERVICE_UUID,
|
||||
ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE,
|
||||
OpenBikeControlConstants.SERVICE_UUID,
|
||||
ThinkRiderVs200Constants.SERVICE_UUID,
|
||||
];
|
||||
|
||||
static BluetoothDevice? fromScanResult(BleDevice scanResult) {
|
||||
@@ -74,6 +76,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('THINK VS') => ThinkRiderVs200(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('RDR') => ShimanoDi2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('SRAM') => SramAxs(scanResult),
|
||||
_ => null,
|
||||
@@ -92,6 +95,7 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
_ when scanResult.name!.toUpperCase().startsWith('KICKR BIKE PRO') => WahooKickrBikePro(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('CYCPLUS') && scanResult.name!.toUpperCase().contains('BC2') =>
|
||||
CycplusBc2(scanResult),
|
||||
_ when scanResult.name!.toUpperCase().startsWith('THINK VS') => ThinkRiderVs200(scanResult),
|
||||
_ when scanResult.services.contains(CycplusBc2Constants.SERVICE_UUID.toLowerCase()) => CycplusBc2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID.toLowerCase()) => ShimanoDi2(scanResult),
|
||||
_ when scanResult.services.contains(ShimanoDi2Constants.SERVICE_UUID_ALTERNATIVE.toLowerCase()) => ShimanoDi2(
|
||||
@@ -104,6 +108,8 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
OpenBikeControlDevice(scanResult),
|
||||
_ when scanResult.services.contains(WahooKickrHeadwindConstants.SERVICE_UUID.toLowerCase()) =>
|
||||
WahooKickrHeadwind(scanResult),
|
||||
_ when scanResult.services.contains(ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase()) =>
|
||||
ThinkRiderVs200(scanResult),
|
||||
// otherwise the service UUIDs will be used
|
||||
_ => null,
|
||||
};
|
||||
@@ -137,16 +143,17 @@ abstract class BluetoothDevice extends BaseDevice {
|
||||
}
|
||||
}
|
||||
|
||||
if (scanResult.name == 'Zwift Ride' && device == null) {
|
||||
if (scanResult.name == 'Zwift Ride' &&
|
||||
device == null &&
|
||||
core.connection.controllerDevices.none((d) => d is ZwiftRide)) {
|
||||
// Fallback for Zwift Ride if nothing else matched => old firmware
|
||||
if (navigatorKey.currentContext?.mounted ?? false) {
|
||||
buildToast(
|
||||
navigatorKey.currentContext!,
|
||||
title: 'Please update your Zwift Ride firmware.',
|
||||
title: 'You may need to update your Zwift Ride firmware.',
|
||||
duration: Duration(seconds: 6),
|
||||
);
|
||||
}
|
||||
device = ZwiftRide(scanResult);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
89
lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart
Normal file
89
lib/bluetooth/devices/thinkrider/thinkrider_vs200.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
import '../bluetooth_device.dart';
|
||||
|
||||
class ThinkRiderVs200 extends BluetoothDevice {
|
||||
ThinkRiderVs200(super.scanResult)
|
||||
: super(
|
||||
availableButtons: ThinkRiderVs200Buttons.values,
|
||||
isBeta: true,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> handleServices(List<BleService> services) async {
|
||||
// Only subscribe to service 0xFEA0
|
||||
final service = services.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.SERVICE_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Service not found: ${ThinkRiderVs200Constants.SERVICE_UUID}'),
|
||||
);
|
||||
final characteristic = service.characteristics.firstWhere(
|
||||
(e) => e.uuid.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase(),
|
||||
orElse: () => throw Exception('Characteristic not found: ${ThinkRiderVs200Constants.CHARACTERISTIC_UUID}'),
|
||||
);
|
||||
|
||||
await UniversalBle.subscribeNotifications(device.deviceId, service.uuid, characteristic.uuid);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) {
|
||||
if (characteristic.toLowerCase() == ThinkRiderVs200Constants.CHARACTERISTIC_UUID.toLowerCase()) {
|
||||
final hexValue = _bytesToHex(bytes).toLowerCase();
|
||||
|
||||
// Log all received values while in beta
|
||||
if (isBeta) {
|
||||
actionStreamInternal.add(LogNotification('VS200 received: $hexValue'));
|
||||
}
|
||||
|
||||
// Check for specific byte patterns
|
||||
if (hexValue == ThinkRiderVs200Constants.SHIFT_UP_PATTERN) {
|
||||
// Plus button pressed
|
||||
actionStreamInternal.add(LogNotification('Shift Up detected: $hexValue'));
|
||||
handleButtonsClickedWithoutLongPressSupport([ThinkRiderVs200Buttons.shiftUp]);
|
||||
} else if (hexValue == ThinkRiderVs200Constants.SHIFT_DOWN_PATTERN) {
|
||||
// Minus button pressed
|
||||
actionStreamInternal.add(LogNotification('Shift Down detected: $hexValue'));
|
||||
handleButtonsClickedWithoutLongPressSupport([ThinkRiderVs200Buttons.shiftDown]);
|
||||
}
|
||||
}
|
||||
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
String _bytesToHex(List<int> bytes) {
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
|
||||
class ThinkRiderVs200Constants {
|
||||
// Service and characteristic UUIDs based on the nRF Connect screenshot
|
||||
static const String SERVICE_UUID = "0000fea0-0000-1000-8000-00805f9b34fb";
|
||||
static const String CHARACTERISTIC_UUID = "0000fea1-0000-1000-8000-00805f9b34fb";
|
||||
|
||||
// Byte patterns for button detection
|
||||
static const String SHIFT_UP_PATTERN = "f3050301fc";
|
||||
static const String SHIFT_DOWN_PATTERN = "f3050300fb";
|
||||
}
|
||||
|
||||
class ThinkRiderVs200Buttons {
|
||||
static const ControllerButton shiftUp = ControllerButton(
|
||||
'shiftUp',
|
||||
action: InGameAction.shiftUp,
|
||||
icon: Icons.add,
|
||||
);
|
||||
|
||||
static const ControllerButton shiftDown = ControllerButton(
|
||||
'shiftDown',
|
||||
action: InGameAction.shiftDown,
|
||||
icon: Icons.remove,
|
||||
);
|
||||
|
||||
static const List<ControllerButton> values = [
|
||||
shiftUp,
|
||||
shiftDown,
|
||||
];
|
||||
}
|
||||
@@ -81,6 +81,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
|
||||
'ble-service-uuids': Uint8List.fromList('FC82'.codeUnits),
|
||||
'mac-address': Uint8List.fromList('50-50-25-6C-66-9C'.codeUnits),
|
||||
'serial-number': Uint8List.fromList('244700181'.codeUnits),
|
||||
'manufacturer-data': Uint8List.fromList('094A0BAAAA'.codeUnits),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
|
||||
|
||||
@override
|
||||
Future<void> processCharacteristic(String characteristic, Uint8List bytes) async {
|
||||
if (kDebugMode) {
|
||||
if (kDebugMode && false) {
|
||||
actionStreamInternal.add(
|
||||
LogNotification(
|
||||
"Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl benötigt Zugriffsberechtigungen, um Ihre Trainings-Apps zu steuern.",
|
||||
"accessibilityDisclaimer": "BikeControl greift nur auf Ihren Bildschirm zu, um die von Ihnen konfigurierten Gesten auszuführen. Es werden keine weiteren Bedienungshilfen oder persönlichen Daten abgerufen.",
|
||||
"accessibilityReasonControl": "• Um Ihnen die Steuerung von Apps wie MyWhoosh, IndieVelo und anderen über Ihre Zwift-Geräte zu ermöglichen",
|
||||
"accessibilityReasonControl": "• Um Ihnen die Steuerung von Apps wie MyWhoosh, TrainingPeaks und anderen über Ihre Zwift-Geräte zu ermöglichen",
|
||||
"accessibilityReasonTouch": "• Um Berührungsgesten auf Ihrem Bildschirm zur Steuerung von Trainer-Apps zu simulieren.",
|
||||
"accessibilityReasonWindow": "• Um zu erkennen, welches Trainings-App-Fenster aktuell aktiv ist",
|
||||
"accessibilityServiceExplanation": "BikeControl benötigt die AccessibilityService API von Android, um ordnungsgemäß zu funktionieren.",
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
},
|
||||
"connection": "Verbindung",
|
||||
"continueAction": "Weitermachen",
|
||||
"continueAction": "Weiter",
|
||||
"controlAppUsingModes": "Steuere {appName} über {modes}",
|
||||
"@controlAppUsingModes": {
|
||||
"placeholders": {
|
||||
@@ -394,6 +394,7 @@
|
||||
"simulateTouch": "Berührung simulieren",
|
||||
"skip": "Überspringen",
|
||||
"stop": "Stoppen",
|
||||
"supportedActions": "Unterstützte Aktionen",
|
||||
"targetOtherDevice": "Anderes Gerät",
|
||||
"targetThisDevice": "Dieses Gerät",
|
||||
"theFollowingPermissionsRequired": "Folgende Berechtigungen sind erforderlich:",
|
||||
@@ -408,7 +409,7 @@
|
||||
"tryingToConnectAgain": "Verbinde erneut...",
|
||||
"unassignAction": "Zuweisung aufheben",
|
||||
"unlockFullVersion": "Vollversion freischalten",
|
||||
"unlockingNotPossible": "Aufgrund technischer Probleme ist eine Entsperrung derzeit nicht möglich; daher kann die App solange uneingeschränkt verwendet werden.",
|
||||
"unlockingNotPossible": "Eine Freischaltung ist derzeit noch nicht möglich, daher ist die App vorerst uneingeschränkt nutzbar!",
|
||||
"update": "Aktualisieren",
|
||||
"useCustomKeymapForButton": "Verwende eine benutzerdefinierte Tastaturbelegung, um die",
|
||||
"version": "Version {version}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl needs accessibility permission to control your training apps.",
|
||||
"accessibilityDisclaimer": "BikeControl will only access your screen to perform the gestures you configure. No other accessibility features or personal information will be accessed.",
|
||||
"accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, IndieVelo, and others using your Zwift devices",
|
||||
"accessibilityReasonControl": "• To enable you to control apps like MyWhoosh, TrainingPeaks, and others using your Zwift devices",
|
||||
"accessibilityReasonTouch": "• To simulate touch gestures on your screen for controlling trainer apps",
|
||||
"accessibilityReasonWindow": "• To detect which training app window is currently active",
|
||||
"accessibilityServiceExplanation": "BikeControl needs to use Android's AccessibilityService API to function properly.",
|
||||
@@ -394,6 +394,7 @@
|
||||
"simulateTouch": "Simulate Touch",
|
||||
"skip": "Skip",
|
||||
"stop": "Stop",
|
||||
"supportedActions": "Supported Actions",
|
||||
"targetOtherDevice": "Other Device",
|
||||
"targetThisDevice": "This Device",
|
||||
"theFollowingPermissionsRequired": "The following permissions are required:",
|
||||
@@ -408,7 +409,7 @@
|
||||
"tryingToConnectAgain": "Trying to connect again...",
|
||||
"unassignAction": "Unassign action",
|
||||
"unlockFullVersion": "Unlock Full Version",
|
||||
"unlockingNotPossible": "Due to technical issues unlocking is currently not possible, so enjoy unlimited usage for the time being!",
|
||||
"unlockingNotPossible": "Unlocking is currently not yet possible, so enjoy unlimited usage for the time being!",
|
||||
"update": "Update",
|
||||
"useCustomKeymapForButton": "Use a custom keymap to support the",
|
||||
"version": "Version {version}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl a besoin d'une autorisation d'accessibilité pour contrôler vos applications d'entraînement.",
|
||||
"accessibilityDisclaimer": "BikeControl n'accédera à votre écran que pour effectuer les gestes que vous aurez configurés. Aucune autre fonctionnalité d'accessibilité ni aucune autre information personnelle ne sera accessible.",
|
||||
"accessibilityReasonControl": "• Pour vous permettre de contrôler des applications telles que MyWhoosh, IndieVelo et d'autres à l'aide de vos appareils Zwift.",
|
||||
"accessibilityReasonControl": "• Pour vous permettre de contrôler des applications telles que MyWhoosh, TrainingPeaks et d'autres à l'aide de vos appareils Zwift.",
|
||||
"accessibilityReasonTouch": "• Pour simuler des gestes tactiles sur votre écran afin de contrôler les applications d'entraînement",
|
||||
"accessibilityReasonWindow": "• Pour détecter quelle fenêtre de l'application d'entraînement est actuellement active",
|
||||
"accessibilityServiceExplanation": "BikeControl doit utiliser l'API AccessibilityService d'Android pour fonctionner correctement.",
|
||||
@@ -394,6 +394,7 @@
|
||||
"simulateTouch": "Simuler le toucher",
|
||||
"skip": "Sauter",
|
||||
"stop": "Arrêt",
|
||||
"supportedActions": "Actions prises en charge",
|
||||
"targetOtherDevice": "Autre appareil",
|
||||
"targetThisDevice": "Cet appareil",
|
||||
"theFollowingPermissionsRequired": "Les autorisations suivantes sont requises :",
|
||||
@@ -408,7 +409,7 @@
|
||||
"tryingToConnectAgain": "Tentative de reconnexion...",
|
||||
"unassignAction": "Action de désaffectation",
|
||||
"unlockFullVersion": "Débloquer la version complète",
|
||||
"unlockingNotPossible": "En raison de problèmes techniques, le déverrouillage est actuellement impossible. Profitez donc d'une utilisation illimitée pour le moment !",
|
||||
"unlockingNotPossible": "Le déverrouillage n'est pas encore possible pour le moment, alors profitez d'une utilisation illimitée pour l'instant !",
|
||||
"update": "Mise à jour",
|
||||
"useCustomKeymapForButton": "Utilisez une configuration de touches personnalisée pour prendre en charge",
|
||||
"version": "Version {version}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl necessita dell'autorizzazione di accessibilità per controllare le tue app di allenamento.",
|
||||
"accessibilityDisclaimer": "BikeControl accederà al tuo schermo solo per eseguire i gesti da te configurati. Non accederà ad altre funzioni di accessibilità o a informazioni personali.",
|
||||
"accessibilityReasonControl": "• Per consentirti di controllare app come MyWhoosh, IndieVelo e altre utilizzando i tuoi dispositivi Zwift",
|
||||
"accessibilityReasonControl": "• Per consentirti di controllare app come MyWhoosh, TrainingPeaks e altre utilizzando i tuoi dispositivi Zwift",
|
||||
"accessibilityReasonTouch": "• Per simulare i gesti touch sullo schermo per controllare le app di allenamento",
|
||||
"accessibilityReasonWindow": "• Per rilevare quale finestra dell'app di allenamento è attualmente attiva",
|
||||
"accessibilityServiceExplanation": "Per funzionare correttamente, BikeControl deve utilizzare l'API AccessibilityService di Android.",
|
||||
@@ -394,6 +394,7 @@
|
||||
"simulateTouch": "Simula il tocco",
|
||||
"skip": "Saltare",
|
||||
"stop": "Stop",
|
||||
"supportedActions": "Azioni supportate",
|
||||
"targetOtherDevice": "Altro dispositivo",
|
||||
"targetThisDevice": "Questo dispositivo",
|
||||
"theFollowingPermissionsRequired": "Sono richieste le seguenti autorizzazioni:",
|
||||
@@ -408,7 +409,7 @@
|
||||
"tryingToConnectAgain": "Sto provando a connettermi di nuovo...",
|
||||
"unassignAction": "Annulla assegnazione azione",
|
||||
"unlockFullVersion": "Sblocca la versione completa",
|
||||
"unlockingNotPossible": "A causa di problemi tecnici, al momento non è possibile sbloccare il tutto, ma per il momento puoi usufruire di un utilizzo illimitato!",
|
||||
"unlockingNotPossible": "Al momento non è ancora possibile sbloccarlo, quindi per il momento goditi un utilizzo illimitato!",
|
||||
"update": "Aggiorna",
|
||||
"useCustomKeymapForButton": "Utilizzare una mappa dei tasti personalizzata per supportare",
|
||||
"version": "Versione{version}",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"accessibilityDescription": "BikeControl potrzebuje uprawnień dostępu, aby móc sterować aplikacjami treningowymi.",
|
||||
"accessibilityDisclaimer": "BikeControl będzie miał dostęp do Twojego ekranu wyłącznie w celu wykonania skonfigurowanych przez Ciebie gestów. Nie będzie miał dostępu do żadnych innych funkcji ułatwień dostępu ani danych osobowych.",
|
||||
"accessibilityReasonControl": "• Aby umożliwić Ci sterowanie aplikacjami takimi jak MyWhoosh, IndieVelo i innymi za pomocą urządzeń Zwift",
|
||||
"accessibilityReasonControl": "• Aby umożliwić Ci sterowanie aplikacjami takimi jak MyWhoosh, TrainingPeaks i innymi za pomocą urządzeń Zwift",
|
||||
"accessibilityReasonTouch": "• Aby symulować gesty dotykowe na ekranie w celu sterowania aplikacją treningową",
|
||||
"accessibilityReasonWindow": "• Aby wykryć, które okno aplikacji treningowej jest aktualnie aktywne",
|
||||
"accessibilityServiceExplanation": "Aby działać prawidłowo, BikeControl musi korzystać z interfejsu API AccessibilityService systemu Android.",
|
||||
@@ -394,6 +394,7 @@
|
||||
"simulateTouch": "Symuluj dotyk",
|
||||
"skip": "Pominąć",
|
||||
"stop": "Stop",
|
||||
"supportedActions": "Obsługiwane działania",
|
||||
"targetOtherDevice": "Inne urządzenie",
|
||||
"targetThisDevice": "To urządzenie",
|
||||
"theFollowingPermissionsRequired": "Wymagane są następujące uprawnienia:",
|
||||
@@ -408,7 +409,7 @@
|
||||
"tryingToConnectAgain": "Próba ponownego połączenia...",
|
||||
"unassignAction": "Anuluj przypisanie akcji",
|
||||
"unlockFullVersion": "Odblokuj pełną wersję",
|
||||
"unlockingNotPossible": "Z uwagi na problemy techniczne odblokowanie nie jest obecnie możliwe, więc ciesz się nieograniczonym użytkowaniem!",
|
||||
"unlockingNotPossible": "Odblokowanie nie jest obecnie możliwe, więc ciesz się nieograniczonym użytkowaniem!",
|
||||
"update": "Aktualizacja",
|
||||
"useCustomKeymapForButton": "Użyj niestandardowej mapy klawiszy, aby obsługiwać",
|
||||
"version": "Wersja {version}",
|
||||
|
||||
@@ -74,12 +74,15 @@ class _DevicePageState extends State<DevicePage> {
|
||||
|
||||
Gap(12),
|
||||
...core.connection.controllerDevices.map(
|
||||
(device) => Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
(device) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -90,12 +93,15 @@ class _DevicePageState extends State<DevicePage> {
|
||||
child: ColoredTitle(text: AppLocalizations.of(context).accessories),
|
||||
),
|
||||
...core.connection.accessories.map(
|
||||
(device) => Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
(device) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Card(
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Theme.of(context).colorScheme.card
|
||||
: Theme.of(context).colorScheme.card.withLuminance(0.95),
|
||||
child: device.showInformation(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -138,7 +138,7 @@ class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
|
||||
final flutterView = WidgetsBinding.instance.platformDispatcher.views.first;
|
||||
|
||||
// figure out notch height for e.g. macOS. On Windows the display size is not available (0,0).
|
||||
final differenceInHeight = (flutterView.display.size.height > 0 && !Platform.isIOS)
|
||||
final differenceInHeight = (!Platform.isWindows && flutterView.display.size.height > 0 && !Platform.isIOS)
|
||||
? (flutterView.display.size.height - flutterView.physicalSize.height) / flutterView.devicePixelRatio
|
||||
: 0.0;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:accessibility/accessibility.dart';
|
||||
import 'package:bike_control/bluetooth/devices/hid/hid_device.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
@@ -14,6 +16,7 @@ class AndroidActions extends BaseActions {
|
||||
WindowEvent? windowInfo;
|
||||
|
||||
final accessibilityHandler = Accessibility();
|
||||
StreamSubscription<void>? _keymapUpdateSubscription;
|
||||
|
||||
AndroidActions({super.supportedModes = const [SupportedMode.touch, SupportedMode.media]});
|
||||
|
||||
@@ -26,6 +29,15 @@ class AndroidActions extends BaseActions {
|
||||
}
|
||||
});
|
||||
|
||||
// Update handled keys list when keymap changes
|
||||
updateHandledKeys();
|
||||
|
||||
// Listen to keymap changes and update handled keys
|
||||
_keymapUpdateSubscription?.cancel();
|
||||
_keymapUpdateSubscription = supportedApp?.keymap.updateStream.listen((_) {
|
||||
updateHandledKeys();
|
||||
});
|
||||
|
||||
hidKeyPressed().listen((keyPressed) async {
|
||||
final hidDevice = HidDevice(keyPressed.source);
|
||||
final button = hidDevice.getOrAddButton(keyPressed.hidKey, () => ControllerButton(keyPressed.hidKey));
|
||||
@@ -90,4 +102,22 @@ class AndroidActions extends BaseActions {
|
||||
void ignoreHidDevices() {
|
||||
accessibilityHandler.ignoreHidDevices();
|
||||
}
|
||||
|
||||
void updateHandledKeys() {
|
||||
if (supportedApp == null) {
|
||||
accessibilityHandler.setHandledKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all keys from the keymap that have a mapping defined
|
||||
final handledKeys = supportedApp!.keymap.keyPairs
|
||||
.filter((keyPair) => !keyPair.hasNoAction)
|
||||
.expand((keyPair) => keyPair.buttons)
|
||||
.filter((e) => e.action == null && e.icon == null)
|
||||
.map((button) => button.name)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
accessibilityHandler.setHandledKeys(handledKeys);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class Permissions {
|
||||
} else if (Platform.isMacOS) {
|
||||
list = [
|
||||
BluetoothTurnedOn(),
|
||||
NotificationRequirement(),
|
||||
if (core.settings.getShowOnboarding()) NotificationRequirement(),
|
||||
];
|
||||
} else if (Platform.isIOS) {
|
||||
list = [
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:purchases_flutter/purchases_flutter.dart';
|
||||
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:version/version.dart';
|
||||
|
||||
/// RevenueCat-based IAP service for iOS, macOS, and Android
|
||||
class RevenueCatService {
|
||||
@@ -23,6 +24,7 @@ class RevenueCatService {
|
||||
static const String _purchaseStatusKey = 'iap_purchase_status';
|
||||
static const String _dailyCommandCountKey = 'iap_daily_command_count';
|
||||
static const String _lastCommandDateKey = 'iap_last_command_date';
|
||||
static const String _syncedPurchasesKey = 'iap_synced_purchases';
|
||||
|
||||
// RevenueCat entitlement identifier
|
||||
static const String fullVersionEntitlement = 'Full Version';
|
||||
@@ -139,6 +141,11 @@ class RevenueCatService {
|
||||
/// Check if the user has an active entitlement
|
||||
Future<void> _checkExistingPurchase() async {
|
||||
try {
|
||||
final storedStatus = await _prefs.read(key: _syncedPurchasesKey);
|
||||
if (storedStatus != "true") {
|
||||
await _prefs.write(key: _syncedPurchasesKey, value: "true");
|
||||
await Purchases.syncPurchases();
|
||||
}
|
||||
// Check current entitlement status from RevenueCat
|
||||
final customerInfo = await Purchases.getCustomerInfo();
|
||||
await _handleCustomerInfoUpdate(customerInfo);
|
||||
@@ -168,8 +175,13 @@ class RevenueCatService {
|
||||
} else {
|
||||
final purchasedVersion = customerInfo.originalApplicationVersion;
|
||||
core.connection.signalNotification(LogNotification('Apple receipt validated for version: $purchasedVersion'));
|
||||
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 1337;
|
||||
isPurchasedNotifier.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
|
||||
if (purchasedVersion != null && purchasedVersion.contains(".")) {
|
||||
final parsedVersion = Version.parse(purchasedVersion);
|
||||
isPurchasedNotifier.value = parsedVersion < Version(4, 2, 0);
|
||||
} else {
|
||||
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 1337;
|
||||
isPurchasedNotifier.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isPurchasedNotifier.value = hasEntitlement;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/main.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/iap/iap_manager.dart';
|
||||
import 'package:bike_control/utils/windows_store_environment.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
@@ -70,8 +71,11 @@ class WindowsIAPService {
|
||||
trialDaysRemaining = 0;
|
||||
}
|
||||
} else {
|
||||
final isStorePackaged = await WindowsStoreEnvironment.isPackaged();
|
||||
trial.isActive = isStorePackaged;
|
||||
trialDaysRemaining = 0;
|
||||
}
|
||||
|
||||
if (trial.isActive && !trial.isTrial && trialDaysRemaining <= 0) {
|
||||
IAPManager.instance.isPurchased.value = true;
|
||||
await _prefs.write(key: _purchaseStatusKey, value: "true");
|
||||
@@ -100,7 +104,7 @@ class WindowsIAPService {
|
||||
}
|
||||
|
||||
/// Check if the trial period has started
|
||||
bool get hasTrialStarted => trialDaysRemaining > 0;
|
||||
bool get hasTrialStarted => trialDaysRemaining >= 0;
|
||||
|
||||
/// Get the number of days remaining in the trial
|
||||
int trialDaysRemaining = 0;
|
||||
|
||||
@@ -14,7 +14,7 @@ import '../keymap.dart';
|
||||
class TrainingPeaks extends SupportedApp {
|
||||
TrainingPeaks()
|
||||
: super(
|
||||
name: 'TrainingPeaks Virtual / IndieVelo',
|
||||
name: 'TrainingPeaks Virtual',
|
||||
packageName: "com.indieVelo.client",
|
||||
compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values,
|
||||
supportsZwiftEmulation: false,
|
||||
|
||||
@@ -97,6 +97,10 @@ class Keymap {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void signalUpdate() {
|
||||
_updateStream.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
class KeyPair {
|
||||
|
||||
36
lib/utils/windows_store_environment.dart
Normal file
36
lib/utils/windows_store_environment.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Utilities for determining whether the app is running in a Windows Store/MSIX
|
||||
/// packaged context.
|
||||
///
|
||||
/// Security note:
|
||||
/// - Build-time secrets (e.g. --dart-define) are extractable from the binary.
|
||||
/// - This check is meant to answer a platform truth: "Is this app packaged?"
|
||||
/// - For real entitlement enforcement, rely on Store license APIs (and
|
||||
/// optionally a backend), not on compile-time flags.
|
||||
class WindowsStoreEnvironment {
|
||||
static const MethodChannel _channel = MethodChannel('bike_control/store_env');
|
||||
|
||||
/// Debug-only escape hatch to simulate Store packaged mode.
|
||||
///
|
||||
/// This is intentionally *not* a security feature.
|
||||
static const bool _forcePackagedForDebug = bool.fromEnvironment('FORCE_STORE_PACKAGED', defaultValue: false);
|
||||
|
||||
/// Returns true when running as an MSIX/Store packaged app.
|
||||
///
|
||||
/// In debug/profile you may set `--dart-define=FORCE_STORE_PACKAGED=true`
|
||||
/// for local testing.
|
||||
static Future<bool> isPackaged() async {
|
||||
if (!kReleaseMode && _forcePackagedForDebug) return true;
|
||||
|
||||
try {
|
||||
final result = await _channel.invokeMethod<bool>('isPackaged');
|
||||
return result ?? false;
|
||||
} catch (_) {
|
||||
// If the platform implementation isn't present (e.g., non-Windows),
|
||||
// treat as unpackaged.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,7 @@ class _LocalTileState extends State<LocalTile> {
|
||||
),
|
||||
];
|
||||
return ConnectionMethod(
|
||||
supportedActions: null,
|
||||
isEnabled: core.settings.getLocalEnabled(),
|
||||
type: ConnectionMethodType.local,
|
||||
showTroubleshooting: true,
|
||||
|
||||
@@ -25,6 +25,7 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
|
||||
valueListenable: core.whooshLink.isConnected,
|
||||
builder: (context, isConnected, _) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: core.whooshLink.supportedActions,
|
||||
isEnabled: core.settings.getMyWhooshLinkEnabled(),
|
||||
type: ConnectionMethodType.network,
|
||||
title: context.i18n.connectUsingMyWhooshLink,
|
||||
|
||||
@@ -24,6 +24,7 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlBluetoothTile> {
|
||||
valueListenable: core.obpBluetoothEmulator.connectedApp,
|
||||
builder: (context, isConnected, _) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: isConnected?.supportedActions,
|
||||
isEnabled: core.settings.getObpBleEnabled(),
|
||||
type: ConnectionMethodType.openBikeControl,
|
||||
title: context.i18n.connectUsingBluetooth,
|
||||
|
||||
@@ -24,6 +24,7 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlMdnsTile> {
|
||||
valueListenable: core.obpMdnsEmulator.connectedApp,
|
||||
builder: (context, isConnected, _) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: isConnected?.supportedActions,
|
||||
isEnabled: core.settings.getObpMdnsEnabled(),
|
||||
type: ConnectionMethodType.openBikeControl,
|
||||
title: context.i18n.connectDirectlyOverNetwork,
|
||||
|
||||
@@ -27,6 +27,7 @@ class _ZwiftTileState extends State<ZwiftMdnsTile> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: core.zwiftMdnsEmulator.supportedActions,
|
||||
type: ConnectionMethodType.network,
|
||||
isEnabled: core.settings.getZwiftMdnsEmulatorEnabled(),
|
||||
title: context.i18n.enableZwiftControllerNetwork,
|
||||
|
||||
@@ -27,6 +27,7 @@ class _ZwiftTileState extends State<ZwiftTile> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: core.zwiftEmulator.supportedActions,
|
||||
isEnabled: core.settings.getZwiftBleEmulatorEnabled(),
|
||||
type: ConnectionMethodType.bluetooth,
|
||||
instructionLink: 'INSTRUCTIONS_ZWIFT.md',
|
||||
|
||||
@@ -172,6 +172,7 @@ class _KeymapExplanationState extends State<KeymapExplanation> {
|
||||
keyPair: selectedKeyPair,
|
||||
keymap: widget.keymap,
|
||||
onUpdate: () {
|
||||
widget.keymap.signalUpdate();
|
||||
widget.onUpdate();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart' show LogLevel;
|
||||
import 'package:bike_control/bluetooth/messages/notification.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/widgets/ui/connection_method.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import '../utils/requirements/multi.dart';
|
||||
|
||||
@@ -26,6 +26,7 @@ class _PairWidgetState extends State<RemotePairingWidget> {
|
||||
valueListenable: core.remotePairing.isConnected,
|
||||
builder: (context, isConnected, child) {
|
||||
return ConnectionMethod(
|
||||
supportedActions: null,
|
||||
isEnabled: core.logic.isRemoteControlEnabled,
|
||||
isStarted: isStarted,
|
||||
showTroubleshooting: true,
|
||||
|
||||
@@ -2,8 +2,10 @@ import 'package:bike_control/gen/l10n.dart';
|
||||
import 'package:bike_control/pages/button_edit.dart';
|
||||
import 'package:bike_control/pages/markdown.dart';
|
||||
import 'package:bike_control/utils/i18n_extension.dart';
|
||||
import 'package:bike_control/utils/keymap/buttons.dart';
|
||||
import 'package:bike_control/utils/requirements/platform.dart';
|
||||
import 'package:bike_control/widgets/ui/beta_pill.dart';
|
||||
import 'package:bike_control/widgets/ui/colored_title.dart';
|
||||
import 'package:bike_control/widgets/ui/permissions_list.dart';
|
||||
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
|
||||
import 'package:bike_control/widgets/ui/toast.dart';
|
||||
@@ -29,6 +31,7 @@ class ConnectionMethod extends StatefulWidget {
|
||||
final bool isEnabled;
|
||||
final bool showTroubleshooting;
|
||||
final List<PlatformRequirement> requirements;
|
||||
final List<InGameAction>? supportedActions;
|
||||
final Function(bool) onChange;
|
||||
|
||||
const ConnectionMethod({
|
||||
@@ -41,6 +44,7 @@ class ConnectionMethod extends StatefulWidget {
|
||||
this.instructionLink,
|
||||
this.showTroubleshooting = false,
|
||||
required this.onChange,
|
||||
required this.supportedActions,
|
||||
required this.requirements,
|
||||
this.isConnected,
|
||||
this.isStarted,
|
||||
@@ -151,19 +155,70 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
|
||||
if (widget.isEnabled && widget.additionalChild != null) widget.additionalChild!,
|
||||
if (widget.instructionLink != null || widget.showTroubleshooting) SizedBox(height: 8),
|
||||
if (widget.instructionLink != null)
|
||||
Button(
|
||||
style: widget.isEnabled && Theme.of(context).brightness == Brightness.light
|
||||
? ButtonStyle.outline().withBorder(border: Border.all(color: Colors.gray.shade500))
|
||||
: ButtonStyle.outline(),
|
||||
leading: Icon(Icons.help_outline),
|
||||
onPressed: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (c) => MarkdownPage(assetPath: widget.instructionLink!),
|
||||
);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context).instructions),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Button(
|
||||
style: widget.isEnabled && Theme.of(context).brightness == Brightness.light
|
||||
? ButtonStyle.outline().withBorder(border: Border.all(color: Colors.gray.shade500))
|
||||
: ButtonStyle.outline(),
|
||||
leading: Icon(Icons.help_outline),
|
||||
onPressed: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.bottom,
|
||||
builder: (c) => MarkdownPage(assetPath: widget.instructionLink!),
|
||||
);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context).instructions),
|
||||
),
|
||||
if (widget.supportedActions != null)
|
||||
Button.outline(
|
||||
leading: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 6),
|
||||
margin: EdgeInsets.only(right: 4),
|
||||
child: Text(
|
||||
widget.supportedActions!.length.toString(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primaryForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
openDrawer(
|
||||
context: context,
|
||||
position: OverlayPosition.right,
|
||||
builder: (c) => Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 32, horizontal: 16),
|
||||
width: 230,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 12,
|
||||
children: [
|
||||
ColoredTitle(
|
||||
text: AppLocalizations.of(context).supportedActions,
|
||||
),
|
||||
Gap(12),
|
||||
...widget.supportedActions!.map(
|
||||
(e) => Basic(
|
||||
leading: e.icon != null ? Icon(e.icon) : null,
|
||||
title: Text(e.title),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(AppLocalizations.of(context).supportedActions),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: bike_control
|
||||
description: "BikeControl - Control your virtual riding"
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
version: 4.3.0+73
|
||||
version: 4.3.1+76
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.0
|
||||
|
||||
86
test/thinkrider_vs200_test.dart
Normal file
86
test/thinkrider_vs200_test.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:bike_control/bluetooth/devices/thinkrider/thinkrider_vs200.dart';
|
||||
import 'package:bike_control/utils/actions/base_actions.dart';
|
||||
import 'package:bike_control/utils/core.dart';
|
||||
import 'package:universal_ble/universal_ble.dart';
|
||||
|
||||
void main() {
|
||||
group('ThinkRider VS200 Virtual Shifter Tests', () {
|
||||
test('Test shift up button press with correct pattern', () {
|
||||
core.actionHandler = StubActions();
|
||||
|
||||
final stubActions = core.actionHandler as StubActions;
|
||||
|
||||
final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285'));
|
||||
|
||||
// Send shift up pattern: F3-05-03-01-FC
|
||||
device.processCharacteristic(
|
||||
ThinkRiderVs200Constants.CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('F3050301FC'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, ThinkRiderVs200Buttons.shiftUp);
|
||||
});
|
||||
|
||||
test('Test shift down button press with correct pattern', () {
|
||||
core.actionHandler = StubActions();
|
||||
final stubActions = core.actionHandler as StubActions;
|
||||
final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285'));
|
||||
|
||||
// Send shift down pattern: F3-05-03-00-FB
|
||||
device.processCharacteristic(
|
||||
ThinkRiderVs200Constants.CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('F3050300FB'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, ThinkRiderVs200Buttons.shiftDown);
|
||||
});
|
||||
|
||||
test('Test multiple button presses', () {
|
||||
core.actionHandler = StubActions();
|
||||
final stubActions = core.actionHandler as StubActions;
|
||||
final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285'));
|
||||
|
||||
// Shift up
|
||||
device.processCharacteristic(
|
||||
ThinkRiderVs200Constants.CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('F3050301FC'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, ThinkRiderVs200Buttons.shiftUp);
|
||||
stubActions.performedActions.clear();
|
||||
|
||||
// Shift down
|
||||
device.processCharacteristic(
|
||||
ThinkRiderVs200Constants.CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('F3050300FB'),
|
||||
);
|
||||
expect(stubActions.performedActions.length, 1);
|
||||
expect(stubActions.performedActions.first, ThinkRiderVs200Buttons.shiftDown);
|
||||
});
|
||||
|
||||
test('Test incorrect pattern does not trigger action', () {
|
||||
core.actionHandler = StubActions();
|
||||
final stubActions = core.actionHandler as StubActions;
|
||||
final device = ThinkRiderVs200(BleDevice(deviceId: 'deviceId', name: 'THINK VS01-0000285'));
|
||||
|
||||
// Send random pattern
|
||||
device.processCharacteristic(
|
||||
ThinkRiderVs200Constants.CHARACTERISTIC_UUID,
|
||||
_hexToUint8List('0000000000'),
|
||||
);
|
||||
expect(stubActions.performedActions.isEmpty, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Uint8List _hexToUint8List(String seq) {
|
||||
return Uint8List.fromList(
|
||||
List.generate(
|
||||
seq.length ~/ 2,
|
||||
(i) => int.parse(seq.substring(i * 2, i * 2 + 2), radix: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class FlutterWindow : public Win32Window {
|
||||
explicit FlutterWindow(const flutter::DartProject& project);
|
||||
virtual ~FlutterWindow();
|
||||
|
||||
flutter::FlutterViewController* GetController() const { return flutter_controller_.get(); }
|
||||
|
||||
protected:
|
||||
// Win32Window:
|
||||
bool OnCreate() override;
|
||||
|
||||
@@ -1,15 +1,56 @@
|
||||
#include <flutter/dart_project.h>
|
||||
#include <flutter/flutter_view_controller.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <appmodel.h>
|
||||
#include "flutter_window.h"
|
||||
#include "utils.h"
|
||||
|
||||
#include <flutter/method_channel.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
bool IsPackagedApp()
|
||||
{
|
||||
UINT32 length = 0;
|
||||
// GetCurrentPackageFullName returns APPMODEL_ERROR_NO_PACKAGE when unpackaged.
|
||||
const LONG rc = GetCurrentPackageFullName(&length, nullptr);
|
||||
return rc != APPMODEL_ERROR_NO_PACKAGE;
|
||||
}
|
||||
|
||||
void RegisterStoreEnvironmentChannel(flutter::FlutterViewController *controller)
|
||||
{
|
||||
auto channel = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||
controller->engine()->messenger(), "bike_control/store_env",
|
||||
&flutter::StandardMethodCodec::GetInstance());
|
||||
|
||||
channel->SetMethodCallHandler(
|
||||
[](const flutter::MethodCall<flutter::EncodableValue> &call,
|
||||
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result)
|
||||
{
|
||||
if (call.method_name() == "isPackaged")
|
||||
{
|
||||
result->Success(flutter::EncodableValue(IsPackagedApp()));
|
||||
return;
|
||||
}
|
||||
result->NotImplemented();
|
||||
});
|
||||
|
||||
// Channel must outlive this function.
|
||||
static std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> s_channel;
|
||||
s_channel = std::move(channel);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
_In_ wchar_t *command_line, _In_ int show_command) {
|
||||
_In_ wchar_t *command_line, _In_ int show_command)
|
||||
{
|
||||
// Attach to console when present (e.g., 'flutter run') or create a
|
||||
// new console when running with a debugger.
|
||||
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) {
|
||||
if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent())
|
||||
{
|
||||
CreateAndAttachConsole();
|
||||
}
|
||||
|
||||
@@ -27,13 +68,20 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"bike_control", origin, size)) {
|
||||
if (!window.Create(L"bike_control", origin, size))
|
||||
{
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
// Register our small environment channel after engine/window creation.
|
||||
// FlutterWindow exposes the controller via GetController().
|
||||
RegisterStoreEnvironmentChannel(window.GetController());
|
||||
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
::MSG msg;
|
||||
while (::GetMessage(&msg, nullptr, 0, 0)) {
|
||||
while (::GetMessage(&msg, nullptr, 0, 0))
|
||||
{
|
||||
::TranslateMessage(&msg);
|
||||
::DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Trial {
|
||||
final bool isTrial;
|
||||
final String remainingDays;
|
||||
final bool isActive;
|
||||
bool isActive;
|
||||
final bool isTrialOwnedByThisUser;
|
||||
|
||||
Trial({
|
||||
|
||||
Reference in New Issue
Block a user