Compare commits

..

25 Commits

Author SHA1 Message Date
Jonas Bark
0c62d64987 version++ 2026-01-07 11:21:22 +01:00
Jonas Bark
546f6c2f8f version++ 2026-01-07 11:03:03 +01:00
Jonas Bark
a6ee15e3ba changelog adjustment 2026-01-07 10:37:46 +01:00
Jonas Bark
51793847cf performance & ui fixes 2026-01-07 10:36:41 +01:00
Jonas Bark
f308aa3847 performance & ui fixes 2026-01-07 10:19:21 +01:00
Jonas Bark
695b994577 fix shorebird logic 2026-01-06 18:08:56 +01:00
Jonas Bark
2301d04c61 refactor update UI 2026-01-06 09:10:27 +01:00
Jonas Bark
e46ec5172c OBC adjustments 2026-01-05 13:23:33 +01:00
Jonas Bark
691e108c82 OBC adjustments 2026-01-05 13:22:23 +01:00
Jonas Bark
166146a8f8 Web fixes 2026-01-03 20:50:06 +01:00
Jonas Bark
2a42cfc80f make notifications optional on macOS & iOS 2026-01-03 09:42:03 +01:00
Jonas Bark
aff1f20ebe Merge branch '4.2.4'
# Conflicts:
#	CHANGELOG.md
#	lib/utils/iap/revenuecat_service.dart
2026-01-02 21:02:54 +01:00
Jonas Bark
804fed799d fix logic 2026-01-02 21:02:02 +01:00
Jonas Bark
14a4234583 instructions UI change 2026-01-02 15:03:46 +01:00
Jonas Bark
72cd165992 instructions for local connection method 2026-01-02 14:03:08 +01:00
Jonas Bark
239aec2083 onboarding UI 2026-01-01 13:50:02 +01:00
Jonas Bark
a43ab60dcb onboarding UI 2026-01-01 13:47:17 +01:00
Jonas Bark
484b7f74e6 onboarding UI 2026-01-01 13:44:45 +01:00
Jonas Bark
8c1ddcb019 ui adjustments 2026-01-01 12:40:45 +01:00
Jonas Bark
306a5badef onboarding for new users 2025-12-31 16:26:40 +01:00
Jonas Bark
e82003cd57 onboarding for new users 2025-12-31 16:16:37 +01:00
Jonas Bark
6a6aafe0a9 onboarding for new users 2025-12-31 15:52:30 +01:00
Jonas Bark
4bd440e167 ui adjustments 2025-12-31 13:51:47 +01:00
Jonas Bark
80d198787f ui adjustments 2025-12-31 12:26:34 +01:00
Jonas Bark
7f7bae477b debug text adjustments 2025-12-31 12:11:06 +01:00
57 changed files with 2227 additions and 1313 deletions

View File

@@ -1,3 +1,13 @@
### 4.3.0 (07-01-2026)
**Features**:
- Onboarding for new users
- support controlling music & volume for Windows, macOS and Android
- App is now available in Italian (thanks to Connect_Thanks2613)
**Fixes**:
- Vibration setting now available for Zwift Ride devices
### 4.2.0 (20-12-2025)
BikeControl now offers a free trial period of 5 days for all features, so you can test everything before deciding to purchase a license. Please contact the support if you experience any issues!

17
INSTRUCTIONS_LOCAL.md Normal file
View File

@@ -0,0 +1,17 @@
## What is the Local connection method?
*
The Local connection method works by directly controlling the target trainer app on the same device by simulating user input (taps, keyboard inputs). This method does not require any network connection or additional hardware, making it the simplest and most straightforward way to connect to the trainer app.
There are predefined keymaps (touch positions or keyboard shortcuts) for popular trainer apps, allowing users to easily set up and start using the Local connection method without needing to configure anything manually. You can configure these keymaps in the Configuration tab. Note though that supported keyboard keys depend on the trainer app.
## When to use the Local connection method?
*
The Local connection method is ideal for users who:
- Are running the trainer app on the same device as the controller app (e.g., both apps on a smartphone or tablet).
- Do not want to deal with network configurations or potential connectivity issues.
## Limitations of the Local connection method
*
While the Local connection method is easy to set up and use, it has some limitations:
- It may not work well with trainer apps that have complex user interfaces or require precise timing.
- It is limited to the device on which both the controller and trainer apps are running, meaning it cannot be used for remote control scenarios.

View File

