feat: redesign and lots of progress

This commit is contained in:
2026-04-26 22:43:22 +02:00
parent 16ac66471a
commit 82ea8125e1
24 changed files with 1095 additions and 1315 deletions

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'package:anyhow/anyhow.dart';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
hide ConnectionStatus, Result, Logger;
@ -173,9 +175,6 @@ class BluetoothController {
(currentState.$1 == ConnectionStatus.connected ||
currentState.$1 == ConnectionStatus.connecting)) {
log.info('Already connected or connecting to $deviceId.');
if (currentState.$1 == ConnectionStatus.connected) {
unawaited(_requestMtuOnConnect(deviceId));
}
return Ok(null);
}
@ -191,6 +190,7 @@ class BluetoothController {
try {
await _connectionStateSubscription?.cancel();
_updateConnectionState(ConnectionStatus.connecting, deviceId);
final connectionResult = Completer<Result<void>>();
_connectionStateSubscription = _ble
.connectToDevice(
@ -199,12 +199,21 @@ class BluetoothController {
servicesWithCharacteristicsToDiscover:
servicesWithCharacteristicsToDiscover,
)
.listen((update) {
.listen((update) async {
switch (update.connectionState) {
case DeviceConnectionState.connected:
_connectedDeviceId = deviceId;
_updateConnectionState(ConnectionStatus.connected, deviceId);
unawaited(_requestMtuOnConnect(deviceId));
if (!connectionResult.isCompleted) {
final mtuResult = await _requestInitialMtu(deviceId);
if (mtuResult.isErr()) {
log.warning(
'Initial MTU request failed for $deviceId: '
'${mtuResult.unwrapErr()}',
);
}
connectionResult.complete(Ok(null));
}
break;
case DeviceConnectionState.connecting:
_updateConnectionState(ConnectionStatus.connecting, deviceId);
@ -214,14 +223,31 @@ class BluetoothController {
break;
case DeviceConnectionState.disconnected:
_cleanUpConnection();
if (!connectionResult.isCompleted) {
connectionResult.complete(
bail('Failed to connect to $deviceId: disconnected'),
);
}
break;
}
}, onError: (Object error, StackTrace st) {
log.severe('Failed to connect to $deviceId: $error', error, st);
_cleanUpConnection();
if (!connectionResult.isCompleted) {
connectionResult.complete(
bail('Failed to connect to $deviceId: $error'),
);
}
});
return Ok(null);
try {
return await connectionResult.future.timeout(timeout);
} on TimeoutException {
await _connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
_cleanUpConnection();
return bail('Timed out connecting to $deviceId');
}
} catch (e) {
_cleanUpConnection();
return bail('Failed to connect to $deviceId: $e');
@ -301,7 +327,7 @@ class BluetoothController {
{int mtu = defaultMtu}) async {
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
if (result.isErr()) {
return bail(result.unwrapErr());
return Err(result.unwrapErr());
}
return Ok(null);
}
@ -310,6 +336,10 @@ class BluetoothController {
{int mtu = defaultMtu}) async {
try {
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
if (negotiatedMtu <= 0) {
return bail(
'Error requesting MTU $mtu for $deviceId: negotiated invalid MTU $negotiatedMtu');
}
log.info(
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
return Ok(negotiatedMtu);
@ -318,12 +348,11 @@ class BluetoothController {
}
}
Future<void> _requestMtuOnConnect(String deviceId) async {
final mtuResult = await requestMtu(deviceId, mtu: defaultMtu);
if (mtuResult.isErr()) {
log.warning(
'MTU request after connect failed for $deviceId: ${mtuResult.unwrapErr()}');
Future<Result<void>> _requestInitialMtu(String deviceId) async {
if (defaultTargetPlatform != TargetPlatform.android) {
return Ok(null);
}
return requestMtu(deviceId, mtu: defaultMtu);
}
Stream<List<int>> subscribeToCharacteristic(

View File

@ -1,627 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:anyhow/anyhow.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'bluetooth.g.dart';
final log = Logger('BluetoothController');
@Riverpod(keepAlive: true)
Future<BluetoothController> bluetooth(Ref ref) async {
ref.keepAlive();
final controller = BluetoothController();
log.info(await controller.init());
return controller;
}
@Riverpod(keepAlive: true)
Stream<(ConnectionStatus, BluetoothDevice?)> connectionStatus(Ref ref) {
// Get the (potentially still loading) BluetoothController
final asyncController = ref.watch(bluetoothProvider);
// If the controller is ready, return its stream. Otherwise, return an empty stream.
// The provider will automatically update when the controller becomes ready.
return asyncController.when(
data: (controller) => controller.connectionStateStream,
loading: () => Stream.value((ConnectionStatus.disconnected, null)),
error: (_, __) => Stream.value((ConnectionStatus.disconnected, null)),
);
}
/// Represents the connection status of the Bluetooth device.
enum ConnectionStatus { disconnected, connecting, connected, disconnecting }
class BluetoothController {
StreamSubscription<BluetoothAdapterState>? _btStateSubscription;
StreamSubscription<List<ScanResult>>? _scanResultsSubscription;
List<ScanResult> _latestScanResults = [];
StreamSubscription<void>? _servicesResetSubscription;
final Map<String, Map<Guid, BluetoothService>> _servicesByDevice = {};
final Map<String, Map<String, BluetoothCharacteristic>>
_characteristicsByDevice = {};
// Connection State
BluetoothDevice? _connectedDevice;
StreamSubscription<BluetoothConnectionState>? _connectionStateSubscription;
final _connectionStateSubject =
BehaviorSubject<(ConnectionStatus, BluetoothDevice?)>.seeded(
(ConnectionStatus.disconnected, null));
/// Stream providing the current connection status and the connected device (if any).
Stream<(ConnectionStatus, BluetoothDevice?)> get connectionStateStream =>
_connectionStateSubject.stream;
/// Gets the latest connection status and device.
(ConnectionStatus, BluetoothDevice?) get currentConnectionState =>
_connectionStateSubject.value;
Future<Result<void>> init() async {
log.severe("CALLED FBPON!");
if (await FlutterBluePlus.isSupported == false) {
log.severe("Bluetooth is not supported on this device!");
return bail("Bluetooth is not supported on this device!");
}
_btStateSubscription =
FlutterBluePlus.adapterState.listen((BluetoothAdapterState state) {
if (state == BluetoothAdapterState.on) {
log.info("Bluetooth is on!");
// usually start scanning, connecting, etc
} else {
log.info("Bluetooth is off!");
// show an error to the user, etc
}
});
if (!kIsWeb && Platform.isAndroid) {
await FlutterBluePlus.turnOn();
}
connectionStateStream.listen((state) {
log.info('Connection state changed: $state');
});
return Ok(null);
}
/// Start scanning for Bluetooth devices
///
/// [withServices] - Optional list of service UUIDs to filter devices by
/// [withNames] - Optional list of device names to filter by
/// [timeout] - Optional duration after which scanning will automatically stop
Future<Result<void>> startScan({
List<Guid>? withServices,
List<String>? withNames,
Duration? timeout,
}) async {
try {
// Wait for Bluetooth to be enabled
await FlutterBluePlus.adapterState
.where((val) => val == BluetoothAdapterState.on)
.first;
// Set up scan results listener
_scanResultsSubscription = FlutterBluePlus.onScanResults.listen(
(results) {
if (results.isNotEmpty) {
_latestScanResults = results;
ScanResult latestResult = results.last;
log.info(
'${latestResult.device.remoteId}: "${latestResult.advertisementData.advName}" found!');
}
},
onError: (e) {
log.severe('Scan error: $e');
},
);
// Clean up subscription when scanning completes
FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!);
// Start scanning with optional parameters
await FlutterBluePlus.startScan(
withServices: withServices ?? [],
withNames: withNames ?? [],
timeout: timeout,
);
return Ok(null);
} catch (e) {
return bail('Failed to start Bluetooth scan: $e');
}
}
/// Stop an ongoing Bluetooth scan
Future<Result<void>> stopScan() async {
try {
await FlutterBluePlus.stopScan();
return Ok(null);
} catch (e) {
return bail('Failed to stop Bluetooth scan: $e');
}
}
/// Get the latest scan results
List<ScanResult> get scanResults => _latestScanResults;
/// Wait for the current scan to complete
Future<Result<void>> waitForScanToComplete() async {
try {
await FlutterBluePlus.isScanning.where((val) => val == false).first;
return Ok(null);
} catch (e) {
return bail('Error waiting for scan to complete: $e');
}
}
/// Check if currently scanning
Future<bool> get isScanning async {
return await FlutterBluePlus.isScanning.first;
}
/// Connects to a specific Bluetooth device.
///
/// Ensures that only one device is connected at a time. If another device
/// is already connected or connecting, it will be disconnected first.
Future<Result<void>> connect(BluetoothDevice device,
{Duration? timeout}) async {
final currentState = currentConnectionState;
final currentDevice = currentState.$2;
// Prevent connecting if already connected/connecting to the *same* device
if (device.remoteId == currentDevice?.remoteId &&
(currentState.$1 == ConnectionStatus.connected ||
currentState.$1 == ConnectionStatus.connecting)) {
log.info('Currently connected device: ${currentState.$2}');
log.info('Already connected or connecting to ${device.remoteId}.');
return Ok(null); // Or potentially an error/different status?
}
log.info('Attempting to connect to ${device.remoteId}...');
// If connecting or connected to a *different* device, disconnect it first.
if (currentDevice != null && device.remoteId != currentDevice.remoteId) {
log.info(
'Disconnecting from previous device ${currentDevice.remoteId} first.');
final disconnectResult = await disconnect();
if (disconnectResult.isErr()) {
return disconnectResult
.context('Failed to disconnect from previous device');
}
// Wait a moment for the disconnection to fully process
await Future.delayed(const Duration(milliseconds: 500));
}
try {
// Cancel any previous connection state listener before starting a new one
await _connectionStateSubscription?.cancel();
_connectionStateSubscription =
device.connectionState.listen((BluetoothConnectionState state) async {
log.info('[${device.remoteId}] Connection state changed: $state');
switch (state) {
case BluetoothConnectionState.connected:
_connectedDevice = device;
_updateConnectionState(ConnectionStatus.connected, device);
// IMPORTANT: Discover services after connecting
try {
_attachServicesResetListener(device);
final servicesResult =
await _discoverAndCacheServices(device, force: true);
if (servicesResult.isErr()) {
throw servicesResult.unwrapErr();
}
log.info(
'[${device.remoteId}] Services discovered: \n${servicesResult.unwrap().map((e) => e.uuid.toString()).join('\n')}');
} catch (e) {
log.severe(
'[${device.remoteId}] Error discovering services: $e. Disconnecting.');
// Disconnect if service discovery fails, as the connection might be unusable
await disconnect();
}
break;
case BluetoothConnectionState.disconnected:
if (_connectionStateSubject.value.$1 !=
ConnectionStatus.connected) {
log.warning(
'[${device.remoteId}] Disconnected WITHOUT being connected! Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}\nDoing nothing');
break;
} else {
log.warning(
'[${device.remoteId}] Disconnected. Reason: ${device.disconnectReason?.code} ${device.disconnectReason?.description}');
// Only clean up if this is the device we were connected/connecting to
if (_connectionStateSubject.value.$2?.remoteId ==
device.remoteId) {
// Clean up connection state, handling disconnection.
// In general, reconnection is better, but this is how it's handled here.
// App behavior would be to go back to the homepage on disconnection
_cleanUpConnection();
} else {
log.info(
'[${device.remoteId}] Received disconnect for a device we were not tracking.');
}
break;
}
case BluetoothConnectionState.connecting:
case BluetoothConnectionState.disconnecting:
// deprecated states
log.warning(
'Received unexpected connection state: ${device.connectionState}. This should not happen.');
break;
}
});
await device.connect(
license: License.free,
timeout: timeout ?? const Duration(seconds: 15),
mtu: 512,
);
// Note: Success is primarily handled by the connectionState listener
log.info(
'Connection initiated for ${device.remoteId}. Waiting for state change.');
_connectionStateSubject.add((ConnectionStatus.connected, device));
return Ok(null);
} catch (e) {
log.severe('Failed to connect to ${device.remoteId}: $e');
_cleanUpConnection(); // Clean up state on connection failure
return bail('Failed to connect to ${device.remoteId}: $e');
}
}
/// Connects to a device using its remote ID string with a specific timeout.
Future<Result<void>> connectById(String remoteId,
{Duration timeout = const Duration(seconds: 10)}) async {
log.info('Attempting to connect by ID: $remoteId with timeout: $timeout');
try {
// Get the BluetoothDevice object from the ID
final device = BluetoothDevice.fromId(remoteId);
// Call the existing connect method, passing the device and timeout
// Assumes the 'connect' method below is modified to accept the timeout.
return await connect(device, timeout: timeout); // Pass timeout here
} catch (e, st) {
// Catch potential errors from fromId or during connection setup before connect() is called
log.severe('Error connecting by ID $remoteId: $e');
_cleanUpConnection(); // Ensure state is cleaned up
return bail('Failed to initiate connection for ID $remoteId: $e', st);
}
}
/// Disconnects from the currently connected device.
Future<Result<void>> disconnect() async {
final deviceToDisconnect =
_connectedDevice ?? _connectionStateSubject.value.$2;
if (deviceToDisconnect == null) {
log.info('No device is currently connected or connecting.');
// Ensure state is definitely disconnected if called unnecessarily
_cleanUpConnection();
return Ok(null);
}
log.info('Disconnecting from ${deviceToDisconnect.remoteId}...');
_updateConnectionState(ConnectionStatus.disconnecting, deviceToDisconnect);
try {
await deviceToDisconnect.disconnect();
log.info('Disconnect command sent to ${deviceToDisconnect.remoteId}.');
// State update to disconnected is handled by the connectionState listener
// but we call cleanup here as a safety measure in case the listener fails
_cleanUpConnection();
return Ok(null);
} catch (e) {
log.severe(
'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e');
// Even on error, try to clean up the state
_cleanUpConnection();
return bail(
'Failed to disconnect from ${deviceToDisconnect.remoteId}: $e');
}
}
void _updateConnectionState(
ConnectionStatus status, BluetoothDevice? device) {
// Avoid emitting redundant states
if (_connectionStateSubject.value.$1 == status &&
_connectionStateSubject.value.$2?.remoteId == device?.remoteId) {
return;
}
_connectionStateSubject.add((status, device));
log.fine(
'Connection state updated: $status, Device: ${device?.remoteId ?? 'none'}');
}
Future<Result<List<BluetoothService>>> discoverServices(
BluetoothDevice device, {
bool force = false,
}) async {
return _discoverAndCacheServices(device, force: force);
}
Future<Result<void>> writeCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid,
List<int> value, {
bool withoutResponse = false,
bool allowLongWrite = false,
int timeout = 15,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return chrResult.context('Failed to resolve characteristic for write');
}
try {
await chrResult.unwrap().write(
value,
withoutResponse: withoutResponse,
allowLongWrite: allowLongWrite,
timeout: timeout,
);
return Ok(null);
} catch (e) {
return bail('Error writing characteristic $characteristicUuid: $e');
}
}
Future<Result<StreamSubscription<List<int>>>> subscribeToNotifications(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
void Function(List<int>)? onValue,
bool useLastValueStream = false,
int timeout = 15,
}) async {
return _subscribeToCharacteristic(
device,
serviceUuid,
characteristicUuid,
useLastValueStream: useLastValueStream,
timeout: timeout,
forceIndications: false,
onValue: onValue,
);
}
Future<Result<StreamSubscription<List<int>>>> subscribeToIndications(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
void Function(List<int>)? onValue,
bool useLastValueStream = false,
int timeout = 15,
}) async {
return _subscribeToCharacteristic(
device,
serviceUuid,
characteristicUuid,
useLastValueStream: useLastValueStream,
timeout: timeout,
forceIndications: true,
onValue: onValue,
);
}
Future<Result<void>> unsubscribeFromCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
int timeout = 15,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return chrResult
.context('Failed to resolve characteristic to unsubscribe');
}
try {
await chrResult.unwrap().setNotifyValue(false, timeout: timeout);
return Ok(null);
} catch (e) {
return bail('Error disabling notifications for $characteristicUuid: $e');
}
}
/// Helper function to clean up connection resources and state.
Future<void> _cleanUpConnection() async {
log.fine('Cleaning up connection state and subscriptions.');
_connectedDevice = null;
await _servicesResetSubscription?.cancel();
_servicesResetSubscription = null;
_servicesByDevice.clear();
_characteristicsByDevice.clear();
await _connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
_updateConnectionState(ConnectionStatus.disconnected, null);
}
Future<Result<void>> dispose() async {
await _scanResultsSubscription?.cancel();
await _btStateSubscription?.cancel();
await disconnect(); // Ensure disconnection on dispose
await _connectionStateSubject.close();
return Ok(null);
}
Future<Result<List<int>>> readCharacteristic(
BluetoothDevice device, String svcUuid, String characteristic) async {
// Implement reading characteristic logic here
// This is a placeholder implementation
log.info(
'Reading characteristic from device: $device, characteristic: $characteristic');
final serviceUUID = Guid(svcUuid);
final characteristicUUID = Guid(characteristic);
if (!device.servicesList.map((e) => e.uuid).contains(serviceUUID)) {
return bail('Service $svcUuid not found on device ${device.remoteId}');
}
final BluetoothService service =
(device.servicesList).firstWhere((s) => s.uuid == serviceUUID);
if (service.characteristics.isEmpty ||
!service.characteristics
.map((c) => c.uuid)
.contains(characteristicUUID)) {
return bail(
'Characteristic $characteristic not found on device ${device.remoteId}');
}
try {
final val = await service.characteristics
.firstWhere((c) => c.uuid == characteristicUUID)
.read();
return Ok(val);
} catch (e) {
return bail('Error reading characteristic: $e');
}
}
String _deviceKey(BluetoothDevice device) => device.remoteId.str;
String _characteristicKey(Guid serviceUuid, Guid characteristicUuid) =>
'${serviceUuid.toString()}|${characteristicUuid.toString()}';
void _cacheServices(BluetoothDevice device, List<BluetoothService> services) {
final serviceMap = <Guid, BluetoothService>{};
final characteristicMap = <String, BluetoothCharacteristic>{};
for (final service in services) {
serviceMap[service.uuid] = service;
for (final chr in service.characteristics) {
characteristicMap[_characteristicKey(service.uuid, chr.uuid)] = chr;
}
}
_servicesByDevice[_deviceKey(device)] = serviceMap;
_characteristicsByDevice[_deviceKey(device)] = characteristicMap;
}
void _attachServicesResetListener(BluetoothDevice device) {
_servicesResetSubscription?.cancel();
_servicesResetSubscription = device.onServicesReset.listen((_) async {
log.info('[${device.remoteId}] Services reset. Re-discovering.');
final res = await _discoverAndCacheServices(device, force: true);
if (res.isErr()) {
log.severe(
'[${device.remoteId}] Failed to re-discover services: ${res.unwrapErr()}');
}
});
device.cancelWhenDisconnected(_servicesResetSubscription!);
}
Future<Result<List<BluetoothService>>> _discoverAndCacheServices(
BluetoothDevice device, {
bool force = false,
}) async {
try {
if (!force) {
final cached = _servicesByDevice[_deviceKey(device)];
if (cached != null && cached.isNotEmpty) {
return Ok(cached.values.toList());
}
}
if (!force && device.servicesList.isNotEmpty) {
_cacheServices(device, device.servicesList);
return Ok(device.servicesList);
}
final services = await device.discoverServices();
_cacheServices(device, services);
return Ok(services);
} catch (e) {
return bail('Failed to discover services for ${device.remoteId}: $e');
}
}
Future<Result<BluetoothCharacteristic>> _getCharacteristic(
BluetoothDevice device,
Guid serviceUuid,
Guid characteristicUuid,
) async {
final deviceKey = _deviceKey(device);
final cached = _characteristicsByDevice[deviceKey]
?[_characteristicKey(serviceUuid, characteristicUuid)];
if (cached != null) {
return Ok(cached);
}
final discoverResult = await _discoverAndCacheServices(device);
if (discoverResult.isErr()) {
return bail(discoverResult.unwrapErr().toString());
}
final refreshed = _characteristicsByDevice[deviceKey]
?[_characteristicKey(serviceUuid, characteristicUuid)];
if (refreshed == null) {
return bail(
'Characteristic $characteristicUuid not found on service $serviceUuid for device ${device.remoteId}');
}
return Ok(refreshed);
}
Future<Result<StreamSubscription<List<int>>>> _subscribeToCharacteristic(
BluetoothDevice device,
String serviceUuid,
String characteristicUuid, {
required bool forceIndications,
required bool useLastValueStream,
required int timeout,
void Function(List<int>)? onValue,
}) async {
final serviceGuid = Guid(serviceUuid);
final characteristicGuid = Guid(characteristicUuid);
final chrResult =
await _getCharacteristic(device, serviceGuid, characteristicGuid);
if (chrResult.isErr()) {
return bail('Failed to resolve characteristic subscription: '
'${chrResult.unwrapErr()}');
}
final characteristic = chrResult.unwrap();
final properties = characteristic.properties;
if (forceIndications && !properties.indicate) {
return bail(
'Characteristic $characteristicUuid does not support indications');
}
if (!forceIndications && !properties.notify && !properties.indicate) {
return bail(
'Characteristic $characteristicUuid does not support notifications');
}
if (forceIndications && !kIsWeb && !Platform.isAndroid) {
return bail('Indications can only be forced on Android.');
}
try {
final stream = useLastValueStream
? characteristic.lastValueStream
: characteristic.onValueReceived;
final subscription = stream.listen(onValue ?? (_) {});
device.cancelWhenDisconnected(subscription);
await characteristic.setNotifyValue(
true,
timeout: timeout,
forceIndications: forceIndications,
);
return Ok(subscription);
} catch (e) {
return bail(
'Error subscribing to characteristic $characteristicUuid: $e');
}
}
}

View File

@ -32,6 +32,15 @@ class NConnectedDevices extends _$NConnectedDevices {
}
return res;
}
Future<Result<void>> updateConnectedDeviceLastConnected(int id) async {
final db = ref.watch(databaseProvider);
final res = await db.updateConnectedDeviceLastConnected(id);
if (res.isOk()) {
ref.invalidateSelf();
}
return res;
}
}
/// Provider for the [AppDatabase] instance
@ -137,6 +146,22 @@ class AppDatabase extends _$AppDatabase {
}
}
Future<Result<void>> updateConnectedDeviceLastConnected(int id) async {
try {
final count = await (update(connectedDevices)
..where((tbl) => tbl.id.equals(id)))
.write(ConnectedDevicesCompanion(
lastConnectedAt: Value(DateTime.now())));
if (count == 0) {
return bail('Device with id $id not found.');
}
return Ok(());
} catch (e, st) {
return bail(
'Failed to update last connected time for device $id: $e', st);
}
}
Stream<List<ConnectedDevice>> getAllConnectedDevicesStream() {
return select(connectedDevices).watch();
}

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/pages/devices_page.dart';
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
@ -29,11 +32,47 @@ Future<void> main() async {
], child: const AbawoBtApp()));
}
class AbawoBtApp extends ConsumerWidget {
class AbawoBtApp extends ConsumerStatefulWidget {
const AbawoBtApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<AbawoBtApp> createState() => _AbawoBtAppState();
}
class _AbawoBtAppState extends ConsumerState<AbawoBtApp>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.hidden ||
state == AppLifecycleState.paused) {
unawaited(_disconnectBluetoothForBackground());
}
}
Future<void> _disconnectBluetoothForBackground() async {
final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth == null) {
return;
}
await bluetooth.stopScan();
await bluetooth.disconnect();
}
@override
Widget build(BuildContext context) {
final themePreference = ref.watch(appThemePreferenceProvider);
return MaterialApp.router(

View File

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:cbor/simple.dart';
const String universalShifterControlServiceUuid =
@ -249,6 +251,9 @@ enum ControlConnectionState {
}
if (raw is String) {
final normalized = raw.toLowerCase();
if (normalized.contains('disconnected')) {
return ControlConnectionState.disconnected;
}
if (normalized.contains('connected')) {
return ControlConnectionState.connected;
}
@ -294,32 +299,21 @@ class TrainerStatus {
static TrainerStatus fromRaw(dynamic raw) {
if (raw is int) {
switch (raw) {
case 1:
return const TrainerStatus(state: TrainerConnectionState.connecting);
case 2:
return const TrainerStatus(state: TrainerConnectionState.pairing);
case 3:
return const TrainerStatus(state: TrainerConnectionState.connected);
case 4:
return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms);
case 5:
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
default:
return const TrainerStatus(state: TrainerConnectionState.idle);
}
return _trainerStatusFromVariant(raw);
}
if (raw is List && raw.isNotEmpty) {
final variant = raw.first;
final value = raw.length > 1 ? raw[1] : null;
if (variant is int && (variant == 5 || variant == 6)) {
if (variant is int && variant == 6) {
return TrainerStatus(
state: TrainerConnectionState.error,
errorCode: value is int ? value : null,
);
}
if (variant is int) {
return _trainerStatusFromVariant(variant);
}
}
if (raw is Map) {
@ -327,13 +321,16 @@ class TrainerStatus {
if (entry != null) {
final key = entry.key;
final value = entry.value;
if ((key is int && (key == 5 || key == 6)) ||
if ((key is int && key == 6) ||
(key is String && key.toLowerCase().contains('error'))) {
return TrainerStatus(
state: TrainerConnectionState.error,
errorCode: value is int ? value : null,
);
}
if (key is int) {
return _trainerStatusFromVariant(key);
}
}
}
@ -362,6 +359,26 @@ class TrainerStatus {
return const TrainerStatus(state: TrainerConnectionState.idle);
}
static TrainerStatus _trainerStatusFromVariant(int variant) {
switch (variant) {
case 1:
return const TrainerStatus(state: TrainerConnectionState.connecting);
case 2:
return const TrainerStatus(state: TrainerConnectionState.pairing);
case 3:
return const TrainerStatus(state: TrainerConnectionState.connected);
case 4:
return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms);
case 5:
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
case 6:
return const TrainerStatus(state: TrainerConnectionState.error);
default:
return const TrainerStatus(state: TrainerConnectionState.idle);
}
}
}
class CentralStatus {
@ -384,16 +401,26 @@ class CentralStatus {
String get statusLine =>
'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}';
static CentralStatus disconnected({dynamic raw}) {
return CentralStatus(
control: ControlConnectionState.disconnected,
trainer: const TrainerStatus(state: TrainerConnectionState.idle),
hasSavedBond: false,
connectedTrainerAddr: null,
lastFailure: null,
raw: raw,
);
}
static CentralStatus fromCborBytes(List<int> bytes) {
if (bytes.isEmpty) {
throw const FormatException('Status payload is empty.');
}
final decoded = cbor.decode(bytes);
if (decoded is! Map) {
return CentralStatus(
control: ControlConnectionState.disconnected,
trainer: const TrainerStatus(state: TrainerConnectionState.idle),
hasSavedBond: false,
connectedTrainerAddr: null,
lastFailure: null,
raw: decoded,
throw FormatException(
'Status payload must decode to a CBOR map, got ${decoded.runtimeType}.',
);
}
@ -428,6 +455,9 @@ List<int>? _toByteList(dynamic value) {
if (value == null) {
return null;
}
if (value is Uint8List) {
return value.toList(growable: false);
}
if (value is List) {
return value.whereType<int>().toList(growable: false);
}

View File

@ -5,9 +5,12 @@ import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
import 'package:abawo_bt_app/service/firmware_update_service.dart';
import 'package:abawo_bt_app/service/shifter_service.dart';
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
show DiscoveredDevice;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:nb_utils/nb_utils.dart';
@ -48,11 +51,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
3.27,
];
bool _isReconnecting = false;
bool _wasConnectedToCurrentDevice = false;
bool _isExitingPage = false;
bool _hasRequestedDisconnect = false;
Timer? _reconnectTimeoutTimer;
bool _hasShownPairingRecoveryDialog = false;
bool _isAssignTrainerDialogOpen = false;
bool _isManualReconnectRunning = false;
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription;
@ -124,7 +127,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
@override
void dispose() {
unawaited(_disconnectOnClose());
_reconnectTimeoutTimer?.cancel();
_connectionStatusSubscription?.close();
_statusSubscription?.cancel();
_shifterService?.dispose();
@ -134,15 +136,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
Future<void> _disconnectOnClose() async {
if (_isFirmwareUpdateBusy) {
return;
}
if (_hasRequestedDisconnect) {
return;
}
_hasRequestedDisconnect = true;
_isExitingPage = true;
_reconnectTimeoutTimer?.cancel();
await _firmwareUpdateService?.cancelUpdate();
await _disposeFirmwareUpdateService();
final bluetooth = ref.read(bluetoothProvider).value;
@ -159,55 +163,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
if (isCurrentDevice && status == ConnectionStatus.connected) {
_wasConnectedToCurrentDevice = true;
_startStatusStreamingIfNeeded();
if (_isReconnecting) {
_reconnectTimeoutTimer?.cancel();
setState(() {
_isReconnecting = false;
});
}
return;
}
if (_wasConnectedToCurrentDevice &&
!_isReconnecting &&
status == ConnectionStatus.disconnected &&
!_isFirmwareUpdateBusy) {
_startReconnect();
}
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
!_isFirmwareUpdateBusy) {
_stopStatusStreaming();
}
}
Future<void> _startReconnect() async {
if (!mounted || _isExitingPage || _isReconnecting) {
return;
Future<void> _startStatusStreamingIfNeeded() async {
bool isCurrentDeviceConnected(BluetoothController bluetooth) {
final connectionState = bluetooth.currentConnectionState;
return connectionState.$1 == ConnectionStatus.connected &&
connectionState.$2 == widget.deviceAddress;
}
setState(() {
_isReconnecting = true;
});
final bluetooth = ref.read(bluetoothProvider).value;
await bluetooth?.connectById(widget.deviceAddress);
_reconnectTimeoutTimer?.cancel();
_reconnectTimeoutTimer = Timer(const Duration(seconds: 10), () {
if (!mounted || !_isReconnecting || _isExitingPage) {
if (_shifterService != null) {
final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
return;
}
_terminateConnectionAndGoHome(
'Connection lost. Could not reconnect in time.',
);
});
}
Future<void> _startStatusStreamingIfNeeded() async {
if (_shifterService != null) {
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
if (!mounted) {
return;
@ -227,16 +204,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} else {
bluetooth = await ref.read(bluetoothProvider.future);
}
if (!isCurrentDeviceConnected(bluetooth)) {
return;
}
final service = ShifterService(
bluetooth: bluetooth,
buttonDeviceId: widget.deviceAddress,
);
final initialStatusResult = await service.readStatus();
if (mounted && initialStatusResult.isOk()) {
_recordStatus(initialStatusResult.unwrap());
if (!mounted) {
await service.dispose();
return;
}
if (initialStatusResult.isErr()) {
await service.dispose();
await _showPairingRecoveryDialog();
return;
}
_recordStatus(initialStatusResult.unwrap());
_statusSubscription = service.statusStream.listen((status) {
if (!mounted) {
return;
@ -251,6 +240,15 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
unawaited(_loadGearRatios());
}
Future<void> _showPairingRecoveryDialog() async {
if (!mounted || _hasShownPairingRecoveryDialog) {
return;
}
_hasShownPairingRecoveryDialog = true;
await showBluetoothPairingRecoveryDialog(context);
}
void _recordStatus(CentralStatus status) {
setState(() {
_latestStatus = status;
@ -360,6 +358,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}
Future<void> _connectButtonToBike() async {
if (_isAssignTrainerDialogOpen) {
return;
}
if (_isFirmwareUpdateBusy) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@ -369,10 +371,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return;
}
final selectedBike = await BikeScanDialog.show(
context,
excludedDeviceId: widget.deviceAddress,
);
_isAssignTrainerDialogOpen = true;
final DiscoveredDevice? selectedBike;
try {
selectedBike = await BikeScanDialog.show(
context,
excludedDeviceId: widget.deviceAddress,
);
} finally {
_isAssignTrainerDialogOpen = false;
}
if (selectedBike == null || !mounted) {
return;
}
@ -533,17 +541,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
});
}
Future<void> _cancelFirmwareUpdate() async {
final updater = _firmwareUpdateService;
if (updater == null || !_isFirmwareUpdateBusy) {
return;
}
setState(() {
_firmwareUserMessage = 'Canceling firmware update...';
});
await updater.cancelUpdate();
}
String _dfuPhaseText(DfuUpdateState state) {
switch (state) {
case DfuUpdateState.idle:
@ -579,27 +576,68 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
}
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
await _disconnectOnClose();
if (!mounted) {
Future<void> _manualReconnect() async {
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
return;
}
toast(toastMessage);
context.replace('/devices');
}
setState(() {
_isManualReconnectRunning = true;
});
Future<void> _cancelReconnect() async {
await _terminateConnectionAndGoHome('Reconnect cancelled.');
try {
final bluetooth = await ref.read(bluetoothProvider.future);
final result = await bluetooth.connectById(
widget.deviceAddress,
timeout: const Duration(seconds: 10),
);
if (!mounted) {
return;
}
if (result.isErr()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Reconnect failed. Is the device turned on and in range?',
),
),
);
}
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Reconnect failed: $error')),
);
} finally {
if (mounted) {
setState(() {
_isManualReconnectRunning = false;
});
}
}
}
Future<void> _exitPage() async {
if (_isFirmwareUpdateBusy) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Firmware update is running. Keep this screen open until it completes.'),
),
);
return;
}
await _disconnectOnClose();
if (!mounted) {
return;
}
context.replace('/devices');
context.go('/devices');
}
void _showStatusHistory() {
@ -713,19 +751,18 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
@override
Widget build(BuildContext context) {
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
final currentConnectionStatus = connectionData != null &&
connectionData.$2 == widget.deviceAddress
? connectionData.$1
: ConnectionStatus.disconnected;
final isCurrentConnected = currentConnectionStatus == ConnectionStatus.connected;
final currentConnectionStatus =
connectionData != null && connectionData.$2 == widget.deviceAddress
? connectionData.$1
: ConnectionStatus.disconnected;
final isCurrentConnected =
currentConnectionStatus == ConnectionStatus.connected;
final canSelectFirmware =
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = isCurrentConnected &&
!_isSelectingFirmware &&
!_isFirmwareUpdateBusy &&
_selectedFirmware != null;
final canCancelFirmware = _isFirmwareUpdateBusy;
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, bool? result) {
@ -756,11 +793,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
),
const SizedBox(height: 20),
if (isCurrentConnected) ...[
_TrainerConnectionCard(
status: _latestStatus,
onAssign: _isFirmwareUpdateBusy ? null : _connectButtonToBike,
onShowStatusConsole: _showStatusHistory,
),
const SizedBox(height: 16),
_StatusBanner(
status: _latestStatus,
@ -775,22 +807,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
},
),
const SizedBox(height: 16),
_FirmwareUpdateCard(
selectedFirmware: _selectedFirmware,
progress: _dfuProgress,
isSelecting: _isSelectingFirmware,
isStarting: _isStartingFirmwareUpdate,
canSelect: canSelectFirmware,
canStart: canStartFirmware,
canCancel: canCancelFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
onSelectFirmware: _selectFirmwareFile,
onStartUpdate: _startFirmwareUpdate,
onCancelUpdate: _cancelFirmwareUpdate,
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
_TrainerConnectionCard(
status: _latestStatus,
onAssign:
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
onShowStatusConsole: _showStatusHistory,
),
const SizedBox(height: 16),
Opacity(
@ -873,50 +894,32 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
),
),
),
const SizedBox(height: 16),
_FirmwareUpdateCard(
selectedFirmware: _selectedFirmware,
progress: _dfuProgress,
isSelecting: _isSelectingFirmware,
isStarting: _isStartingFirmwareUpdate,
canSelect: canSelectFirmware,
canStart: canStartFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
onSelectFirmware: _selectFirmwareFile,
onStartUpdate: _startFirmwareUpdate,
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
),
] else ...[
const _DisconnectedDetailCard(),
_DisconnectedDetailCard(
isReconnecting: _isManualReconnectRunning,
onReconnect: _manualReconnect,
onBackToDevices: _exitPage,
),
],
],
),
),
if (_isReconnecting)
Positioned.fill(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.55),
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.55),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'Reconnecting...',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
TextButton(
onPressed: _cancelReconnect,
child: const Text('Cancel'),
),
],
),
),
),
),
),
],
),
),
@ -942,14 +945,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
required this.isStarting,
required this.canSelect,
required this.canStart,
required this.canCancel,
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.ackSequenceHex,
required this.onSelectFirmware,
required this.onStartUpdate,
required this.onCancelUpdate,
});
final DfuV1PreparedFirmware? selectedFirmware;
@ -958,14 +959,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
final bool isStarting;
final bool canSelect;
final bool canStart;
final bool canCancel;
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String ackSequenceHex;
final Future<void> Function() onSelectFirmware;
final Future<void> Function() onStartUpdate;
final Future<void> Function() onCancelUpdate;
bool get _showProgress {
return progress.totalBytes > 0 ||
@ -986,126 +985,123 @@ class _FirmwareUpdateCard extends StatelessWidget {
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.system_update_alt_rounded, color: colorScheme.primary),
const SizedBox(width: 10),
const Text(
'Firmware Update',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
],
),
const SizedBox(height: 8),
Text(
'Select a firmware image, review the transfer state, and start the update when ready.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: canSelect ? onSelectFirmware : null,
icon: isSelecting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: const Text('Select Firmware'),
),
FilledButton.icon(
onPressed: canStart ? onStartUpdate : null,
icon: isStarting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt),
label: const Text('Start Update'),
),
TextButton.icon(
onPressed: canCancel ? onCancelUpdate : null,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('Cancel Update'),
),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
selectedFirmware == null
? 'Selected file: none'
: 'Selected file: ${selectedFirmware!.fileName}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
Icon(Icons.system_update_alt_rounded,
color: colorScheme.primary),
const SizedBox(width: 10),
const Text(
'Firmware Update',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
if (selectedFirmware != null) ...[
const SizedBox(height: 4),
Text(
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: theme.textTheme.bodySmall,
),
],
],
),
),
const SizedBox(height: 14),
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
if (_showProgress) ...[
const SizedBox(height: 10),
LinearProgressIndicator(
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
minHeight: 10,
borderRadius: BorderRadius.circular(999),
),
const SizedBox(height: 6),
Text(
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
style: theme.textTheme.bodySmall,
),
],
if (_showRebootExpectation) ...[
const SizedBox(height: 8),
Text(
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
'Select a firmware image, review the transfer state, and start the update when ready.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
],
if (statusText != null && statusText!.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(
statusText!,
style: theme.textTheme.bodySmall?.copyWith(
color: progress.state == DfuUpdateState.failed
? colorScheme.error
: theme.textTheme.bodySmall?.color,
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: canSelect ? onSelectFirmware : null,
icon: isSelecting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: const Text('Select Firmware'),
),
FilledButton.icon(
onPressed: canStart ? onStartUpdate : null,
icon: isStarting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.system_update_alt),
label: const Text('Start Update'),
),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color:
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedFirmware == null
? 'Selected file: none'
: 'Selected file: ${selectedFirmware!.fileName}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (selectedFirmware != null) ...[
const SizedBox(height: 4),
Text(
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
style: theme.textTheme.bodySmall,
),
],
],
),
),
const SizedBox(height: 14),
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
if (_showProgress) ...[
const SizedBox(height: 10),
LinearProgressIndicator(
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
minHeight: 10,
borderRadius: BorderRadius.circular(999),
),
const SizedBox(height: 6),
Text(
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
style: theme.textTheme.bodySmall,
),
],
if (_showRebootExpectation) ...[
const SizedBox(height: 8),
Text(
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
if (statusText != null && statusText!.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(
statusText!,
style: theme.textTheme.bodySmall?.copyWith(
color: progress.state == DfuUpdateState.failed
? colorScheme.error
: theme.textTheme.bodySmall?.color,
),
),
],
],
],
),
),
),
);
}
@ -1207,12 +1203,10 @@ class _StatusBanner extends StatelessWidget {
Widget _buildDeviceOverviewCard(
BuildContext context,
WidgetRef ref,
String deviceAddress,
{
String deviceAddress, {
required ConnectionStatus connectionStatus,
required CentralStatus? status,
}
) {
}) {
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
return asyncSavedDevices.when(
@ -1301,9 +1295,10 @@ class _DeviceOverviewCard extends StatelessWidget {
children: [
Text(
device.deviceName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
_DetailStatusChip(status: connectionStatus),
@ -1311,7 +1306,8 @@ class _DeviceOverviewCard extends StatelessWidget {
Text(
trainerAddress,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
color:
colorScheme.onSurface.withValues(alpha: 0.68),
),
),
],
@ -1395,7 +1391,8 @@ class _TrainerConnectionCard extends StatelessWidget {
shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.12),
),
child: Icon(Icons.pedal_bike_rounded, color: colorScheme.primary),
child: Icon(Icons.pedal_bike_rounded,
color: colorScheme.primary),
),
const SizedBox(width: 14),
Expanded(
@ -1412,7 +1409,8 @@ class _TrainerConnectionCard extends StatelessWidget {
Text(
trainerText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
color:
colorScheme.onSurface.withValues(alpha: 0.68),
),
),
],
@ -1424,12 +1422,14 @@ class _TrainerConnectionCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
color:
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
Icon(Icons.bluetooth_connected_rounded, color: colorScheme.primary),
Icon(Icons.bluetooth_connected_rounded,
color: colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
@ -1470,21 +1470,86 @@ class _TrainerConnectionCard extends StatelessWidget {
}
class _DisconnectedDetailCard extends StatelessWidget {
const _DisconnectedDetailCard();
const _DisconnectedDetailCard({
required this.isReconnecting,
required this.onReconnect,
required this.onBackToDevices,
});
final bool isReconnecting;
final VoidCallback onReconnect;
final VoidCallback onBackToDevices;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Text(
'This device is not currently connected. Reopen it from Devices to reconnect and manage trainer pairing, firmware, and gear ratios.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.68),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.error.withValues(alpha: 0.12),
),
child: Icon(
Icons.bluetooth_disabled_rounded,
color: colorScheme.error,
),
),
const SizedBox(width: 14),
Expanded(
child: Text(
'No connection',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 14),
Text(
'This device is not currently connected. Turn it on and keep it nearby, then reconnect when you are ready.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: isReconnecting ? null : onReconnect,
icon: isReconnecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth_connected_rounded),
label:
Text(isReconnecting ? 'Reconnecting...' : 'Reconnect'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: isReconnecting ? null : onBackToDevices,
icon: const Icon(Icons.arrow_back_rounded),
label: const Text('Devices'),
),
),
],
),
],
),
),
);
@ -1501,9 +1566,14 @@ class _DetailStatusChip extends StatelessWidget {
final (label, color) = switch (status) {
ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)),
ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)),
ConnectionStatus.disconnecting => ('Disconnecting', const Color(0xFFFFB649)),
ConnectionStatus.disconnected =>
('Disconnected', Theme.of(context).colorScheme.primary),
ConnectionStatus.disconnecting => (
'Disconnecting',
const Color(0xFFFFB649)
),
ConnectionStatus.disconnected => (
'Disconnected',
Theme.of(context).colorScheme.primary
),
};
return Container(

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/database/database.dart';
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
import 'package:abawo_bt_app/util/constants.dart';
import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart';
import 'package:anyhow/anyhow.dart';
@ -63,9 +64,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
return;
}
final isAbawoDevice = isAbawoDeviceIdent(device.manufacturerData);
final isConnectable =
device.serviceUuids.any(isConnectableAbawoDeviceGuid);
final isAbawoDevice = device.serviceUuids.any(isAbawoDeviceGuid);
final isConnectable = device.serviceUuids.any(isConnectableAbawoDeviceGuid);
if (!isAbawoDevice) {
ScaffoldMessenger.of(context).showSnackBar(
@ -122,13 +122,18 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
),
);
} else {
context.push('/device/${device.id}');
context.go('/device/${device.id}');
}
break;
case Err(:final v):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connection unsuccessful:\n${v.toString()}')),
);
final error = v.toString();
if (error.toLowerCase().contains('disconnected')) {
await showBluetoothPairingRecoveryDialog(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connection unsuccessful:\n$error')),
);
}
break;
}
}
@ -224,7 +229,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
.toSet();
return btAsyncValue.when(
loading: () => const Center(child: CircularProgressIndicator()),
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (err, stack) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _ScanMessageCard(
@ -275,15 +281,16 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
return ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
itemCount: filteredResults.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
separatorBuilder: (_, __) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final device = filteredResults[index];
final isAlreadyConnected =
connectedDeviceAddresses.contains(device.id);
final tone = _ScanResultTone.resolve(
isAlreadyConnected: isAlreadyConnected,
isAbawoDevice:
isAbawoDeviceIdent(device.manufacturerData),
isAbawoDevice: hasConnectableAbawoDeviceGuid(
device.serviceUuids),
isConnectable: device.serviceUuids
.any(isConnectableAbawoDeviceGuid),
);

View File

@ -168,6 +168,14 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
}
if (result.isOk()) {
await ref
.read(nConnectedDevicesProvider.notifier)
.updateConnectedDeviceLastConnected(device.id);
if (!mounted) {
return;
}
context.push('/device/${device.deviceAddress}');
} else {
ScaffoldMessenger.of(context).showSnackBar(
@ -256,21 +264,29 @@ class _ActiveDeviceCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (devices.isEmpty) {
return _MessageCard(
title: 'No connected devices yet',
message: 'Your saved shifters will show up here with status and shortcuts.',
actionLabel: 'Connect Device',
onAction: () => context.push('/connect_device'),
);
final shifterDevices = devices
.where(
(device) =>
deviceTypeFromString(device.deviceType) ==
DeviceType.universalShifters,
)
.toList()
..sort((a, b) {
final aLastConnected = a.lastConnectedAt ?? a.createdAt;
final bLastConnected = b.lastConnectedAt ?? b.createdAt;
return bLastConnected.compareTo(aLastConnected);
});
if (shifterDevices.isEmpty) {
return const SizedBox.shrink();
}
final connectedId = connectionData?.$2;
final primaryDevice = connectedId == null
? devices.first
: devices.firstWhere(
? shifterDevices.first
: shifterDevices.firstWhere(
(device) => device.deviceAddress == connectedId,
orElse: () => devices.first,
orElse: () => shifterDevices.first,
);
final isConnected = connectedId == primaryDevice.deviceAddress &&
connectionData?.$1 == ConnectionStatus.connected;
@ -306,10 +322,9 @@ class _ActiveDeviceCard extends StatelessWidget {
children: [
Text(
primaryDevice.deviceName,
style:
Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
_StatusChip(
@ -412,14 +427,16 @@ class _SavedDeviceTile extends StatelessWidget {
shape: BoxShape.circle,
color: isConnected
? colorScheme.primary.withValues(alpha: 0.14)
: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
: colorScheme.surfaceContainerHighest
.withValues(alpha: 0.7),
),
child: Icon(
deviceTypeFromString(device.deviceType) ==
DeviceType.universalShifters
? Icons.bluetooth_rounded
: Icons.memory_rounded,
color: isConnected ? colorScheme.primary : colorScheme.onSurface,
color:
isConnected ? colorScheme.primary : colorScheme.onSurface,
),
),
const SizedBox(width: 14),
@ -447,7 +464,8 @@ class _SavedDeviceTile extends StatelessWidget {
Text(
device.deviceAddress,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.62),
color:
colorScheme.onSurface.withValues(alpha: 0.62),
),
),
],

View File

@ -186,6 +186,14 @@ class _DevicesListState extends ConsumerState<DevicesList> {
}
if (result.isOk()) {
await ref
.read(nConnectedDevicesProvider.notifier)
.updateConnectedDeviceLastConnected(device.id);
if (!context.mounted) {
return;
}
context.go('/device/${device.deviceAddress}');
} else {
ScaffoldMessenger.of(context).showSnackBar(

View File

@ -629,7 +629,7 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
final connectResult =
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
if (connectResult.isErr()) {
return bail(connectResult.unwrapErr());
return Err(connectResult.unwrapErr());
}
final currentState = bluetoothController.currentConnectionState;
@ -663,7 +663,7 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
try {
final statusResult = await shifterService.readStatus().timeout(timeout);
if (statusResult.isErr()) {
return bail(statusResult.unwrapErr());
return Err(statusResult.unwrapErr());
}
return Ok(null);
} on TimeoutException {

View File

@ -3,6 +3,9 @@ import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:anyhow/anyhow.dart';
import 'package:logging/logging.dart';
final _log = Logger('ShifterService');
class ShifterService {
ShifterService({
@ -83,7 +86,7 @@ class ShifterService {
universalShifterGearRatiosCharacteristicUuid,
);
if (readRes.isErr()) {
return bail(readRes.unwrapErr());
return Err(readRes.unwrapErr());
}
final raw = readRes.unwrap();
@ -159,7 +162,7 @@ class ShifterService {
universalShifterStatusCharacteristicUuid,
);
if (readRes.isErr()) {
return bail(readRes.unwrapErr());
return Err(readRes.unwrapErr());
}
try {
@ -240,25 +243,34 @@ class ShifterService {
return;
}
_statusSubscription = _requireBluetooth
.subscribeToCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterStatusCharacteristicUuid,
)
.listen(
(data) {
try {
final status = CentralStatus.fromCborBytes(data);
_statusController.add(status);
} catch (_) {
// Ignore malformed payloads but keep stream alive.
}
},
onError: (_) {
// Keep UI running; reconnection logic is handled elsewhere.
},
);
try {
_statusSubscription = _requireBluetooth
.subscribeToCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterStatusCharacteristicUuid,
)
.listen(
(data) {
try {
final status = CentralStatus.fromCborBytes(data);
_statusController.add(status);
} catch (error, st) {
_log.warning(
'Failed to decode status notification from $buttonDeviceId: '
'bytes=${_formatBytes(data)}',
error,
st,
);
}
},
onError: (Object error, StackTrace st) {
_log.warning('Status notification stream failed', error, st);
},
);
} catch (error, st) {
_log.warning('Could not start status notifications', error, st);
}
}
Future<void> stopStatusNotifications() async {
@ -286,6 +298,12 @@ class ShifterService {
double _decodeGearRatio(int raw) {
return raw / 64.0;
}
String _formatBytes(List<int> bytes) {
return bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(' ');
}
}
abstract interface class DfuPreflightBluetoothAdapter {

View File

@ -0,0 +1,44 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const MethodChannel _settingsChannel = MethodChannel('abawo/settings');
Future<bool> openBluetoothSettings() async {
if (!Platform.isAndroid) {
return false;
}
try {
return await _settingsChannel.invokeMethod<bool>('openBluetoothSettings') ??
false;
} on PlatformException {
return false;
}
}
Future<void> showBluetoothPairingRecoveryDialog(BuildContext context) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Bluetooth pairing may be broken'),
content: const Text(
'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nOpen Bluetooth settings, remove/forget this device, then come back and connect again.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Not now'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
await openBluetoothSettings();
},
child: const Text('Open Bluetooth settings'),
),
],
),
);
}

View File

@ -22,6 +22,10 @@ bool isConnectableAbawoDeviceGuid(Uuid guid) {
return isAbawoUniversalShiftersDeviceGuid(guid);
}
bool hasConnectableAbawoDeviceGuid(List<Uuid> guid) => guid
.map((id) => isConnectableAbawoDeviceGuid(id))
.fold(false, (v, e) => v || e);
bool isAbawoDeviceIdent(List<int> manuData) {
if (manuData.length < abawoManuIdentData.length) return false;
for (int i = 0; i < abawoManuIdentData.length; i++) {

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:flutter/material.dart';
@ -29,19 +31,36 @@ class BikeScanDialog extends ConsumerStatefulWidget {
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
bool _showAll = false;
bool _isStartingScan = true;
String? _scanError;
BluetoothController? _controller;
@override
void initState() {
super.initState();
_startScan();
unawaited(_startScan());
}
Future<void> _startScan() async {
final controller = await ref.read(bluetoothProvider.future);
_controller = controller;
await controller.stopScan();
await controller.startScan();
setState(() {
_isStartingScan = true;
_scanError = null;
});
try {
final controller = await ref.read(bluetoothProvider.future);
_controller = controller;
await controller.stopScan();
await controller.startScan();
} catch (error) {
_scanError = error.toString();
} finally {
if (mounted) {
setState(() {
_isStartingScan = false;
});
}
}
}
@override
@ -73,6 +92,7 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
children: [
_DialogHeader(
showAll: _showAll,
isScanning: _isStartingScan,
onChanged: (value) {
setState(() {
_showAll = value;
@ -81,141 +101,163 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
onRescan: _startScan,
),
Expanded(
child: StreamBuilder<List<DiscoveredDevice>>(
stream: controller.scanResultsStream,
initialData: controller.scanResults,
builder: (context, snapshot) {
final devices = _filteredDevices(snapshot.data ?? const []);
if (devices.isEmpty) {
return const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: Text(
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
textAlign: TextAlign.center,
),
child: _scanError != null
? _ScanMessage(
message: 'Could not start trainer scan: $_scanError',
action: TextButton.icon(
onPressed: _startScan,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
);
}
)
: StreamBuilder<List<DiscoveredDevice>>(
stream: controller.scanResultsStream,
initialData: controller.scanResults,
builder: (context, snapshot) {
if (_isStartingScan &&
(snapshot.data == null ||
snapshot.data!.isEmpty)) {
return const Center(
child: CircularProgressIndicator());
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: devices.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final device = devices[index];
final isFtms =
device.serviceUuids.contains(Uuid.parse(ftmsServiceUuid));
return Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(22),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () => Navigator.of(context).pop(device),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
final devices =
_filteredDevices(snapshot.data ?? const []);
if (devices.isEmpty) {
return const _ScanMessage(
message:
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
itemCount: devices.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final device = devices[index];
final isFtms = device.serviceUuids
.contains(Uuid.parse(ftmsServiceUuid));
return Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.55),
),
),
child: Row(
children: [
Container(
width: 50,
height: 50,
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: () =>
Navigator.of(context).pop(device),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.55),
),
),
child: Icon(
Icons.pedal_bike_rounded,
color: Theme.of(context)
.colorScheme
.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
child: Row(
children: [
Text(
device.name.isEmpty
? 'Unknown Device'
: device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w700,
),
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.12),
),
child: Icon(
Icons.pedal_bike_rounded,
color: Theme.of(context)
.colorScheme
.primary,
),
),
const SizedBox(height: 4),
Text(
isFtms ? 'FTMS' : 'Nearby trainer',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
fontWeight: FontWeight.w600,
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
device.name.isEmpty
? 'Unknown Device'
: device.name,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight:
FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
isFtms
? 'FTMS'
: 'Nearby trainer',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(context)
.colorScheme
.primary,
fontWeight:
FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
device.id,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(
alpha: 0.62),
),
),
],
),
),
const SizedBox(height: 4),
Text(
device.id,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.62),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: [
_RssiBadge(rssi: device.rssi),
const SizedBox(height: 12),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.55),
),
],
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_RssiBadge(rssi: device.rssi),
const SizedBox(height: 12),
Icon(
Icons.chevron_right_rounded,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.55),
),
],
),
],
),
),
),
);
},
);
},
),
),
);
},
);
},
),
),
],
);
@ -242,11 +284,13 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
class _DialogHeader extends StatelessWidget {
const _DialogHeader({
required this.showAll,
required this.isScanning,
required this.onChanged,
required this.onRescan,
});
final bool showAll;
final bool isScanning;
final ValueChanged<bool> onChanged;
final VoidCallback onRescan;
@ -265,9 +309,10 @@ class _DialogHeader extends StatelessWidget {
children: [
Text(
'Assign Trainer',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
@ -306,10 +351,22 @@ class _DialogHeader extends StatelessWidget {
],
),
),
OutlinedButton.icon(
onPressed: onRescan,
icon: const Icon(Icons.refresh),
label: const Text('Rescan'),
SizedBox(
width: 132,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
),
onPressed: isScanning ? null : onRescan,
icon: isScanning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: const Text('Rescan'),
),
),
],
),
@ -319,6 +376,38 @@ class _DialogHeader extends StatelessWidget {
}
}
class _ScanMessage extends StatelessWidget {
const _ScanMessage({
required this.message,
this.action,
});
final String message;
final Widget? action;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
message,
textAlign: TextAlign.center,
),
if (action != null) ...[
const SizedBox(height: 12),
SizedBox(width: 132, child: action!),
],
],
),
),
);
}
}
class _RssiBadge extends StatelessWidget {
const _RssiBadge({required this.rssi});

View File

@ -165,22 +165,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
),
if (_isEditing) ...[
TextButton(
onPressed: _isSaving ? null : _onCancel,
child: const Text('Cancel'),
),
FilledButton(
onPressed: _isSaving ? null : _onSave,
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
] else
if (!_isEditing)
IconButton(
tooltip: 'Edit ratios',
onPressed: (widget.isLoading || widget.errorText != null)
@ -191,6 +176,11 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
],
),
),
if (_isEditing)
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: _buildEditActions(),
),
if (widget.isLoading)
const Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 24),
@ -365,6 +355,9 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
duration: _animDuration,
switchInCurve: _animCurve,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: (currentChild, previousChildren) {
return currentChild ?? const SizedBox.shrink();
},
transitionBuilder: _snappyTransition,
child: Column(
key: ValueKey('editors-$_gearLayoutVersion'),
@ -373,6 +366,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 4, 14, 14),
child: _buildEditActions(),
),
],
],
],
@ -381,6 +378,32 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
);
}
Widget _buildEditActions() {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isSaving ? null : _onCancel,
child: const Text('Cancel'),
),
const SizedBox(width: 8),
FilledButton(
style: FilledButton.styleFrom(
minimumSize: const Size(88, 52),
),
onPressed: _isSaving ? null : _onSave,
child: _isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Done'),
),
],
);
}
Widget _snappyTransition(Widget child, Animation<double> animation) {
final curved = CurvedAnimation(parent: animation, curve: _animCurve);
return FadeTransition(