Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
d53a1905b1 Create base classes MdnsEmulator and BluetoothEmulator with shared logic
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-24 08:48:04 +00:00
copilot-swe-agent[bot]
8c689cbdb1 Initial plan 2025-12-24 08:35:45 +00:00
50 changed files with 579 additions and 1729 deletions

View File

@@ -119,7 +119,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: macos
args: "-- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}"
args: "-- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
- name: Decode Keystore
if: inputs.build_android
@@ -133,7 +133,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: android
args: "--artifact=apk -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}"
args: "--artifact=apk"
- name: Build Web
if: inputs.build_web
@@ -169,7 +169,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: ios
args: "--export-options-plist ios/ExportOptions.plist -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}"
args: "--export-options-plist ios/ExportOptions.plist -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
- name: Prepare App Store authentication key
if: inputs.build_ios || inputs.build_mac

View File

@@ -91,21 +91,21 @@ jobs:
with:
platform: macos
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
args: '--allow-asset-diffs --allow-native-diffs'
- name: 🚀 Shorebird Patch Android
uses: shorebirdtech/shorebird-patch@v1
with:
platform: android
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }}'
args: '--allow-asset-diffs --allow-native-diffs'
- name: 🚀 Shorebird Patch iOS
uses: shorebirdtech/shorebird-patch@v1
with:
platform: ios
release-version: latest
args: '--allow-asset-diffs --allow-native-diffs -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
args: '--allow-asset-diffs --allow-native-diffs'
# shorebird struggles with the app from GitHub
- name: Build macOS

View File

@@ -1,10 +1,11 @@
## 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!
3) open BikeControl, follow the on-screen instructions
2) launch MyWhoosh Link, check if the "Link" connection works
3) **close MyWhoosh Link** - very important!
4) open BikeControl, follow 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.
Once you've confirmed the connection in BikeControl you won't have to repeat step 2 and 3 again in the future. This is just to make sure the connection works in general.
And here's a video with a few explanations:

View File

@@ -5,10 +5,10 @@ import android.os.Handler
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.android.FlutterActivity
import org.flame_engine.gamepads_android.GamepadsCompatibleActivity
class MainActivity: FlutterFragmentActivity(), GamepadsCompatibleActivity {
class MainActivity: FlutterActivity(), GamepadsCompatibleActivity {
var keyListener: ((KeyEvent) -> Boolean)? = null
var motionListener: ((MotionEvent) -> Boolean)? = null

View File

@@ -34,22 +34,8 @@ PODS:
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- purchases_flutter (9.10.2):
- Flutter
- PurchasesHybridCommon (= 17.25.0)
- purchases_ui_flutter (9.10.2):
- Flutter
- PurchasesHybridCommonUI (= 17.25.0)
- PurchasesHybridCommon (17.25.0):
- RevenueCat (= 5.51.1)
- PurchasesHybridCommonUI (17.25.0):
- PurchasesHybridCommon (= 17.25.0)
- RevenueCatUI (= 5.51.1)
- restart_app (0.0.1):
- Flutter
- RevenueCat (5.51.1)
- RevenueCatUI (5.51.1):
- RevenueCat (= 5.51.1)
- sensors_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -80,8 +66,6 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`)
- purchases_ui_flutter (from `.symlinks/plugins/purchases_ui_flutter/ios`)
- restart_app (from `.symlinks/plugins/restart_app/ios`)
- sensors_plus (from `.symlinks/plugins/sensors_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -89,13 +73,6 @@ DEPENDENCIES:
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
- PurchasesHybridCommon
- PurchasesHybridCommonUI
- RevenueCat
- RevenueCatUI
EXTERNAL SOURCES:
bluetooth_low_energy_darwin:
:path: ".symlinks/plugins/bluetooth_low_energy_darwin/darwin"
@@ -129,10 +106,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
purchases_flutter:
:path: ".symlinks/plugins/purchases_flutter/ios"
purchases_ui_flutter:
:path: ".symlinks/plugins/purchases_ui_flutter/ios"
restart_app:
:path: ".symlinks/plugins/restart_app/ios"
sensors_plus:
@@ -163,13 +136,7 @@ SPEC CHECKSUMS:
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
purchases_flutter: 7cbb87481a018c1bb8b4966b7d446a7c19d96d87
purchases_ui_flutter: 1905cebf3f46e03aeefba0f6258000f3cdd32641
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
PurchasesHybridCommonUI: 3c1f78addfb3f470713548b4eac8c59254d0efe6
restart_app: 806659942bf932f6ce51c5372f91ce5e81c8c14a
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
RevenueCatUI: c6acb3648fa58ccb4f0f178556ed0278b796234f
sensors_plus: 7229095999f30740798f0eeef5cd120357a8f4f2
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6

View File

@@ -38,11 +38,6 @@ class KeyPressSimulator {
return _platform.simulateKeyPress(key: key, modifiers: modifiers, keyDown: false);
}
/// Simulate media key press.
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) {
return _platform.simulateMediaKey(mediaKey);
}
@Deprecated('Please use simulateKeyDown & simulateKeyUp methods.')
Future<void> simulateCtrlCKeyPress() async {
const key = PhysicalKeyboardKey.keyC;

View File

@@ -22,9 +22,6 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
case "simulateMouseClick":
simulateMouseClick(call, result: result)
break
case "simulateMediaKey":
simulateMediaKey(call, result: result)
break
default:
result(FlutterMethodNotImplemented)
}
@@ -117,62 +114,4 @@ public class KeypressSimulatorMacosPlugin: NSObject, FlutterPlugin {
eventKeyPress!.flags = flags
return eventKeyPress!
}
public func simulateMediaKey(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
let args:[String: Any] = call.arguments as! [String: Any]
let keyIdentifier: String = args["key"] as! String
// Map string identifier to macOS NX key codes
var mediaKeyCode: Int32 = 0
switch keyIdentifier {
case "playPause":
mediaKeyCode = NX_KEYTYPE_PLAY
case "stop":
// macOS doesn't have a dedicated stop key in its media control API.
// Following macOS conventions, we map stop to play/pause which toggles playback.
// This matches the behavior of the physical media keys on Mac keyboards.
mediaKeyCode = NX_KEYTYPE_PLAY
case "next":
mediaKeyCode = NX_KEYTYPE_FAST
case "previous":
mediaKeyCode = NX_KEYTYPE_REWIND
case "volumeUp":
mediaKeyCode = NX_KEYTYPE_SOUND_UP
case "volumeDown":
mediaKeyCode = NX_KEYTYPE_SOUND_DOWN
default:
result(FlutterError(code: "UNSUPPORTED_KEY", message: "Unsupported media key identifier", details: nil))
return
}
// Create and post the media key event (key down)
let eventDown = NSEvent.otherEvent(
with: .systemDefined,
location: NSPoint.zero,
modifierFlags: NSEvent.ModifierFlags(rawValue: 0xa00),
timestamp: 0,
windowNumber: 0,
context: nil,
subtype: 8,
data1: Int((mediaKeyCode << 16) | (0xa << 8)),
data2: -1
)
eventDown?.cgEvent?.post(tap: .cghidEventTap)
// Create and post the media key event (key up)
let eventUp = NSEvent.otherEvent(
with: .systemDefined,
location: NSPoint.zero,
modifierFlags: NSEvent.ModifierFlags(rawValue: 0xb00),
timestamp: 0,
windowNumber: 0,
context: nil,
subtype: 8,
data1: Int((mediaKeyCode << 16) | (0xb << 8)),
data2: -1
)
eventUp?.cgEvent?.post(tap: .cghidEventTap)
result(true)
}
}

View File

@@ -61,27 +61,4 @@ class MethodChannelKeyPressSimulator extends KeyPressSimulatorPlatform {
};
await methodChannel.invokeMethod('simulateMouseClick', arguments);
}
@override
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) async {
// Map PhysicalKeyboardKey to string identifier since keyCode is null for media keys
final keyMap = {
PhysicalKeyboardKey.mediaPlayPause: 'playPause',
PhysicalKeyboardKey.mediaStop: 'stop',
PhysicalKeyboardKey.mediaTrackNext: 'next',
PhysicalKeyboardKey.mediaTrackPrevious: 'previous',
PhysicalKeyboardKey.audioVolumeUp: 'volumeUp',
PhysicalKeyboardKey.audioVolumeDown: 'volumeDown',
};
final keyIdentifier = keyMap[mediaKey];
if (keyIdentifier == null) {
throw UnsupportedError('Unsupported media key: $mediaKey');
}
final Map<String, Object?> arguments = {
'key': keyIdentifier,
};
await methodChannel.invokeMethod('simulateMediaKey', arguments);
}
}

View File

@@ -42,10 +42,6 @@ abstract class KeyPressSimulatorPlatform extends PlatformInterface {
}
Future<void> simulateMouseClick(Offset position, {required bool keyDown}) {
throw UnimplementedError('simulateMouseClick() has not been implemented.');
}
Future<void> simulateMediaKey(PhysicalKeyboardKey mediaKey) {
throw UnimplementedError('simulateMediaKey() has not been implemented.');
throw UnimplementedError('simulateKeyPress() has not been implemented.');
}
}

View File

@@ -12,7 +12,6 @@
#include <memory>
#include <sstream>
#include <unordered_map>
using flutter::EncodableList;
using flutter::EncodableMap;
@@ -254,49 +253,6 @@ HWND FindTargetWindow(const std::string& processName, const std::string& windowT
return data.foundWindow;
}
void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
const EncodableMap& args = std::get<EncodableMap>(*method_call.arguments());
std::string keyIdentifier = std::get<std::string>(args.at(EncodableValue("key")));
// Map string identifier to Windows virtual key codes
static const std::unordered_map<std::string, UINT> keyMap = {
{"playPause", VK_MEDIA_PLAY_PAUSE},
{"stop", VK_MEDIA_STOP},
{"next", VK_MEDIA_NEXT_TRACK},
{"previous", VK_MEDIA_PREV_TRACK},
{"volumeUp", VK_VOLUME_UP},
{"volumeDown", VK_VOLUME_DOWN}
};
auto it = keyMap.find(keyIdentifier);
if (it == keyMap.end()) {
result->Error("UNSUPPORTED_KEY", "Unsupported media key identifier");
return;
}
UINT vkCode = it->second;
// Send key down event
INPUT inputs[2] = {};
inputs[0].type = INPUT_KEYBOARD;
inputs[0].ki.wVk = static_cast<WORD>(vkCode);
inputs[0].ki.dwFlags = 0; // Key down
// Send key up event
inputs[1].type = INPUT_KEYBOARD;
inputs[1].ki.wVk = static_cast<WORD>(vkCode);
inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;
UINT eventsSent = SendInput(2, inputs, sizeof(INPUT));
if (eventsSent != 2) {
result->Error("SEND_INPUT_FAILED", "Failed to send media key input events");
return;
}
result->Success(flutter::EncodableValue(true));
}
void KeypressSimulatorWindowsPlugin::HandleMethodCall(
@@ -306,8 +262,6 @@ void KeypressSimulatorWindowsPlugin::HandleMethodCall(
SimulateKeyPress(method_call, std::move(result));
} else if (method_call.method_name().compare("simulateMouseClick") == 0) {
SimulateMouseClick(method_call, std::move(result));
} else if (method_call.method_name().compare("simulateMediaKey") == 0) {
SimulateMediaKey(method_call, std::move(result));
} else {
result->NotImplemented();
}

View File

@@ -30,9 +30,7 @@ class KeypressSimulatorWindowsPlugin : public flutter::Plugin {
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
void KeypressSimulatorWindowsPlugin::SimulateMediaKey(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
// Called when a method is called on this plugin's channel from Dart.
void HandleMethodCall(

View File

@@ -11,7 +11,6 @@ import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:bike_control/gen/l10n.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/requirements/android.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
@@ -325,8 +324,6 @@ class Connection {
await device.connect();
signalChange(device);
IAPManager.instance.setAttributes();
core.actionHandler.supportedApp?.keymap.addNewButtons(device.availableButtons);
_streamSubscriptions[device] = actionSubscription;

View File

@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart' show LogLevel;
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/actions/desktop.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
@@ -229,7 +228,7 @@ abstract class BaseDevice {
_getCommandLimitMessage(),
buttonTitle: AppLocalizations.current.purchase,
onTap: () {
IAPManager.instance.purchaseFullVersion(navigatorKey.currentContext!);
IAPManager.instance.purchaseFullVersion();
},
),
);

View File

@@ -0,0 +1,174 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
/// Base class for Bluetooth Low Energy (BLE) peripheral emulators.
/// Provides common functionality for peripheral management, service advertising,
/// and connection state handling.
abstract class BluetoothEmulator extends TrainerConnection {
final _peripheralManager = PeripheralManager();
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
BluetoothEmulator({
required super.title,
required super.supportedActions,
});
/// Gets the peripheral manager instance.
@protected
PeripheralManager get peripheralManager => _peripheralManager;
/// Gets whether the emulator is currently loading/initializing.
bool get isLoading => _isLoading;
/// Sets the loading state.
@protected
set isLoading(bool value) => _isLoading = value;
/// Gets whether services have been added to the peripheral manager.
@protected
bool get isServiceAdded => _isServiceAdded;
/// Sets whether services have been added.
@protected
set isServiceAdded(bool value) => _isServiceAdded = value;
/// Gets whether event subscriptions are active.
@protected
bool get isSubscribedToEvents => _isSubscribedToEvents;
/// Sets whether event subscriptions are active.
@protected
set isSubscribedToEvents(bool value) => _isSubscribedToEvents = value;
/// Gets the current connected central device.
@protected
Central? get central => _central;
/// Sets the connected central device.
@protected
set central(Central? value) => _central = value;
/// Subscribes to peripheral manager state changes.
@protected
void subscribeToStateChanges() {
_peripheralManager.stateChanged.forEach((state) {
if (kDebugMode) {
print('Peripheral manager state: ${state.state}');
}
});
}
/// Subscribes to connection state changes on Android.
/// Handles connection and disconnection events.
@protected
void subscribeToConnectionStateChanges(VoidCallback? onUpdate) {
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
if (kDebugMode) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
}
if (state.state == ConnectionState.connected) {
// Override in subclass if needed
} else if (state.state == ConnectionState.disconnected) {
handleDisconnection();
onUpdate?.call();
}
});
}
}
/// Handles disconnection events. Can be overridden by subclasses.
@protected
void handleDisconnection() {
_central = null;
isConnected.value = false;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
}
/// Requests Bluetooth advertise permission on Android.
/// Returns true if permission is granted, false otherwise.
@protected
Future<bool> requestBluetoothAdvertisePermission() async {
if (!kIsWeb && Platform.isAndroid) {
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
if (kDebugMode) {
print('Bluetooth advertise permission not granted');
}
return false;
}
}
return true;
}
/// Waits for the peripheral manager to be powered on.
/// Returns true if powered on, false if cancelled (e.g., by user action).
@protected
Future<bool> waitForPoweredOn(bool Function() shouldContinue) async {
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && shouldContinue()) {
if (kDebugMode) {
print('Waiting for peripheral manager to be powered on...');
}
await Future.delayed(const Duration(seconds: 1));
}
return _peripheralManager.state == BluetoothLowEnergyState.poweredOn;
}
/// Adds a GATT service to the peripheral manager.
@protected
Future<void> addService(GATTService service) async {
await _peripheralManager.addService(service);
}
/// Starts advertising with the given advertisement configuration.
@protected
Future<void> startAdvertising(Advertisement advertisement) async {
await _peripheralManager.startAdvertising(advertisement);
}
/// Stops advertising and resets state.
@protected
Future<void> stopAdvertising() async {
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
}
/// Notifies a characteristic with the given value to the connected central.
@protected
Future<void> notifyCharacteristic(
Central central,
GATTCharacteristic characteristic, {
required Uint8List value,
}) async {
await _peripheralManager.notifyCharacteristic(central, characteristic, value: value);
}
/// Cleans up resources by stopping advertising and removing services.
@protected
void cleanup() {
_peripheralManager.stopAdvertising();
_peripheralManager.removeAllServices();
_isServiceAdded = false;
_isSubscribedToEvents = false;
_central = null;
isConnected.value = false;
isStarted.value = false;
_isLoading = false;
}
}