@@ -50,6 +50,8 @@ class Connection {
final Map<BaseDevice, StreamSubscription<bool>> _connectionSubscriptions = {};
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
Stream<BaseDevice> get connectionStream => _connectionStreams.stream;
final StreamController<BluetoothDevice> _rssiConnectionStreams = StreamController<BluetoothDevice>.broadcast();
Stream<BluetoothDevice> get rssiConnectionStream => _rssiConnectionStreams.stream;
final _lastScanResult = <BleDevice>[];
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
@@ -83,7 +85,7 @@ class Connection {
);
if (existingDevice != null && existingDevice.rssi != result.rssi) {
existingDevice.rssi = result.rssi;
_connectionStreams.add(existingDevice); // Notify UI of update
_rssiConnectionStreams.add(existingDevice); // Notify UI of update
}
if (_lastScanResult.none((e) => e.deviceId == result.deviceId && e.services.contentEquals(result.services))) {
@@ -96,7 +98,9 @@ class Connection {
final scanResult = BluetoothDevice.fromScanResult(result);
if (scanResult != null) {
_actionStreams.add(LogNotification('Found new device: ${kIsWeb ? scanResult.name : scanResult.runtimeType}'));
_actionStreams.add(
LogNotification('Found new device: ${kIsWeb ? scanResult.toString() : scanResult.runtimeType}'),
);
addDevices([scanResult]);
} else {
final manufacturerData = result.manufacturerDataList;
@@ -123,7 +127,7 @@ class Connection {
// on web, log all characteristic changes for debugging
_actionStreams.add(
LogNotification(
'Characteristic update for device ${device.name}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
'Characteristic update for device ${device.toString()}, char: $characteristicUuid, value: ${bytesToReadableHex(value)}',
),
);
}
@@ -132,7 +136,7 @@ class Connection {
} catch (e, backtrace) {
_actionStreams.add(
LogNotification(
"Error processing characteristic for device ${device.name} and char: $characteristicUuid: $e\n$backtrace",
"Error processing characteristic for device ${device.toString()} and char: $characteristicUuid: $e\n$backtrace",
),
);
if (kDebugMode) {
@@ -267,11 +271,11 @@ class Connection {
if (_connectionQueue.isNotEmpty && !_handlingConnectionQueue && !screenshotMode) {
_handlingConnectionQueue = true;
final device = _connectionQueue.removeAt(0);
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.name}'));
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connecting to: ${device.toString()}'));
_connect(device)
.then((_) {
_handlingConnectionQueue = false;
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.name}'));
_actionStreams.add(AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connection finished: ${device.toString()}'));
if (_connectionQueue.isNotEmpty) {
_handleConnectionQueue();
}
@@ -281,11 +285,11 @@ class Connection {
_handlingConnectionQueue = false;
if (e is TimeoutException) {
_actionStreams.add(
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.name}: Timeout'),
AlertNotification(LogLevel.LOGLEVEL_WARNING, 'Unable to connect to ${device.toString()}: Timeout'),
);
} else {
_actionStreams.add(
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.name} - $e'),
AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Connection failed: ${device.toString()} - $e'),
);
}
if (_connectionQueue.isNotEmpty) {
@@ -306,11 +310,11 @@ class Connection {
_connectionStreams.add(device);
core.flutterLocalNotificationsPlugin.show(
1338,
'${device.name} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}',
'${device.toString()} ${state ? AppLocalizations.current.connected.decapitalize() : AppLocalizations.current.disconnected.decapitalize()}',
!state ? AppLocalizations.current.tryingToConnectAgain : null,
NotificationDetails(
android: AndroidNotificationDetails('Connection', 'Connection Status'),
iOS: DarwinNotificationDetails(presentAlert: true),
iOS: DarwinNotificationDetails(presentAlert: true, presentSound: false),
),
);
if (!device.isConnected) {
@@ -364,8 +368,8 @@ class Connection {
if (device is BluetoothDevice) {
if (persistForget) {
// Add device to ignored list when forgetting
await core.settings.addIgnoredDevice(device.device.deviceId, device.name);
_actionStreams.add(LogNotification('Device ignored: ${device.name}'));
await core.settings.addIgnoredDevice(device.device.deviceId, device.toString());
_actionStreams.add(LogNotification('Device ignored: ${device.toString()}'));
}
if (!forget) {
// allow reconnection

View File

@@ -36,7 +36,8 @@ abstract class BaseDevice {
@override
bool operator ==(Object other) =>
identical(this, other) || other is BaseDevice && runtimeType == other.runtimeType && name == other.name;
identical(this, other) ||
other is BaseDevice && runtimeType == other.runtimeType && toString() == other.toString();
@override
int get hashCode => name.hashCode;

View File

@@ -14,6 +14,7 @@ import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
import 'package:bike_control/bluetooth/devices/zwift/zwift_play.dart';
import 'package:bike_control/bluetooth/devices/zwift/zwift_ride.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/pages/device.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
@@ -21,6 +22,7 @@ import 'package:bike_control/widgets/ui/beta_pill.dart';
import 'package:bike_control/widgets/ui/device_info.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:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@@ -120,28 +122,33 @@ abstract class BluetoothDevice extends BaseDevice {
?.payload;
if (data == null || data.isEmpty) {
return null;
} else {
final type = ZwiftDeviceType.fromManufacturerData(data.first);
device = switch (type) {
ZwiftDeviceType.click => ZwiftClick(scanResult),
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
_ => null,
};
}
final type = ZwiftDeviceType.fromManufacturerData(data.first);
return switch (type) {
ZwiftDeviceType.click => ZwiftClick(scanResult),
ZwiftDeviceType.playRight => ZwiftPlay(scanResult),
ZwiftDeviceType.playLeft => ZwiftPlay(scanResult),
ZwiftDeviceType.rideLeft => ZwiftRide(scanResult),
//DeviceType.rideRight => ZwiftRide(scanResult), // see comment above
ZwiftDeviceType.clickV2Left => ZwiftClickV2(scanResult),
//DeviceType.clickV2Right => ZwiftClickV2(scanResult), // see comment above
_
when scanResult.name == 'Zwift Ride' &&
type != ZwiftDeviceType.rideRight &&
type != ZwiftDeviceType.rideLeft =>
ZwiftRide(scanResult), // e.g. old firmware
_ => null,
};
} else {
return null;
}
if (scanResult.name == 'Zwift Ride' && device == null) {
// Fallback for Zwift Ride if nothing else matched => old firmware
if (navigatorKey.currentContext?.mounted ?? false) {
buildToast(
navigatorKey.currentContext!,
title: 'Please update your Zwift Ride firmware.',
duration: Duration(seconds: 6),
);
}
device = ZwiftRide(scanResult);
}
return device;
}
@override
@@ -152,11 +159,6 @@ abstract class BluetoothDevice extends BaseDevice {
@override
int get hashCode => scanResult.deviceId.hashCode;
@override
String toString() {
return name + (firmwareVersion != null ? ' v$firmwareVersion' : '');
}
BleDevice get device => scanResult;
@override
@@ -230,7 +232,7 @@ abstract class BluetoothDevice extends BaseDevice {
spacing: 8,
children: [
Text(
device.name?.screenshot ?? runtimeType.toString(),
toString().screenshot ?? runtimeType.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),
@@ -316,15 +318,22 @@ abstract class BluetoothDevice extends BaseDevice {
),
if (rssi != null)
DeviceInfo(
title: context.i18n.signal,
icon: switch (rssi!) {
>= -50 => Icons.signal_cellular_4_bar,
>= -60 => Icons.signal_cellular_alt_2_bar,
>= -70 => Icons.signal_cellular_alt_1_bar,
_ => Icons.signal_cellular_alt,
StreamBuilder(
stream: core.connection.rssiConnectionStream
.where((device) => device == this)
.map((event) => event.rssi),
builder: (context, rssiValue) {
return DeviceInfo(
title: context.i18n.signal,
icon: switch (rssiValue.data ?? rssi!) {
>= -50 => Icons.signal_cellular_4_bar,
>= -60 => Icons.signal_cellular_alt_2_bar,
>= -70 => Icons.signal_cellular_alt_1_bar,
_ => Icons.signal_cellular_alt,
},
value: '$rssi dBm',
);
},
value: '$rssi dBm',
),
],
),

View File

@@ -1,13 +1,13 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:gamepads/gamepads.dart';
import 'package:bike_control/bluetooth/devices/base_device.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/pages/device.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/widgets/ui/beta_pill.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:gamepads/gamepads.dart';
class GamepadDevice extends BaseDevice {
final String id;
@@ -68,7 +68,7 @@ class GamepadDevice extends BaseDevice {
spacing: 8,
children: [
Text(
name.screenshot,
toString().screenshot,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),

View File

@@ -200,7 +200,7 @@ class GyroscopeSteering extends BaseDevice {
spacing: 12,
children: [
Text(
name.screenshot,
toString().screenshot,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (isBeta) BetaPill(),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:bike_control/bluetooth/devices/base_device.dart';
import 'package:bike_control/utils/actions/android.dart';
import 'package:bike_control/utils/core.dart';
import 'package:flutter/material.dart';
class HidDevice extends BaseDevice {
HidDevice(super.name) : super(availableButtons: []);
@@ -15,7 +15,7 @@ class HidDevice extends BaseDevice {
Widget showInformation(BuildContext context) {
return Row(
children: [
Expanded(child: Text(name)),
Expanded(child: Text(toString())),
PopupMenuButton(
itemBuilder: (c) => [
PopupMenuItem(

View File

@@ -1,9 +1,12 @@
import 'dart:io';
import 'package:bike_control/bluetooth/ble.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.pbenum.dart';
import 'package:bike_control/bluetooth/messages/notification.dart' show AlertNotification, LogNotification;
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
@@ -13,8 +16,6 @@ import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import '../../messages/notification.dart' show AlertNotification;
class OpenBikeControlBluetoothEmulator extends TrainerConnection {
late final _peripheralManager = PeripheralManager();
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier<AppInfo?>(null);
@@ -79,6 +80,12 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
await _peripheralManager.respondReadRequestWithValue(
eventArgs.request,
value: Uint8List.fromList([100]),
);
return;
default:
print('Unhandled read request for characteristic: ${eventArgs.characteristic.uuid}');
}
@@ -116,9 +123,9 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
);
print('Parsed App Info: $appInfo');
core.connection.signalNotification(LogNotification('Parsed App Info: $appInfo'));
} catch (e) {
print('Error parsing App Info: $e');
core.connection.signalNotification(LogNotification('Error parsing App Info ${bytesToHex(value)}: $e'));
}
break;
default:

View File

@@ -136,14 +136,18 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
final messageType = data[0];
switch (messageType) {
case OpenBikeProtocolParser.MSG_TYPE_APP_INFO:
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data));
isConnected.value = true;
connectedApp.value = appInfo;
try {
final appInfo = OpenBikeProtocolParser.parseAppInfo(Uint8List.fromList(data));
isConnected.value = true;
connectedApp.value = appInfo;
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
);
supportedActions = appInfo.supportedButtons.mapNotNull((b) => b.action).toList();
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, 'Connected to app: ${appInfo.appId}'),
);
} catch (e) {
core.connection.signalNotification(LogNotification('Failed to parse app info: $e'));
}
break;
default:
print('Unknown message type: $messageType');

View File

@@ -8,6 +8,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:dartx/dartx.dart';
@@ -17,7 +18,7 @@ class ProtocolParseException implements Exception {
ProtocolParseException(this.message, [this.raw]);
@override
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${raw!.length}' : ''}';
String toString() => 'ProtocolParseException: $message${raw != null ? ' raw=${bytesToReadableHex(raw!)}' : ''}';
}
class OpenBikeProtocolParser {

View File

@@ -1,6 +1,3 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.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/zwift_ride.dart';
@@ -9,6 +6,9 @@ import 'package:bike_control/pages/markdown.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/widgets/ui/warning.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class ZwiftClickV2 extends ZwiftRide {
ZwiftClickV2(super.scanResult)
@@ -39,6 +39,11 @@ class ZwiftClickV2 extends ZwiftRide {
@override
bool get canVibrate => false;
@override
String toString() {
return "$name V2";
}
@override
Future<void> setupHandshake() async {
super.setupHandshake();
@@ -65,51 +70,58 @@ class ZwiftClickV2 extends ZwiftRide {
children: [
super.showInformation(context),
if (isConnected && _noLongerSendsEvents)
if (isConnected)
if (core.settings.getShowZwiftClickV2ReconnectWarning())
Warning(
Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
Warning(
children: [
Expanded(
child: Text(
AppLocalizations.of(context).clickV2Instructions,
).xSmall,
),
IconButton.link(
icon: Icon(Icons.close),
Text(
'Important Setup Information',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.destructive,
),
).small,
Text(
AppLocalizations.of(context).clickV2Instructions,
style: TextStyle(
color: Theme.of(context).colorScheme.destructive,
),
).xSmall,
if (kDebugMode)
GhostButton(
onPressed: () {
sendCommand(Opcode.RESET, null);
},
child: Text('Reset now'),
),
Button.secondary(
onPressed: () {
core.settings.setShowZwiftClickV2ReconnectWarning(false);
setState(() {});
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
);
},
leading: const Icon(Icons.help_outline_outlined),
child: Text(context.i18n.instructions),
),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
GhostButton(
onPressed: () {
sendCommand(Opcode.RESET, null);
},
child: Text('Reset now'),
Align(
alignment: Alignment.topRight,
child: IconButton.link(
icon: Icon(
Icons.close,
color: Theme.of(context).colorScheme.destructive,
),
OutlineButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
),
);
},
leading: const Icon(Icons.open_in_new),
child: Text(context.i18n.troubleshootingGuide),
),
],
onPressed: () {
core.settings.setShowZwiftClickV2ReconnectWarning(false);
setState(() {});
},
),
),
],
)
@@ -123,11 +135,10 @@ class ZwiftClickV2 extends ZwiftRide {
LinkButton(
child: Text(context.i18n.troubleshootingGuide),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
),
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
);
},
),

View File

@@ -5,10 +5,12 @@ 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/messages/notification.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/single_line_exception.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:universal_ble/universal_ble.dart';
abstract class ZwiftDevice extends BluetoothDevice {
@@ -90,7 +92,7 @@ abstract class ZwiftDevice extends BluetoothDevice {
if (kDebugMode) {
actionStreamInternal.add(
LogNotification(
"${DateTime.now().toString().split(" ").last} Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
"Received data on $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
),
);
}
@@ -191,4 +193,24 @@ abstract class ZwiftDevice extends BluetoothDevice {
withoutResponse: true,
);
}
@override
Widget showInformation(BuildContext context) {
return Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
super.showInformation(context),
if (canVibrate)
Checkbox(
trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)),
state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked,
onChanged: (value) async {
await core.settings.setVibrationEnabled(value == CheckboxState.checked);
},
),
],
);
}
}

View File

@@ -1,11 +1,8 @@
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:flutter/foundation.dart';
class ZwiftPlay extends ZwiftDevice {
ZwiftPlay(super.scanResult)
@@ -62,23 +59,4 @@ class ZwiftPlay extends ZwiftDevice {
@override
String get latestFirmwareVersion => '1.3.1';
@override
Widget showInformation(BuildContext context) {
return Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
super.showInformation(context),
Checkbox(
trailing: Expanded(child: Text(context.i18n.enableVibrationFeedback)),
state: core.settings.getVibrationEnabled() ? CheckboxState.checked : CheckboxState.unchecked,
onChanged: (value) async {
await core.settings.setVibrationEnabled(value == CheckboxState.checked);
},
),
],
);
}
}

View File

@@ -31,6 +31,7 @@
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "Im letzten Schritt wählen Sie die Verbindungsmethode aus für {trainerApp}",
"battery": "Batterie",
"beforeDate": "Vor dem {date}",
"bluetoothAdvertiseAccess": "Bluetooth-Zugriff",
@@ -44,7 +45,7 @@
"chooseBikeControlInConnectionScreen": "Wähle im Verbindungsbildschirm BikeControl aus.",
"clickAButtonOnYourController": "Klicke eine Controller-Taste, um deren Aktion zu bearbeiten, oder tippe auf das Bearbeitungssymbol.",
"clickV2EventInfo": "Dein Click V2 sendet möglicherweise keine Tastenereignisse mehr. Probier mal ein paar Tasten aus und schau, ob sie in BikeControl angezeigt werden.",
"clickV2Instructions": "Damit dein Zwift Click V2 optimal funktioniert, solltest du vor jeder Trainings-Session in der Zwift-App verbinden.\nWenn du das nicht machst, funktioniert der Click V2 nach einer Minute nicht mehr.\n\n1. Öffne die Zwift-App.\n2. Melde dich an (kein Abonnement nötig) und öffne den Bildschirm für die Geräteverbindung.\n3. Verbinde deinen Trainer und dann den Zwift Click V2.\n4. Schließe die Zwift-App wieder und verbinde dich erneut in BikeControl.",
"clickV2Instructions": "Damit dein Zwift Click V2 optimal funktioniert, solltest du das Gerät vor jeder Trainings-Session in der Zwift-App verbinden.\nAnsonsten funktioniert der Click V2 nach einer Minute nicht mehr.\n\n1. Öffne die Zwift-App.\n2. Melde dich an (kein Abonnement nötig) und öffne den Bildschirm für die Geräteverbindung.\n3. Verbinde deinen Trainer und dann den Zwift Click V2.\n4. Schließe die Zwift-App wieder und verbinde dich erneut in BikeControl.",
"close": "Schließen",
"commandsRemainingToday": "{commandsRemainingToday}/{dailyCommandLimit} verbleibende Befehle heute",
"configuration": "Konfiguration",
@@ -77,6 +78,7 @@
}
}
},
"controllerConnectedClickButton": "Super! Dein Controller ist verbunden. Drücke eine Taste auf deinem Controller, um fortzufahren.",
"controllers": "Controller",
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "{button} konnte nicht ausgeführt werden: Keine Konfiguration festgelegt",
"create": "Erstellen",
@@ -138,7 +140,7 @@
"enableAutoRotation": "Aktiviere die automatische Drehung auf Ihrem Gerät, um sicherzustellen, dass die App ordnungsgemäß funktioniert.",
"enableBluetooth": "Bluetooth aktivieren",
"enableKeyboardAccessMessage": "Aktiviere im folgenden Bildschirm die Tastatursteuerung für BikeControl. Falls BikeControl nicht angezeigt wird, füge es bitte manuell hinzu.",
"enableKeyboardMouseControl": "Aktiviere die Tastatur- und Maussteuerung für eine bessere Interaktion mit {appName}. Sobald die Funktion aktiviert ist, sind keine weiteren Aktionen oder Verbindungen erforderlich. BikeControl sendet die Maus- oder Tastatureingaben direkt an {appName} weiter.",
"enableKeyboardMouseControl": "BikeControl sendet Maus- oder Tastaturkommandos an {appName}",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
@@ -214,6 +216,7 @@
}
}
},
"letsGetYouSetUp": "Starten wir die Einrichtung",
"license": "Lizenz",
"licenseStatus": "Lizenzstatus",
"loadScreenshotForPlacement": "Lade einen Screenshot aus dem Spiel zur Platzierung hoch.",
@@ -239,7 +242,7 @@
"myWhooshDirectConnectAction": "MyWhoosh „Link”-Aktion",
"myWhooshDirectConnection": " z. B. mit MyWhoosh „Link”.",
"myWhooshLinkConnected": "MyWhoosh „Link“ verbunden",
"myWhooshLinkDescriptionLocal": "Verbinde dich direkt mit MyWhoosh über die „Link-Methode. Unterstützte Aktionen sind unter anderem Schalten, Emotes und Richtungswechsel. Die MyWhoosh Link-Begleit-App darf dabei NICHT gleichzeitig laufen.",
"myWhooshLinkDescriptionLocal": "Direkte Verbindung zu MyWhoosh über die „Link-Methode. Beachte die Anleitung, um sicherzustellen, dass eine Verbindung möglich ist. Die MyWhoosh Link-App darf nicht gleichzeitig aktiv sein.",
"myWhooshLinkInfo": "Schau mal im Abschnitt zur Fehlerbehebung nach, wenn du Probleme hast. Eine deutlich zuverlässigere Verbindungsmethode kommt bald!",
"needHelpClickHelp": "Hilfe benötigt? Klicke auf",
"needHelpDontHesitate": "den Button oben und zögere nicht, uns zu kontaktieren.",
@@ -379,16 +382,17 @@
"selectTrainerAppAndTarget": "Trainer-App und Zielgerät auswählen",
"selectTrainerAppPlaceholder": "Trainer-App auswählen",
"setting": "Einstellung",
"setupComplete": "Einrichtung abgeschlossen!",
"setupTrainer": "Trainer einrichten",
"share": "Teilen",
"showDonation": "Zeige Deine Wertschätzung durch eine Spende.",
"showSupportedControllers": "Unterstützte Controller anzeigen",
"showTroubleshootingGuide": "Anleitung zur Fehlerbehebung anzeigen",
"signal": "Signal",
"simulateButtons": "Trainersteuerung",
"simulateKeyboardShortcut": "Tastenkombination simulieren",
"simulateMediaKey": "Medientaste simulieren",
"simulateTouch": "Berührung simulieren",
"skip": "Überspringen",
"stop": "Stoppen",
"targetOtherDevice": "Anderes Gerät",
"targetThisDevice": "Dieses Gerät",
@@ -400,10 +404,11 @@
"trialExpired": "Testphase abgelaufen. Befehle beschränkt auf {dailyCommandLimit} pro Tag.",
"trialPeriodActive": "Testphase aktiv - {trialDaysRemaining} verbleibende Tage",
"trialPeriodDescription": "Während der Testphase stehen unbegrenzt viele Befehle zur Verfügung. Nach Ablauf der Testphase sind die Befehle auf {dailyCommandLimit} pro Tag eingeschränkt.",
"troubleshootingGuide": "Leitfaden zur Fehlerbehebung",
"troubleshootingGuide": "Fragen und Antworten",
"tryingToConnectAgain": "Verbinde erneut...",
"unassignAction": "Zuweisung aufheben",
"unlockFullVersion": "Vollversion freischalten",
"unlockingNotPossible": "Aufgrund technischer Probleme ist eine Entsperrung derzeit nicht möglich; daher kann die App solange uneingeschränkt verwendet werden.",
"update": "Aktualisieren",
"useCustomKeymapForButton": "Verwende eine benutzerdefinierte Tastaturbelegung, um die",
"version": "Version {version}",

View File

@@ -31,6 +31,7 @@
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "As a final step you'll choose how to connect to {trainerApp}.",
"battery": "Battery",
"beforeDate": "Before {date}",
"bluetoothAdvertiseAccess": "Bluetooth Advertise access",
@@ -77,6 +78,7 @@
}
}
},
"controllerConnectedClickButton": "Great! Your controller is connected. Click a button on your controller to continue.",
"controllers": "Controllers",
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Could not perform {button}: No keymap set",
"create": "Create",
@@ -138,7 +140,7 @@
"enableAutoRotation": "Enable auto-rotation on your device to make sure the app works correctly.",
"enableBluetooth": "Enable Bluetooth",
"enableKeyboardAccessMessage": "Enable keyboard access in the following screen for BikeControl. If you don't see BikeControl, please add it manually.",
"enableKeyboardMouseControl": "Enable keyboard and mouse control for better interaction with {appName}. Once active, no further action or connection is needed. BikeControl will directly send the mouse or keyboard input to {appName}.",
"enableKeyboardMouseControl": "BikeControl will send mouse or keyboard actions to control {appName}.",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
@@ -214,6 +216,7 @@
}
}
},
"letsGetYouSetUp": "Let's get you set up!",
"license": "License",
"licenseStatus": "License Status",
"loadScreenshotForPlacement": "Load in-game screenshot for placement",
@@ -239,7 +242,7 @@
"myWhooshDirectConnectAction": "MyWhoosh \"Link\" Action",
"myWhooshDirectConnection": " e.g. by using MyWhoosh \"Link\"",
"myWhooshLinkConnected": "MyWhoosh \"Link\" connected",
"myWhooshLinkDescriptionLocal": "Connect directly to MyWhoosh via the \"Link\" method. Supported actions are shifting, Emotes, turn directions, among others. The MyWhoosh Link companion app must NOT be running at the same time.",
"myWhooshLinkDescriptionLocal": "Connect directly to MyWhoosh via the \"Link\" method. Check the instructions to ensure a connection is possible. The \"MyWhoosh Link\" app must not be active at the same time.",
"myWhooshLinkInfo": "Please check the troubleshooting section if you encounter any issues. A much more reliable connection method is coming soon!",
"needHelpClickHelp": "Need help? Click on the",
"needHelpDontHesitate": "button on top and don't hesitate to contact us.",
@@ -379,16 +382,17 @@
"selectTrainerAppAndTarget": "Select Trainer App & Target Device",
"selectTrainerAppPlaceholder": "Select Trainer app",
"setting": "Setting",
"setupComplete": "Setup Complete!",
"setupTrainer": "Setup Trainer",
"share": "Share",
"showDonation": "Show your appreciation by donating",
"showSupportedControllers": "Show Supported Controllers",
"showTroubleshootingGuide": "Show Troubleshooting Guide",
"signal": "Signal",
"simulateButtons": "Trainer Controls",
"simulateKeyboardShortcut": "Simulate Keyboard shortcut",
"simulateMediaKey": "Simulate Media key",
"simulateTouch": "Simulate Touch",
"skip": "Skip",
"stop": "Stop",
"targetOtherDevice": "Other Device",
"targetThisDevice": "This Device",
@@ -400,10 +404,11 @@
"trialExpired": "Trial expired. Commands limited to {dailyCommandLimit} per day.",
"trialPeriodActive": "Trial Period Active - {trialDaysRemaining} days remaining",
"trialPeriodDescription": "Enjoy unlimited commands during your trial period. After the trial, commands will be limited to {dailyCommandLimit} per day.",
"troubleshootingGuide": "Troubleshooting Guide",
"troubleshootingGuide": "Questions & Answers",
"tryingToConnectAgain": "Trying to connect again...",
"unassignAction": "Unassign action",
"unlockFullVersion": "Unlock Full Version",
"unlockingNotPossible": "Due to technical issues unlocking is currently not possible, so enjoy unlimited usage for the time being!",
"update": "Update",
"useCustomKeymapForButton": "Use a custom keymap to support the",
"version": "Version {version}",

View File

@@ -31,6 +31,7 @@
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "Dernière étape : vous choisirez comment vous connecter à {trainerApp} .",
"battery": "Batterie",
"beforeDate": "Avant {date}",
"bluetoothAdvertiseAccess": "Accès à la publicité Bluetooth",
@@ -77,6 +78,7 @@
}
}
},
"controllerConnectedClickButton": "Super ! Votre manette est connectée. Cliquez sur un bouton de votre manette pour continuer.",
"controllers": "Contrôleurs",
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Impossible d'effectuer {button}: Aucun clavier défini",
"create": "Créer",
@@ -138,7 +140,7 @@
"enableAutoRotation": "Activez la rotation automatique sur votre appareil pour vous assurer que l'application fonctionne correctement.",
"enableBluetooth": "Activer le Bluetooth",
"enableKeyboardAccessMessage": "Activez l'accès au clavier dans l'écran suivant pour BikeControl. Si vous ne voyez pas BikeControl, veuillez l'ajouter manuellement.",
"enableKeyboardMouseControl": "Activez le contrôle du clavier et de la souris pour une meilleure interaction avec {appName} Une fois activé, aucune autre action ni connexion n'est nécessaire. BikeControl enverra directement les entrées de la souris ou du clavier à {appName} .",
"enableKeyboardMouseControl": "BikeControl enverra des actions de souris ou de clavier pour contrôler {appName} .",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
@@ -214,6 +216,7 @@
}
}
},
"letsGetYouSetUp": "On va vous installer !",
"license": "Licence",
"licenseStatus": "Statut de la licence",
"loadScreenshotForPlacement": "Charger une capture d'écran du jeu pour le placement",
@@ -239,7 +242,7 @@
"myWhooshDirectConnectAction": "Action «Link» de MyWhoosh",
"myWhooshDirectConnection": " par exemple en utilisant MyWhoosh «Link».",
"myWhooshLinkConnected": "MyWhoosh « Link » connecté",
"myWhooshLinkDescriptionLocal": "Connecte-toi directement à MyWhoosh avec la méthode « Link ». Tu peux faire des trucs comme changer de vitesse, utiliser des émoticônes, indiquer la direction à prendre, et plein d'autres choses. L'appli MyWhoosh Link ne doit PAS être ouverte en même temps.",
"myWhooshLinkDescriptionLocal": "Connectez-vous directement à MyWhoosh via la méthode « Link ». Consultez les instructions pour vérifier la compatibilité de la connexion. Lapplication « MyWhoosh Link » ne doit pas être active simultanément.",
"myWhooshLinkInfo": "Si tu rencontres des problèmes, jette un œil à la section dépannage. Une méthode de connexion bien plus fiable sera bientôt disponible !",
"needHelpClickHelp": "Besoin d'aide ? Cliquez sur le",
"needHelpDontHesitate": "bouton en haut et n'hésitez pas à nous contacter.",
@@ -379,16 +382,17 @@
"selectTrainerAppAndTarget": "Sélectionnez l'application Trainer et l'appareil cible",
"selectTrainerAppPlaceholder": "Sélectionner l'application Trainer",
"setting": "Paramètre",
"setupComplete": "Installation terminée !",
"setupTrainer": "Configurer le Trainer",
"share": "Partager",
"showDonation": "Exprimez votre reconnaissance en faisant un don",
"showSupportedControllers": "Afficher les manettes compatibles",
"showTroubleshootingGuide": "Afficher le guide de dépannage",
"signal": "Signal",
"simulateButtons": "Commandes de l'entraîneur",
"simulateKeyboardShortcut": "Simuler un raccourci clavier",
"simulateMediaKey": "Simuler la touche média",
"simulateTouch": "Simuler le toucher",
"skip": "Sauter",
"stop": "Arrêt",
"targetOtherDevice": "Autre appareil",
"targetThisDevice": "Cet appareil",
@@ -400,10 +404,11 @@
"trialExpired": "Période d'essai expirée. Commandes limitées à {dailyCommandLimit} par jour.",
"trialPeriodActive": "Période d'essai active -{trialDaysRemaining} jours restants",
"trialPeriodDescription": "Profitez de commandes illimitées pendant votre période d'essai. Après la période d'essai, le nombre de commandes sera limité à {dailyCommandLimit} par jour.",
"troubleshootingGuide": "Guide de dépannage",
"troubleshootingGuide": "Questions et réponses",
"tryingToConnectAgain": "Tentative de reconnexion...",
"unassignAction": "Action de désaffectation",
"unlockFullVersion": "Débloquer la version complète",
"unlockingNotPossible": "En raison de problèmes techniques, le déverrouillage est actuellement impossible. Profitez donc d'une utilisation illimitée pour le moment !",
"update": "Mise à jour",
"useCustomKeymapForButton": "Utilisez une configuration de touches personnalisée pour prendre en charge",
"version": "Version {version}",

View File

@@ -31,6 +31,7 @@
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "Come ultimo passaggio sceglierai come connetterti a {trainerApp} .",
"battery": "Batteria",
"beforeDate": "Prima del {date}",
"bluetoothAdvertiseAccess": "Accesso pubblicitario Bluetooth",
@@ -77,6 +78,7 @@
}
}
},
"controllerConnectedClickButton": "Ottimo! Il tuo controller è connesso. Clicca su un pulsante del controller per continuare.",
"controllers": "Controller",
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Non è stato possibile eseguire{button} : Nessuna mappa dei tasti impostata",
"create": "Crea",
@@ -138,7 +140,7 @@
"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": "BikeControl invierà azioni del mouse o della tastiera per controllare {appName} .",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
@@ -214,6 +216,7 @@
}
}
},
"letsGetYouSetUp": "Ti aiuteremo a prepararti!",
"license": "Licenza",
"licenseStatus": "Stato della licenza",
"loadScreenshotForPlacement": "Carica lo screenshot del gioco per il posizionamento",
@@ -239,7 +242,7 @@
"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.",
"myWhooshLinkDescriptionLocal": "Connettiti direttamente a MyWhoosh tramite il metodo \"Link\". Controlla le istruzioni per assicurarti che la connessione sia possibile. L'app \"MyWhoosh Link\" non deve essere attiva 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.",
@@ -379,16 +382,17 @@
"selectTrainerAppAndTarget": "Seleziona l'app Trainer e il dispositivo di destinazione",
"selectTrainerAppPlaceholder": "Seleziona l'app Trainer",
"setting": "Impostazioni",
"setupComplete": "Installazione completata!",
"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",
"skip": "Saltare",
"stop": "Stop",
"targetOtherDevice": "Altro dispositivo",
"targetThisDevice": "Questo dispositivo",
@@ -400,10 +404,11 @@
"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",
"troubleshootingGuide": "Domande e risposte",
"tryingToConnectAgain": "Sto provando a connettermi di nuovo...",
"unassignAction": "Annulla assegnazione azione",
"unlockFullVersion": "Sblocca la versione completa",
"unlockingNotPossible": "A causa di problemi tecnici, al momento non è possibile sbloccare il tutto, ma per il momento puoi usufruire di un utilizzo illimitato!",
"update": "Aggiorna",
"useCustomKeymapForButton": "Utilizzare una mappa dei tasti personalizzata per supportare",
"version": "Versione{version}",

View File

@@ -31,6 +31,7 @@
}
}
},
"asAFinalStepYoullChooseHowToConnectTo": "W ostatnim kroku wybierzesz sposób połączenia {trainerApp} .",
"battery": "Bateria",
"beforeDate": "Zanim {date}",
"bluetoothAdvertiseAccess": "Dostęp do reklamy Bluetooth",
@@ -38,7 +39,7 @@
"browserNotSupported": "Ta przeglądarka nie obsługuje technologii Web Bluetooth i platforma nie jest obsługiwana :(",
"button": "przycisk.",
"cancel": "Anuluj",
"changelog": "Changelog",
"changelog": "Dziennik zmian",
"checkMyWhooshConnectionScreen": "Sprawdź ekran połączenia w MyWhoosh, aby zobaczyć, czy „Link” jest połączony.",
"chooseAnotherScreenshot": "Wybierz inny zrzut ekranu",
"chooseBikeControlInConnectionScreen": "Wybierz BikeControl na ekranie połączenia.",
@@ -77,6 +78,7 @@
}
}
},
"controllerConnectedClickButton": "Świetnie! Twój kontroler jest połączony. Kliknij przycisk na kontrolerze, aby kontynuować.",
"controllers": "Kontrolery",
"couldNotPerformButtonnamesplitbyuppercaseNoKeymapSet": "Nie można wykonać {button}: Nie zmapowano klawisza",
"create": "Utwórz",
@@ -138,7 +140,7 @@
"enableAutoRotation": "Włącz funkcję automatycznego obracania na urządzeniu, aby mieć pewność, że aplikacja będzie działać prawidłowo.",
"enableBluetooth": "Włącz Bluetooth",
"enableKeyboardAccessMessage": "Włącz dostęp do klawiatury dla BikeControl na poniższym ekranie. Jeśli nie widzisz BikeControl, dodaj go ręcznie.",
"enableKeyboardMouseControl": "Włącz sterowanie klawiaturą i myszą, aby zapewnić lepszą interakcję z {appName}. Po aktywacji nie jest wymagane żadne dalsze działanie, ani połączenie. BikeControl będzie bezpośrednio wysyłał dane z myszy lub klawiatury do {appName}.",
"enableKeyboardMouseControl": "BikeControl będzie wysyłał akcje myszy lub klawiatury do kontrolera {appName} .",
"@enableKeyboardMouseControl": {
"placeholders": {
"appName": {
@@ -214,10 +216,11 @@
}
}
},
"letsGetYouSetUp": "Przygotujmy Cię!",
"license": "Licencja",
"licenseStatus": "Status licencji",
"loadScreenshotForPlacement": "Prześlij zrzut ekranu z gry w celu ustalenia miejsca",
"logViewer": "Przeglądarka dziennika zdarzeń",
"logViewer": "Podgląd dziennika zdarzeń",
"logs": "Dziennik zdarzeń",
"logsAreAlsoAt": "Dziennik zdarzeń jest dostępny również na",
"logsHaveBeenCopiedToClipboard": "Dziennik zdarzeń został skopiowany do schowka",
@@ -239,10 +242,10 @@
"myWhooshDirectConnectAction": "Akcja „Link” MyWhoosh",
"myWhooshDirectConnection": " np. za pomocą MyWhoosh „Link”",
"myWhooshLinkConnected": "Połączono MyWhoosh „Link”",
"myWhooshLinkDescriptionLocal": "Połącz się bezpośrednio z MyWhoosh za pomocą „Link”. Obsługiwane akcje to m.in. zmiana biegów, emotikony i zmiana kierunku. Aplikacja MyWhoosh Link NIE może być uruchomiona w tym samym czasie.",
"myWhooshLinkDescriptionLocal": "Połącz się bezpośrednio z MyWhoosh za pomocą metody „Link”. Sprawdź instrukcje, aby upewnić się, że połączenie jest możliwe. Aplikacja MyWhoosh Link” nie może być jednocześnie aktywna.",
"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.",
"needHelpDontHesitate": "przycisk na górze i skontaktuj się z nami.",
"newConnectionMethodAnnouncement": "{trainerApp} już wkrótce będzie wspierać znacznie lepsze i bardziej niezawodne metody połączeń — bądź na bieżąco z aktualizacjami!",
"newCustomProfile": "Nowy profil niestandardowy",
"newProfileName": "Nowa nazwa profilu",
@@ -379,16 +382,17 @@
"selectTrainerAppAndTarget": "Wybierz aplikację treningową i docelowe urządzenie",
"selectTrainerAppPlaceholder": "Wybierz aplikację treningową",
"setting": "Ustawienie",
"setupComplete": "Konfiguracja ukończona!",
"setupTrainer": "Konfiguracja trenażera",
"share": "Udostępnij",
"showDonation": "Wyraź swoją wdzięczność poprzez darowiznę",
"showSupportedControllers": "Pokaż wspierane kontrolery",
"showTroubleshootingGuide": "Pokaż instrukcję rozwiązywania problemów",
"signal": "Sygnał",
"simulateButtons": "Sterowanie trenażerem",
"simulateKeyboardShortcut": "Symuluj skrót klawiaturowy",
"simulateMediaKey": "Symuluj przycisk multimedialny",
"simulateTouch": "Symuluj dotyk",
"skip": "Pominąć",
"stop": "Stop",
"targetOtherDevice": "Inne urządzenie",
"targetThisDevice": "To urządzenie",
@@ -400,10 +404,11 @@
"trialExpired": "Wersja próbna wygasła. Liczba poleceń ograniczona do {dailyCommandLimit} na dzień.",
"trialPeriodActive": "Okres próbny jest aktywny - pozostało {trialDaysRemaining} dni",
"trialPeriodDescription": "Korzystaj z nieograniczonej liczby poleceń w okresie próbnym. Po zakończeniu okresu próbnego polecenia będą ograniczone do {dailyCommandLimit} na dzień.",
"troubleshootingGuide": "Instrukcja rozwiązywania problemów",
"troubleshootingGuide": "Pytania i odpowiedzi",
"tryingToConnectAgain": "Próba ponownego połączenia...",
"unassignAction": "Anuluj przypisanie akcji",
"unlockFullVersion": "Odblokuj pełną wersję",
"unlockingNotPossible": "Z uwagi na problemy techniczne odblokowanie nie jest obecnie możliwe, więc ciesz się nieograniczonym użytkowaniem!",
"update": "Aktualizacja",
"useCustomKeymapForButton": "Użyj niestandardowej mapy klawiszy, aby obsługiwać",
"version": "Wersja {version}",

View File