View File

@@ -0,0 +1,153 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:flutter/foundation.dart';
import 'package:nsd/nsd.dart';
/// Base class for mDNS-based emulators that advertise services over the network.
/// Provides common functionality for TCP server management, mDNS registration,
/// and network interface discovery.
abstract class MdnsEmulator extends TrainerConnection {
ServerSocket? _tcpServer;
Registration? _mdnsRegistration;
Socket? _socket;
MdnsEmulator({
required super.title,
required super.supportedActions,
});
/// Gets the TCP server instance, if available.
@protected
ServerSocket? get tcpServer => _tcpServer;
/// Gets the current client socket connection, if available.
@protected
Socket? get socket => _socket;
/// Sets the client socket connection.
@protected
set socket(Socket? value) => _socket = value;
/// Gets the mDNS registration, if available.
@protected
Registration? get mdnsRegistration => _mdnsRegistration;
/// Finds and returns a local IPv4 address from available network interfaces.
/// Returns null if no suitable address is found.
@protected
Future<InternetAddress?> findLocalIP() async {
final interfaces = await NetworkInterface.list();
InternetAddress? localIP;
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
localIP = addr;
break;
}
}
if (localIP != null) break;
}
return localIP;
}
/// Creates a TCP server on the specified port.
/// The server listens on all IPv6 addresses (with v6Only: false to also accept IPv4).
@protected
Future<ServerSocket> createTcpServer(int port) async {
try {
_tcpServer = await ServerSocket.bind(
InternetAddress.anyIPv6,
port,
shared: true,
v6Only: false,
);
if (kDebugMode) {
print('TCP Server started on port ${_tcpServer!.port}');
}
return _tcpServer!;
} catch (e) {
if (kDebugMode) {
print('Failed to start server: $e');
}
rethrow;
}
}
/// Registers an mDNS service with the given configuration.
@protected
Future<Registration> registerMdnsService(Service service) async {
if (kDebugMode) {
enableLogging(LogTopic.calls);
enableLogging(LogTopic.errors);
}
disableServiceTypeValidation(true);
_mdnsRegistration = await register(service);
if (kDebugMode) {
print('mDNS service registered: ${service.name}');
}
return _mdnsRegistration!;
}
/// Unregisters the mDNS service if one is registered.
@protected
void unregisterMdnsService() {
if (_mdnsRegistration != null) {
unregister(_mdnsRegistration!);
_mdnsRegistration = null;
}
}
/// Closes the TCP server and client socket.
@protected
void closeTcpServer() {
_socket?.destroy();
_socket = null;
_tcpServer?.close();
_tcpServer = null;
}
/// Writes data to the client socket.
@protected
void writeToSocket(Socket socket, List<int> data) {
if (kDebugMode) {
print('Sending response: ${bytesToHex(data)}');
}
socket.add(data);
}
/// Stops the emulator by closing connections and unregistering services.
void stop() {
isStarted.value = false;
isConnected.value = false;
closeTcpServer();
unregisterMdnsService();
if (kDebugMode) {
print('Stopped ${runtimeType}');
}
}
}
/// Converts a list of bytes to a hexadecimal string representation.
String bytesToHex(List<int> bytes, {bool spaced = false}) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : '');
}
/// Converts a list of bytes to a readable hexadecimal string with spaces.
String bytesToReadableHex(List<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
}
/// Converts a hexadecimal string to a list of bytes.
List<int> hexToBytes(String hex) {
final bytes = <int>[];
for (var i = 0; i < hex.length; i += 2) {
final byte = hex.substring(i, i + 2);
bytes.add(int.parse(byte, radix: 16));
}
return bytes;
}

View File

@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
@@ -15,12 +15,8 @@ import 'package:flutter/foundation.dart';
import '../../messages/notification.dart' show AlertNotification;
class OpenBikeControlBluetoothEmulator extends TrainerConnection {
late final _peripheralManager = PeripheralManager();
class OpenBikeControlBluetoothEmulator extends BluetoothEmulator {
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier<AppInfo?>(null);
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
late GATTCharacteristic _buttonCharacteristic;
@@ -35,13 +31,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
Future<void> startServer() async {
isStarted.value = true;
_peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
subscribeToStateChanges();
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
peripheralManager.connectionStateChanged.forEach((state) {
if (kDebugMode) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
}
if (state.state == ConnectionState.connected) {
} else if (state.state == ConnectionState.disconnected) {
if (connectedApp.value != null) {
@@ -51,14 +47,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
}
isConnected.value = false;
connectedApp.value = null;
_central = null;
central = null;
}
});
}
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getObpBleEnabled()) {
print('Waiting for peripheral manager to be powered on...');
await Future.delayed(Duration(seconds: 1));
if (!await waitForPoweredOn(() => core.settings.getObpBleEnabled())) {
return;
}
_buttonCharacteristic = GATTCharacteristic.mutable(
@@ -70,12 +65,12 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
permissions: [],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!isServiceAdded) {
await Future.delayed(const Duration(seconds: 1));
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
_peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
if (!isSubscribedToEvents) {
isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
@@ -85,20 +80,20 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
final request = eventArgs.request;
final trimmedValue = Uint8List.fromList([]);
await _peripheralManager.respondReadRequestWithValue(
await peripheralManager.respondReadRequestWithValue(
request,
value: trimmedValue,
);
// You can respond to read requests here if needed
});
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
_central = char.central;
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
central = char.central;
print(
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
);
});
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
final characteristic = eventArgs.characteristic;
final request = eventArgs.request;
final value = request.value;
@@ -125,13 +120,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
}
await _peripheralManager.respondWriteRequest(request);
await peripheralManager.respondWriteRequest(request);
});
}
if (!Platform.isWindows) {
// Device Information
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180A'),
isPrimary: true,
@@ -162,7 +157,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
);
}
// Battery Service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
@@ -184,7 +179,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
);
// Unknown Service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString(OpenBikeControlConstants.SERVICE_UUID),
isPrimary: true,
@@ -206,7 +201,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
includedServices: [],
),
);
_isServiceAdded = true;
isServiceAdded = true;
}
final advertisement = Advertisement(
@@ -215,16 +210,14 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
);
print('Starting advertising with OpenBikeControl service...');
await _peripheralManager.startAdvertising(advertisement);
await startAdvertising(advertisement);
}
Future<void> stopServer() async {
if (kDebugMode) {
print('Stopping OpenBikeControl BLE server...');
}
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
await stopAdvertising();
connectedApp.value = null;
}
@@ -238,7 +231,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
if (inGameAction == null) {
return Error('Invalid in-game action for key pair: $keyPair');
} else if (_central == null) {
} else if (central == null) {
return Error('No central connected');
} else if (connectedApp.value == null) {
return Error('No app info received from central');
@@ -250,16 +243,16 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
);
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataDown);
await notifyCharacteristic(central!, _buttonCharacteristic, value: responseDataDown);
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
);
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataUp);
await notifyCharacteristic(central!, _buttonCharacteristic, value: responseDataUp);
} else {
final responseData = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
);
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseData);
await notifyCharacteristic(central!, _buttonCharacteristic, value: responseData);
}
return Success('Buttons ${inGameAction.title} sent');

View File

@@ -1,10 +1,8 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
@@ -14,16 +12,11 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:nsd/nsd.dart';
class OpenBikeControlMdnsEmulator extends TrainerConnection {
ServerSocket? _server;
Registration? _mdnsRegistration;
class OpenBikeControlMdnsEmulator extends MdnsEmulator {
static const String connectionTitle = 'OpenBikeControl mDNS Emulator';
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier(null);
Socket? _socket;
OpenBikeControlMdnsEmulator()
: super(
title: connectionTitle,
@@ -35,18 +28,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
isStarted.value = true;
// Get local IP
final interfaces = await NetworkInterface.list();
InternetAddress? localIP;
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
localIP = addr;
break;
}
}
if (localIP != null) break;
}
final localIP = await findLocalIP();
if (localIP == null) {
throw 'Could not find network interface';
@@ -54,15 +36,9 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
await _createTcpServer();
if (kDebugMode) {
enableLogging(LogTopic.calls);
enableLogging(LogTopic.errors);
}
disableServiceTypeValidation(true);
try {
// Create service
_mdnsRegistration = await register(
await registerMdnsService(
Service(
name: 'BikeControl',
type: '_openbikecontrol._tcp',
@@ -79,7 +55,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
},
),
);
print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration');
print('Service: ${mdnsRegistration!.id} at ${localIP.address}:$mdnsRegistration');
print('Server started - advertising service!');
} catch (e, s) {
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start mDNS server: $e'));
@@ -91,37 +67,17 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
if (kDebugMode) {
print('Stopping OpenBikeControl mDNS server...');
}
if (_mdnsRegistration != null) {
unregister(_mdnsRegistration!);
_mdnsRegistration = null;
}
isStarted.value = false;
isConnected.value = false;
stop();
connectedApp.value = null;
_socket?.destroy();
_socket = null;
}
Future<void> _createTcpServer() async {
try {
_server = await ServerSocket.bind(
InternetAddress.anyIPv6,
36867,
shared: true,
v6Only: false,
);
} catch (e) {
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start server: $e'));
rethrow;
}
if (kDebugMode) {
print('Server started on port ${_server!.port}');
}
await createTcpServer(36867);
// Accept connection
_server!.listen(
tcpServer!.listen(
(Socket socket) {
_socket = socket;
this.socket = socket;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
@@ -155,7 +111,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
);
isConnected.value = false;
connectedApp.value = null;
_socket = null;
this.socket = null;
},
);
},
@@ -172,7 +128,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
if (inGameAction == null) {
return Error('Invalid in-game action for key pair: $keyPair');
} else if (_socket == null) {
} else if (socket == null) {
print('No client connected, cannot send button press');
return Error('No client connected');
} else if (connectedApp.value == null) {
@@ -185,23 +141,18 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
);
_write(_socket!, responseDataDown);
writeToSocket(socket!, responseDataDown);
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
);
_write(_socket!, responseDataUp);
writeToSocket(socket!, responseDataUp);
} else {
final responseData = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
);
_write(_socket!, responseData);
writeToSocket(socket!, responseData);
}
return Success('Sent ${inGameAction.title} button press');
}
void _write(Socket socket, List<int> responseData) {
debugPrint('Sending response: ${bytesToHex(responseData)}');
socket.add(responseData);
}
}

View File