@@ -4,6 +4,7 @@ import 'dart:isolate';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/pages/onboarding.dart';
import 'package:bike_control/utils/actions/android.dart';
import 'package:bike_control/utils/actions/desktop.dart';
import 'package:bike_control/utils/actions/remote.dart';
@@ -103,7 +104,7 @@ Future<void> _persistCrash({
..writeln('Error: $error')
..writeln('Stack: ${stack ?? 'no stack'}')
..writeln('Info: ${information ?? ''}')
..writeln(debugText())
..writeln(await debugText())
..writeln()
..writeln();
@@ -163,12 +164,19 @@ void initializeActions(ConnectionType connectionType) {
core.actionHandler.init(core.settings.getKeyMap());
}
class BikeControlApp extends StatelessWidget {
class BikeControlApp extends StatefulWidget {
final Widget? customChild;
final BCPage page;
final String? error;
const BikeControlApp({super.key, this.error, this.page = BCPage.devices, this.customChild});
@override
State<BikeControlApp> createState() => _BikeControlAppState();
}
class _BikeControlAppState extends State<BikeControlApp> {
BCPage? _showPage;
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.sizeOf(context).width < 600;
@@ -197,27 +205,63 @@ class BikeControlApp extends StatelessWidget {
),
),
//themeMode: ThemeMode.dark,
home: error != null
home: widget.error != null
? Center(
child: Text(
'There was an error starting the App. Please contact support:\n$error',
'There was an error starting the App. Please contact support:\n${widget.error}',
style: TextStyle(color: Colors.white),
),
)
: ToastLayer(
key: ValueKey('Test'),
padding: isMobile ? EdgeInsets.only(bottom: 60, left: 24, right: 24, top: 60) : null,
child: Stack(
children: [
customChild ?? Navigation(page: page),
Positioned.fill(child: Testbed()),
],
child: _Starter(
child: Stack(
children: [
widget.customChild ??
(AnimatedSwitcher(
duration: Duration(milliseconds: 600),
child: core.settings.getShowOnboarding()
? OnboardingPage(
onComplete: () {
setState(() {
_showPage = BCPage.trainer;
});
},
)
: Navigation(page: _showPage ?? widget.page),
)),
Positioned.fill(child: Testbed()),
],
),
),
),
);
}
}
class _Starter extends StatefulWidget {
final Widget child;
const _Starter({super.key, required this.child});
@override
State<_Starter> createState() => _StarterState();
}
class _StarterState extends State<_Starter> {
@override
void initState() {
super.initState();
core.connection.initialize();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class OtherLocalizationsDelegate extends LocalizationsDelegate<ShadcnLocalizations> {
const OtherLocalizationsDelegate();

View File

@@ -524,7 +524,7 @@ class SelectableCard extends StatelessWidget {
hoverColor: Theme.of(context).colorScheme.card,
),
onPressed: onPressed,
alignment: Alignment.centerLeft,
alignment: Alignment.topLeft,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@@ -16,8 +16,9 @@ import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class ConfigurationPage extends StatefulWidget {
final bool onboardingMode;
final VoidCallback onUpdate;
const ConfigurationPage({super.key, required this.onUpdate});
const ConfigurationPage({super.key, required this.onUpdate, this.onboardingMode = false});
@override
State<ConfigurationPage> createState() => _ConfigurationPageState();
@@ -28,6 +29,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
Widget build(BuildContext context) {
return Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -111,7 +113,9 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
},
),
if (core.settings.getTrainerApp() != null) ...[
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true && !screenshotMode)
if (core.settings.getTrainerApp()!.supportsOpenBikeProtocol == true &&
!screenshotMode &&
!widget.onboardingMode)
Text(
AppLocalizations.of(context).openBikeControlAnnouncement(core.settings.getTrainerApp()!.name),
).xSmall,
@@ -162,7 +166,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> {
],
),
],
if (core.settings.getTrainerApp()?.star == true && !screenshotMode)
if (core.settings.getTrainerApp()?.star == true && !screenshotMode && !widget.onboardingMode)
Row(
spacing: 8,
children: [

View File

@@ -13,7 +13,8 @@ import 'package:bike_control/widgets/ui/warning.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class CustomizePage extends StatefulWidget {
const CustomizePage({super.key});
final bool isMobile;
const CustomizePage({super.key, required this.isMobile});
@override
State<CustomizePage> createState() => _CustomizeState();
@@ -23,7 +24,7 @@ class _CustomizeState extends State<CustomizePage> {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: EdgeInsets.all(16),
padding: EdgeInsets.only(bottom: widget.isMobile ? 146 : 16, left: 16, right: 16, top: 16),
child: Column(
spacing: 12,
mainAxisAlignment: MainAxisAlignment.start,
@@ -44,77 +45,86 @@ class _CustomizeState extends State<CustomizePage> {
),
),
Select<SupportedApp?>(
constraints: BoxConstraints(minWidth: 300),
value: core.actionHandler.supportedApp,
popup: SelectPopup(
items: SelectItemList(
children: [
..._getAllApps().map(
(a) => SelectItemButton(
value: a,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Row(
spacing: 8,
children: [
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 300),
child: Select<SupportedApp?>(
value: core.actionHandler.supportedApp,
popup: SelectPopup(
items: SelectItemList(
children: [
Expanded(child: Text(a.name)),
if (a is CustomApp)
BetaPill(text: 'CUSTOM')
else if (a.supportsOpenBikeProtocol)
Icon(Icons.star, size: 16),
..._getAllApps().map(
(a) => SelectItemButton(
value: a,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(a.name)),
if (a is CustomApp)
BetaPill(text: 'CUSTOM')
else if (a.supportsOpenBikeProtocol)
Icon(Icons.star, size: 16),
],
),
),
),
SelectItemButton(
value: CustomApp(profileName: 'New'),
child: Row(
spacing: 6,
children: [
Icon(Icons.add, color: Theme.of(context).colorScheme.mutedForeground),
Expanded(child: Text(context.i18n.createNewKeymap).normal.muted),
],
),
),
],
),
),
),
SelectItemButton(
value: CustomApp(profileName: 'New'),
child: Row(
spacing: 6,
).call,
itemBuilder: (c, app) => Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.add, color: Theme.of(context).colorScheme.mutedForeground),
Expanded(child: Text(context.i18n.createNewKeymap).normal.muted),
Expanded(child: Text(screenshotMode ? 'Trainer app' : app!.name)),
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
],
),
placeholder: Text(context.i18n.selectKeymap),
onChanged: (app) async {
if (app == null) {
return;
} else if (app.name == 'New') {
final profileName = await KeymapManager().showNewProfileDialog(context);
if (profileName != null && profileName.isNotEmpty) {
final customApp = CustomApp(profileName: profileName);
core.actionHandler.init(customApp);
await core.settings.setKeyMap(customApp);
setState(() {});
}
} else {
core.actionHandler.init(app);
await core.settings.setKeyMap(app);
setState(() {});
}
},
),
],
),
),
).call,
itemBuilder: (c, app) => Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(screenshotMode ? 'Trainer app' : app!.name)),
if (app is CustomApp) BetaPill(text: 'CUSTOM'),
],
),
placeholder: Text(context.i18n.selectKeymap),
onChanged: (app) async {
if (app == null) {
return;
} else if (app.name == 'New') {
final profileName = await KeymapManager().showNewProfileDialog(context);
if (profileName != null && profileName.isNotEmpty) {
final customApp = CustomApp(profileName: profileName);
core.actionHandler.init(customApp);
await core.settings.setKeyMap(customApp);
KeymapManager().getManageProfileDialog(
context,
core.actionHandler.supportedApp is CustomApp ? core.actionHandler.supportedApp?.name : null,
onDone: () {
setState(() {});
}
} else {
core.actionHandler.init(app);
await core.settings.setKeyMap(app);
setState(() {});
}
},
},
),
],
),
KeymapManager().getManageProfileDialog(
context,
core.actionHandler.supportedApp is CustomApp ? core.actionHandler.supportedApp?.name : null,
onDone: () {
setState(() {});
},
),
if (core.actionHandler.supportedApp is! CustomApp)
Text(
context.i18n.customizeKeymapHint,

View File

@@ -2,19 +2,23 @@ import 'dart:async';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/pages/button_simulator.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/iap_status_widget.dart';
import 'package:bike_control/widgets/ignored_devices_dialog.dart';
import 'package:bike_control/widgets/scan.dart';
import 'package:bike_control/widgets/ui/colored_title.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../bluetooth/devices/base_device.dart';
class DevicePage extends StatefulWidget {
final bool isMobile;
final VoidCallback onUpdate;
const DevicePage({super.key, required this.onUpdate});
const DevicePage({super.key, required this.onUpdate, required this.isMobile});
@override
State<DevicePage> createState() => _DevicePageState();
@@ -43,10 +47,9 @@ class _DevicePageState extends State<DevicePage> {
return Scrollbar(
child: SingleChildScrollView(
primary: true,
padding: EdgeInsets.all(16),
padding: EdgeInsets.only(bottom: widget.isMobile ? 166 : 16, left: 16, right: 16, top: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
ValueListenableBuilder(
valueListenable: IAPManager.instance.isPurchased,
@@ -62,12 +65,14 @@ class _DevicePageState extends State<DevicePage> {
// leave it in for the extra scanning options
ScanWidget(),
Gap(12),
if (core.connection.controllerDevices.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ColoredTitle(text: context.i18n.connectedControllers),
),
Gap(12),
...core.connection.controllerDevices.map(
(device) => Card(
filled: true,
@@ -78,6 +83,7 @@ class _DevicePageState extends State<DevicePage> {
),
),
Gap(12),
if (core.connection.accessories.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(top: 8.0),
@@ -94,6 +100,75 @@ class _DevicePageState extends State<DevicePage> {
),
],
Gap(12),
if (!screenshotMode)
Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlineButton(
onPressed: () {
launchUrlString(
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices',
);
},
leading: Icon(Icons.gamepad_outlined),
child: Text(context.i18n.showSupportedControllers),
),
if (core.settings.getIgnoredDevices().isNotEmpty)
OutlineButton(
leading: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.destructive,
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(horizontal: 6),
margin: EdgeInsets.only(right: 4),
child: Text(
core.settings.getIgnoredDevices().length.toString(),
style: TextStyle(
color: Theme.of(context).colorScheme.primaryForeground,
),
),
),
onPressed: () async {
await showDialog(
context: context,
builder: (context) => IgnoredDevicesDialog(),
);
setState(() {});
},
child: Text(context.i18n.manageIgnoredDevices),
),
if (core.connection.controllerDevices.isEmpty)
PrimaryButton(
leading: Icon(Icons.computer_outlined),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => ButtonSimulator(),
),
);
},
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: "${AppLocalizations.of(context).noControllerUseCompanionMode.split("?").first}?\n",
),
TextSpan(
text: AppLocalizations.of(context).noControllerUseCompanionMode.split("? ").last,
style: TextStyle(color: Theme.of(context).colorScheme.muted, fontSize: 12),
),
],
),
),
),
],
),
Gap(12),
SizedBox(),
if (core.connection.controllerDevices.isNotEmpty)
Row(

View File

@@ -1,10 +1,8 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart' show BackButton;
import 'package:bike_control/widgets/ui/gradient_text.dart';
import 'package:flutter/services.dart';
import 'package:flutter_md/flutter_md.dart';
import 'package:http/http.dart' as http;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/widgets/ui/colored_title.dart';
import 'package:url_launcher/url_launcher_string.dart';
class MarkdownPage extends StatefulWidget {
@@ -40,51 +38,36 @@ class _ChangelogPageState extends State<MarkdownPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
headers: [
AppBar(
leading: [
BackButton(),
],
title: Text(
widget.assetPath
.replaceAll('.md', '')
.split('_')
.joinToString(separator: ' ', transform: (s) => s.toLowerCase().capitalize()),
),
),
],
child: _error != null
? Center(child: Text(_error!))
: _groups == null
? Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Accordion(
items: _groups!
.map(
(group) => AccordionItem(
trigger: AccordionTrigger(child: ColoredTitle(text: group.title)),
content: MarkdownWidget(
markdown: group.markdown,
theme: MarkdownThemeData(
textStyle: TextStyle(
fontSize: 14.0,
color: Theme.of(context).colorScheme.brightness == Brightness.dark
? Colors.white.withAlpha(255 * 70)
: Colors.black.withAlpha(87 * 255),
),
onLinkTap: (title, url) {
launchUrlString(url);
},
return _error != null
? Center(child: Text(_error!))
: _groups == null
? Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Accordion(
items: _groups!
.map(
(group) => AccordionItem(
trigger: AccordionTrigger(child: GradientText(group.title).bold),
content: MarkdownWidget(
markdown: group.markdown,
theme: MarkdownThemeData(
textStyle: TextStyle(
fontSize: 14.0,
color: Theme.of(context).colorScheme.brightness == Brightness.dark
? Colors.white.withAlpha(255 * 70)
: Colors.black.withAlpha(87 * 255),
),
onLinkTap: (title, url) {
launchUrlString(url);
},
),
),
)
.toList(),
),
),
)
.toList(),
),
);
);
}
void _parseMarkdown(String md) {

View File

@@ -11,6 +11,7 @@ import 'package:bike_control/widgets/logviewer.dart';
import 'package:bike_control/widgets/menu.dart';
import 'package:bike_control/widgets/title.dart';
import 'package:bike_control/widgets/ui/colors.dart';
import 'package:bike_control/widgets/ui/help_button.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -64,7 +65,6 @@ class _NavigationState extends State<Navigation> {
_selectedPage = widget.page;
core.connection.initialize();
core.logic.startEnabledConnectionMethod();
core.connection.actionStream.listen((_) {
@@ -134,27 +134,43 @@ class _NavigationState extends State<Navigation> {
Widget build(BuildContext context) {
return Scaffold(
headers: [
AppBar(
padding:
const EdgeInsets.only(top: 12, bottom: 8, left: 12, right: 12) *
(screenshotMode ? 2 : Theme.of(context).scaling),
title: AppTitle(),
backgroundColor: Theme.of(context).colorScheme.background,
trailing: buildMenuButtons(
context,
_selectedPage,
_isMobile
? () {
setState(() {
_selectedPage = BCPage.logs;
});
}
: null,
),
Stack(
children: [
AppBar(
padding:
const EdgeInsets.only(top: 12, bottom: 8, left: 12, right: 12) *
(screenshotMode ? 2 : Theme.of(context).scaling),
title: AppTitle(),
backgroundColor: Theme.of(context).colorScheme.background,
trailing: buildMenuButtons(
context,
_selectedPage,
_isMobile
? () {
setState(() {
_selectedPage = BCPage.logs;
});
}
: null,
),
),
if (!_isMobile)
Container(
alignment: Alignment.topCenter,
child: HelpButton(isMobile: false),
),
],
),
Divider(),
],
footers: _isMobile ? [Divider(), _buildNavigationBar()] : [],
footers: _isMobile
? [
if (_isMobile) Center(child: HelpButton(isMobile: true)),
Divider(),
_buildNavigationBar(),
]
: [],
floatingFooter: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -163,21 +179,25 @@ class _NavigationState extends State<Navigation> {
VerticalDivider(),
],
Expanded(
child: Container(
alignment: Alignment.topLeft,
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: switch (_selectedPage) {
BCPage.devices => DevicePage(
key: _pageKeys[BCPage.devices],
child: AnimatedSwitcher(
duration: Duration(milliseconds: 200),
child: switch (_selectedPage) {
BCPage.devices => Align(
alignment: Alignment.topLeft,
key: _pageKeys[BCPage.devices],
child: DevicePage(
isMobile: _isMobile,
onUpdate: () {
setState(() {
_selectedPage = BCPage.trainer;
});
},
),
BCPage.trainer => TrainerPage(
key: _pageKeys[BCPage.trainer],
),
BCPage.trainer => Align(
alignment: Alignment.topLeft,
key: _pageKeys[BCPage.trainer],
child: TrainerPage(
onUpdate: () {
setState(() {});
},
@@ -186,15 +206,21 @@ class _NavigationState extends State<Navigation> {
_selectedPage = BCPage.customization;
});
},
isMobile: _isMobile,
),
BCPage.customization => CustomizePage(
key: _pageKeys[BCPage.customization],
),
BCPage.logs => LogViewer(
),
BCPage.customization => Align(
alignment: Alignment.topLeft,
key: _pageKeys[BCPage.customization],
child: CustomizePage(isMobile: _isMobile),
),
BCPage.logs => Padding(
padding: EdgeInsets.only(bottom: _isMobile ? 146 : 16, left: 16, right: 16, top: 16),
child: LogViewer(
key: _pageKeys[BCPage.logs],
),
},
),
),
},
),
),
],

331
lib/pages/onboarding.dart Normal file
View File

@@ -0,0 +1,331 @@
import 'dart:async';
import 'package:bike_control/bluetooth/devices/base_device.dart';
import 'package:bike_control/bluetooth/devices/zwift/zwift_device.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/requirements/platform.dart';
import 'package:bike_control/widgets/scan.dart';
import 'package:bike_control/widgets/title.dart';
import 'package:bike_control/widgets/ui/help_button.dart';
import 'package:bike_control/widgets/ui/permissions_list.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../utils/i18n_extension.dart';
import '../widgets/ui/colored_title.dart';
import 'configuration.dart';
class OnboardingPage extends StatefulWidget {
final VoidCallback onComplete;
const OnboardingPage({super.key, required this.onComplete});
@override
State<OnboardingPage> createState() => _OnboardingPageState();
}
enum _OnboardingStep {
permissions,
connect,
trainer,
finish,
}
class _OnboardingPageState extends State<OnboardingPage> {
var _currentStep = _OnboardingStep.permissions;
bool _isMobile = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_isMobile = MediaQuery.sizeOf(context).width < 600;
}
@override
Widget build(BuildContext context) {
return Scaffold(
loadingProgress: _OnboardingStep.values.indexOf(_currentStep) / (_OnboardingStep.values.length - 1),
headers: [
AppBar(
backgroundColor: Theme.of(context).colorScheme.primaryForeground,
leading: [
Image.asset('icon.png', height: 40),
SizedBox(width: 10),
AppTitle(),
],
trailing: [
Button(
style: ButtonStyle.outline(size: ButtonSize.small),
child: Text(AppLocalizations.of(context).skip),
onPressed: () {
core.settings.setShowOnboarding(false);
widget.onComplete();
},
),
],
),
Divider(),
],
floatingFooter: true,
footers: [
Center(
child: HelpButton(
isMobile: true,
),
),
],
child: Center(
child: Container(
alignment: Alignment.topCenter,
constraints: !_isMobile ? BoxConstraints(maxWidth: 500) : null,
child: SingleChildScrollView(
padding: EdgeInsets.only(top: !_isMobile ? 42 : 22.0, bottom: !_isMobile ? 42 : 68.0, left: 16, right: 16),
child: AnimatedSwitcher(
duration: Duration(milliseconds: 600),
child: switch (_currentStep) {
_OnboardingStep.permissions => _PermissionsOnboardingStep(
onComplete: () {
setState(() {
_currentStep = _OnboardingStep.connect;
});
},
),
_OnboardingStep.connect => _ConnectOnboardingStep(
onComplete: () {
setState(() {
_currentStep = _OnboardingStep.trainer;
});
},
),
_OnboardingStep.trainer => _TrainerOnboardingStep(
onComplete: () {
setState(() {
_currentStep = _OnboardingStep.finish;
});
},
),
_OnboardingStep.finish => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 12,
children: [
SizedBox(height: 30),
Icon(Icons.check_circle, size: 58, color: Colors.green),
ColoredTitle(text: AppLocalizations.of(context).setupComplete),
Text(
AppLocalizations.of(
context,
).asAFinalStepYoullChooseHowToConnectTo(core.settings.getTrainerApp()?.name ?? 'your trainer'),
textAlign: TextAlign.center,
).small.muted,
SizedBox(height: 30),
PrimaryButton(
leading: Icon(Icons.check),
onPressed: () {
core.settings.setShowOnboarding(false);
widget.onComplete();
},
child: Text(context.i18n.continueAction),
),
],
),
},
),
),
),
),
);
}
}
class _PermissionsOnboardingStep extends StatefulWidget {
final VoidCallback onComplete;
const _PermissionsOnboardingStep({super.key, required this.onComplete});
@override
State<_PermissionsOnboardingStep> createState() => _PermissionsOnboardingStepState();
}
class _PermissionsOnboardingStepState extends State<_PermissionsOnboardingStep> {
void _checkRequirements() {
core.permissions.getScanRequirements().then((permissions) {
if (!mounted) return;
setState(() {
_needsPermissions = permissions;
});
if (permissions.isEmpty) {
widget.onComplete();
}
});
}
List<PlatformRequirement>? _needsPermissions;
@override
void initState() {
super.initState();
_checkRequirements();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(height: 8),
Text(AppLocalizations.of(context).letsGetYouSetUp).h3,
if (_needsPermissions != null && _needsPermissions!.isNotEmpty)
PermissionList(
requirements: _needsPermissions!,
onDone: () {
widget.onComplete();
},
),
],
);
}
}
class _ConnectOnboardingStep extends StatefulWidget {
final VoidCallback onComplete;
const _ConnectOnboardingStep({super.key, required this.onComplete});
@override
State<_ConnectOnboardingStep> createState() => _ConnectOnboardingStepState();
}
class _ConnectOnboardingStepState extends State<_ConnectOnboardingStep> {
late StreamSubscription<BaseDevice> _connectionStateSubscription;
late StreamSubscription<BaseNotification> _actionSubscription;
@override
void initState() {
super.initState();
_actionSubscription = core.connection.actionStream.listen((data) async {
setState(() {});
if (data is ButtonNotification) {
widget.onComplete();
}
});
_connectionStateSubscription = core.connection.connectionStream.listen((state) async {
setState(() {});
});
}
@override
void dispose() {
_connectionStateSubscription.cancel();
_actionSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
ColoredTitle(text: context.i18n.connectControllers),
if (core.connection.controllerDevices.isEmpty) ...[
ScanWidget(),
OutlineButton(
onPressed: () {
launchUrlString('https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices');
},
leading: Icon(Icons.gamepad_outlined),
child: Text(context.i18n.showSupportedControllers),
),
PrimaryButton(
leading: Icon(Icons.computer_outlined),
onPressed: () {
widget.onComplete();
},
child: Text.rich(
TextSpan(
children: [
TextSpan(text: "${AppLocalizations.of(context).noControllerUseCompanionMode.split("?").first}?\n"),
TextSpan(
text: AppLocalizations.of(context).noControllerUseCompanionMode.split("? ").last,
style: TextStyle(color: Theme.of(context).colorScheme.muted, fontSize: 12),
),
],
),
),
),
] else ...[
if (core.connection.controllerDevices.any((d) => d.isConnected && d is ZwiftDevice))
RepeatedAnimationBuilder<double>(
duration: Duration(seconds: 1),
start: 0.5,
end: 1.0,
curve: Curves.easeInOut,
mode: LoopingMode.pingPong,
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Text(
AppLocalizations.of(context).controllerConnectedClickButton,
).small,
);
},
),
SizedBox(),
...core.connection.controllerDevices.map(
(device) => device.showInformation(context),
),
if (core.connection.controllerDevices.any((d) => d.isConnected))
PrimaryButton(
leading: Icon(Icons.check),
onPressed: () {
widget.onComplete();
},
child: Text(context.i18n.continueAction),
),
SizedBox(),
],
],
);
}
}
class _TrainerOnboardingStep extends StatefulWidget {
final VoidCallback onComplete;
const _TrainerOnboardingStep({super.key, required this.onComplete});
@override
State<_TrainerOnboardingStep> createState() => _TrainerOnboardingStepState();
}
class _TrainerOnboardingStepState extends State<_TrainerOnboardingStep> {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
SizedBox(),
ConfigurationPage(
onboardingMode: true,
onUpdate: () {
setState(() {});
},
),
if (core.settings.getTrainerApp() != null) SizedBox(height: 20),
if (core.settings.getTrainerApp() != null)
PrimaryButton(
leading: Icon(Icons.check),
onPressed: () {
widget.onComplete();
},
child: Text(context.i18n.continueAction),
),
],
);
}
}