@@ -28,19 +28,20 @@ class OpenBikeProtocolParser {
0x02: ControllerButton('Shift Down', identifier: 0x02, action: InGameAction.shiftDown),
0x03: ControllerButton('Gear Set', identifier: 0x03),
// Navigation (0x10-0x1F)
0x10: ControllerButton('Up', identifier: 0x10, action: InGameAction.up),
0x11: ControllerButton('Down', identifier: 0x11, action: InGameAction.down),
0x10: ControllerButton('Up/Steer Left', identifier: 0x10, action: InGameAction.steerLeft),
0x11: ControllerButton('Down/Steer Right', identifier: 0x11, action: InGameAction.steerRight),
0x12: ControllerButton('Left/Look Left', identifier: 0x12, action: InGameAction.navigateLeft),
0x13: ControllerButton('Right/Look Right', identifier: 0x13, action: InGameAction.navigateRight),
0x14: ControllerButton('Select/Confirm', identifier: 0x14, action: InGameAction.select),
0x15: ControllerButton('Back/Cancel', identifier: 0x15, action: InGameAction.back),
0x16: ControllerButton('Menu', identifier: 0x16),
0x17: ControllerButton('Home', identifier: 0x17),
0x18: ControllerButton('Steer Left', identifier: 0x18, action: InGameAction.steerLeft),
0x19: ControllerButton('Steer Right', identifier: 0x19, action: InGameAction.steerRight),
// Social/Emotes (0x20-0x2F)
0x20: ControllerButton('Emote', identifier: 0x20, action: InGameAction.emote),
0x21: ControllerButton('Push to Talk', identifier: 0x21),
0x20: ControllerButton('Wave', identifier: 0x20, action: InGameAction.emote),
0x21: ControllerButton('Thumbs Up', identifier: 0x21, action: InGameAction.emote),
0x22: ControllerButton('Hammer Time', identifier: 0x22, action: InGameAction.emote),
0x23: ControllerButton('Bell', identifier: 0x23),
0x24: ControllerButton('Screenshot', identifier: 0x24),
// Training Controls (0x30-0x3F)
0x30: ControllerButton('ERG Up', identifier: 0x30, action: InGameAction.increaseResistance),
0x31: ControllerButton('ERG Down', identifier: 0x31, action: InGameAction.decreaseResistance),

View File

@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' show RideKeyPadStatus;
@@ -15,13 +15,9 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:nsd/nsd.dart';
class FtmsMdnsEmulator extends TrainerConnection {
ServerSocket? _tcpServer;
Registration? _mdnsRegistration;
class FtmsMdnsEmulator extends MdnsEmulator {
static const String connectionTitle = 'Zwift Network Emulator';
Socket? _socket;
var lastMessageId = 0;
FtmsMdnsEmulator()
@@ -46,18 +42,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
print('Starting mDNS server...');
// Get local IP
final interfaces = await NetworkInterface.list();
InternetAddress? localIP;
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
localIP = addr;
break;
}
}
if (localIP != null) break;
}
final localIP = await findLocalIP();
if (localIP == null) {
throw 'Could not find network interface';
@@ -65,13 +50,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
await _createTcpServer();
if (kDebugMode) {
enableLogging(LogTopic.calls);
enableLogging(LogTopic.errors);
}
disableServiceTypeValidation(true);
_mdnsRegistration = await register(
await registerMdnsService(
Service(
name: 'KICKR BIKE PRO 1337',
addresses: [localIP],
@@ -87,41 +66,13 @@ class FtmsMdnsEmulator extends TrainerConnection {
print('Server started - advertising service!');
}
void stop() {
isStarted.value = false;
isConnected.value = false;
_tcpServer?.close();
if (_mdnsRegistration != null) {
unregister(_mdnsRegistration!);
}
_tcpServer = null;
_mdnsRegistration = null;
_socket = null;
print('Stopped FtmsMdnsEmulator');
}
Future<void> _createTcpServer() async {
try {
_tcpServer = await ServerSocket.bind(
InternetAddress.anyIPv6,
36867,
shared: true,
v6Only: false,
);
} catch (e) {
if (kDebugMode) {
print('Failed to start server: $e');
}
rethrow;
}
if (kDebugMode) {
print('Server started on port ${_tcpServer!.port}');
}
await createTcpServer(36867);
// Accept connection
_tcpServer!.listen(
tcpServer!.listen(
(Socket socket) {
_socket = socket;
this.socket = socket;
isConnected.value = true;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
@@ -171,7 +122,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
// Expected 0101000000100000fc8200001000800000805f9b34fb
// Got 0101000000100000fc8200001000800000805f9b34fb
_write(socket, bytes);
writeToSocket(socket, bytes);
case FtmsMdnsConstants.DC_MESSAGE_DISCOVER_CHARACTERISTICS:
final rawUUID = body.takeBytes(16);
final serviceUUID = bytesToHex(rawUUID).toUUID();
@@ -201,7 +152,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
];
// OK: 0102010000430000fc8200001000800000805f9b34fb0000000319ca465186e5fa29dcdd09d1020000000219ca465186e5fa29dcdd09d1040000000419ca465186e5fa29dcdd09d104
_write(socket, responseData);
writeToSocket(socket, responseData);
}
case FtmsMdnsConstants.DC_MESSAGE_READ_CHARACTERISTIC:
final rawUUID = body.takeBytes(16);
@@ -220,7 +171,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
...responseBody,
];
_write(socket, responseData);
writeToSocket(socket, responseData);
case FtmsMdnsConstants.DC_MESSAGE_WRITE_CHARACTERISTIC:
final rawUUID = body.takeBytes(16);
final characteristicUUID = bytesToHex(rawUUID).toUUID();
@@ -239,7 +190,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
...responseBody,
];
_write(socket, responseData);
writeToSocket(socket, responseData);
final response = core.zwiftEmulator.handleWriteRequest(
characteristicUUID,
@@ -269,7 +220,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
];
// 0106050000180000000419ca465186e5fa29dcdd09d1526964654f6e0203
_write(socket, responseData);
writeToSocket(socket, responseData);
if (response.contentEquals(ZwiftConstants.RIDE_ON)) {
_sendKeepAlive();
@@ -293,7 +244,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
...responseBody,
];
_write(socket, responseData);
writeToSocket(socket, responseData);
case FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION:
print('Hamlo');
default:
@@ -308,20 +259,13 @@ class FtmsMdnsEmulator extends TrainerConnection {
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
_socket = null;
this.socket = null;
},
);
},
);
}
void _write(Socket socket, List<int> responseData) {
if (kDebugMode) {
print('Sending response: ${bytesToHex(responseData)}');
}
socket.add(responseData);
}
int _propertyVal(List<String> properties) {
int res = 0;
@@ -368,7 +312,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
]),
);
_write(_socket!, commandProto);
writeToSocket(socket!, commandProto);
}
if (isKeyUp) {
@@ -377,7 +321,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
);
_write(_socket!, zero);
writeToSocket(socket!, zero);
}
if (kDebugMode) {
print('Sent action $isKeyUp vs $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator');
@@ -411,9 +355,9 @@ class FtmsMdnsEmulator extends TrainerConnection {
Future<void> _sendKeepAlive() async {
await Future.delayed(const Duration(seconds: 5));
if (_socket != null) {
_write(
_socket!,
if (socket != null) {
writeToSocket(
socket!,
_buildNotify(
ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
hexToBytes('B70100002041201C00180004001B4F00B701000020798EC5BDEFCBE4563418269E4926FBE1'),
@@ -464,23 +408,6 @@ extension on List<int> {
}
}
String bytesToHex(List<int> bytes, {bool spaced = false}) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : '');
}
String bytesToReadableHex(List<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
}
List<int> hexToBytes(String hex) {
final bytes = <int>[];
for (var i = 0; i < hex.length; i += 2) {
final byte = hex.substring(i, i + 2);
bytes.add(int.parse(byte, radix: 16));
}
return bytes;
}
class FtmsMdnsConstants {
static const DC_RC_REQUEST_COMPLETED_SUCCESSFULLY = 0; // Request completed successfully
static const DC_RC_UNKNOWN_MESSAGE_TYPE = 1; // Unknown Message Type

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:bike_control/bluetooth/ble.dart';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
@@ -17,18 +17,10 @@ import 'package:bike_control/utils/requirements/multi.dart';
import 'package:bike_control/widgets/title.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
class ZwiftEmulator extends TrainerConnection {
bool get isLoading => _isLoading;
class ZwiftEmulator extends BluetoothEmulator {
static const String connectionTitle = 'Zwift BLE Emulator';
late final _peripheralManager = PeripheralManager();
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
GATTCharacteristic? _asyncCharacteristic;
GATTCharacteristic? _syncTxCharacteristic;
@@ -50,51 +42,30 @@ class ZwiftEmulator extends TrainerConnection {
);
Future<void> reconnect() async {
await _peripheralManager.stopAdvertising();
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
isServiceAdded = false;
startAdvertising(() {});
}
Future<void> startAdvertising(VoidCallback onUpdate) async {
_isLoading = true;
isLoading = true;
isStarted.value = true;
onUpdate();
_peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
subscribeToStateChanges();
subscribeToConnectionStateChanges(onUpdate);
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
} else if (state.state == ConnectionState.disconnected) {
_central = null;
isConnected.value = false;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
onUpdate();
}
});
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
isStarted.value = false;
onUpdate();
return;
}
if (!await requestBluetoothAdvertisePermission()) {
isStarted.value = false;
onUpdate();
return;
}
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn &&
core.settings.getZwiftBleEmulatorEnabled()) {
print('Waiting for peripheral manager to be powered on...');
if (!await waitForPoweredOn(() => core.settings.getZwiftBleEmulatorEnabled())) {
if (core.settings.getLastTarget() == Target.thisDevice) {
return;
}
await Future.delayed(Duration(seconds: 1));
}
_syncTxCharacteristic = GATTCharacteristic.mutable(
@@ -118,12 +89,12 @@ class ZwiftEmulator extends TrainerConnection {
permissions: [],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!isServiceAdded) {
await Future.delayed(const Duration(seconds: 1));
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
_peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
if (!isSubscribedToEvents) {
isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
@@ -131,7 +102,7 @@ class ZwiftEmulator extends TrainerConnection {
print('Handling read request for SYNC TX characteristic');
break;
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
await _peripheralManager.respondReadRequestWithValue(
await peripheralManager.respondReadRequestWithValue(
eventArgs.request,
value: Uint8List.fromList([100]),
);
@@ -142,20 +113,20 @@ class ZwiftEmulator extends TrainerConnection {
final request = eventArgs.request;
final trimmedValue = Uint8List.fromList([]);
await _peripheralManager.respondReadRequestWithValue(
await peripheralManager.respondReadRequestWithValue(
request,
value: trimmedValue,
);
// You can respond to read requests here if needed
});
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
print(
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
);
});
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
_central = eventArgs.central;
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
central = eventArgs.central;
isConnected.value = true;
core.connection.signalNotification(
@@ -165,8 +136,8 @@ class ZwiftEmulator extends TrainerConnection {
final request = eventArgs.request;
final response = handleWriteRequest(eventArgs.characteristic.uuid.toString(), request.value);
if (response != null) {
await _peripheralManager.notifyCharacteristic(
_central!,
await notifyCharacteristic(
central!,
_syncTxCharacteristic!,
value: response,
);
@@ -176,13 +147,13 @@ class ZwiftEmulator extends TrainerConnection {
}
}
await _peripheralManager.respondWriteRequest(request);
await peripheralManager.respondWriteRequest(request);
});
}
if (!Platform.isWindows) {
// Device Information
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180A'),
isPrimary: true,
@@ -213,7 +184,7 @@ class ZwiftEmulator extends TrainerConnection {
);
}
// Battery Service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
@@ -235,7 +206,7 @@ class ZwiftEmulator extends TrainerConnection {
);
// Unknown Service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID),
isPrimary: true,
@@ -276,7 +247,7 @@ class ZwiftEmulator extends TrainerConnection {
includedServices: [],
),
);
_isServiceAdded = true;
isServiceAdded = true;
}
final advertisement = Advertisement(
@@ -294,23 +265,21 @@ class ZwiftEmulator extends TrainerConnection {
);
print('Starting advertising with Zwift service...');
await _peripheralManager.startAdvertising(advertisement);
_isLoading = false;
await startAdvertising(advertisement);
isLoading = false;
onUpdate();
}
@override
Future<void> stopAdvertising() async {
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
await super.stopAdvertising();
}
Future<void> _sendKeepAlive() async {
await Future.delayed(const Duration(seconds: 5));
if (isConnected.value && _central != null) {
if (isConnected.value && central != null) {
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
_peripheralManager.notifyCharacteristic(_central!, _syncTxCharacteristic!, value: zero);
await notifyCharacteristic(central!, _syncTxCharacteristic!, value: zero);
_sendKeepAlive();
}
}
@@ -347,8 +316,8 @@ class ZwiftEmulator extends TrainerConnection {
...bytes,
]);
_peripheralManager.notifyCharacteristic(
_central!,
await notifyCharacteristic(
central!,
_asyncCharacteristic!,
value: commandProto,
);
@@ -356,7 +325,7 @@ class ZwiftEmulator extends TrainerConnection {
if (isKeyUp) {
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
_peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
await notifyCharacteristic(central!, _asyncCharacteristic!, value: zero);
}
return Success('Sent action: ${keyPair.inGameAction!.name}');
@@ -462,14 +431,8 @@ class ZwiftEmulator extends TrainerConnection {
return null;
}
@override
void cleanup() {
_peripheralManager.stopAdvertising();
_peripheralManager.removeAllServices();
_isServiceAdded = false;
_isSubscribedToEvents = false;
_central = null;
isConnected.value = false;
isStarted.value = false;
_isLoading = false;
super.cleanup();
}
}

View File

@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
@@ -11,19 +11,10 @@ import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/requirements/multi.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import '../utils/keymap/keymap.dart';
class RemotePairing extends TrainerConnection {
bool get isLoading => _isLoading;
late final _peripheralManager = PeripheralManager();
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
class RemotePairing extends BluetoothEmulator {
GATTCharacteristic? _inputReport;
static const String connectionTitle = 'Remote Control';
@@ -35,9 +26,9 @@ class RemotePairing extends TrainerConnection {
);
Future<void> reconnect() async {
await _peripheralManager.stopAdvertising();
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
isServiceAdded = false;
startAdvertising().catchError((e) {
core.settings.setRemoteControlEnabled(false);
core.connection.signalNotification(
@@ -47,40 +38,21 @@ class RemotePairing extends TrainerConnection {
}
Future<void> startAdvertising() async {
_isLoading = true;
isLoading = true;
isStarted.value = true;
_peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
subscribeToStateChanges();
subscribeToConnectionStateChanges(null);
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
} else if (state.state == ConnectionState.disconnected) {
_central = null;
isConnected.value = false;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
}
});
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
isStarted.value = false;
return;
}
if (!await requestBluetoothAdvertisePermission()) {
isStarted.value = false;
return;
}
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getRemoteControlEnabled()) {
print('Waiting for peripheral manager to be powered on...');
if (!await waitForPoweredOn(() => core.settings.getRemoteControlEnabled())) {
if (core.settings.getLastTarget() == Target.thisDevice) {
return;
}
await Future.delayed(Duration(seconds: 1));
}
final inputReport = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4D'),
@@ -95,8 +67,8 @@ class RemotePairing extends TrainerConnection {
],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!isServiceAdded) {
await Future.delayed(const Duration(seconds: 1));
final reportMapDataAbsolute = Uint8List.fromList([
0x05, 0x01, // Usage Page (Generic Desktop)
@@ -206,21 +178,21 @@ class RemotePairing extends TrainerConnection {
includedServices: [],
);
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
_peripheralManager.characteristicReadRequested.forEach((char) {
if (!isSubscribedToEvents) {
isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((char) {
print('Read request for characteristic: ${char}');
// You can respond to read requests here if needed
});
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
if (char.characteristic.uuid == inputReport.uuid) {
if (char.state) {
_inputReport = char.characteristic;
_central = char.central;
central = char.central;
} else {
_inputReport = null;
_central = null;
central = null;
}
}
print(
@@ -228,10 +200,10 @@ class RemotePairing extends TrainerConnection {
);
});
}
await _peripheralManager.addService(hidService);
await addService(hidService);
// 3) Optional Battery service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
@@ -245,7 +217,7 @@ class RemotePairing extends TrainerConnection {
includedServices: [],
),
);
_isServiceAdded = true;
isServiceAdded = true;
}
final advertisement = Advertisement(
@@ -259,20 +231,18 @@ class RemotePairing extends TrainerConnection {
);
print('Starting advertising with Zwift service...');
await _peripheralManager.startAdvertising(advertisement);
_isLoading = false;
await startAdvertising(advertisement);
isLoading = false;
}
@override
Future<void> stopAdvertising() async {
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
await super.stopAdvertising();
}
Future<void> notifyCharacteristic(Uint8List value) async {
if (_inputReport != null && _central != null) {
await _peripheralManager.notifyCharacteristic(_central!, _inputReport!, value: value);
if (_inputReport != null && central != null) {
await peripheralManager.notifyCharacteristic(central!, _inputReport!, value: value);
}
}

View File

@@ -13,7 +13,6 @@
"accessories": "Zubehör",
"action": "Aktion",
"adjustControllerButtons": "Controller-Tasten anpassen",
"afterDate": "Nach dem {date}",
"allow": "Erlauben",
"allowAccessibilityService": "Barrierefreiheitsdienst zulassen",
"allowBluetoothConnections": "Bluetooth-Verbindungen zulassen",
@@ -32,7 +31,6 @@
}
},
"battery": "Batterie",
"beforeDate": "Vor dem {date}",
"bluetoothAdvertiseAccess": "Bluetooth-Zugriff",
"bluetoothTurnedOn": "Bluetooth ist eingeschaltet",
"browserNotSupported": "Dieser Browser unterstützt kein Web-Bluetooth und die Plattform wird nicht unterstützt :(",
@@ -340,7 +338,6 @@
"requirement": "Anforderung",
"reset": "Zurücksetzen",
"restart": "Neustart",
"restorePurchaseInfo": "Klicke auf den Knopf oben und anschließend auf „Kauf wiederherstellen“. Bei Problemen kontaktiere mich bitte direkt.",
"runAppOnPlatformRemotely": "{appName} auf {platform} laufen lassen und es von diesem Gerät aus fernsteuern via {preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {

View File

@@ -13,7 +13,6 @@
"accessories": "Accessories",
"action": "Action",
"adjustControllerButtons": "Adjust Controller Buttons",
"afterDate": "After {date}",
"allow": "Allow",
"allowAccessibilityService": "Allow Accessibility Service",
"allowBluetoothConnections": "Allow Bluetooth Connections",
@@ -32,7 +31,6 @@
}
},
"battery": "Battery",
"beforeDate": "Before {date}",
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
"bluetoothTurnedOn": "Bluetooth turned on",
"browserNotSupported": "This Browser does not support Web Bluetooth and platform is not supported :(",
@@ -340,7 +338,6 @@
"requirement": "Requirement",
"reset": "Reset",
"restart": "Restart",
"restorePurchaseInfo": "Click on the button above, then on \"Restore Purchase\". Please contact me directly if you have any issues.",
"runAppOnPlatformRemotely": "Run {appName} on {platform} and control it remotely from this device{preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {

View File

@@ -13,7 +13,6 @@
"accessories": "Accessoires",
"action": "Action",
"adjustControllerButtons": "Ajuster les boutons de la manette",
"afterDate": "Après {date}",
"allow": "Permettre",
"allowAccessibilityService": "Autoriser le service d'accessibilité",
"allowBluetoothConnections": "Autoriser les connexions Bluetooth",
@@ -32,7 +31,6 @@
}
},
"battery": "Batterie",
"beforeDate": "Avant {date}",
"bluetoothAdvertiseAccess": "Accès à la publicité Bluetooth",
"bluetoothTurnedOn": "Bluetooth activé",
"browserNotSupported": "Ce navigateur ne prend pas en charge Web Bluetooth et la plateforme n'est pas prise en charge :(",
@@ -340,7 +338,6 @@
"requirement": "Exigence",
"reset": "Réinitialiser",
"restart": "Redémarrage",
"restorePurchaseInfo": "Cliquez sur le bouton ci-dessus, puis sur « Restaurer lachat ». Veuillez me contacter directement en cas de problème.",
"runAppOnPlatformRemotely": "Exécutez {appName} sur {platform} et contrôlez-le à distance depuis cet appareil{preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {

View File

@@ -1,434 +0,0 @@
{
"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",
"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.",
"accessibilityServiceNotRunning": "Il servizio di accessibilità non è in esecuzione. Seguire le istruzioni su",
"accessibilityServicePermissionRequired": "Autorizzazione al servizio di accessibilità richiesta",
"accessibilityUsageGestures": "• Quando premi i pulsanti sui tuoi dispositivi Zwift Click, Zwift Ride o Zwift Play, BikeControl simula i gesti touch in posizioni specifiche dello schermo",
"accessibilityUsageMonitor": "• L'applicazione monitora quale finestra dell'app di allenamento è attiva per garantire che i gesti vengano inviati all'app corretta",
"accessibilityUsageNoData": "• Nessun dato personale viene raccolto o consultato tramite questo servizio",
"accessories": "Accessori",
"action": "Azione",
"adjustControllerButtons": "Regola i pulsanti del controller",
"afterDate": "Dopo il {date}",
"allow": "Consentire",
"allowAccessibilityService": "Consenti servizio di accessibilità",
"allowBluetoothConnections": "Consenti connessioni Bluetooth",
"allowBluetoothScan": "Consenti scansione Bluetooth",
"allowLocationForBluetooth": "Consentire in modo che la scansione Bluetooth funzioni",
"allowPersistentNotification": "Consenti notifiche",
"allowsRunningInBackground": "Consente a BikeControl di continuare a funzionare in background",
"alreadyBoughtTheApp": "Hai già acquistato l'app? Non devi più pagare per BikeControl. A causa di problemi tecnici, non è possibile determinare se l'app è già stata acquistata.\n\nInserisci il tuo ID di acquisto sul Play Store (ad esempio GPA.3356-1337-1338-1339) per sbloccare la versione completa. Se non riesci a trovarlo, contattami direttamente.",
"alreadyBoughtTheAppPreviously": "Hai già acquistato l'app in precedenza?",
"appIdActions": "{appId} azioni",
"@appIdActions": {
"placeholders": {
"appId": {
"type": "String"
}
}
},
"battery": "Batteria",
"beforeDate": "Prima del {date}",
"bluetoothAdvertiseAccess": "Accesso pubblicitario Bluetooth",
"bluetoothTurnedOn": "Bluetooth attivato",
"browserNotSupported": "Questo browser non supporta il Web Bluetooth e la piattaforma non è supportata :(",
"button": "pulsante.",
"cancel": "Cancella",
"changelog": "Registro delle modifiche",
"checkMyWhooshConnectionScreen": "Controlla la schermata di connessione in MyWhoosh per vedere se è connesso.",
"chooseAnotherScreenshot": "Scegli un altro screenshot",
"chooseBikeControlInConnectionScreen": "Selezionare BikeControl nella schermata di connessione.",
"clickAButtonOnYourController": "Fai clic su un pulsante del controller per modificarne l'azione oppure tocca l'icona di modifica.",
"clickV2EventInfo": "Il tuo Click V2 potrebbe non inviare più eventi tramite i pulsanti. Verifica toccando alcuni pulsanti e verifica se sono visibili in BikeControl.",
"clickV2Instructions": "Per far funzionare al meglio il tuo Zwift Click V2, dovresti collegarlo all'app Zwift prima di ogni sessione di allenamento. In caso contrario, il Click V2 smetterà di funzionare dopo un minuto. \n1. Apri l'app Zwift \n2. Accedi (non richiesto un abbonamento attivo) e apri la schermata di connessione del dispositivo \n3. Collega il tuo trainer, quindi collega lo Zwift Click V2, verifica che i comandi del controller funzionino \n4. Chiudi nuovamente l'app Zwift e riconnettiti a BikeControl",
"close": "Chiudi",
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} comandi rimanenti oggi",
"configuration": "Configurazione",
"connectControllerToPreview": "Collega un dispositivo controller per visualizzare in anteprima e personalizzare la mappa dei tasti.",
"connectControllers": "Connetti i controller",
"connectDirectlyOverNetwork": "Connettiti direttamente tramite la rete",
"connectToTrainerApp": "Connettiti all'app di allenamento",
"connectUsingBluetooth": "Connettiti tramite Bluetooth",
"connectUsingMyWhooshLink": "Connettiti tramite MyWhoosh \"Link\"",
"connected": "Connesso",
"connectedControllers": "Controller connessi",
"connectedTo": "Connesso a{appId}",
"@connectedTo": {
"placeholders": {
"appId": {
"type": "String"
}
}
},
"connection": "Connessione",
"continueAction": "Continua",
"controlAppUsingModes": "Controllare{appName} usando{modes}",
"@controlAppUsingModes": {
"placeholders": {
"appName": {
"type": "String"
},
"modes": {
"type": "String"
}
}
},
"controllers": "Controller",
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Non è stato possibile eseguire{button} : Nessuna mappa dei tasti impostata",
"create": "Crea",
"createNewKeymap": "Crea una nuova mappa dei tasti",
"createNewProfileByDuplicating": "Crea un nuovo profilo personalizzato duplicando \"{profileName}\"",
"@createNewProfileByDuplicating": {
"placeholders": {
"profileName": {
"type": "String"
}
}
},
"createdNewCustomProfile": "Creato un nuovo profilo personalizzato:{profileName}",
"@createdNewCustomProfile": {
"placeholders": {
"profileName": {
"type": "String"
}
}
},
"customizeControllerButtons": "Personalizza i pulsanti del controller per{appName}",
"@customizeControllerButtons": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"customizeKeymapHint": "Personalizza la mappa dei tasti, se riscontri problemi (ad esempio, output della tastiera errato o posizionamenti del tocco non allineati)",
"dailyCommandLimitReachedNotification": "Limite giornaliero di comandi raggiunto per oggi. Esegui l'upgrade per sbloccare la versione completa con comandi illimitati.",
"dailyLimitReached": "Limite giornaliero raggiunto ({dailyCommandCount} /{dailyCommandLimit} usato)",
"delete": "Cancella",
"deleteProfile": "Elimina profilo",
"deleteProfileConfirmation": "Sei sicuro di voler eliminare \"{profileName}\" ? Questa azione non può essere annullata.",
"@deleteProfileConfirmation": {
"placeholders": {
"profileName": {
"type": "String"
}
}
},
"deny": "Nega",
"deviceButton": "{deviceName}pulsante",
"@deviceButton": {
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"disconnectDevices": "Dispositivi disconnessi",
"disconnected": "Disconnesso",
"donateByBuyingFromPlayStore": "acquistando l'app dal Play Store",
"donateViaCreditCard": "tramite carta di credito, Google Pay, Apple Pay e altri",
"donateViaPaypal": "tramite PayPal",
"download": "Download",
"dragToReposition": "Trascina per riposizionare",
"duplicate": "Duplica",
"enableAutoRotation": "Abilita la rotazione automatica sul tuo dispositivo per assicurarti che l'app funzioni correttamente.",
"enableBluetooth": "Abilita Bluetooth",
"enableKeyboardAccessMessage": "Abilita l'accesso tramite tastiera nella schermata seguente per BikeControl. Se BikeControl non è presente, aggiungilo manualmente.",
"enableKeyboardMouseControl": "Abilita il controllo della tastiera e del mouse per una migliore interazione con{appName} Una volta attivo, non sono necessarie ulteriori azioni o connessioni. BikeControl invierà direttamente l'input del mouse o della tastiera a{appName} .",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"enableMediaKeyDetection": "Abilita rilevamento tasti multimediali",
"enablePairingProcess": "Abilita processo di associazione",
"enablePermissions": "Abilita autorizzazioni",
"enableSteeringWithPhone": "Abilita i sensori dei telefoni per abilitare ad esempio lo sterzo",
"enableVibrationFeedback": "Abilita il feedback delle vibrazioni durante il cambio marcia",
"enableZwiftControllerBluetooth": "Abilita il controller Zwift (Bluetooth)",
"enableZwiftControllerNetwork": "Abilita Zwift Controller (rete)",
"errorStartingMyWhooshLink": "Errore durante l'avvio del server MyWhoosh Link. Assicurati che l'app \"MyWhoosh Link\" non sia già in esecuzione su questo dispositivo.",
"errorStartingOpenBikeControlBluetoothServer": "Errore durante l'avvio del server Bluetooth OpenBikeControl.",
"errorStartingOpenBikeControlServer": "Errore durante l'avvio del server OpenBikeControl.",
"exportAction": "Esporta",
"failedToImportProfile": "Impossibile importare il profilo. Formato non valido.",
"failedToUpdate": "Aggiornamento non riuscito:{error}",
"@failedToUpdate": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"firmware": "Firmware",
"forceCloseToUpdate": "Forza la chiusura dell'app per utilizzare la nuova versione",
"fullVersion": "Versione completa",
"fullVersionDescription": "La versione completa include: \n- Comandi illimitati al giorno \n- Accesso a tutti gli aggiornamenti futuri \n- Nessun abbonamento! Solo un canone una tantum :)",
"getSupport": "Ottieni supporto",
"gotIt": "Fatto!",
"grant": "Concedi",
"granted": "Concesso",
"helpRequested": "Aiuto richiesto per BikeControl v{version}",
"@helpRequested": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"howBikeControlUsesPermission": "In che modo BikeControl utilizza questa autorizzazione?",
"ignoredDevices": "Dispositivi ignorati",
"importAction": "Importa",
"importProfile": "Importa profilo",
"instructions": "Istruzioni",
"jsonData": "Dati JSON",
"keyboardAccess": "Accesso tramite tastiera",
"latestVersion": "ultima:{version}",
"@latestVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"leaveAReview": "Lascia una recensione",
"letsAppConnectOverBluetooth": "Lascia che {appName} si connetta a BikeControl tramite Bluetooth.",
"@letsAppConnectOverBluetooth": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"letsAppConnectOverNetwork": "Lascia che {appName} si connetta direttamente tramite la rete. Seleziona BikeControl nella schermata di connessione.",
"@letsAppConnectOverNetwork": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"license": "Licenza",
"licenseStatus": "Stato della licenza",
"loadScreenshotForPlacement": "Carica lo screenshot del gioco per il posizionamento",
"logViewer": "Visualizzatore di registri",
"logs": "Registri",
"logsAreAlsoAt": "I registri sono anche a",
"logsHaveBeenCopiedToClipboard": "I registri sono stati copiati negli appunti",
"longPress": "pressione prolungata",
"longPressMode": "Modalità pressione prolungata (rispetto alla ripetizione)",
"mailSupportExplanation": "Fornire supporto individuale via email è un impegno notevole per me. \n\nVi invito a utilizzare Reddit, Facebook o GitHub per domande e problemi, in modo che l'intera comunità possa trarne beneficio.",
"manageIgnoredDevices": "Gestisci dispositivi ignorati",
"manageProfile": "Gestisci profilo",
"manualyControllingButton": "Controlla {trainerApp} manualmente!",
"mediaKeyDetectionTooltip": "Abilita questa opzione per consentire a BikeControl di rilevare i telecomandi Bluetooth. \nPer fare ciò, BikeControl deve funzionare come lettore multimediale.",
"miuiDeviceDetected": "Dispositivo MIUI rilevato",
"miuiDisableBatteryOptimization": "• Disattivare l'ottimizzazione della batteria per BikeControl",
"miuiEnableAutostart": "• Abilita l'avvio automatico per BikeControl",
"miuiEnsureProperWorking": "Per garantire il corretto funzionamento di BikeControl:",
"miuiLockInRecentApps": "• Blocca l'app nelle app recenti",
"miuiWarningDescription": "Il tuo dispositivo utilizza MIUI, che è noto per disattivare in modo aggressivo i servizi in background e i servizi di accessibilità.",
"moreInformation": "Ulteriori informazioni",
"mustChooseAllowOrDeny": "Per continuare, devi scegliere se consentire o negare questa autorizzazione.",
"myWhooshDirectConnectAction": "Azione MyWhoosh \"Link\"",
"myWhooshDirectConnection": " ad esempio utilizzando MyWhoosh \"Link\"",
"myWhooshLinkConnected": "MyWhoosh \"Link\" connesso",
"myWhooshLinkDescriptionLocal": "Collegati direttamente a MyWhoosh tramite il metodo \"Link\". Le azioni supportate includono cambio, emoji, indicazioni di svolta, tra le altre. L'app complementare MyWhoosh Link NON deve essere in esecuzione contemporaneamente.",
"myWhooshLinkInfo": "In caso di problemi, consulta la sezione relativa alla risoluzione dei problemi. Presto sarà disponibile un metodo di connessione molto più affidabile!",
"needHelpClickHelp": "Hai bisogno di aiuto? Clicca sul",
"needHelpDontHesitate": "pulsante in alto e non esitare a contattarci.",
"newConnectionMethodAnnouncement": "{trainerApp}supporterà presto metodi di connessione molto migliori e affidabili: restate sintonizzati per gli aggiornamenti!",
"newCustomProfile": "Nuovo profilo personalizzato",
"newProfileName": "Nuovo nome del profilo",
"newVersionAvailable": "Nuova versione disponibile",
"newVersionAvailableWithVersion": "Nuova versione disponibile:{version}",
"@newVersionAvailableWithVersion": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"next": "Successivo",
"no": "No",
"noActionAssigned": "Nessuna azione assegnata",
"noActionAssignedForButton": "Non è stato possibile eseguire{button} : Nessuna azione assegnata",
"noConnectionMethodIsConnectedOrActive": "Nessun metodo di connessione è connesso o attivo.",
"noConnectionMethodSelected": "Nessun metodo di connessione selezionato",
"noControllerConnected": "Nessuno connesso",
"noControllerUseCompanionMode": "Non hai un controller? Usa la Companion Mode",
"noIgnoredDevices": "Nessun dispositivo ignorato.",
"noTrainerSelected": "Nessun Trainer selezionato",
"notConnected": "Non connesso",
"notificationDescription": "In questo modo l'app rimane attiva in background e ti aggiorna quando cambia la connessione ai tuoi dispositivi.",
"ok": "Ok",
"openBikeControlActions": "Azioni OpenBikeControl",
"openBikeControlAnnouncement": "Ottime notizie -{trainerApp} supporta il protocollo OpenBikeControl, così avrai la migliore esperienza possibile!",
"openBikeControlConnection": " ad esempio utilizzando la connessione OpenBikeControl",
"otherConnectionMethods": "Altri metodi di connessione",
"pairingDescription": "L'associazione consente la personalizzazione completa, ma potrebbe non funzionare su tutti i dispositivi.",
"pairingInstructions": "Sul tuo{targetName} Accedi alle impostazioni Bluetooth e cerca BikeControl o il nome del tuo dispositivo. L'associazione è necessaria se vuoi utilizzare la funzione di controllo remoto.",
"@pairingInstructions": {
"placeholders": {
"targetName": {
"type": "String"
}
}
},
"pairingInstructionsIOS": "Sul tuo iPad, vai su Impostazioni > Accessibilità > Tocco > Assistenza Tocco > Dispositivi di puntamento > Dispositivi e associa il tuo dispositivo. Assicurati che Assistenza Tocco sia abilitato.",
"pasteExportedJsonData": "Incolla i dati JSON esportati qui sotto:",
"pathCopiedToClipboard": "Il percorso è stato copiato negli appunti",
"permissionsRequired": "Per consentire a BikeControl di cercare i dispositivi nelle vicinanze e di aggiornarti quando cambia la connessione, abilita le seguenti autorizzazioni:",
"platformNotSupported": "Questo{platform} non è supportato :(",
"@platformNotSupported": {
"placeholders": {
"platform": {
"type": "String"
}
}
},
"platformRestrictionNotSupported": "A causa delle restrizioni della piattaforma, questo scenario non è supportato.",
"platformRestrictionOtherDevicesOnly": "A causa delle restrizioni della piattaforma, solo il controllo{appName} su altri dispositivi è supportato.",
"@platformRestrictionOtherDevicesOnly": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"playPause": "Riproduci/Pausa",
"pleaseSelectAConnectionMethodFirst": "Per prima cosa seleziona un metodo di connessione nelle impostazioni del Trainer.",
"predefinedAction": "Predefinito{appName} azione",
"@predefinedAction": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"pressButtonOnClickDevice": "Premi un pulsante sul tuo dispositivo Click",
"pressKeyToAssign": "Premi un tasto sulla tastiera per assegnarlo a{buttonName}",
"@pressKeyToAssign": {
"placeholders": {
"buttonName": {
"type": "String"
}
}
},
"previous": "Precedente",
"profileExportedToClipboard": "Profilo \"{profileName}\" esportato negli appunti",
"@profileExportedToClipboard": {
"placeholders": {
"profileName": {
"type": "String"
}
}
},
"profileImportedSuccessfully": "Profilo importato con successo",
"profileName": "Nome del profilo",
"purchase": "Acquista",
"recommendedConnectionMethods": "Metodi di connessione consigliati",
"removeFromIgnoredList": "Rimuovi dall'elenco degli ignorati",
"rename": "Rinomina",
"renameProfile": "Rinomina profilo",
"requirement": "Requisiti",
"reset": "Reset",
"restart": "Riavvia",
"restorePurchaseInfo": "Clicca sul pulsante qui sopra, poi su \"Ripristina acquisto\". In caso di problemi, contattami direttamente.",
"runAppOnPlatformRemotely": "Avvia {appName} su{platform} e controllarlo da remoto da questo dispositivo{preferredConnection} .",
"@runAppOnPlatformRemotely": {
"placeholders": {
"appName": {
"type": "String"
},
"platform": {
"type": "String"
},
"preferredConnection": {
"type": "String"
}
}
},
"runAppOnThisDevice": "Avvia {appName} su questo dispositivo.",
"@runAppOnThisDevice": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"save": "Salva",
"scan": "Scansione",
"scanningForDevices": "Ricerca dispositivi in corso... Assicurarsi che siano accesi e nel raggio d'azione e che non siano connessi a un altro dispositivo.",
"selectKeymap": "Seleziona la mappa dei tasti",
"selectTargetWhereAppRuns": "Seleziona la destinazione dove{appName} è in esecuzione",
"@selectTargetWhereAppRuns": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"selectTrainerApp": "Seleziona l'app Trainer",
"selectTrainerAppAndTarget": "Seleziona l'app Trainer e il dispositivo di destinazione",
"selectTrainerAppPlaceholder": "Seleziona l'app Trainer",
"setting": "Impostazioni",
"setupTrainer": "Impostazioni Trainer",
"share": "Condividi",
"showDonation": "Mostra il tuo apprezzamento donando",
"showSupportedControllers": "Mostra controller supportati",
"showTroubleshootingGuide": "Mostra Guida alla risoluzione dei problemi",
"signal": "Segnale",
"simulateButtons": "Controlli del Trainer",
"simulateKeyboardShortcut": "Simula scorciatoia da tastiera",
"simulateMediaKey": "Simula il tasto multimediale",
"simulateTouch": "Simula il tocco",
"stop": "Stop",
"targetOtherDevice": "Altro dispositivo",
"targetThisDevice": "Questo dispositivo",
"theFollowingPermissionsRequired": "Sono richieste le seguenti autorizzazioni:",
"touchAreaInstructions": "1. Crea uno screenshot in-game della tua app (ad esempio all'interno di MyWhoosh) in orientamento orizzontale \n2. Carica lo screenshot con il pulsante qui sotto \n3. L'app viene automaticamente impostata in orientamento orizzontale per una mappatura accurata \n4. Premi un pulsante sul tuo dispositivo Click per creare un'area touch \n5. Trascina le aree touch nella posizione desiderata sullo screenshot \n6. Salva e chiudi questa schermata",
"touchSimulationForegroundMessage": "Per simulare i tocchi, l'app deve rimanere in primo piano.",
"trainer": "Trainer",
"trialDaysRemaining": "{trialDaysRemaining}giorni rimanenti",
"trialExpired": "Prova scaduta. Comandi limitati a{dailyCommandLimit} al giorno.",
"trialPeriodActive": "Periodo di prova attivo -{trialDaysRemaining} giorni rimanenti",
"trialPeriodDescription": "Goditi comandi illimitati durante il periodo di prova. Dopo la prova, i comandi saranno limitati a{dailyCommandLimit} al giorno.",
"troubleshootingGuide": "Guida alla risoluzione dei problemi",
"tryingToConnectAgain": "Sto provando a connettermi di nuovo...",
"unassignAction": "Annulla assegnazione azione",
"unlockFullVersion": "Sblocca la versione completa",
"update": "Aggiorna",
"useCustomKeymapForButton": "Utilizzare una mappa dei tasti personalizzata per supportare",
"version": "Versione{version}",
"@version": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"viewDetailedInstructions": "Visualizza le istruzioni dettagliate",
"volumeDown": "Abbassa il volume",
"volumeUp": "Aumenta il volume",
"waiting": "In attesa...",
"waitingForConnectionKickrBike": "In attesa di connessione. Scegli KICKR BIKE PRO in{appName} menu di associazione del controller.",
"@waitingForConnectionKickrBike": {
"placeholders": {
"appName": {
"type": "String"
}
}
},
"whatsNew": "Novità",
"whyPermissionNeeded": "Perché è necessaria questa autorizzazione?",
"yes": "SÌ",
"zwiftControllerAction": "Azione del controller Zwift",
"zwiftControllerDescription": "Consente a BikeControl di funzionare come controller compatibile con Zwift."
}

View File

@@ -13,7 +13,6 @@
"accessories": "Akcesoria",
"action": "Działanie",
"adjustControllerButtons": "Dostosuj przyciski kontrolera",
"afterDate": "Po {date}",
"allow": "Zezwól",
"allowAccessibilityService": "Zezwól na usługę ułatwień dostępu",
"allowBluetoothConnections": "Zezwól na połączenia Bluetooth",
@@ -32,7 +31,6 @@
}
},
"battery": "Bateria",
"beforeDate": "Zanim {date}",
"bluetoothAdvertiseAccess": "Dostęp do reklamy Bluetooth",
"bluetoothTurnedOn": "Włączono Bluetooth",
"browserNotSupported": "Ta przeglądarka nie obsługuje technologii Web Bluetooth i platforma nie jest obsługiwana :(",
@@ -243,7 +241,7 @@
"myWhooshLinkInfo": "W razie napotkania błędów prosimy o sprawdzenie sekcji rozwiązywania problemów. Wkrótce pojawi się znacznie bardziej niezawodna metoda połączenia!",
"needHelpClickHelp": "Potrzebujesz pomocy? Kliknij",
"needHelpDontHesitate": "przycisk na górze i prosimy o kontakt.",
"newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będzie wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądź na bieżąco z aktualizacjami!",
"newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będziemy wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądźcie na bieżąco z aktualizacjami!",
"newCustomProfile": "Nowy profil niestandardowy",
"newProfileName": "Nowa nazwa profilu",
"newVersionAvailable": "Dostępna jest nowa wersja",
@@ -264,7 +262,7 @@
"noControllerConnected": "Brak połączenia",
"noControllerUseCompanionMode": "Nie masz kontrolera? Użyj Companion Mode.",
"noIgnoredDevices": "Brak ignorowanych urządzeń.",
"noTrainerSelected": "Nie wybrano trenażera",
"noTrainerSelected": "Nie wybrano aplikacji treningowej",
"notConnected": "Nie połączono",
"notificationDescription": "Dzięki temu aplikacja działa w tle i powiadamia Cię o każdej zmianie połączenia z Twoim urządzeniem.",
"ok": "OK",
@@ -303,7 +301,7 @@
}
},
"playPause": "Start/Pauza",
"pleaseSelectAConnectionMethodFirst": "Najpierw wybierz metodę połączenia w ustawieniach trenażera.",
"pleaseSelectAConnectionMethodFirst": "Najpierw wybierz metodę połączenia w ustawieniach aplikacji treningowej.",
"predefinedAction": "Predefiniowana akcja {appName}",
"@predefinedAction": {
"placeholders": {
@@ -340,7 +338,6 @@
"requirement": "Wymóg",
"reset": "Reset",
"restart": "Uruchom ponownie",
"restorePurchaseInfo": "Kliknij przycisk powyżej, a następnie „Przywróć zakup”. W razie jakichkolwiek problemów skontaktuj się ze mną bezpośrednio.",
"runAppOnPlatformRemotely": "Uruchom {appName} na {platform} i steruj nim zdalnie z tego urządzenia {preferredConnection}.",
"@runAppOnPlatformRemotely": {
"placeholders": {

View File

@@ -269,7 +269,7 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
builder: (c) => DropdownMenu(
children: [
MenuButton(
leading: Icon(Icons.play_arrow_outlined),
child: Text(context.i18n.playPause),
onPressed: (c) {
_keyPair.physicalKey = PhysicalKeyboardKey.mediaPlayPause;
_keyPair.logicalKey = null;
@@ -277,10 +277,9 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
setState(() {});
widget.onUpdate();
},
child: Text(context.i18n.playPause),
),
MenuButton(
leading: Icon(Icons.stop_outlined),
child: Text(context.i18n.stop),
onPressed: (c) {
_keyPair.physicalKey = PhysicalKeyboardKey.mediaStop;
_keyPair.logicalKey = null;
@@ -288,10 +287,10 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
setState(() {});
widget.onUpdate();
},
child: Text(context.i18n.stop),
),
MenuButton(
leading: Icon(Icons.skip_previous_outlined),
child: Text(context.i18n.previous),
onPressed: (c) {
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackPrevious;
_keyPair.logicalKey = null;
@@ -299,10 +298,9 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
setState(() {});
widget.onUpdate();
},
child: Text(context.i18n.previous),
),
MenuButton(
leading: Icon(Icons.skip_next_outlined),
child: Text(context.i18n.next),
onPressed: (c) {
_keyPair.physicalKey = PhysicalKeyboardKey.mediaTrackNext;
_keyPair.logicalKey = null;
@@ -310,10 +308,8 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
setState(() {});
widget.onUpdate();
},
child: Text(context.i18n.next),
),
MenuButton(
leading: Icon(Icons.volume_up_outlined),
onPressed: (c) {
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeUp;
_keyPair.logicalKey = null;
@@ -324,7 +320,6 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
child: Text(context.i18n.volumeUp),
),
MenuButton(
leading: Icon(Icons.volume_down_outlined),
child: Text(context.i18n.volumeDown),
onPressed: (c) {
_keyPair.physicalKey = PhysicalKeyboardKey.audioVolumeDown;
@@ -439,8 +434,6 @@ class _ButtonEditPageState extends State<ButtonEditPage> {
title: Text(action.title),
subtitle: (action.possibleValues != null && action == _keyPair.inGameAction)
? Text(_keyPair.inGameActionValue!.toString())
: action.alternativeTitle != null
? Text(action.alternativeTitle!)
: null,
isActive: _keyPair.inGameAction == action && supportedActions.contains(_keyPair.inGameAction),
onPressed: () {

View File

@@ -86,7 +86,6 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
InGameAction? _pressedAction;
DateTime? _lastDown;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
@@ -99,7 +98,6 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
@override
void dispose() {
_focusNode.dispose();
_scrollController.dispose();
super.dispose();
}
@@ -202,9 +200,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
),
],
child: Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -477,7 +473,7 @@ class _ButtonSimulatorState extends State<ButtonSimulator> {
);
return;
} else {
if (!down && _lastDown != null && action.isLongPress) {
if (!down && _lastDown != null) {
final timeSinceLastDown = DateTime.now().difference(_lastDown!);
if (timeSinceLastDown < Duration(milliseconds: 400)) {
// wait a bit so actions actually get applied correctly for some trainer apps

View File

@@ -20,7 +20,7 @@ class DevicePage extends StatefulWidget {
State<DevicePage> createState() => _DevicePageState();
}
class _DevicePageState extends State<DevicePage> {
class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
late StreamSubscription<BaseDevice> _connectionStateSubscription;
@override

View File

@@ -176,11 +176,11 @@ abstract class BaseActions {
class StubActions extends BaseActions {
StubActions({super.supportedModes = const []});
final List<(ControllerButton button, bool isDown, bool isUp)> performedActions = [];
final List<ControllerButton> performedActions = [];
@override
Future<ActionResult> performAction(ControllerButton button, {bool isKeyDown = true, bool isKeyUp = false}) async {
performedActions.add((button, isKeyDown, isKeyUp));
performedActions.add(button);
return Future.value(Ignored('${button.name.splitByUpperCase()} clicked'));
}
}

View File

@@ -20,18 +20,6 @@ class DesktopActions extends BaseActions {
final keyPair = supportedApp!.keymap.getKeyPair(button)!;
if (core.settings.getLocalEnabled()) {
// Handle media keys
if (keyPair.isSpecialKey) {
try {
await keyPressSimulator.simulateMediaKey(keyPair.physicalKey!);
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
return Success('Media key pressed: $keyPair');
} catch (e) {
return Error('Failed to simulate media key: $e');
}
}
if (keyPair.physicalKey != null) {
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();

View File

@@ -77,7 +77,6 @@ class Permissions {
} else if (Platform.isWindows) {
list = [
BluetoothTurnedOn(),
NotificationRequirement(),
];
} else if (Platform.isAndroid) {
final deviceInfoPlugin = DeviceInfoPlugin();

View File

@@ -3,9 +3,7 @@ import 'dart:io';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/iap/iap_service.dart';
import 'package:bike_control/utils/iap/revenuecat_service.dart';
import 'package:bike_control/utils/iap/windows_iap_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
@@ -19,7 +17,6 @@ class IAPManager {
static int dailyCommandLimit = 15;
IAPService? _iapService;
RevenueCatService? _revenueCatService;
WindowsIAPService? _windowsIapService;
ValueNotifier<bool> isPurchased = ValueNotifier<bool>(false);
@@ -36,21 +33,9 @@ class IAPManager {
try {
if (Platform.isWindows) {
// Keep Windows using the existing windows_iap implementation
_windowsIapService = WindowsIAPService(prefs);
await _windowsIapService!.initialize();
} else if (Platform.isIOS || Platform.isMacOS || Platform.isAndroid) {
// Use RevenueCat for supported platforms when API key is available
_revenueCatService = RevenueCatService(
prefs,
isPurchasedNotifier: isPurchased,
getDailyCommandLimit: () => dailyCommandLimit,
setDailyCommandLimit: (limit) => dailyCommandLimit = limit,
);
await _revenueCatService!.initialize();
} else {
// Fall back to legacy IAP service
debugPrint('Using legacy IAP service (no RevenueCat key)');
_iapService = IAPService(prefs);
await _iapService!.initialize();
}
@@ -61,9 +46,7 @@ class IAPManager {
/// Check if the trial period has started
bool get hasTrialStarted {
if (_revenueCatService != null) {
return _revenueCatService!.hasTrialStarted;
} else if (_iapService != null) {
if (_iapService != null) {
return _iapService!.hasTrialStarted;
} else if (_windowsIapService != null) {
return _windowsIapService!.hasTrialStarted;
@@ -73,18 +56,14 @@ class IAPManager {
/// Start the trial period
Future<void> startTrial() async {
if (_revenueCatService != null) {
await _revenueCatService!.startTrial();
} else if (_iapService != null) {
if (_iapService != null) {
await _iapService!.startTrial();
}
}
/// Get the number of days remaining in the trial
int get trialDaysRemaining {
if (_revenueCatService != null) {
return _revenueCatService!.trialDaysRemaining;
} else if (_iapService != null) {
if (_iapService != null) {
return _iapService!.trialDaysRemaining;
} else if (_windowsIapService != null) {
return _windowsIapService!.trialDaysRemaining;
@@ -94,9 +73,7 @@ class IAPManager {
/// Check if the trial has expired
bool get isTrialExpired {
if (_revenueCatService != null) {
return _revenueCatService!.isTrialExpired;
} else if (_iapService != null) {
if (_iapService != null) {
return _iapService!.isTrialExpired;
} else if (_windowsIapService != null) {
return _windowsIapService!.isTrialExpired;
@@ -107,11 +84,9 @@ class IAPManager {
/// Check if the user can execute a command
bool get canExecuteCommand {
// If IAP is not initialized or not available, allow commands
if (_revenueCatService == null && _iapService == null && _windowsIapService == null) return true;
if (_iapService == null && _windowsIapService == null) return true;
if (_revenueCatService != null) {
return _revenueCatService!.canExecuteCommand;
} else if (_iapService != null) {
if (_iapService != null) {
return _iapService!.canExecuteCommand;
} else if (_windowsIapService != null) {
return _windowsIapService!.canExecuteCommand;
@@ -121,9 +96,7 @@ class IAPManager {
/// Get the number of commands remaining today (for free tier after trial)
int get commandsRemainingToday {
if (_revenueCatService != null) {
return _revenueCatService!.commandsRemainingToday;
} else if (_iapService != null) {
if (_iapService != null) {
return _iapService!.commandsRemainingToday;
} else if (_windowsIapService != null) {
return _windowsIapService!.commandsRemainingToday;
@@ -133,9 +106,7 @@ class IAPManager {
/// Get the daily command count
int get dailyCommandCount {
if (_revenueCatService != null) {
return _revenueCatService!.dailyCommandCount;
} else if (_iapService != null) {
if (_iapService != null) {
return _iapService!.dailyCommandCount;
} else if (_windowsIapService != null) {
return _windowsIapService!.dailyCommandCount;
@@ -145,9 +116,7 @@ class IAPManager {
/// Increment the daily command count
Future<void> incrementCommandCount() async {
if (_revenueCatService != null) {
await _revenueCatService!.incrementCommandCount();
} else if (_iapService != null) {
if (_iapService != null) {
await _iapService!.incrementCommandCount();
} else if (_windowsIapService != null) {
await _windowsIapService!.incrementCommandCount();
@@ -160,7 +129,7 @@ class IAPManager {
if (IAPManager.instance.isPurchased.value) {
return AppLocalizations.current.fullVersion;
} else if (!hasTrialStarted) {
return '${_revenueCatService?.trialDaysRemaining ?? _iapService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available';
return '${_iapService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available';
} else if (!isTrialExpired) {
return AppLocalizations.current.trialDaysRemaining(trialDaysRemaining);
} else {
@@ -169,10 +138,8 @@ class IAPManager {
}
/// Purchase the full version
Future<void> purchaseFullVersion(BuildContext context) async {
if (_revenueCatService != null) {
return await _revenueCatService!.purchaseFullVersion(context);
} else if (_iapService != null) {
Future<void> purchaseFullVersion() async {
if (_iapService != null) {
return await _iapService!.purchaseFullVersion();
} else if (_windowsIapService != null) {
return await _windowsIapService!.purchaseFullVersion();
@@ -181,40 +148,24 @@ class IAPManager {
/// Restore previous purchases
Future<void> restorePurchases() async {
if (_revenueCatService != null) {
await _revenueCatService!.restorePurchases();
} else if (_iapService != null) {
if (_iapService != null) {
await _iapService!.restorePurchases();
}
// Windows doesn't have a separate restore mechanism in the stub
}
/// Check if RevenueCat is being used
bool get isUsingRevenueCat => _revenueCatService != null;
/// Dispose the manager
void dispose() {
_revenueCatService?.dispose();
_iapService?.dispose();
_windowsIapService?.dispose();
}
Future<void> reset(bool fullReset) async {
isPurchased.value = false;
void reset(bool fullReset) {
_windowsIapService?.reset();
await _revenueCatService?.reset(fullReset);
await _iapService?.reset(fullReset);
_iapService?.reset(fullReset);
}
Future<void> redeem(String purchaseId) async {
if (_revenueCatService != null) {
await _revenueCatService!.redeem(purchaseId);
} else if (_iapService != null) {
await _iapService!.redeem();
}
}
void setAttributes() {
_revenueCatService?.setAttributes();
Future<void> redeem() async {
await _iapService!.redeem();
}
}

View File

@@ -107,7 +107,7 @@ class IAPService {
final lastPurchaseCheck = await _prefs.read(key: _lastPurchaseCheckKey);
final hasPurchased = await _prefs.read(key: _hasPurchasedKey);
String todayDate = DateFormat('yyyy-MM-dd').format(DateTime.now());
String todayDate = DateFormat('yMd').format(DateTime.now());
if (storedStatus == "true") {
if (Platform.isAndroid) {
@@ -145,9 +145,10 @@ class IAPService {
if (receiptContent != null) {
debugPrint('Existing Apple user detected - validating receipt $receiptContent');
var sharedSecret =
Platform.environment['VERIFYING_SHARED_SECRET'] ?? const String.fromEnvironment("VERIFYING_SHARED_SECRET");
Platform.environment['VERIFYING_SHARED_SECRET'] ?? String.fromEnvironment("VERIFYING_SHARED_SECRET");
if (sharedSecret.isEmpty) {
sharedSecret = 'ac978d8af9f64db19fdbe6fbc494de2a';
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Shared Secret is empty'));
}
core.connection.signalNotification(
@@ -274,10 +275,10 @@ class IAPService {
Future<void> _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) async {
for (final purchase in purchaseDetailsList) {
core.connection.signalNotification(
LogNotification('Purchase found: ${purchase.purchaseID} ${purchase.productID} - ${purchase.status}'),
LogNotification('Purchase found: ${purchase.productID} - ${purchase.status}'),
);
if (purchase.status == PurchaseStatus.purchased || purchase.status == PurchaseStatus.restored) {
IAPManager.instance.isPurchased.value = true;
IAPManager.instance.isPurchased.value = !kDebugMode;
await _prefs.write(key: _hasPurchasedKey, value: "true");
await _prefs.write(key: _purchaseStatusKey, value: IAPManager.instance.isPurchased.value.toString());
@@ -301,9 +302,6 @@ class IAPService {
final available = await _inAppPurchase.isAvailable();
if (!available) {
debugPrint('IAP not available');
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP is not available on this platform.'),
);
return;
}
@@ -313,25 +311,11 @@ class IAPService {
final response = await _inAppPurchase.queryProductDetails({productId});
if (response.error != null) {
debugPrint('Error querying products: ${response.error}');
if (response.error!.code == 'storekit_no_response') {
_trialStartDate = DateTime.now().toIso8601String();
core.connection.signalNotification(
AlertNotification(
LogLevel.LOGLEVEL_WARNING,
'Unlock will be available, soon! Trial days have been extended.',
),
);
} else {
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP issue: ${response.error!.toString()}'),
);
}
return;
}
if (response.productDetails.isEmpty) {
debugPrint('Product not found: $productId');
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_INFO, 'IAP issue: Product not found.'));
return;
}
@@ -339,7 +323,6 @@ class IAPService {
final purchaseParam = PurchaseParam(productDetails: product);
await _inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
await restorePurchases();
} catch (e, s) {
debugPrint('Error purchasing: $e');
recordError(e, s, context: 'Error purchasing');
@@ -432,13 +415,13 @@ class IAPService {
_subscription?.cancel();
}
Future<void> reset(bool fullReset) async {
void reset(bool fullReset) {
if (fullReset) {
await _prefs.deleteAll();
_prefs.deleteAll();
} else {
await _prefs.delete(key: _purchaseStatusKey);
_prefs.delete(key: _purchaseStatusKey);
_isInitialized = false;
await initialize();
initialize();
}
}

View File

@@ -1,361 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart' as zp;
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/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
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';
/// RevenueCat-based IAP service for iOS, macOS, and Android
class RevenueCatService {
static const int trialDays = 5;
static const String _trialStartDateKey = 'iap_trial_start_date';
static const String _purchaseStatusKey = 'iap_purchase_status';
static const String _dailyCommandCountKey = 'iap_daily_command_count';
static const String _lastCommandDateKey = 'iap_last_command_date';
// RevenueCat entitlement identifier
static const String fullVersionEntitlement = 'Full Version';
final FlutterSecureStorage _prefs;
final ValueNotifier<bool> isPurchasedNotifier;
final int Function() getDailyCommandLimit;
final void Function(int limit) setDailyCommandLimit;
bool _isInitialized = false;
String? _trialStartDate;
String? _lastCommandDate;
int? _dailyCommandCount;
StreamSubscription<CustomerInfo>? _customerInfoSubscription;
RevenueCatService(
this._prefs, {
required this.isPurchasedNotifier,
required this.getDailyCommandLimit,
required this.setDailyCommandLimit,
});
/// Initialize the RevenueCat service
Future<void> initialize() async {
if (_isInitialized) return;
try {
// Skip RevenueCat initialization on web or unsupported platforms
if (kIsWeb) {
debugPrint('RevenueCat not supported on web');
_isInitialized = true;
return;
}
// Get API key from environment variable
final String apiKey;
if (Platform.isAndroid) {
apiKey =
Platform.environment['REVENUECAT_API_KEY_ANDROID'] ??
const String.fromEnvironment('REVENUECAT_API_KEY_ANDROID', defaultValue: '');
} else if (Platform.isIOS || Platform.isMacOS) {
apiKey =
Platform.environment['REVENUECAT_API_KEY_IOS'] ??
const String.fromEnvironment('REVENUECAT_API_KEY_IOS', defaultValue: '');
} else {
apiKey = '';
}
if (apiKey.isEmpty) {
debugPrint('RevenueCat API key not found in environment');
core.connection.signalNotification(
LogNotification('RevenueCat API key not configured'),
);
isPurchasedNotifier.value = false;
_isInitialized = true;
return;
}
// Configure RevenueCat
final configuration = PurchasesConfiguration(apiKey);
// Enable debug logs in debug mode
if (kDebugMode) {
await Purchases.setLogLevel(LogLevel.debug);
}
await Purchases.configure(configuration);
debugPrint('RevenueCat initialized successfully');
core.connection.signalNotification(
LogNotification('RevenueCat initialized'),
);
// Listen for customer info updates
Purchases.addCustomerInfoUpdateListener((customerInfo) {
_handleCustomerInfoUpdate(customerInfo);
});
_trialStartDate = await _prefs.read(key: _trialStartDateKey);
core.connection.signalNotification(
LogNotification('Trial start date: $_trialStartDate => $trialDaysRemaining'),
);
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
final commandCount = await _prefs.read(key: _dailyCommandCountKey) ?? '0';
_dailyCommandCount = int.tryParse(commandCount);
// Check existing purchase status
await _checkExistingPurchase();
_isInitialized = true;
if (!isTrialExpired && Platform.isAndroid) {
setDailyCommandLimit(80);
}
} catch (e, s) {
recordError(e, s, context: 'Initializing RevenueCat Service');
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error initializing RevenueCat. Please check your configuration.',
),
);
debugPrint('Failed to initialize RevenueCat: $e');
isPurchasedNotifier.value = false;
_isInitialized = true;
}
}
/// Check if the user has an active entitlement
Future<void> _checkExistingPurchase() async {
try {
// Check current entitlement status from RevenueCat
final customerInfo = await Purchases.getCustomerInfo();
await _handleCustomerInfoUpdate(customerInfo);
} catch (e, s) {
debugPrint('Error checking existing purchase: $e');
recordError(e, s, context: 'Checking existing purchase');
}
}
/// Handle customer info updates from RevenueCat
Future<bool> _handleCustomerInfoUpdate(CustomerInfo customerInfo) async {
final hasEntitlement = customerInfo.entitlements.active.containsKey(fullVersionEntitlement);
final userId = await Purchases.appUserID;
core.connection.signalNotification(LogNotification('User ID: $userId at ${customerInfo.requestDate}'));
core.connection.signalNotification(LogNotification('Full Version entitlement: $hasEntitlement'));
if (!hasEntitlement) {
// purchased before IAP migration
if (Platform.isAndroid) {
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
if (storedStatus == "true") {
core.connection.signalNotification(LogNotification('Setting full version based on stored status'));
await Purchases.setAttributes({_purchaseStatusKey: "true"});
isPurchasedNotifier.value = true;
}
} else {
final purchasedVersion = customerInfo.originalApplicationVersion;
core.connection.signalNotification(LogNotification('Apple receipt validated for version: $purchasedVersion'));
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 0;
isPurchasedNotifier.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
}
} else {
isPurchasedNotifier.value = hasEntitlement;
}
return isPurchasedNotifier.value;
}
/// Present the RevenueCat paywall
Future<void> presentPaywall() async {
try {
if (!_isInitialized) {
await initialize();
}
final paywallResult = await RevenueCatUI.presentPaywall(displayCloseButton: true);
debugPrint('Paywall result: $paywallResult');
// The customer info listener will handle the purchase update
} catch (e, s) {
debugPrint('Error presenting paywall: $e');
recordError(e, s, context: 'Presenting paywall');
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error displaying the paywall. Please try again.',
),
);
}
}
/// Restore previous purchases
Future<void> restorePurchases() async {
try {
final customerInfo = await Purchases.restorePurchases();
final result = await _handleCustomerInfoUpdate(customerInfo);
if (result) {
core.connection.signalNotification(
AlertNotification(zp.LogLevel.LOGLEVEL_INFO, 'Purchase restored'),
);
}
} catch (e, s) {
core.connection.signalNotification(
AlertNotification(
zp.LogLevel.LOGLEVEL_ERROR,
'There was an error restoring purchases. Please try again.',
),
);
recordError(e, s, context: 'Restore Purchases');
debugPrint('Error restoring purchases: $e');
}
}
/// Purchase the full version (use paywall instead)
Future<void> purchaseFullVersion(BuildContext context) async {
// Direct the user to the paywall for a better experience
if (Platform.isMacOS) {
try {
final offerings = await Purchases.getOfferings();
final purchaseParams = PurchaseParams.package(offerings.current!.availablePackages.first);
PurchaseResult result = await Purchases.purchase(purchaseParams);
core.connection.signalNotification(
LogNotification('Purchase result: $result'),
);
} on PlatformException catch (e) {
var errorCode = PurchasesErrorHelper.getErrorCode(e);
if (errorCode != PurchasesErrorCode.purchaseCancelledError) {
buildToast(context, title: e.message);
}
}
} else {
await presentPaywall();
}
}
/// Check if the trial period has started
bool get hasTrialStarted {
return _trialStartDate != null;
}
/// Start the trial period
Future<void> startTrial() async {
if (!hasTrialStarted) {
await _prefs.write(key: _trialStartDateKey, value: DateTime.now().toIso8601String());
}
}
/// Get the number of days remaining in the trial
int get trialDaysRemaining {
if (isPurchasedNotifier.value) return 0;
final trialStart = _trialStartDate;
if (trialStart == null) return trialDays;
final startDate = DateTime.parse(trialStart);
final now = DateTime.now();
final daysPassed = now.difference(startDate).inDays;
final remaining = trialDays - daysPassed;
return remaining > 0 ? remaining : 0;
}
/// Check if the trial has expired
bool get isTrialExpired {
return (!isPurchasedNotifier.value && hasTrialStarted && trialDaysRemaining <= 0);
}
/// Get the number of commands executed today
int get dailyCommandCount {
final lastDate = _lastCommandDate;
final today = DateTime.now().toIso8601String().split('T')[0];
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 0;
}
return _dailyCommandCount ?? 0;
}
/// Increment the daily command count
Future<void> incrementCommandCount() async {
final today = DateTime.now().toIso8601String().split('T')[0];
final lastDate = await _prefs.read(key: _lastCommandDateKey);
if (lastDate != today) {
// Reset counter for new day
_lastCommandDate = today;
_dailyCommandCount = 1;
await _prefs.write(key: _lastCommandDateKey, value: today);
await _prefs.write(key: _dailyCommandCountKey, value: '1');
} else {
final count = _dailyCommandCount ?? 0;
_dailyCommandCount = count + 1;
await _prefs.write(key: _dailyCommandCountKey, value: _dailyCommandCount.toString());
}
}
/// Check if the user can execute a command
bool get canExecuteCommand {
if (isPurchasedNotifier.value) return true;
if (!isTrialExpired && !Platform.isAndroid) return true;
return dailyCommandCount < getDailyCommandLimit();
}
/// Get the number of commands remaining today (for free tier after trial)
int get commandsRemainingToday {
if (isPurchasedNotifier.value || (!isTrialExpired && !Platform.isAndroid)) return -1; // Unlimited
final remaining = getDailyCommandLimit() - dailyCommandCount;
return remaining > 0 ? remaining : 0; // Never return negative
}
/// Dispose the service
void dispose() {
_customerInfoSubscription?.cancel();
}
Future<void> reset(bool fullReset) async {
if (fullReset) {
await _prefs.deleteAll();
} else {
await _prefs.delete(key: _purchaseStatusKey);
_isInitialized = false;
Purchases.invalidateCustomerInfoCache();
await initialize();
_checkExistingPurchase();
}
}
Future<void> redeem(String purchaseId) async {
await Purchases.setAttributes({"purchase_id": purchaseId});
core.connection.signalNotification(LogNotification('Redeemed purchase ID: $purchaseId'));
Purchases.invalidateCustomerInfoCache();
_checkExistingPurchase();
isPurchasedNotifier.value = true;
}
Future<void> setAttributes() async {
// attributes are fully anonymous
await Purchases.setAttributes({
"bikecontrol_trainer": core.settings.getTrainerApp()?.name ?? '-',
"bikecontrol_target": core.settings.getLastTarget()?.name ?? '-',
if (core.connection.controllerDevices.isNotEmpty)
'bikecontrol_controllers': core.connection.controllerDevices.joinToString(
transform: (d) => d.name,
separator: ',',
),
'bikecontrol_keymap': core.settings.getKeyMap()?.name ?? '-',
});
}
}

View File

@@ -4,7 +4,6 @@ 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/widgets/ui/toast.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:windows_iap/windows_iap.dart';
@@ -87,11 +86,11 @@ class WindowsIAPService {
final status = await _windowsIapPlugin.makePurchase(productId);
if (status == StorePurchaseStatus.succeeded || status == StorePurchaseStatus.alreadyPurchased) {
IAPManager.instance.isPurchased.value = true;
buildToast(
/*buildToast(
navigatorKey.currentContext!,
title: 'Purchase Successful',
subtitle: 'Thank you for your purchase! You now have unlimited access.',
);
);*/
}
} catch (e, s) {
recordError(e, s, context: 'Purchasing on Windows');

View File

@@ -33,11 +33,7 @@ enum InGameAction {
// headwind
headwindSpeed('Headwind Speed', possibleValues: [0, 25, 50, 75, 100]),
headwindHeartRateMode('Headwind HR Mode'),
// openbikecontrol
up('Up', icon: RadixIcons.arrowUp),
down('Down', icon: RadixIcons.arrowDown);
headwindHeartRateMode('Headwind HR Mode');
final String title;
final bool isLongPress;

View File

@@ -130,18 +130,15 @@ class KeyPair {
IconData? get icon {
return switch (physicalKey) {
_ when isSpecialKey && core.actionHandler.supportedModes.contains(SupportedMode.media) => switch (physicalKey) {
PhysicalKeyboardKey.mediaPlayPause => Icons.play_arrow_outlined,
PhysicalKeyboardKey.mediaStop => Icons.stop,
PhysicalKeyboardKey.mediaTrackPrevious => Icons.skip_previous,
PhysicalKeyboardKey.mediaTrackNext => Icons.skip_next,
PhysicalKeyboardKey.audioVolumeUp => Icons.volume_up,
PhysicalKeyboardKey.audioVolumeDown => Icons.volume_down,
_ => Icons.keyboard,
},
//_ when inGameAction != null && core.logic.emulatorEnabled => Icons.link,
_ when inGameAction != null && inGameAction!.icon != null => inGameAction!.icon,
PhysicalKeyboardKey.mediaPlayPause ||
PhysicalKeyboardKey.mediaStop ||
PhysicalKeyboardKey.mediaTrackPrevious ||
PhysicalKeyboardKey.mediaTrackNext ||
PhysicalKeyboardKey.audioVolumeUp ||
PhysicalKeyboardKey.audioVolumeDown => Icons.music_note_outlined,
_ when physicalKey != null && core.actionHandler.supportedModes.contains(SupportedMode.keyboard) =>
RadixIcons.keyboard,
_

View File

@@ -3,19 +3,15 @@ import 'dart:isolate';
import 'dart:ui';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/actions/android.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/requirements/platform.dart';
import 'package:bike_control/widgets/accessibility_disclosure_dialog.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AccessibilityRequirement extends PlatformRequirement {
AccessibilityRequirement()
@@ -125,35 +121,21 @@ class NotificationRequirement extends PlatformRequirement {
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
} else if (Platform.isIOS) {
final result = await core.flutterLocalNotificationsPlugin
await core.flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: false,
sound: false,
);
if (result == false) {
buildToast(
navigatorKey.currentContext!,
title: 'Enable notifications for BikeControl in System Preferences → Notifications → Bike Control',
);
launchUrlString('x-apple.systempreferences:com.apple.preference.notifications');
}
} else if (Platform.isMacOS) {
final result = await core.flutterLocalNotificationsPlugin
await core.flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: false,
sound: false,
);
if (result == false) {
buildToast(
navigatorKey.currentContext!,
title: 'Enable notifications for BikeControl in System Preferences → Notifications → Bike Control',
);
launchUrlString('x-apple.systempreferences:com.apple.preference.notifications');
}
}
await getStatus();
return;
@@ -201,11 +183,6 @@ class NotificationRequirement extends PlatformRequirement {
macOS: DarwinInitializationSettings(
requestAlertPermission: false,
),
windows: WindowsInitializationSettings(
appName: 'BikeControl',
appUserModelId: 'OpenBikeControl.BikeControl',
guid: UUID.short(0x12).toString(),
),
),
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
onDidReceiveNotificationResponse: (n) {

View File

@@ -181,7 +181,6 @@ class Settings {
Future<void> setLastTarget(Target target) async {
await prefs.setString('last_target', target.name);
initializeActions(target.connectionType);
IAPManager.instance.setAttributes();
}
Future<void> setLastSeenVersion(String version) async {

View File

@@ -1,8 +1,8 @@
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:flutter/services.dart';
import 'package:flutter_md/flutter_md.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/i18n_extension.dart';
class ChangelogDialog extends StatelessWidget {
final Markdown entry;
@@ -26,10 +26,8 @@ class ChangelogDialog extends StatelessWidget {
],
),
content: Container(
constraints: BoxConstraints(minWidth: 460, maxHeight: 500),
child: Scrollbar(
child: SingleChildScrollView(child: MarkdownWidget(markdown: latestVersion)),
),
constraints: BoxConstraints(minWidth: 460),
child: MarkdownWidget(markdown: latestVersion),
),
actions: [
TextButton(

View File

@@ -6,12 +6,9 @@ import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/widgets/ui/loading_widget.dart';
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -25,14 +22,11 @@ class IAPStatusWidget extends StatefulWidget {
}
final _normalDate = DateTime(2026, 1, 15, 0, 0, 0, 0, 0);
final _iapDate = DateTime(2025, 12, 21, 0, 0, 0, 0, 0);
enum AlreadyBoughtOption { fullPurchase, iap, no }
class _IAPStatusWidgetState extends State<IAPStatusWidget> {
bool _isPurchasing = false;
bool _isSmall = false;
AlreadyBoughtOption? _alreadyBoughtQuestion;
bool? _alreadyBoughtQuestion = null;
final _purchaseIdField = const TextFieldKey(#purchaseId);
@@ -74,11 +68,11 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
}
: () {
if (Platform.isAndroid) {
if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) {
_handlePurchase(context);
if (_alreadyBoughtQuestion == false) {
_handlePurchase();
}
} else {
_handlePurchase(context);
_handlePurchase();
}
},
style: ButtonStyle.card().withBackgroundColor(
@@ -187,7 +181,7 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
),
),
],
if (!isPurchased && !_isSmall) ...[
if (!IAPManager.instance.isPurchased.value && !_isSmall) ...[
if (Platform.isAndroid)
Padding(
padding: const EdgeInsets.only(left: 42.0, top: 16.0),
@@ -201,44 +195,12 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
Text(AppLocalizations.of(context).alreadyBoughtTheAppPreviously).small,
Row(
children: [
Builder(
builder: (context) {
return OutlineButton(
child: Text(AppLocalizations.of(context).yes),
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: [
MenuButton(
child: Text(
AppLocalizations.of(
context,
).beforeDate(DateFormat.yMMMd().format(_iapDate)),
),
onPressed: (c) {
setState(() {
_alreadyBoughtQuestion = AlreadyBoughtOption.fullPurchase;
});
},
),
MenuButton(
child: Text(
AppLocalizations.of(
context,
).afterDate(DateFormat.yMMMd().format(_iapDate)),
),
onPressed: (c) {
setState(() {
_alreadyBoughtQuestion = AlreadyBoughtOption.iap;
});
},
),
],
),
);
},
);
OutlineButton(
child: Text(AppLocalizations.of(context).yes),
onPressed: () {
setState(() {
_alreadyBoughtQuestion = true;
});
},
),
const SizedBox(width: 8),
@@ -246,19 +208,19 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
child: Text(AppLocalizations.of(context).no),
onPressed: () {
setState(() {
_alreadyBoughtQuestion = AlreadyBoughtOption.no;
_alreadyBoughtQuestion = false;
});
},
),
],
),
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.fullPurchase) ...[
] else if (_alreadyBoughtQuestion == true) ...[
Text(
AppLocalizations.of(context).alreadyBoughtTheApp,
).small,
Form(
onSubmit: (context, values) async {
String purchaseId = _purchaseIdField[values]!.trim();
String purchaseId = _purchaseIdField[values]!;
setState(() {
_isLoading = true;
});
@@ -269,7 +231,7 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
supabaseUrl: 'https://pikrcyynovdvogrldfnw.supabase.co',
);
if (redeemed) {
await IAPManager.instance.redeem(purchaseId);
await IAPManager.instance.redeem();
buildToast(context, title: 'Success', subtitle: 'Purchase redeemed successfully!');
setState(() {
_isLoading = false;
@@ -290,10 +252,9 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
actions: [
OutlineButton(
child: Text(context.i18n.getSupport),
onPressed: () async {
final appUserId = await Purchases.appUserID;
onPressed: () {
launchUrlString(
'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help%20for%20$appUserId',
'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help',
);
},
),
@@ -344,9 +305,9 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
],
),
),
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.no) ...[
] else if (_alreadyBoughtQuestion == false) ...[
PrimaryButton(
onPressed: _isPurchasing ? null : () => _handlePurchase(context),
onPressed: _isPurchasing ? null : _handlePurchase,
leading: Icon(Icons.star),
child: _isPurchasing
? Row(
@@ -361,34 +322,6 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
: Text(AppLocalizations.of(context).unlockFullVersion),
),
Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) ...[
PrimaryButton(
onPressed: _isPurchasing ? null : () => _handlePurchase(context),
leading: Icon(Icons.star),
child: _isPurchasing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SmallProgressIndicator(),
const SizedBox(width: 8),
Text('Processing...'),
],
)
: Text(AppLocalizations.of(context).unlockFullVersion),
),
Text(
AppLocalizations.of(context).restorePurchaseInfo,
).xSmall,
OutlineButton(
child: Text(context.i18n.getSupport),
onPressed: () async {
final appUserId = await Purchases.appUserID;
launchUrlString(
'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help%20for%20$appUserId',
);
},
),
],
],
),
@@ -397,39 +330,22 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(left: 42.0),
child: Builder(
builder: (context) {
return PrimaryButton(
onPressed: _isPurchasing ? null : () => _handlePurchase(context),
leading: Icon(Icons.star),
child: _isPurchasing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SmallProgressIndicator(),
const SizedBox(width: 8),
Text('Processing...'),
],
)
: Text(AppLocalizations.of(context).unlockFullVersion),
);
},
child: PrimaryButton(
onPressed: _isPurchasing ? null : _handlePurchase,
leading: Icon(Icons.star),
child: _isPurchasing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SmallProgressIndicator(),
const SizedBox(width: 8),
Text('Processing...'),
],
)
: Text(AppLocalizations.of(context).unlockFullVersion),
),
),
if (Platform.isMacOS)
Padding(
padding: const EdgeInsets.only(left: 42.0, top: 8.0, bottom: 8),
child: LoadingWidget(
futureCallback: () async {
await IAPManager.instance.restorePurchases();
},
renderChild: (isLoading, tap) => LinkButton(
onPressed: tap,
child: isLoading ? SmallProgressIndicator() : const Text('Restore Purchase').small,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 42.0, top: 8.0),
child: Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
@@ -444,14 +360,13 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
);
}
Future<void> _handlePurchase(BuildContext context) async {
Future<void> _handlePurchase() async {
setState(() {
_isPurchasing = true;
});
try {
// Use RevenueCat paywall if available, otherwise fall back to legacy
await IAPManager.instance.purchaseFullVersion(context);
await IAPManager.instance.purchaseFullVersion();
} catch (e) {
if (mounted) {
buildToast(
@@ -474,9 +389,9 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
required String supabaseAnonKey,
required String purchaseId,
}) async {
final uri = Uri.parse('$supabaseUrl/functions/v1/redeem-purchase');
final appUserId = await Purchases.appUserID;
final uri = Uri.parse(
'$supabaseUrl/functions/v1/redeem-purchase',
);
final response = await http.post(
uri,
@@ -486,7 +401,6 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
},
body: jsonEncode({
'purchaseId': purchaseId,
'userId': appUserId,
}),
);

View File

@@ -250,7 +250,6 @@ class BKMenuButton extends StatelessWidget {
MenuButton(
child: Text(context.i18n.continueAction),
onPressed: (c) {
IAPManager.instance.purchaseFullVersion(context);
core.connection.addDevices([
ZwiftClickV2(
BleDevice(

View File

@@ -49,7 +49,7 @@ class Testbed extends StatefulWidget {
State<Testbed> createState() => _TestbedState();
}
class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin {
late final Ticker _ticker;
late StreamSubscription<BaseNotification> _actionSubscription;
@@ -67,8 +67,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
Offset? _lastMove;
bool _isInBackground = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -76,19 +74,13 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
_isMobile = MediaQuery.sizeOf(context).width < 600;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_isInBackground = state == AppLifecycleState.paused || state == AppLifecycleState.hidden;
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_focusNode = FocusNode(debugLabel: 'TestbedFocus', canRequestFocus: true, skipTraversal: true);
_actionSubscription = core.connection.actionStream.listen((data) async {
if (!mounted || (_isInBackground && data is! AlertNotification)) {
if (!mounted) {
return;
}
if (data is ButtonNotification && data.buttonsClicked.isNotEmpty) {
@@ -170,7 +162,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
void dispose() {
_ticker.dispose();
_focusNode.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

View File

@@ -113,7 +113,7 @@ class _AppTitleState extends State<AppTitle> {
final body = res.body;
final regex = RegExp(
r'>Version ([0-9]{1,2}\.[0-9]{1,2}.[0-9]{1,2})</h4>',
r'whats-new__latest__version">Version ([0-9]{1,2}\.[0-9]{1,2}.[0-9]{1,2})</p>',
dotAll: true,
);
final match = regex.firstMatch(body);

View File

@@ -19,7 +19,6 @@ import media_key_detector_macos
import nsd_macos
import package_info_plus
import path_provider_foundation
import purchases_flutter
import screen_retriever_macos
import shared_preferences_foundation
import universal_ble
@@ -42,7 +41,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))

View File

@@ -32,12 +32,6 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- purchases_flutter (9.10.2):
- FlutterMacOS
- PurchasesHybridCommon (= 17.25.0)
- PurchasesHybridCommon (17.25.0):
- RevenueCat (= 5.51.1)
- RevenueCat (5.51.1)
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
@@ -69,7 +63,6 @@ DEPENDENCIES:
- nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- purchases_flutter (from `Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
@@ -77,11 +70,6 @@ DEPENDENCIES:
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
SPEC REPOS:
trunk:
- PurchasesHybridCommon
- RevenueCat
EXTERNAL SOURCES:
bluetooth_low_energy_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/bluetooth_low_energy_darwin/darwin
@@ -113,8 +101,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
purchases_flutter:
:path: Flutter/ephemeral/.symlinks/plugins/purchases_flutter/macos
screen_retriever_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
shared_preferences_foundation:
@@ -144,9 +130,6 @@ SPEC CHECKSUMS:
nsd_macos: 1a38a38a33adbb396b4c6f303bc076073514cadc
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
purchases_flutter: 777401787df16312c7b8b53b2d7144d26b6da0f0
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6

View File

@@ -289,14 +289,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
expressions:
dependency: transitive
description:
@@ -1266,22 +1258,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.7"
purchases_flutter:
dependency: "direct main"
description:
name: purchases_flutter
sha256: "0a8ce3855dacb8c28e1e8de99cfe5592be2cb350c1210259d6fa4d9d0b152f89"
url: "https://pub.dev"
source: hosted
version: "9.10.2"
purchases_ui_flutter:
dependency: "direct main"
description:
name: purchases_ui_flutter
sha256: cb9eac034536fa2c62beb0c4a9e921a09e505ee7fbe304370a6764044ed9aef2
url: "https://pub.dev"
source: hosted
version: "9.10.2"
quiver:
dependency: transitive
description:

View File

@@ -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.2.4+71
version: 4.2.2+64
environment:
sdk: ^3.9.0
@@ -36,8 +36,6 @@ dependencies:
path: ios_receipt
flutter_secure_storage: ^10.0.0
in_app_purchase: ^3.2.1
purchases_flutter: ^9.10.2
purchases_ui_flutter: ^9.10.2
windows_iap:
path: windows_iap
window_manager: ^0.5.1

View File

@@ -1,12 +1,11 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:bike_control/bluetooth/devices/shimano/shimano_di2.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/apps/openbikecontrol.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:universal_ble/universal_ble.dart';
Future<void> main() async {
@@ -39,37 +38,5 @@ Future<void> main() async {
);
expect(stubActions.performedActions.isEmpty, false);
});
test('should transmit all events', () async {
final instance = ShimanoDi2(BleDevice(name: 'Di2', deviceId: ''));
await instance.processCharacteristic(
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
Uint8List.fromList([0x21, 0x13, 0xF0, 0xF0]),
);
expect(stubActions.performedActions.isEmpty, true);
await instance.processCharacteristic(
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
Uint8List.fromList([0x21, 0x13, 0xF0, 0xF0]),
);
expect(stubActions.performedActions.isEmpty, true);
await instance.processCharacteristic(
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
Uint8List.fromList([0x21, 0x14, 0xF0, 0xF0]),
);
final button = ControllerButton('D-Fly Channel 1');
expect(stubActions.performedActions, equals([(button, true, false), (button, false, true)]));
await instance.processCharacteristic(
ShimanoDi2Constants.D_FLY_CHANNEL_UUID,
Uint8List.fromList([0x21, 0x15, 0xF0, 0xF0]),
);
expect(
stubActions.performedActions,
equals([(button, true, false), (button, false, true), (button, true, false), (button, false, true)]),
);
});
});
}