View File

@@ -7,7 +7,6 @@ import 'package:bike_control/pages/navigation.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/utils/requirements/multi.dart';
import 'package:bike_control/widgets/apps/local_tile.dart';
import 'package:bike_control/widgets/apps/mywhoosh_link_tile.dart';
import 'package:bike_control/widgets/apps/openbikecontrol_ble_tile.dart';
@@ -18,17 +17,20 @@ import 'package:bike_control/widgets/iap_status_widget.dart';
import 'package:bike_control/widgets/pair_widget.dart';
import 'package:bike_control/widgets/ui/colored_title.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../bluetooth/devices/zwift/protocol/zp.pbenum.dart';
import '../utils/keymap/apps/supported_app.dart';
class TrainerPage extends StatefulWidget {
final bool isMobile;
final VoidCallback onUpdate;
final VoidCallback goToNextPage;
const TrainerPage({super.key, required this.onUpdate, required this.goToNextPage});
const TrainerPage({super.key, required this.onUpdate, required this.goToNextPage, required this.isMobile});
@override
State<TrainerPage> createState() => _TrainerPageState();
@@ -89,106 +91,170 @@ class _TrainerPageState extends State<TrainerPage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
final showLocalAsOther =
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showLocalControl;
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) &&
core.logic.showLocalControl &&
!core.settings.getLocalEnabled();
final showWhooshLinkAsOther =
(core.logic.showObpBluetoothEmulator || core.logic.showObpMdnsEmulator) && core.logic.showMyWhooshLink;
final isMobile = MediaQuery.sizeOf(context).width < 800;
final recommendedTiles = [
if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(),
if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(),
if (core.logic.showZwiftMsdnEmulator)
ZwiftMdnsTile(
onUpdate: () {
core.connection.signalNotification(
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
);
},
),
if (core.logic.showZwiftBleEmulator)
ZwiftTile(
onUpdate: () {
if (mounted) {
core.connection.signalNotification(
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
);
setState(() {});
}
},
),
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
];
final otherTiles = [
if (core.logic.showRemote) RemotePairingWidget(),
if (showLocalAsOther) LocalTile(),
if (showWhooshLinkAsOther) MyWhooshLinkTile(),
];
return Scrollbar(
controller: _scrollController,
child: SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.all(16),
padding: EdgeInsets.only(bottom: widget.isMobile ? 166 : 16, left: 16, right: 16, top: 16),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
ValueListenableBuilder(
valueListenable: IAPManager.instance.isPurchased,
builder: (context, value, child) => value ? SizedBox.shrink() : IAPStatusWidget(small: true),
),
ConfigurationPage(
onUpdate: () {
setState(() {});
widget.onUpdate();
if (_scrollController.position.pixels != _scrollController.position.maxScrollExtent &&
core.settings.getLastTarget() == Target.otherDevice) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.animateTo(
_scrollController.offset + 300,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
});
}
},
SizedBox(
width: double.infinity,
child: Accordion(
items: [
AccordionItem(
trigger: AccordionTrigger(
child: IgnorePointer(
child: Row(
spacing: 12,
children: [
Flexible(
child: Select<SupportedApp>(
itemBuilder: (c, app) => Row(
spacing: 4,
children: [
Expanded(child: Text(screenshotMode ? 'Trainer app' : app.name)),
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
],
),
popup: SelectPopup(
items: SelectItemList(
children: SupportedApp.supportedApps.map((app) {
return SelectItemButton(
value: app,
child: Row(
spacing: 4,
children: [
Text(app.name),
if (app.supportsOpenBikeProtocol) Icon(Icons.star),
],
),
);
}).toList(),
),
).call,
placeholder: Text(context.i18n.selectTrainerAppPlaceholder),
value: core.settings.getTrainerApp(),
onChanged: (selectedApp) async {},
),
),
if (core.settings.getLastTarget() != null) ...[
if (!widget.isMobile) Icon(core.settings.getLastTarget()!.icon),
Text(core.settings.getLastTarget()!.getTitle(context)),
],
],
),
),
),
content: ConfigurationPage(
onUpdate: () {
setState(() {});
widget.onUpdate();
},
),
),
],
),
),
if (core.settings.getTrainerApp() != null) ...[
SizedBox(height: 8),
if (core.logic.hasRecommendedConnectionMethods)
Gap(22),
if (recommendedTiles.isNotEmpty) ...[
ColoredTitle(text: context.i18n.recommendedConnectionMethods),
Gap(12),
],
if (core.logic.showObpMdnsEmulator) OpenBikeControlMdnsTile(),
if (core.logic.showObpBluetoothEmulator) OpenBikeControlBluetoothTile(),
if (core.logic.showZwiftMsdnEmulator)
ZwiftMdnsTile(
onUpdate: () {
core.connection.signalNotification(
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
);
},
),
if (core.logic.showZwiftBleEmulator)
ZwiftTile(
onUpdate: () {
if (mounted) {
core.connection.signalNotification(
LogNotification('Zwift Emulator status changed to ${core.zwiftEmulator.isConnected.value}'),
);
setState(() {});
}
},
),
if (core.logic.showLocalControl && !showLocalAsOther) LocalTile(),
if (core.logic.showMyWhooshLink && !showWhooshLinkAsOther) MyWhooshLinkTile(),
Text.rich(
TextSpan(
children: [
TextSpan(text: '${context.i18n.needHelpClickHelp} '),
WidgetSpan(
child: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Icon(Icons.help_outline),
),
for (final grouped in recommendedTiles.chunked(widget.isMobile ? 1 : 2)) ...[
IntrinsicHeight(
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: grouped.map((tile) => Expanded(child: tile)).toList(),
),
TextSpan(text: ' ${context.i18n.needHelpDontHesitate}'),
],
),
).small.muted,
if (core.logic.showRemote || showLocalAsOther || showWhooshLinkAsOther) ...[
SizedBox(height: 16),
Accordion(
items: [
AccordionItem(
trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)),
content: Column(
children: [
if (core.logic.showRemote) RemotePairingWidget(),
if (showLocalAsOther) LocalTile(),
if (showWhooshLinkAsOther) MyWhooshLinkTile(),
],
),
),
],
),
),
],
Gap(12),
if (otherTiles.isNotEmpty) ...[
SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: Accordion(
items: [
AccordionItem(
trigger: AccordionTrigger(child: ColoredTitle(text: context.i18n.otherConnectionMethods)),
content: Column(
children: [
for (final grouped in otherTiles.chunked(widget.isMobile ? 1 : 2)) ...[
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: IntrinsicHeight(
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: grouped.map((tile) => Expanded(child: tile)).toList(),
),
),
),
],
],
),
),
],
),
),
],
Gap(12),
SizedBox(height: 4),
Flex(
direction: isMobile ? Axis.vertical : Axis.horizontal,
direction: widget.isMobile ? Axis.vertical : Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,

View File

@@ -30,7 +30,9 @@ class AndroidActions extends BaseActions {
final hidDevice = HidDevice(keyPressed.source);
final button = hidDevice.getOrAddButton(keyPressed.hidKey, () => ControllerButton(keyPressed.hidKey));
var availableDevice = core.connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
var availableDevice = core.connection.controllerDevices.firstOrNullWhere(
(e) => e.toString() == hidDevice.toString(),
);
if (availableDevice == null) {
core.connection.addDevices([hidDevice]);
availableDevice = hidDevice;

View File

@@ -1,10 +1,11 @@
import 'dart:ui';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class DesktopActions extends BaseActions {
DesktopActions({super.supportedModes = const [SupportedMode.keyboard, SupportedMode.touch, SupportedMode.media]});
@@ -31,13 +32,30 @@ class DesktopActions extends BaseActions {
return Error('Failed to simulate media key: $e');
}
}
if (keyPair.physicalKey != null) {
// Increment command count after successful execution
await IAPManager.instance.incrementCommandCount();
if (keyPair.logicalKey != null && navigatorKey.currentContext?.mounted == true) {
final label = keyPair.logicalKey!.keyLabel;
final keyName = label.isNotEmpty ? label : keyPair.logicalKey!.debugName ?? 'Key';
buildToast(
navigatorKey.currentContext!,
location: ToastLocation.bottomLeft,
title:
'${isKeyDown
? ""
: isKeyUp
? ""
: ""} $keyName',
);
}
if (isKeyDown && isKeyUp) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);
await keyPressSimulator.simulateKeyUp(keyPair.physicalKey, keyPair.modifiers);
return Success('Key clicked: $keyPair');
} else if (isKeyDown) {
await keyPressSimulator.simulateKeyDown(keyPair.physicalKey, keyPair.modifiers);

View File

@@ -412,7 +412,9 @@ class MediaKeyHandler {
() => ControllerButton(keyPressed),
);
var availableDevice = core.connection.controllerDevices.firstOrNullWhere((e) => e.name == hidDevice.name);
var availableDevice = core.connection.controllerDevices.firstOrNullWhere(
(e) => e.toString() == hidDevice.toString(),
);
if (availableDevice == null) {
core.connection.addDevices([hidDevice]);
availableDevice = hidDevice;

View File

@@ -157,7 +157,9 @@ class IAPManager {
/// Get a status message for the user
String getStatusMessage() {
/// Get a status message for the user
if (IAPManager.instance.isPurchased.value) {
if (kIsWeb) {
return "Web";
} else if (IAPManager.instance.isPurchased.value) {
return AppLocalizations.current.fullVersion;
} else if (!hasTrialStarted) {
return '${_revenueCatService?.trialDaysRemaining ?? _iapService?.trialDaysRemaining ?? _windowsIapService?.trialDaysRemaining} day trial available';

View File

@@ -3,6 +3,7 @@ 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/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/widgets/ui/toast.dart';
@@ -31,6 +32,7 @@ class RevenueCatService {
final int Function() getDailyCommandLimit;
final void Function(int limit) setDailyCommandLimit;
static const _isAndroidWorking = false;
bool _isInitialized = false;
String? _trialStartDate;
String? _lastCommandDate;
@@ -115,7 +117,9 @@ class RevenueCatService {
_isInitialized = true;
if (!isTrialExpired && Platform.isAndroid) {
if (Platform.isAndroid && !isPurchasedNotifier.value && !_isAndroidWorking) {
setDailyCommandLimit(10000);
} else if (!isTrialExpired && Platform.isAndroid) {
setDailyCommandLimit(80);
}
} catch (e, s) {
@@ -164,7 +168,7 @@ class RevenueCatService {
} else {
final purchasedVersion = customerInfo.originalApplicationVersion;
core.connection.signalNotification(LogNotification('Apple receipt validated for version: $purchasedVersion'));
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 0;
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 1337;
isPurchasedNotifier.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
}
} else {
@@ -223,7 +227,14 @@ class RevenueCatService {
/// 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) {
if (Platform.isAndroid && !_isAndroidWorking) {
buildToast(
context,
title: AppLocalizations.of(context).unlockingNotPossible,
duration: Duration(seconds: 5),
);
setDailyCommandLimit(10000);
} else if (Platform.isMacOS) {
try {
final offerings = await Purchases.getOfferings();
final purchaseParams = PurchaseParams.package(offerings.current!.availablePackages.first);
@@ -352,7 +363,7 @@ class RevenueCatService {
"bikecontrol_target": core.settings.getLastTarget()?.name ?? '-',
if (core.connection.controllerDevices.isNotEmpty)
'bikecontrol_controllers': core.connection.controllerDevices.joinToString(
transform: (d) => d.name,
transform: (d) => d.toString(),
separator: ',',
),
'bikecontrol_keymap': core.settings.getKeyMap()?.name ?? '-',

View File

@@ -1,13 +1,13 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:bike_control/bluetooth/devices/elite/elite_square.dart';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/requirements/multi.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../keymap.dart';
@@ -19,6 +19,7 @@ class TrainingPeaks extends SupportedApp {
compatibleTargets: !kIsWeb && Platform.isIOS ? [Target.otherDevice] : Target.values,
supportsZwiftEmulation: false,
supportsOpenBikeProtocol: false,
star: true,
keymap: Keymap(
keyPairs: [
// Explicit controller-button mappings with updated touch coordinates

View File

@@ -1,9 +1,9 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'apps/custom_app.dart';
@@ -45,8 +45,8 @@ class KeymapManager {
}) {
return Builder(
builder: (context) {
return OutlineButton(
child: Text(context.i18n.manageProfile),
return Button.outline(
child: Icon(Icons.settings),
onPressed: () => showDropdown(
context: context,
builder: (c) => DropdownMenu(

View File

@@ -22,6 +22,7 @@ class AccessibilityRequirement extends PlatformRequirement {
: super(
AppLocalizations.current.allowAccessibilityService,
description: AppLocalizations.current.accessibilityDescription,
icon: Icons.accessibility_new,
);
@override
@@ -62,7 +63,7 @@ class AccessibilityRequirement extends PlatformRequirement {
}
class BluetoothScanRequirement extends PlatformRequirement {
BluetoothScanRequirement() : super(AppLocalizations.current.allowBluetoothScan);
BluetoothScanRequirement() : super(AppLocalizations.current.allowBluetoothScan, icon: Icons.bluetooth_searching);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
@@ -79,7 +80,7 @@ class BluetoothScanRequirement extends PlatformRequirement {
}
class LocationRequirement extends PlatformRequirement {
LocationRequirement() : super(AppLocalizations.current.allowLocationForBluetooth);
LocationRequirement() : super(AppLocalizations.current.allowLocationForBluetooth, icon: Icons.location_on);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
@@ -96,7 +97,8 @@ class LocationRequirement extends PlatformRequirement {
}
class BluetoothConnectRequirement extends PlatformRequirement {
BluetoothConnectRequirement() : super(AppLocalizations.current.allowBluetoothConnections);
BluetoothConnectRequirement()
: super(AppLocalizations.current.allowBluetoothConnections, icon: Icons.bluetooth_connected);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
@@ -117,13 +119,20 @@ class NotificationRequirement extends PlatformRequirement {
: super(
AppLocalizations.current.allowPersistentNotification,
description: AppLocalizations.current.notificationDescription,
icon: Icons.notifications_active,
);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
if (Platform.isAndroid) {
await core.flutterLocalNotificationsPlugin
final result = await core.flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
if (result == false) {
buildToast(
navigatorKey.currentContext!,
title: 'Enable notifications for BikeControl in Android Settings',
);
}
} else if (Platform.isIOS) {
final result = await core.flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
@@ -132,6 +141,7 @@ class NotificationRequirement extends PlatformRequirement {
badge: false,
sound: false,
);
core.settings.setHasAskedPermissions(true);
if (result == false) {
buildToast(
navigatorKey.currentContext!,
@@ -147,6 +157,7 @@ class NotificationRequirement extends PlatformRequirement {
badge: false,
sound: false,
);
core.settings.setHasAskedPermissions(true);
if (result == false) {
buildToast(
navigatorKey.currentContext!,
@@ -172,12 +183,12 @@ class NotificationRequirement extends PlatformRequirement {
final permissions = await core.flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>()
?.checkPermissions();
status = permissions?.isEnabled == true;
status = permissions?.isEnabled == true || core.settings.hasAskedPermissions();
} else if (Platform.isMacOS) {
final permissions = await core.flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>()
?.checkPermissions();
status = permissions?.isEnabled == true;
status = permissions?.isEnabled == true || core.settings.hasAskedPermissions();
} else {
status = true;
}

View File

@@ -1,10 +1,5 @@
import 'dart:io';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/core.dart';
@@ -14,10 +9,15 @@ import 'package:bike_control/utils/keymap/apps/supported_app.dart';
import 'package:bike_control/utils/keymap/apps/zwift.dart';
import 'package:bike_control/utils/requirements/platform.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:universal_ble/universal_ble.dart';
class KeyboardRequirement extends PlatformRequirement {
KeyboardRequirement() : super(AppLocalizations.current.keyboardAccess);
KeyboardRequirement() : super(AppLocalizations.current.keyboardAccess, icon: Icons.keyboard);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
@@ -36,7 +36,8 @@ class KeyboardRequirement extends PlatformRequirement {
}
class BluetoothAdvertiseRequirement extends PlatformRequirement {
BluetoothAdvertiseRequirement() : super(AppLocalizations.current.bluetoothAdvertiseAccess);
BluetoothAdvertiseRequirement()
: super(AppLocalizations.current.bluetoothAdvertiseAccess, icon: Icons.bluetooth_audio);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
@@ -51,7 +52,7 @@ class BluetoothAdvertiseRequirement extends PlatformRequirement {
}
class BluetoothTurnedOn extends PlatformRequirement {
BluetoothTurnedOn() : super(AppLocalizations.current.bluetoothTurnedOn);
BluetoothTurnedOn() : super(AppLocalizations.current.bluetoothTurnedOn, icon: Icons.bluetooth);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {
@@ -99,6 +100,7 @@ class UnsupportedPlatform extends PlatformRequirement {
kIsWeb
? AppLocalizations.current.browserNotSupported
: AppLocalizations.current.platformNotSupported('platform'),
icon: Icons.error_outline,
) {
status = false;
}
@@ -113,7 +115,7 @@ class UnsupportedPlatform extends PlatformRequirement {
}
class ErrorRequirement extends PlatformRequirement {
ErrorRequirement(super.name) {
ErrorRequirement(super.name, {required super.icon}) {
status = false;
}
@@ -194,16 +196,3 @@ enum Target {
};
}
}
class PlaceholderRequirement extends PlatformRequirement {
PlaceholderRequirement() : super(AppLocalizations.current.requirement);
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Future<bool> getStatus() async {
status = false;
return false;
}
}

View File

@@ -3,9 +3,10 @@ import 'package:flutter/material.dart';
abstract class PlatformRequirement {
String name;
String? description;
final IconData icon;
late bool status;
PlatformRequirement(this.name, {this.description});
PlatformRequirement(this.name, {this.description, required this.icon});
Future<bool> getStatus();

View File

@@ -7,6 +7,7 @@ import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/utils/keymap/apps/supported_app.dart';
import 'package:bike_control/utils/requirements/multi.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider_windows/path_provider_windows.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -26,6 +27,12 @@ class Settings {
prefs = await SharedPreferences.getInstance();
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
if (getShowOnboarding() && getTrainerApp() != null) {
// If onboarding is to be shown, but a trainer app is already set,
// skip onboarding and set to not show again.
await setShowOnboarding(false);
}
if (core.actionHandler is DesktopActions) {
// Must add this line.
await windowManager.ensureInitialized();
@@ -375,4 +382,20 @@ class Settings {
final v = ms.clamp(_sramAxsDoubleClickWindowMinMs, _sramAxsDoubleClickWindowMaxMs);
await prefs.setInt('sram_axs_double_click_window_ms', v);
}
bool getShowOnboarding() {
return !kIsWeb && (prefs.getBool('show_onboarding') ?? true);
}
Future<void> setShowOnboarding(bool show) async {
await prefs.setBool('show_onboarding', show);
}
bool hasAskedPermissions() {
return prefs.getBool('asked_permissions') ?? false;
}
Future<void> setHasAskedPermissions(bool asked) async {
await prefs.setBool('asked_permissions', asked);
}
}

View File

@@ -192,6 +192,7 @@ class _LocalTileState extends State<LocalTile> {
isEnabled: core.settings.getLocalEnabled(),
type: ConnectionMethodType.local,
showTroubleshooting: true,
instructionLink: 'INSTRUCTIONS_LOCAL.md',
title: context.i18n.controlAppUsingModes(
core.settings.getTrainerApp()?.name ?? '',
core.actionHandler.supportedModes.joinToString(transform: (e) => e.name.capitalize()),

View File

@@ -42,17 +42,16 @@ class _MywhooshLinkTileState extends State<MyWhooshLinkTile> {
core.whooshLink.stopServer();
} else if (value) {
buildToast(
context,
navigatorKey.currentContext!,
title: AppLocalizations.of(context).myWhooshLinkInfo,
level: LogLevel.LOGLEVEL_INFO,
duration: Duration(seconds: 6),
closeTitle: 'Open',
onClose: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MarkdownPage(assetPath: 'INSTRUCTIONS_MYWHOOSH_LINK.md'),
),
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (c) => MarkdownPage(assetPath: 'INSTRUCTIONS_MYWHOOSH_LINK.md'),
);
},
);

View File

@@ -4,6 +4,7 @@ import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/widgets/ui/connection_method.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class OpenBikeControlBluetoothTile extends StatefulWidget {
@@ -27,7 +28,9 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlBluetoothTile> {
type: ConnectionMethodType.openBikeControl,
title: context.i18n.connectUsingBluetooth,
description: isConnected != null
? context.i18n.connectedTo(isConnected.appId)
? context.i18n.connectedTo(
"${isConnected.appId}:\n${isConnected.supportedActions.joinToString(transform: (s) => s.title)}",
)
: isStarted
? context.i18n.chooseBikeControlInConnectionScreen
: context.i18n.letsAppConnectOverBluetooth(core.settings.getTrainerApp()?.name ?? ''),

View File

@@ -4,6 +4,7 @@ import 'package:bike_control/main.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/widgets/ui/connection_method.dart';
import 'package:dartx/dartx.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class OpenBikeControlMdnsTile extends StatefulWidget {
@@ -28,7 +29,9 @@ class _OpenBikeProtocolTileState extends State<OpenBikeControlMdnsTile> {
title: context.i18n.connectDirectlyOverNetwork,
description: isConnected != null
? context.i18n.connectedTo(isConnected.appId)
? context.i18n.connectedTo(
"${isConnected.appId}:\n${isConnected.supportedActions.joinToString(transform: (s) => s.title)}",
)
: isStarted
? context.i18n.chooseBikeControlInConnectionScreen
: context.i18n.letsAppConnectOverNetwork(core.settings.getTrainerApp()?.name ?? ''),

View File

@@ -42,7 +42,7 @@ class ChangelogDialog extends StatelessWidget {
static Future<void> showIfNeeded(BuildContext context, String currentVersion, String? lastSeenVersion) async {
// Show dialog if this is a new version
if (lastSeenVersion != currentVersion && !screenshotMode) {
if (lastSeenVersion != currentVersion && lastSeenVersion != null && !screenshotMode) {
try {
final entry = await rootBundle.loadString('CHANGELOG.md');
if (context.mounted) {

View File

@@ -9,6 +9,7 @@ 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:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
@@ -65,383 +66,394 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
final commandsRemaining = iapManager.commandsRemainingToday;
final dailyCommandCount = iapManager.dailyCommandCount;
return Button(
onPressed: _isSmall
? () {
setState(() {
_isSmall = false;
});
}
: () {
if (Platform.isAndroid) {
if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) {
_handlePurchase(context);
}
} else {
_handlePurchase(context);
}
},
style: ButtonStyle.card().withBackgroundColor(
color: Theme.of(context).colorScheme.muted,
hoverColor: Theme.of(context).colorScheme.primaryForeground,
),
child: AnimatedContainer(
duration: Duration(milliseconds: 700),
width: double.infinity,
child: ValueListenableBuilder(
valueListenable: IAPManager.instance.isPurchased,
builder: (context, isPurchased, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (isPurchased) ...[
Row(
return kIsWeb
? SizedBox()
: Button(
onPressed: _isSmall
? () {
setState(() {
_isSmall = false;
});
}
: () {
if (Platform.isAndroid) {
if (_alreadyBoughtQuestion == AlreadyBoughtOption.iap) {
_handlePurchase(context);
}
} else {
_handlePurchase(context);
}
},
style: ButtonStyle.card().withBackgroundColor(
color: Theme.of(context).colorScheme.muted,
hoverColor: Theme.of(context).colorScheme.primaryForeground,
),
child: AnimatedContainer(
duration: Duration(milliseconds: 700),
width: double.infinity,
child: ValueListenableBuilder(
valueListenable: IAPManager.instance.isPurchased,
builder: (context, isPurchased, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).fullVersion,
style: TextStyle(
color: Colors.green,
),
),
],
),
] else if (!isTrialExpired) ...[
if (!Platform.isAndroid)
Basic(
leadingAlignment: Alignment.centerLeft,
leading: Icon(Icons.access_time, color: Colors.blue),
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
subtitle: _isSmall
? null
: Text(AppLocalizations.of(context).trialPeriodDescription(IAPManager.dailyCommandLimit)),
trailing: _isSmall ? Icon(Icons.expand_more) : null,
)
else
Basic(
leadingAlignment: Alignment.centerLeft,
leading: Icon(Icons.lock),
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
SizedBox(),
Text(
commandsRemaining >= 0
? context.i18n
.commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit)
.replaceAll(
'${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}',
IAPManager.dailyCommandLimit.toString(),
)
: AppLocalizations.of(
context,
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
).small,
if (commandsRemaining >= 0)
SizedBox(
width: 300,
child: LinearProgressIndicator(
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
backgroundColor: Colors.gray[300],
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
if (isPurchased) ...[
Row(
children: [
Icon(Icons.check_circle, color: Colors.green),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context).fullVersion,
style: TextStyle(
color: Colors.green,
),
),
],
),
trailing: _isSmall ? Icon(Icons.expand_more) : null,
trailingAlignment: Alignment.centerRight,
),
] else ...[
Basic(
leadingAlignment: Alignment.centerLeft,
leading: Icon(Icons.lock),
title: Text(AppLocalizations.of(context).trialExpired(IAPManager.dailyCommandLimit)),
trailing: _isSmall ? Icon(Icons.expand_more) : null,
trailingAlignment: Alignment.centerRight,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
SizedBox(),
Text(
commandsRemaining >= 0
? context.i18n.commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit)
: AppLocalizations.of(
context,
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
).small,
if (commandsRemaining >= 0)
SizedBox(
width: 300,
child: LinearProgressIndicator(
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
backgroundColor: Colors.gray[300],
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
),
),
],
),
),
],
if (!isPurchased && !_isSmall) ...[
if (Platform.isAndroid)
Padding(
padding: const EdgeInsets.only(left: 42.0, top: 16.0),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(),
const SizedBox(),
if (_alreadyBoughtQuestion == null && DateTime.now().isBefore(_normalDate)) ...[
Text(AppLocalizations.of(context).alreadyBoughtTheAppPreviously).small,
Row(
],
),
] else if (!isTrialExpired) ...[
if (!Platform.isAndroid)
Basic(
leadingAlignment: Alignment.centerLeft,
leading: Icon(Icons.access_time, color: Colors.blue),
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
subtitle: _isSmall
? null
: Text(
AppLocalizations.of(context).trialPeriodDescription(IAPManager.dailyCommandLimit),
),
trailing: _isSmall ? Icon(Icons.expand_more) : null,
)
else
Basic(
leadingAlignment: Alignment.centerLeft,
leading: Icon(Icons.lock),
title: Text(AppLocalizations.of(context).trialPeriodActive(trialDaysRemaining)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
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;
});
},
),
],
),
);
},
);
},
),
const SizedBox(width: 8),
OutlineButton(
child: Text(AppLocalizations.of(context).no),
onPressed: () {
setState(() {
_alreadyBoughtQuestion = AlreadyBoughtOption.no;
});
},
),
],
),
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.fullPurchase) ...[
Text(
AppLocalizations.of(context).alreadyBoughtTheApp,
).small,
Form(
onSubmit: (context, values) async {
String purchaseId = _purchaseIdField[values]!.trim();
setState(() {
_isLoading = true;
});
final redeemed = await _redeemPurchase(
purchaseId: purchaseId,
supabaseAnonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpa3JjeXlub3Zkdm9ncmxkZm53Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYwNjMyMzksImV4cCI6MjA4MTYzOTIzOX0.oxJovYahRiZ6XvCVR-qww6OQ5jY6cjOyUiFHJsW9MVk',
supabaseUrl: 'https://pikrcyynovdvogrldfnw.supabase.co',
);
if (redeemed) {
await IAPManager.instance.redeem(purchaseId);
buildToast(context, title: 'Success', subtitle: 'Purchase redeemed successfully!');
setState(() {
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
if (mounted) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Error'),
content: Text(
'Failed to redeem purchase. Please check your Purchase ID and try again or contact me directly. Sorry about that!',
),
actions: [
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',
);
},
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('OK'),
),
],
);
},
);
}
}
},
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: FormField(
showErrors: {
FormValidationMode.submitted,
FormValidationMode.changed,
},
key: _purchaseIdField,
label: Text('Purchase ID'),
validator: RegexValidator(
RegExp(r'GPA.[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{5}'),
message: 'Please enter a valid Purchase ID.',
),
child: TextField(
placeholder: Text('GPA.****-****-****-*****'),
),
SizedBox(),
Text(
commandsRemaining >= 0
? context.i18n
.commandsRemainingToday(commandsRemaining, IAPManager.dailyCommandLimit)
.replaceAll(
'${IAPManager.dailyCommandLimit}/${IAPManager.dailyCommandLimit}',
IAPManager.dailyCommandLimit.toString(),
)
: AppLocalizations.of(
context,
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
).small,
if (commandsRemaining >= 0 && dailyCommandCount > 0)
SizedBox(
width: 300,
child: LinearProgressIndicator(
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
backgroundColor: Colors.gray[300],
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
),
),
FormErrorBuilder(
builder: (context, errors, child) {
return PrimaryButton(
onPressed: errors.isEmpty ? () => context.submitForm() : null,
child: _isLoading
? SmallProgressIndicator(color: Colors.black)
: const Text('Submit'),
],
),
trailing: _isSmall ? Icon(Icons.expand_more) : null,
trailingAlignment: Alignment.centerRight,
),
] else ...[
Basic(
leadingAlignment: Alignment.centerLeft,
leading: Icon(Icons.lock),
title: Text(AppLocalizations.of(context).trialExpired(IAPManager.dailyCommandLimit)),
trailing: _isSmall ? Icon(Icons.expand_more) : null,
trailingAlignment: Alignment.centerRight,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 6,
children: [
SizedBox(),
Text(
commandsRemaining >= 0
? context.i18n.commandsRemainingToday(
commandsRemaining,
IAPManager.dailyCommandLimit,
)
: AppLocalizations.of(
context,
).dailyLimitReached(dailyCommandCount, IAPManager.dailyCommandLimit),
).small,
if (commandsRemaining >= 0)
SizedBox(
width: 300,
child: LinearProgressIndicator(
value: dailyCommandCount.toDouble() / IAPManager.dailyCommandLimit.toDouble(),
backgroundColor: Colors.gray[300],
color: commandsRemaining > 0 ? Colors.orange : Colors.red,
),
),
],
),
),
],
if (!isPurchased && !_isSmall) ...[
if (Platform.isAndroid)
Padding(
padding: const EdgeInsets.only(left: 42.0, top: 16.0),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(),
const SizedBox(),
if (_alreadyBoughtQuestion == null && DateTime.now().isBefore(_normalDate)) ...[
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;
});
},
),
],
),
);
},
);
},
),
const SizedBox(width: 8),
OutlineButton(
child: Text(AppLocalizations.of(context).no),
onPressed: () {
setState(() {
_alreadyBoughtQuestion = AlreadyBoughtOption.no;
});
},
),
],
),
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.fullPurchase) ...[
Text(
AppLocalizations.of(context).alreadyBoughtTheApp,
).small,
Form(
onSubmit: (context, values) async {
String purchaseId = _purchaseIdField[values]!.trim();
setState(() {
_isLoading = true;
});
final redeemed = await _redeemPurchase(
purchaseId: purchaseId,
supabaseAnonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpa3JjeXlub3Zkdm9ncmxkZm53Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYwNjMyMzksImV4cCI6MjA4MTYzOTIzOX0.oxJovYahRiZ6XvCVR-qww6OQ5jY6cjOyUiFHJsW9MVk',
supabaseUrl: 'https://pikrcyynovdvogrldfnw.supabase.co',
);
if (redeemed) {
await IAPManager.instance.redeem(purchaseId);
buildToast(
context,
title: 'Success',
subtitle: 'Purchase redeemed successfully!',
);
setState(() {
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
if (mounted) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Error'),
content: Text(
'Failed to redeem purchase. Please check your Purchase ID and try again or contact me directly. Sorry about that!',
),
actions: [
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',
);
},
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('OK'),
),
],
);
},
);
}
}
},
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: FormField(
showErrors: {
FormValidationMode.submitted,
FormValidationMode.changed,
},
key: _purchaseIdField,
label: Text('Purchase ID'),
validator: RegexValidator(
RegExp(r'GPA.[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{5}'),
message: 'Please enter a valid Purchase ID.',
),
child: TextField(
placeholder: Text('GPA.****-****-****-*****'),
),
),
),
FormErrorBuilder(
builder: (context, errors, child) {
return PrimaryButton(
onPressed: errors.isEmpty ? () => context.submitForm() : null,
child: _isLoading
? SmallProgressIndicator(color: Colors.black)
: const Text('Submit'),
);
},
),
],
),
),
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.no) ...[
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).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',
);
},
),
],
),
],
),
] else if (_alreadyBoughtQuestion == AlreadyBoughtOption.no) ...[
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).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',
)
else ...[
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),
);
},
),
],
],
),
)
else ...[
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),
);
},
),
),
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,
),
],
],
],
);
},
),
),
);
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,
),
],
],
],
);
},
),
),
);
}
Future<void> _handlePurchase(BuildContext context) async {

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart' show SelectionArea;
import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@@ -76,36 +77,39 @@ class _LogviewerState extends State<LogViewer> {
: Expanded(
child: Card(
child: SelectionArea(
child: ListView(
child: SingleChildScrollView(
controller: _scrollController,
reverse: true,
children: core.connection.lastLogEntries
.map(
(action) => Text.rich(
TextSpan(
children: [
TextSpan(
text: action.date.toString().split(" ").last,
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: "monospace",
fontFamilyFallback: <String>["Courier"],
child: SizedBox(
width: double.infinity,
child: Text.rich(
TextSpan(
children: core.connection.lastLogEntries
.map(
(action) => [
TextSpan(
text: action.date.toString().split(" ").last,
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontFamily: "monospace",
fontFamilyFallback: <String>["Courier"],
),
),
),
TextSpan(
text: " ${action.entry}",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
TextSpan(
text: " ${action.entry}\n",
style: TextStyle(
fontSize: 12,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.bold,
),
),
),
],
),
),
)
.toList(),
],
)
.flatten()
.toList(),
),
),
),
),
),
),

View File

@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/zwift/zwift_clickv2.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/pages/markdown.dart';
import 'package:bike_control/pages/navigation.dart';
import 'package:bike_control/utils/core.dart';
@@ -12,212 +11,85 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show showLicensePage;
import 'package:in_app_review/in_app_review.dart';
import 'package:intl/intl.dart';
import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../utils/iap/iap_manager.dart';
List<Widget> buildMenuButtons(BuildContext context, BCPage currentPage, VoidCallback? openLogs) {
return [
Builder(
builder: (context) {
return OutlineButton(
density: ButtonDensity.icon,
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: [
if ((!Platform.isIOS && !Platform.isMacOS)) ...[
MenuLabel(child: Text(context.i18n.showDonation)),
MenuButton(
child: Text(context.i18n.donateViaCreditCard),
onPressed: (c) {
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
final link = switch (currency.currencyName) {
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
};
launchUrlString(link);
},
),
if (!kIsWeb && Platform.isAndroid && isFromPlayStore == false)
if (IAPManager.instance.isPurchased.value)
Builder(
builder: (context) {
return OutlineButton(
density: ButtonDensity.icon,
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: [
if ((!Platform.isIOS && !Platform.isMacOS)) ...[
MenuLabel(child: Text(context.i18n.showDonation)),
MenuButton(
child: Text(context.i18n.donateByBuyingFromPlayStore),
child: Text(context.i18n.donateViaCreditCard),
onPressed: (c) {
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
final currency = NumberFormat.simpleCurrency(locale: kIsWeb ? 'de_DE' : Platform.localeName);
final link = switch (currency.currencyName) {
'USD' => 'https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201',
_ => 'https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200',
};
launchUrlString(link);
},
),
MenuButton(
child: Text(context.i18n.donateViaPaypal),
onPressed: (c) {
launchUrlString('https://paypal.me/boni');
},
),
],
MenuButton(
leading: Icon(Icons.star_rate),
child: Text(context.i18n.leaveAReview),
onPressed: (c) async {
final InAppReview inAppReview = InAppReview.instance;
if (await inAppReview.isAvailable()) {
inAppReview.requestReview();
} else {
inAppReview.openStoreListing(appStoreId: 'id6753721284', microsoftStoreId: '9NP42GS03Z26');
}
},
),
],
),
);
},
child: Icon(
Icons.favorite,
color: Colors.red,
size: 18,
),
);
},
),
Gap(4),
Builder(
builder: (context) {
return OutlineButton(
density: ButtonDensity.icon,
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: [
MenuButton(
leading: Icon(Icons.help_outline),
child: Text(context.i18n.troubleshootingGuide),
onPressed: (c) {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
),
MenuDivider(),
MenuLabel(child: Text(context.i18n.getSupport)),
MenuButton(
leading: Icon(Icons.reddit_outlined),
onPressed: (c) {
launchUrlString('https://www.reddit.com/r/BikeControl/');
},
child: Text('Reddit'),
),
MenuButton(
leading: Icon(Icons.facebook_outlined),
onPressed: (c) {
launchUrlString('https://www.facebook.com/groups/1892836898778912');
},
child: Text('Facebook'),
),
MenuButton(
leading: Icon(RadixIcons.githubLogo),
onPressed: (c) {
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
child: Text('GitHub'),
),
if (!kIsWeb) ...[
MenuButton(
leading: Icon(Icons.email_outlined),
child: Text('Mail'),
onPressed: (c) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Mail Support'),
content: Container(
constraints: BoxConstraints(maxWidth: 400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
Text(
AppLocalizations.of(context).mailSupportExplanation,
),
...[
OutlineButton(
leading: Icon(Icons.reddit_outlined),
onPressed: () {
Navigator.pop(context);
launchUrlString('https://www.reddit.com/r/BikeControl/');
},
child: const Text('Reddit'),
),
OutlineButton(
leading: Icon(Icons.facebook_outlined),
onPressed: () {
Navigator.pop(context);
launchUrlString('https://www.facebook.com/groups/1892836898778912');
},
child: const Text('Facebook'),
),
OutlineButton(
leading: Icon(RadixIcons.githubLogo),
onPressed: () {
Navigator.pop(context);
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
child: const Text('GitHub'),
),
SecondaryButton(
leading: Icon(Icons.mail_outlined),
onPressed: () {
Navigator.pop(context);
final isFromStore = (Platform.isAndroid
? isFromPlayStore == true
: Platform.isIOS);
final suffix = isFromStore ? '' : '-sw';
String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app');
String subject = Uri.encodeComponent(
context.i18n.helpRequested(packageInfoValue?.version ?? ''),
);
String body = Uri.encodeComponent("""
${debugText()}""");
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
launchUrl(mail);
},
child: const Text('Mail'),
),
],
],
),
),
);
if (!kIsWeb && Platform.isAndroid && isFromPlayStore == false)
MenuButton(
child: Text(context.i18n.donateByBuyingFromPlayStore),
onPressed: (c) {
launchUrlString('https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol');
},
);
),
MenuButton(
child: Text(context.i18n.donateViaPaypal),
onPressed: (c) {
launchUrlString('https://paypal.me/boni');
},
),
],
MenuButton(
leading: Icon(Icons.star_rate),
child: Text(context.i18n.leaveAReview),
onPressed: (c) async {
final InAppReview inAppReview = InAppReview.instance;
if (await inAppReview.isAvailable()) {
inAppReview.requestReview();
} else {
inAppReview.openStoreListing(appStoreId: 'id6753721284', microsoftStoreId: '9NP42GS03Z26');
}
},
),
],
],
),
);
},
child: Icon(
Icons.help_outline,
size: 18,
),
);
},
),
),
);
},
child: Icon(
Icons.favorite,
color: Colors.red,
size: 18,
),
);
},
),
Gap(4),
BKMenuButton(openLogs: openLogs, currentPage: currentPage),
];
}
String debugText() {
Future<String> debugText() async {
final userId = IAPManager.instance.isUsingRevenueCat ? (await Purchases.appUserID) : null;
return '''
---
@@ -227,6 +99,7 @@ Target: ${core.settings.getLastTarget()?.name ?? '-'}
Trainer App: ${core.settings.getTrainerApp()?.name ?? '-'}
Connected Controllers: ${core.connection.devices.map((e) => e.toString()).join(', ')}
Connected Trainers: ${core.logic.connectedTrainerConnections.map((e) => e.title).join(', ')}
Status: ${IAPManager.instance.isPurchased.value ? 'Full Version' : 'Test Version'}${userId != null ? ' (User ID: $userId)' : ''}
Logs:
${core.connection.lastLogEntries.reversed.joinToString(separator: '\n', transform: (e) => '${e.date.toString().split('.').first} - ${e.entry}')}
''';
@@ -294,7 +167,11 @@ class BKMenuButton extends StatelessWidget {
leading: Icon(Icons.update_outlined),
child: Text(context.i18n.changelog),
onPressed: (c) {
Navigator.push(context, MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md')));
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (c) => MarkdownPage(assetPath: 'CHANGELOG.md'),
);
},
),
MenuButton(

View File

@@ -1,18 +1,13 @@
import 'dart:io';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/pages/button_simulator.dart';
import 'package:bike_control/pages/markdown.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/requirements/platform.dart';
import 'package:bike_control/widgets/ignored_devices_dialog.dart';
import 'package:bike_control/widgets/ui/connection_method.dart';
import 'package:bike_control/widgets/ui/wifi_animation.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ScanWidget extends StatefulWidget {
const ScanWidget({super.key});
@@ -36,6 +31,7 @@ class _ScanWidgetState extends State<ScanWidget> {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (_needsPermissions != null && _needsPermissions!.isNotEmpty)
Card(
@@ -70,17 +66,18 @@ class _ScanWidgetState extends State<ScanWidget> {
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(),
Row(
spacing: 14,
children: [
SizedBox(),
SmoothWifiAnimation(),
Expanded(
child: Text(context.i18n.scanningForDevices).small.muted,
),
],
),
if (core.connection.controllerDevices.isEmpty)
Column(
spacing: 14,
children: [
SizedBox(),
SmoothWifiAnimation(),
Text(
context.i18n.scanningForDevices,
textAlign: TextAlign.center,
).small.muted,
],
),
SizedBox(),
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows))
ValueListenableBuilder(
@@ -100,7 +97,7 @@ class _ScanWidgetState extends State<ScanWidget> {
);
},
),
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS))
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS) && !core.settings.getShowOnboarding())
Checkbox(
state: core.settings.getPhoneSteeringEnabled()
? CheckboxState.checked
@@ -113,58 +110,6 @@ class _ScanWidgetState extends State<ScanWidget> {
},
),
SizedBox(),
if (!screenshotMode)
Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlineButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
leading: Icon(Icons.help_outline),
child: Text(context.i18n.showTroubleshootingGuide),
),
OutlineButton(
onPressed: () {
launchUrlString(
'https://github.com/jonasbark/swiftcontrol/?tab=readme-ov-file#supported-devices',
);
},
leading: Icon(Icons.gamepad_outlined),
child: Text(context.i18n.showSupportedControllers),
),
if (core.settings.getIgnoredDevices().isNotEmpty)
OutlineButton(
leading: Icon(Icons.block_outlined),
onPressed: () async {
await showDialog(
context: context,
builder: (context) => IgnoredDevicesDialog(),
);
setState(() {});
},
child: Text(context.i18n.manageIgnoredDevices),
),
if (core.connection.controllerDevices.isEmpty)
PrimaryButton(
leading: Icon(Icons.computer_outlined),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (c) => ButtonSimulator(),
),
);
},
child: Text(AppLocalizations.of(context).noControllerUseCompanionMode),
),
],
),
],
);
} else {

View File

@@ -92,7 +92,18 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
return;
}
if (data is ButtonNotification && data.buttonsClicked.isNotEmpty) {
if (core.actionHandler.supportedApp == null) {
if (core.settings.getShowOnboarding()) {
final button = data.buttonsClicked.first;
final sample = _KeySample(
button: button,
text: '🔘 ${button.name}',
timestamp: DateTime.now(),
);
_keys.insert(0, sample);
if (_keys.length > widget.maxKeyboardEvents) {
_keys.removeLast();
}
} else if (core.actionHandler.supportedApp == null) {
buildToast(context, level: LogLevel.LOGLEVEL_WARNING, title: context.i18n.selectTrainerAppAndTarget);
} else {
final button = data.buttonsClicked.first;
@@ -224,29 +235,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
setState(() {});
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (!widget.enabled || !widget.showKeyboard || event is KeyUpEvent) return KeyEventResult.ignored;
final label = event.logicalKey.keyLabel;
final keyName = label.isNotEmpty ? label : event.logicalKey.debugName ?? 'Key';
final isDown = event is KeyDownEvent;
final isUp = event is KeyUpEvent;
buildToast(
context,
location: ToastLocation.bottomLeft,
title:
'${isDown
? ""
: isUp
? ""
: ""} $keyName',
);
// We don't want to prevent normal text input, so we return ignored.
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
return Listener(
@@ -260,7 +248,6 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
autofocus: true,
canRequestFocus: true,
descendantsAreFocusable: true,
onKeyEvent: _onKey,
child: Stack(
fit: StackFit.passthrough,
children: [
@@ -280,7 +267,7 @@ class _TestbedState extends State<Testbed> with SingleTickerProviderStateMixin,
if (widget.showKeyboard)
Positioned(
right: 12,
bottom: _isMobile ? 92 : 12,
bottom: _isMobile && !core.settings.getShowOnboarding() ? 92 : 12,
child: IgnorePointer(
child: _KeyboardOverlay(
items: _keys,

View File

@@ -6,6 +6,7 @@ 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/gradient_text.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:flutter/foundation.dart';
@@ -29,12 +30,23 @@ class AppTitle extends StatefulWidget {
State<AppTitle> createState() => _AppTitleState();
}
class _AppTitleState extends State<AppTitle> {
enum UpdateType {
playStore,
shorebird,
appStore,
windowsStore,
}
class _AppTitleState extends State<AppTitle> with WidgetsBindingObserver {
final updater = ShorebirdUpdater();
Version? _newVersion;
UpdateType? _updateType;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
if (updater.isAvailable) {
updater.readCurrentPatch().then((patch) {
@@ -54,6 +66,19 @@ class _AppTitleState extends State<AppTitle> {
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkForUpdate();
}
}
@override
dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
void _checkForUpdate() async {
if (screenshotMode) {
return;
@@ -63,13 +88,27 @@ class _AppTitleState extends State<AppTitle> {
updater
.update()
.then((value) {
_showShorebirdRestartSnackbar();
setState(() {
_updateType = UpdateType.shorebird;
});
})
.catchError((e) {
buildToast(context, title: AppLocalizations.current.failedToUpdate(e.toString()));
});
} else if (updateStatus == UpdateStatus.restartRequired) {
_showShorebirdRestartSnackbar();
_updateType = UpdateType.shorebird;
}
if (_updateType == UpdateType.shorebird) {
final nextPatch = await updater.readNextPatch();
setState(() {
final currentVersion = Version.parse(packageInfoValue!.version);
_newVersion = Version(
currentVersion.major,
currentVersion.minor,
currentVersion.patch,
build: nextPatch?.number.toString() ?? '',
);
});
}
}
@@ -79,14 +118,9 @@ class _AppTitleState extends State<AppTitle> {
try {
final appUpdateInfo = await InAppUpdate.checkForUpdate();
if (context.mounted && appUpdateInfo.updateAvailability == UpdateAvailability.updateAvailable) {
buildToast(
context,
title: AppLocalizations.current.newVersionAvailable,
closeTitle: AppLocalizations.current.update,
onClose: () {
InAppUpdate.performImmediateUpdate();
},
);
setState(() {
_updateType = UpdateType.playStore;
});
}
isFromPlayStore = true;
return null;
@@ -140,33 +174,69 @@ class _AppTitleState extends State<AppTitle> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GradientText('BikeControl', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22)),
GradientText(
'BikeControl',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 22),
),
if (packageInfoValue != null)
Text(
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''} - ${IAPManager.instance.getStatusMessage()}',
'v${packageInfoValue!.version}${shorebirdPatch != null ? '+${shorebirdPatch!.number}' : ''} - ${core.settings.getShowOnboarding() ? 'Onboarding' : IAPManager.instance.getStatusMessage()}',
style: TextStyle(fontSize: 12),
).mono.muted
else
SmallProgressIndicator(),
if (_newVersion != null && _updateType != null)
Container(
margin: EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.destructive,
),
borderRadius: BorderRadius.circular(8),
),
child: LoadingWidget(
futureCallback: () async {
if (_updateType == UpdateType.shorebird) {
await _shorebirdRestart();
} else if (_updateType == UpdateType.playStore) {
await launchUrlString(
'https://play.google.com/store/apps/details?id=org.jonasbark.swiftcontrol',
mode: LaunchMode.externalApplication,
);
} else if (_updateType == UpdateType.appStore) {
await launchUrlString(
'https://apps.apple.com/app/id6753721284',
mode: LaunchMode.externalApplication,
);
} else if (_updateType == UpdateType.windowsStore) {
await launchUrlString(
'ms-windows-store://pdp/?productid=9NP42GS03Z26',
mode: LaunchMode.externalApplication,
);
}
},
renderChild: (isLoading, tap) => GhostButton(
onPressed: tap,
trailing: isLoading ? SmallProgressIndicator() : Icon(Icons.update),
child: Text(AppLocalizations.current.newVersionAvailableWithVersion(_newVersion.toString())),
),
),
),
],
);
}
void _showShorebirdRestartSnackbar() {
buildToast(
context,
title: AppLocalizations.current.forceCloseToUpdate,
closeTitle: AppLocalizations.current.restart,
onClose: () {
core.connection.disconnectAll();
core.connection.stop();
if (Platform.isIOS) {
Restart.restartApp(delayBeforeRestart: 1000);
} else {
exit(0);
}
},
);
Future<void> _shorebirdRestart() async {
setState(() {
core.connection.disconnectAll();
core.connection.stop();
if (Platform.isIOS) {
Restart.restartApp(delayBeforeRestart: 1000);
} else {
exit(0);
}
});
}
void _compareVersion(String versionString) {
@@ -174,23 +244,21 @@ class _AppTitleState extends State<AppTitle> {
final current = Version.parse(packageInfoValue!.version);
if (parsed > current && mounted && !kDebugMode) {
if (Platform.isAndroid) {
_showUpdateSnackbar(parsed, 'https://play.google.com/store/apps/details?id=org.jonasbark.swiftcontrol');
setState(() {
_updateType = UpdateType.playStore;
_newVersion = parsed;
});
} else if (Platform.isIOS || Platform.isMacOS) {
_showUpdateSnackbar(parsed, 'https://apps.apple.com/app/id6753721284');
setState(() {
_updateType = UpdateType.appStore;
_newVersion = parsed;
});
} else if (Platform.isWindows) {
_showUpdateSnackbar(parsed, 'ms-windows-store://pdp/?productid=9NP42GS03Z26');
setState(() {
_updateType = UpdateType.appStore;
_newVersion = parsed;
});
}
}
}
void _showUpdateSnackbar(Version newVersion, String url) {
buildToast(
context,
title: AppLocalizations.current.newVersionAvailableWithVersion(newVersion.toString()),
closeTitle: AppLocalizations.current.download,
onClose: () {
launchUrlString(url);
},
);
}
}

View File

@@ -1,5 +1,5 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:bike_control/widgets/ui/gradient_text.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class ColoredTitle extends StatelessWidget {
final String text;
@@ -7,6 +7,6 @@ class ColoredTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GradientText(text, style: TextStyle(fontSize: 18)).bold;
return GradientText(text, style: TextStyle(fontSize: 22)).bold;
}
}

View File

@@ -4,6 +4,7 @@ import 'package:bike_control/pages/markdown.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/requirements/platform.dart';
import 'package:bike_control/widgets/ui/beta_pill.dart';
import 'package:bike_control/widgets/ui/permissions_list.dart';
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:dartx/dartx.dart';
@@ -156,30 +157,14 @@ class _ConnectionMethodState extends State<ConnectionMethod> with WidgetsBinding
: ButtonStyle.outline(),
leading: Icon(Icons.help_outline),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: widget.instructionLink!)),
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (c) => MarkdownPage(assetPath: widget.instructionLink!),
);
},
child: Text(AppLocalizations.of(context).instructions),
),
if (widget.showTroubleshooting && widget.instructionLink == null)
Button(
style: widget.isEnabled && Theme.of(context).brightness == Brightness.light
? ButtonStyle.outline().withBorder(border: Border.all(color: Colors.gray.shade500))
: ButtonStyle.outline(),
leading: Icon(Icons.help_outline),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
),
);
},
child: Text(context.i18n.troubleshootingGuide),
),
],
),
);
@@ -200,94 +185,15 @@ Future openPermissionSheet(BuildContext context, List<PlatformRequirement> notDo
return openSheet(
context: context,
draggable: true,
builder: (context) => _PermissionList(requirements: notDone),
builder: (context) => Padding(
padding: const EdgeInsets.all(16.0),
child: PermissionList(
requirements: notDone,
onDone: () {
closeSheet(context);
},
),
),
position: OverlayPosition.bottom,
);
}
class _PermissionList extends StatefulWidget {
final List<PlatformRequirement> requirements;
const _PermissionList({super.key, required this.requirements});
@override
State<_PermissionList> createState() => _PermissionListState();
}
class _PermissionListState extends State<_PermissionList> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (widget.requirements.isNotEmpty) {
if (state == AppLifecycleState.resumed) {
Future.wait(widget.requirements.map((e) => e.getStatus())).then((_) {
final allDone = widget.requirements.every((e) => e.status);
if (allDone && context.mounted) {
closeSheet(context);
} else if (context.mounted) {
setState(() {});
}
});
}
}
}
@override
void dispose() {
super.dispose();
WidgetsBinding.instance.removeObserver(this);
}
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 18,
children: [
Text(
context.i18n.theFollowingPermissionsRequired,
style: TextStyle(fontWeight: FontWeight.bold),
),
...widget.requirements.map(
(e) => Row(
children: [
Expanded(
child: Basic(
title: Text(e.name),
subtitle: e.description != null ? Text(e.description!) : null,
trailing: Button(
style: e.status ? ButtonStyle.secondary() : ButtonStyle.primary(),
onPressed: e.status
? null
: () {
e
.call(context, () {
setState(() {});
})
.then((_) {
setState(() {});
if (widget.requirements.all((e) => e.status)) {
closeSheet(context);
}
});
},
child: e.status ? Text(context.i18n.granted) : Text(context.i18n.grant),
),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,168 @@
import 'dart:io';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/pages/markdown.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/widgets/menu.dart';
import 'package:bike_control/widgets/title.dart';
import 'package:flutter/foundation.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
class HelpButton extends StatelessWidget {
final bool isMobile;
const HelpButton({super.key, required this.isMobile});
@override
Widget build(BuildContext context) {
final border = isMobile
? BorderRadius.only(topRight: Radius.circular(8), topLeft: Radius.circular(8))
: BorderRadius.only(bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8));
return Container(
decoration: BoxDecoration(
borderRadius: border,
),
child: Builder(
builder: (context) {
return Button(
onPressed: () {
showDropdown(
context: context,
builder: (c) => DropdownMenu(
children: [
MenuLabel(child: Text(context.i18n.getSupport)),
MenuButton(
leading: Icon(Icons.reddit_outlined),
onPressed: (c) {
launchUrlString('https://www.reddit.com/r/BikeControl/');
},
child: Text('Reddit'),
),
MenuButton(
leading: Icon(Icons.facebook_outlined),
onPressed: (c) {
launchUrlString('https://www.facebook.com/groups/1892836898778912');
},
child: Text('Facebook'),
),
MenuButton(
leading: Icon(RadixIcons.githubLogo),
onPressed: (c) {
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
child: Text('GitHub'),
),
if (!kIsWeb) ...[
MenuButton(
leading: Icon(Icons.email_outlined),
child: Text('Mail'),
onPressed: (c) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Mail Support'),
content: Container(
constraints: BoxConstraints(maxWidth: 400),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 16,
children: [
Text(
AppLocalizations.of(context).mailSupportExplanation,
),
...[
OutlineButton(
leading: Icon(Icons.reddit_outlined),
onPressed: () {
Navigator.pop(context);
launchUrlString('https://www.reddit.com/r/BikeControl/');
},
child: const Text('Reddit'),
),
OutlineButton(
leading: Icon(Icons.facebook_outlined),
onPressed: () {
Navigator.pop(context);
launchUrlString('https://www.facebook.com/groups/1892836898778912');
},
child: const Text('Facebook'),
),
OutlineButton(
leading: Icon(RadixIcons.githubLogo),
onPressed: () {
Navigator.pop(context);
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
child: const Text('GitHub'),
),
SecondaryButton(
leading: Icon(Icons.mail_outlined),
onPressed: () async {
Navigator.pop(context);
final isFromStore = (Platform.isAndroid
? isFromPlayStore == true
: Platform.isIOS);
final suffix = isFromStore ? '' : '-sw';
String email = Uri.encodeComponent('jonas$suffix@bikecontrol.app');
String subject = Uri.encodeComponent(
context.i18n.helpRequested(packageInfoValue?.version ?? ''),
);
final dbg = await debugText();
String body = Uri.encodeComponent("""
$dbg""");
Uri mail = Uri.parse("mailto:$email?subject=$subject&body=$body");
launchUrl(mail);
},
child: const Text('Mail'),
),
],
],
),
),
);
},
);
},
),
],
MenuDivider(),
MenuLabel(child: Text(context.i18n.instructions)),
MenuButton(
leading: Icon(Icons.help_outline),
child: Text(context.i18n.troubleshootingGuide),
onPressed: (c) {
openDrawer(
context: context,
position: OverlayPosition.bottom,
builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md'),
);
},
),
],
),
);
},
leading: Icon(Icons.help_outline),
style: ButtonVariance.primary.withBorderRadius(
borderRadius: border,
hoverBorderRadius: border,
),
child: Padding(
padding: EdgeInsets.only(
bottom: core.settings.getShowOnboarding() && (kIsWeb || Platform.isAndroid || Platform.isIOS) ? 14 : 0,
),
child: Text(context.i18n.troubleshootingGuide),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,126 @@
import 'dart:io';
import 'package:bike_control/utils/requirements/android.dart';
import 'package:bike_control/utils/requirements/platform.dart';
import 'package:dartx/dartx.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import '../../utils/i18n_extension.dart';
class PermissionList extends StatefulWidget {
final VoidCallback onDone;
final List<PlatformRequirement> requirements;
const PermissionList({super.key, required this.requirements, required this.onDone});
@override
State<PermissionList> createState() => _PermissionListState();
}
class _PermissionListState extends State<PermissionList> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (widget.requirements.isNotEmpty) {
if (state == AppLifecycleState.resumed) {
Future.wait(widget.requirements.map((e) => e.getStatus())).then((_) {
final allDone = widget.requirements.every((e) => e.status);
if (allDone && mounted) {
closeSheet(context);
} else if (mounted) {
setState(() {});
}
});
}
}
}
@override
void dispose() {
super.dispose();
WidgetsBinding.instance.removeObserver(this);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 18,
children: [
SizedBox(),
Center(
child: Text(
context.i18n.theFollowingPermissionsRequired,
textAlign: TextAlign.center,
).muted.small,
),
...widget.requirements.map(
(e) {
final onPressed = e.status
? null
: () {
e
.call(context, () {
setState(() {});
})
.then((_) {
setState(() {});
if (widget.requirements.all((e) => e.status)) {
widget.onDone();
}
});
};
final optional = e is NotificationRequirement && (Platform.isMacOS || Platform.isIOS);
return SizedBox(
width: double.infinity,
child: Button(
onPressed: onPressed,
style: ButtonStyle.card().withBackgroundColor(
color: Theme.of(context).brightness == Brightness.dark
? Theme.of(context).colorScheme.card
: Theme.of(context).colorScheme.card.withLuminance(0.95),
),
child: Basic(
leading: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.primaryForeground,
),
padding: EdgeInsets.all(8),
child: Icon(e.icon),
),
title: Row(
spacing: 8,
children: [
Expanded(child: Text(e.name)),
Button(
style: e.status
? ButtonStyle.secondary(size: ButtonSize.small)
: ButtonStyle.primary(size: ButtonSize.small),
onPressed: onPressed,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
e.status ? Text(context.i18n.granted) : Text(context.i18n.grant),
if (optional) Text('Optional', style: TextStyle(fontSize: 10)).muted,
],
),
),
],
),
subtitle: e.description != null ? Text(e.description!) : null,
),
),
);
},
),
],
);
}
}

View File

@@ -10,17 +10,32 @@ class Warning extends StatelessWidget {
return Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: (important ? Theme.of(context).colorScheme.destructive : Theme.of(context).colorScheme.secondary)
.withAlpha(80),
color: (important
? Theme.of(context).colorScheme.destructive.withAlpha(30)
: Theme.of(context).colorScheme.secondary.withAlpha(80)),
border: Border.all(
color: important ? Theme.of(context).colorScheme.destructive : Theme.of(context).colorScheme.secondary,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: children.map((e) => e.small).toList(),
spacing: 8,
children: [
if (important)
Icon(
Icons.warning_amber_rounded,
color: Theme.of(context).colorScheme.destructive,
),
Expanded(
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: children,
),
),
],
),
);
}

View File

@@ -1,61 +1,172 @@
import 'package:bike_control/widgets/ui/colors.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
class SmoothWifiAnimation extends StatefulWidget {
const SmoothWifiAnimation({super.key});
const SmoothWifiAnimation({
super.key,
this.size = 160,
this.label = 'SCANNING',
});
final double size;
final String label;
@override
State<SmoothWifiAnimation> createState() => _SmoothWifiAnimationState();
State<SmoothWifiAnimation> createState() => _ScanningIndicatorState();
}
class _SmoothWifiAnimationState extends State<SmoothWifiAnimation> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
final _animationIcons = [
Icons.wifi,
Icons.wifi_1_bar,
Icons.wifi_2_bar,
];
int _currentIndex = 0;
class _ScanningIndicatorState extends State<SmoothWifiAnimation> with SingleTickerProviderStateMixin {
late final AnimationController _c;
@override
void initState() {
super.initState();
_controller =
AnimationController(
duration: const Duration(milliseconds: 600),
vsync: this,
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_controller.reverse();
} else if (status == AnimationStatus.dismissed) {
_currentIndex = (_currentIndex + 1) % _animationIcons.length;
setState(() {});
_controller.forward();
}
});
_controller.forward();
_c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: Icon(
_animationIcons[_currentIndex],
color: Theme.of(context).colorScheme.cardForeground,
key: ValueKey(_currentIndex),
size: 26,
final s = widget.size;
// Colors close to the screenshot
const ringColor = Color(0xFFBFEFF2); // pale cyan
const innerBorder = Color(0xFFE6E6E6);
const iconColor = BKColor.main; // teal
return SizedBox(
width: s,
height: s + 28, // room for the pill label
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
// Pulsing outer halo (two staggered ripples looks nicer)
_Ripple(
controller: _c,
color: ringColor,
beginScale: 0.80,
endScale: 1.15,
opacityCurve: Curves.easeOut,
interval: const Interval(0.0, 1.0),
),
_Ripple(
controller: _c,
color: ringColor,
beginScale: 0.70,
endScale: 1.05,
opacityCurve: Curves.easeOut,
interval: const Interval(0.35, 1.0),
),
// Static ring + inner circle
Container(
width: s,
height: s,
alignment: Alignment.center,
child: Container(
width: s * 0.62,
height: s * 0.62,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(color: innerBorder, width: 1.5),
),
alignment: Alignment.center,
child: Icon(Icons.wifi_tethering, color: iconColor, size: 40),
),
),
// Bottom pill label
Positioned(
bottom: 26,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: ShapeDecoration(
color: Theme.of(context).colorScheme.primary,
shape: StadiumBorder(),
shadows: [
BoxShadow(
blurRadius: 10,
offset: Offset(0, 3),
color: Color(0x33000000),
),
],
),
child: Text(
widget.label,
style: TextStyle(
color: Theme.of(context).colorScheme.primaryForeground,
fontSize: 10,
letterSpacing: 2.0,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
);
}
}
class _Ripple extends StatelessWidget {
const _Ripple({
required this.controller,
required this.color,
required this.beginScale,
required this.endScale,
required this.opacityCurve,
required this.interval,
});
final AnimationController controller;
final Color color;
final double beginScale;
final double endScale;
final Curve opacityCurve;
final Interval interval;
@override
Widget build(BuildContext context) {
final t = CurvedAnimation(parent: controller, curve: interval);
// Scale 0..1 -> beginScale..endScale
final scale = Tween<double>(begin: beginScale, end: endScale).animate(
CurvedAnimation(parent: t, curve: Curves.easeOutCubic),
);
// Opacity starts visible and fades out
final opacity = Tween<double>(begin: 0.35, end: 0.0).animate(
CurvedAnimation(parent: t, curve: opacityCurve),
);
return AnimatedBuilder(
animation: controller,
builder: (_, __) {
return Transform.scale(
scale: scale.value,
child: Opacity(
opacity: opacity.value,
child: Container(
width: 160,
height: 160,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: color, width: 12),
),
),
),
);
},
);
}
}

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.3.0+73
environment:
sdk: ^3.9.0
@@ -99,6 +99,7 @@ flutter:
- INSTRUCTIONS_REMOTE_CONTROL.md
- INSTRUCTIONS_ROUVY.md
- INSTRUCTIONS_ZWIFT.md
- INSTRUCTIONS_LOCAL.md
- shorebird.yaml
- icon.png