feat: working connection, conn setting, and gear ratio setting for universal shifters

This commit is contained in:
2026-02-22 23:05:12 +01:00
parent f92d6d04f5
commit dcb1e6596e
93 changed files with 10538 additions and 668 deletions

View File

@ -1,131 +1,358 @@
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:flutter_reactive_ble/flutter_reactive_ble.dart'
hide ConnectionStatus, Result, Logger;
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
@Riverpod(keepAlive: true)
FlutterReactiveBle reactiveBle(Ref ref) {
ref.keepAlive();
return FlutterReactiveBle();
}
@Riverpod(keepAlive: true)
Future<BluetoothController> bluetooth(Ref ref) async {
final controller = BluetoothController();
log.info(await controller.init());
ref.keepAlive();
final controller = BluetoothController(ref.read(reactiveBleProvider));
await controller.init();
return controller;
}
@Riverpod(keepAlive: true)
Stream<(ConnectionStatus, String?)> connectionStatus(Ref ref) {
final asyncController = ref.watch(bluetoothProvider);
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 = [];
BluetoothController(this._ble);
static const int defaultMtu = 64;
final FlutterReactiveBle _ble;
StreamSubscription<BleStatus>? _bleStatusSubscription;
StreamSubscription<DiscoveredDevice>? _scanResultsSubscription;
Timer? _scanTimeout;
final Map<String, DiscoveredDevice> _scanResultsById = {};
final _scanResultsSubject =
BehaviorSubject<List<DiscoveredDevice>>.seeded(const []);
final _isScanningSubject = BehaviorSubject<bool>.seeded(false);
String? _connectedDeviceId;
StreamSubscription<ConnectionStateUpdate>? _connectionStateSubscription;
final _connectionStateSubject =
BehaviorSubject<(ConnectionStatus, String?)>.seeded(
(ConnectionStatus.disconnected, null));
Stream<(ConnectionStatus, String?)> get connectionStateStream =>
_connectionStateSubject.stream;
(ConnectionStatus, String?) get currentConnectionState =>
_connectionStateSubject.value;
Stream<List<DiscoveredDevice>> get scanResultsStream =>
_scanResultsSubject.stream;
Stream<bool> get isScanningStream => _isScanningSubject.stream;
List<DiscoveredDevice> get scanResults => _scanResultsSubject.value;
Future<Result<void>> init() async {
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
}
_bleStatusSubscription ??= _ble.statusStream.listen((status) {
log.info('BLE status: $status');
});
if (!kIsWeb && Platform.isAndroid) {
await FlutterBluePlus.turnOn();
}
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,
List<Uuid>? withServices,
Duration? timeout,
ScanMode scanMode = ScanMode.lowLatency,
bool requireLocationServicesEnabled = true,
}) async {
if (_isScanningSubject.value) {
return Ok(null);
}
try {
// Wait for Bluetooth to be enabled
await FlutterBluePlus.adapterState
.where((val) => val == BluetoothAdapterState.on)
.first;
final status = _ble.status;
if (status != BleStatus.ready) {
await _ble.statusStream
.where((value) => value == BleStatus.ready)
.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');
},
);
_scanTimeout?.cancel();
_scanResultsById.clear();
_scanResultsSubject.add(const []);
_isScanningSubject.add(true);
// Clean up subscription when scanning completes
FlutterBluePlus.cancelWhenScanComplete(_scanResultsSubscription!);
_scanResultsSubscription = _ble
.scanForDevices(
withServices: withServices ?? const [],
scanMode: scanMode,
requireLocationServicesEnabled: requireLocationServicesEnabled,
)
.listen((device) {
_scanResultsById[device.id] = device;
_scanResultsSubject
.add(_scanResultsById.values.toList(growable: false));
}, onError: (Object error, StackTrace st) {
log.severe('Scan error: $error', error, st);
_isScanningSubject.add(false);
});
// Start scanning with optional parameters
await FlutterBluePlus.startScan(
withServices: withServices ?? [],
withNames: withNames ?? [],
timeout: timeout,
);
if (timeout != null) {
_scanTimeout = Timer(timeout, () {
unawaited(stopScan());
});
}
return Ok(null);
} catch (e) {
_isScanningSubject.add(false);
return bail('Failed to start Bluetooth scan: $e');
}
}
/// Stop an ongoing Bluetooth scan
Future<Result<void>> stopScan() async {
try {
await FlutterBluePlus.stopScan();
_scanTimeout?.cancel();
_scanTimeout = null;
await _scanResultsSubscription?.cancel();
_scanResultsSubscription = null;
_isScanningSubject.add(false);
return Ok(null);
} catch (e) {
_isScanningSubject.add(false);
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;
await isScanningStream.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;
Future<bool> get isScanning async => isScanningStream.first;
Future<Result<void>> connect(DiscoveredDevice device,
{Duration? timeout}) async {
return connectById(device.id, timeout: timeout ?? Duration(seconds: 10));
}
Future<Result<void>> connectById(
String deviceId, {
Duration timeout = const Duration(seconds: 10),
Map<Uuid, List<Uuid>>? servicesWithCharacteristicsToDiscover,
}) async {
final currentState = currentConnectionState;
final currentDeviceId = currentState.$2;
if (deviceId == currentDeviceId &&
(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);
}
if (currentDeviceId != null && deviceId != currentDeviceId) {
final disconnectResult = await disconnect();
if (disconnectResult.isErr()) {
return disconnectResult
.context('Failed to disconnect from previous device');
}
await Future.delayed(const Duration(milliseconds: 300));
}
try {
await _connectionStateSubscription?.cancel();
_updateConnectionState(ConnectionStatus.connecting, deviceId);
_connectionStateSubscription = _ble
.connectToDevice(
id: deviceId,
connectionTimeout: timeout,
servicesWithCharacteristicsToDiscover:
servicesWithCharacteristicsToDiscover,
)
.listen((update) {
switch (update.connectionState) {
case DeviceConnectionState.connected:
_connectedDeviceId = deviceId;
_updateConnectionState(ConnectionStatus.connected, deviceId);
unawaited(_requestMtuOnConnect(deviceId));
break;
case DeviceConnectionState.connecting:
_updateConnectionState(ConnectionStatus.connecting, deviceId);
break;
case DeviceConnectionState.disconnecting:
_updateConnectionState(ConnectionStatus.disconnecting, deviceId);
break;
case DeviceConnectionState.disconnected:
_cleanUpConnection();
break;
}
}, onError: (Object error, StackTrace st) {
log.severe('Failed to connect to $deviceId: $error', error, st);
_cleanUpConnection();
});
return Ok(null);
} catch (e) {
_cleanUpConnection();
return bail('Failed to connect to $deviceId: $e');
}
}
Future<Result<void>> disconnect() async {
final deviceIdToDisconnect =
_connectedDeviceId ?? _connectionStateSubject.value.$2;
if (deviceIdToDisconnect == null) {
_cleanUpConnection();
return Ok(null);
}
_updateConnectionState(
ConnectionStatus.disconnecting, deviceIdToDisconnect);
try {
await _connectionStateSubscription?.cancel();
_connectionStateSubscription = null;
_cleanUpConnection();
return Ok(null);
} catch (e) {
_cleanUpConnection();
return bail('Failed to disconnect from $deviceIdToDisconnect: $e');
}
}
Future<Result<List<int>>> readCharacteristic(
String deviceId,
String serviceUuid,
String characteristicUuid,
) async {
try {
final characteristic = QualifiedCharacteristic(
serviceId: Uuid.parse(serviceUuid),
characteristicId: Uuid.parse(characteristicUuid),
deviceId: deviceId,
);
final value = await _ble.readCharacteristic(characteristic);
return Ok(value);
} catch (e) {
return bail('Error reading characteristic: $e');
}
}
Future<Result<void>> writeCharacteristic(
String deviceId,
String serviceUuid,
String characteristicUuid,
List<int> value, {
bool withResponse = true,
}) async {
try {
final characteristic = QualifiedCharacteristic(
serviceId: Uuid.parse(serviceUuid),
characteristicId: Uuid.parse(characteristicUuid),
deviceId: deviceId,
);
if (withResponse) {
await _ble.writeCharacteristicWithResponse(
characteristic,
value: value,
);
} else {
await _ble.writeCharacteristicWithoutResponse(
characteristic,
value: value,
);
}
return Ok(null);
} catch (e) {
return bail('Error writing characteristic: $e');
}
}
Future<Result<void>> requestMtu(String deviceId,
{int mtu = defaultMtu}) async {
try {
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
log.info(
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
return Ok(null);
} catch (e) {
return bail('Error requesting MTU $mtu for $deviceId: $e');
}
}
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()}');
}
}
Stream<List<int>> subscribeToCharacteristic(
String deviceId,
String serviceUuid,
String characteristicUuid,
) {
final characteristic = QualifiedCharacteristic(
serviceId: Uuid.parse(serviceUuid),
characteristicId: Uuid.parse(characteristicUuid),
deviceId: deviceId,
);
return _ble.subscribeToCharacteristic(characteristic);
}
void _updateConnectionState(ConnectionStatus status, String? deviceId) {
if (_connectionStateSubject.value.$1 == status &&
_connectionStateSubject.value.$2 == deviceId) {
return;
}
_connectionStateSubject.add((status, deviceId));
log.fine(
'Connection state updated: $status, device: ${deviceId ?? 'none'}');
}
void _cleanUpConnection() {
_connectedDeviceId = null;
_updateConnectionState(ConnectionStatus.disconnected, null);
}
Future<Result<void>> dispose() async {
_scanTimeout?.cancel();
await _scanResultsSubscription?.cancel();
await _btStateSubscription?.cancel();
await _bleStatusSubscription?.cancel();
await disconnect();
await _scanResultsSubject.close();
await _isScanningSubject.close();
await _connectionStateSubject.close();
return Ok(null);
}
}

View File

@ -6,7 +6,24 @@ part of 'bluetooth.dart';
// RiverpodGenerator
// **************************************************************************
String _$bluetoothHash() => r'5e9c37c57e723b84dd08fd8763e7c445b3a4dbf3';
String _$bluetoothHash() => r'f1fb75c72a7a473fc545baea6bedfdf4a21ab26b';
String _$reactiveBleHash() => r'9c4d4f37f7a0da1741b42d6a4c3f0f00c2c07f3c';
/// See also [reactiveBle].
@ProviderFor(reactiveBle)
final reactiveBleProvider = AutoDisposeProvider<FlutterReactiveBle>.internal(
reactiveBle,
name: r'reactiveBleProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$reactiveBleHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ReactiveBleRef = AutoDisposeProviderRef<FlutterReactiveBle>;
/// See also [bluetooth].
@ProviderFor(bluetooth)
@ -23,5 +40,24 @@ final bluetoothProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef BluetoothRef = AutoDisposeFutureProviderRef<BluetoothController>;
String _$connectionStatusHash() => r'4dba587ef7703dabca4b2b9800e0798decfe2977';
/// See also [connectionStatus].
@ProviderFor(connectionStatus)
final connectionStatusProvider =
AutoDisposeStreamProvider<(ConnectionStatus, String?)>.internal(
connectionStatus,
name: r'connectionStatusProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$connectionStatusHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ConnectionStatusRef
= AutoDisposeStreamProviderRef<(ConnectionStatus, String?)>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -0,0 +1,627 @@
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

@ -0,0 +1 @@

143
lib/database/database.dart Normal file
View File

@ -0,0 +1,143 @@
import 'package:anyhow/anyhow.dart';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'database.g.dart';
@riverpod
class NConnectedDevices extends _$NConnectedDevices {
@override
Future<List<ConnectedDevice>> build() async {
final db = await ref.watch(databaseProvider);
return await db.getAllConnectedDevices();
}
Future<Result<int>> addConnectedDevice(
ConnectedDevicesCompanion device) async {
final db = await ref.watch(databaseProvider);
final res = await db.addConnectedDevice(device);
if (res.isOk()) {
ref.invalidateSelf();
}
return res;
}
Future<Result<void>> deleteConnectedDevice(int id) async {
final db = await ref.watch(databaseProvider);
final res = await db.deleteConnectedDevice(id);
if (res.isOk()) {
ref.invalidateSelf();
}
return res;
}
}
/// Provider for the [AppDatabase] instance
final databaseProvider = Provider<AppDatabase>((ref) {
final database = AppDatabase();
ref.onDispose(() => database.close());
return database;
});
/// Provider for all connected devices as a stream
final connectedDevicesStreamProvider =
StreamProvider<List<ConnectedDevice>>((ref) {
final database = ref.watch(databaseProvider);
return database.getAllConnectedDevicesStream();
});
/// Provider for all connected devices as a future
final connectedDevicesProvider = FutureProvider<List<ConnectedDevice>>((ref) {
final database = ref.watch(databaseProvider);
return database.getAllConnectedDevices();
});
class ConnectedDevices extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get deviceName => text()();
TextColumn get deviceAddress => text()();
TextColumn get deviceType => text()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get lastConnectedAt => dateTime().nullable()();
}
@DriftDatabase(tables: [ConnectedDevices])
class AppDatabase extends _$AppDatabase {
// After generating code, this class needs to define a `schemaVersion` getter
// and a constructor telling drift where the database should be stored.
// These are described in the getting started guide: https://drift.simonbinder.eu/setup/
AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 1;
static QueryExecutor _openConnection() {
return driftDatabase(
name: 'my_database',
native: const DriftNativeOptions(
// By default, `driftDatabase` from `package:drift_flutter` stores the
// database files in `getApplicationDocumentsDirectory()`.
databaseDirectory: getApplicationSupportDirectory,
),
// If you need web support, see https://drift.simonbinder.eu/platforms/web/
);
}
Future<List<ConnectedDevice>> getAllConnectedDevices() {
return select(connectedDevices).get();
}
/// Adds a new connected device to the database.
///
/// [device] is a [ConnectedDevicesCompanion] representing the device to be inserted.
///
/// Returns a [Result] indicating success or failure of the device insertion.
/// On successful insertion, returns [Ok]\(rowid\). On failure, returns an error with a descriptive message.
Future<Result<int>> addConnectedDevice(
ConnectedDevicesCompanion device) async {
try {
if (!device.deviceAddress.present) {
return bail('Device address is required to save a connected device.');
}
final exists = await (select(connectedDevices)
..where(
(tbl) => tbl.deviceAddress.equals(device.deviceAddress.value)))
.getSingleOrNull();
if (exists != null) {
return bail('Device ${device.deviceAddress.value} is already added.');
}
final rowid = await into(connectedDevices).insert(device);
return Ok(rowid);
} catch (e, st) {
return bail('Failed to add device: $e', st);
}
}
/// Deletes a connected device from the database by its ID.
///
/// [id] is the ID of the device to be deleted.
///
/// Returns a [Result] indicating success or failure of the deletion.
/// On successful deletion, returns [Ok](number of deleted rows). On failure, returns an error with a descriptive message.
Future<Result<void>> deleteConnectedDevice(int id) async {
try {
final count = await (delete(connectedDevices)
..where((tbl) => tbl.id.equals(id)))
.go();
if (count == 0) {
return bail('Device with id $id not found.');
}
return Ok(());
} catch (e, st) {
return bail('Failed to delete device with id $id: $e', st);
}
}
Stream<List<ConnectedDevice>> getAllConnectedDevicesStream() {
return select(connectedDevices).watch();
}
}

View File

@ -0,0 +1,586 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'database.dart';
// ignore_for_file: type=lint
class $ConnectedDevicesTable extends ConnectedDevices
with TableInfo<$ConnectedDevicesTable, ConnectedDevice> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$ConnectedDevicesTable(this.attachedDatabase, [this._alias]);
static const VerificationMeta _idMeta = const VerificationMeta('id');
@override
late final GeneratedColumn<int> id = GeneratedColumn<int>(
'id', aliasedName, false,
hasAutoIncrement: true,
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _deviceNameMeta =
const VerificationMeta('deviceName');
@override
late final GeneratedColumn<String> deviceName = GeneratedColumn<String>(
'device_name', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _deviceAddressMeta =
const VerificationMeta('deviceAddress');
@override
late final GeneratedColumn<String> deviceAddress = GeneratedColumn<String>(
'device_address', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _deviceTypeMeta =
const VerificationMeta('deviceType');
@override
late final GeneratedColumn<String> deviceType = GeneratedColumn<String>(
'device_type', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _createdAtMeta =
const VerificationMeta('createdAt');
@override
late final GeneratedColumn<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime);
static const VerificationMeta _lastConnectedAtMeta =
const VerificationMeta('lastConnectedAt');
@override
late final GeneratedColumn<DateTime> lastConnectedAt =
GeneratedColumn<DateTime>('last_connected_at', aliasedName, true,
type: DriftSqlType.dateTime, requiredDuringInsert: false);
@override
List<GeneratedColumn> get $columns =>
[id, deviceName, deviceAddress, deviceType, createdAt, lastConnectedAt];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'connected_devices';
@override
VerificationContext validateIntegrity(Insertable<ConnectedDevice> instance,
{bool isInserting = false}) {
final context = VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
if (data.containsKey('device_name')) {
context.handle(
_deviceNameMeta,
deviceName.isAcceptableOrUnknown(
data['device_name']!, _deviceNameMeta));
} else if (isInserting) {
context.missing(_deviceNameMeta);
}
if (data.containsKey('device_address')) {
context.handle(
_deviceAddressMeta,
deviceAddress.isAcceptableOrUnknown(
data['device_address']!, _deviceAddressMeta));
} else if (isInserting) {
context.missing(_deviceAddressMeta);
}
if (data.containsKey('device_type')) {
context.handle(
_deviceTypeMeta,
deviceType.isAcceptableOrUnknown(
data['device_type']!, _deviceTypeMeta));
} else if (isInserting) {
context.missing(_deviceTypeMeta);
}
if (data.containsKey('created_at')) {
context.handle(_createdAtMeta,
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta));
}
if (data.containsKey('last_connected_at')) {
context.handle(
_lastConnectedAtMeta,
lastConnectedAt.isAcceptableOrUnknown(
data['last_connected_at']!, _lastConnectedAtMeta));
}
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
ConnectedDevice map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return ConnectedDevice(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
deviceName: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}device_name'])!,
deviceAddress: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}device_address'])!,
deviceType: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}device_type'])!,
createdAt: attachedDatabase.typeMapping
.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!,
lastConnectedAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, data['${effectivePrefix}last_connected_at']),
);
}
@override
$ConnectedDevicesTable createAlias(String alias) {
return $ConnectedDevicesTable(attachedDatabase, alias);
}
}
class ConnectedDevice extends DataClass implements Insertable<ConnectedDevice> {
final int id;
final String deviceName;
final String deviceAddress;
final String deviceType;
final DateTime createdAt;
final DateTime? lastConnectedAt;
const ConnectedDevice(
{required this.id,
required this.deviceName,
required this.deviceAddress,
required this.deviceType,
required this.createdAt,
this.lastConnectedAt});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['device_name'] = Variable<String>(deviceName);
map['device_address'] = Variable<String>(deviceAddress);
map['device_type'] = Variable<String>(deviceType);
map['created_at'] = Variable<DateTime>(createdAt);
if (!nullToAbsent || lastConnectedAt != null) {
map['last_connected_at'] = Variable<DateTime>(lastConnectedAt);
}
return map;
}
ConnectedDevicesCompanion toCompanion(bool nullToAbsent) {
return ConnectedDevicesCompanion(
id: Value(id),
deviceName: Value(deviceName),
deviceAddress: Value(deviceAddress),
deviceType: Value(deviceType),
createdAt: Value(createdAt),
lastConnectedAt: lastConnectedAt == null && nullToAbsent
? const Value.absent()
: Value(lastConnectedAt),
);
}
factory ConnectedDevice.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return ConnectedDevice(
id: serializer.fromJson<int>(json['id']),
deviceName: serializer.fromJson<String>(json['deviceName']),
deviceAddress: serializer.fromJson<String>(json['deviceAddress']),
deviceType: serializer.fromJson<String>(json['deviceType']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
lastConnectedAt: serializer.fromJson<DateTime?>(json['lastConnectedAt']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'deviceName': serializer.toJson<String>(deviceName),
'deviceAddress': serializer.toJson<String>(deviceAddress),
'deviceType': serializer.toJson<String>(deviceType),
'createdAt': serializer.toJson<DateTime>(createdAt),
'lastConnectedAt': serializer.toJson<DateTime?>(lastConnectedAt),
};
}
ConnectedDevice copyWith(
{int? id,
String? deviceName,
String? deviceAddress,
String? deviceType,
DateTime? createdAt,
Value<DateTime?> lastConnectedAt = const Value.absent()}) =>
ConnectedDevice(
id: id ?? this.id,
deviceName: deviceName ?? this.deviceName,
deviceAddress: deviceAddress ?? this.deviceAddress,
deviceType: deviceType ?? this.deviceType,
createdAt: createdAt ?? this.createdAt,
lastConnectedAt: lastConnectedAt.present
? lastConnectedAt.value
: this.lastConnectedAt,
);
ConnectedDevice copyWithCompanion(ConnectedDevicesCompanion data) {
return ConnectedDevice(
id: data.id.present ? data.id.value : this.id,
deviceName:
data.deviceName.present ? data.deviceName.value : this.deviceName,
deviceAddress: data.deviceAddress.present
? data.deviceAddress.value
: this.deviceAddress,
deviceType:
data.deviceType.present ? data.deviceType.value : this.deviceType,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
lastConnectedAt: data.lastConnectedAt.present
? data.lastConnectedAt.value
: this.lastConnectedAt,
);
}
@override
String toString() {
return (StringBuffer('ConnectedDevice(')
..write('id: $id, ')
..write('deviceName: $deviceName, ')
..write('deviceAddress: $deviceAddress, ')
..write('deviceType: $deviceType, ')
..write('createdAt: $createdAt, ')
..write('lastConnectedAt: $lastConnectedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(
id, deviceName, deviceAddress, deviceType, createdAt, lastConnectedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is ConnectedDevice &&
other.id == this.id &&
other.deviceName == this.deviceName &&
other.deviceAddress == this.deviceAddress &&
other.deviceType == this.deviceType &&
other.createdAt == this.createdAt &&
other.lastConnectedAt == this.lastConnectedAt);
}
class ConnectedDevicesCompanion extends UpdateCompanion<ConnectedDevice> {
final Value<int> id;
final Value<String> deviceName;
final Value<String> deviceAddress;
final Value<String> deviceType;
final Value<DateTime> createdAt;
final Value<DateTime?> lastConnectedAt;
const ConnectedDevicesCompanion({
this.id = const Value.absent(),
this.deviceName = const Value.absent(),
this.deviceAddress = const Value.absent(),
this.deviceType = const Value.absent(),
this.createdAt = const Value.absent(),
this.lastConnectedAt = const Value.absent(),
});
ConnectedDevicesCompanion.insert({
this.id = const Value.absent(),
required String deviceName,
required String deviceAddress,
required String deviceType,
this.createdAt = const Value.absent(),
this.lastConnectedAt = const Value.absent(),
}) : deviceName = Value(deviceName),
deviceAddress = Value(deviceAddress),
deviceType = Value(deviceType);
static Insertable<ConnectedDevice> custom({
Expression<int>? id,
Expression<String>? deviceName,
Expression<String>? deviceAddress,
Expression<String>? deviceType,
Expression<DateTime>? createdAt,
Expression<DateTime>? lastConnectedAt,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (deviceName != null) 'device_name': deviceName,
if (deviceAddress != null) 'device_address': deviceAddress,
if (deviceType != null) 'device_type': deviceType,
if (createdAt != null) 'created_at': createdAt,
if (lastConnectedAt != null) 'last_connected_at': lastConnectedAt,
});
}
ConnectedDevicesCompanion copyWith(
{Value<int>? id,
Value<String>? deviceName,
Value<String>? deviceAddress,
Value<String>? deviceType,
Value<DateTime>? createdAt,
Value<DateTime?>? lastConnectedAt}) {
return ConnectedDevicesCompanion(
id: id ?? this.id,
deviceName: deviceName ?? this.deviceName,
deviceAddress: deviceAddress ?? this.deviceAddress,
deviceType: deviceType ?? this.deviceType,
createdAt: createdAt ?? this.createdAt,
lastConnectedAt: lastConnectedAt ?? this.lastConnectedAt,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (deviceName.present) {
map['device_name'] = Variable<String>(deviceName.value);
}
if (deviceAddress.present) {
map['device_address'] = Variable<String>(deviceAddress.value);
}
if (deviceType.present) {
map['device_type'] = Variable<String>(deviceType.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (lastConnectedAt.present) {
map['last_connected_at'] = Variable<DateTime>(lastConnectedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('ConnectedDevicesCompanion(')
..write('id: $id, ')
..write('deviceName: $deviceName, ')
..write('deviceAddress: $deviceAddress, ')
..write('deviceType: $deviceType, ')
..write('createdAt: $createdAt, ')
..write('lastConnectedAt: $lastConnectedAt')
..write(')'))
.toString();
}
}
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
late final $ConnectedDevicesTable connectedDevices =
$ConnectedDevicesTable(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [connectedDevices];
}
typedef $$ConnectedDevicesTableCreateCompanionBuilder
= ConnectedDevicesCompanion Function({
Value<int> id,
required String deviceName,
required String deviceAddress,
required String deviceType,
Value<DateTime> createdAt,
Value<DateTime?> lastConnectedAt,
});
typedef $$ConnectedDevicesTableUpdateCompanionBuilder
= ConnectedDevicesCompanion Function({
Value<int> id,
Value<String> deviceName,
Value<String> deviceAddress,
Value<String> deviceType,
Value<DateTime> createdAt,
Value<DateTime?> lastConnectedAt,
});
class $$ConnectedDevicesTableFilterComposer
extends Composer<_$AppDatabase, $ConnectedDevicesTable> {
$$ConnectedDevicesTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get deviceName => $composableBuilder(
column: $table.deviceName, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get deviceAddress => $composableBuilder(
column: $table.deviceAddress, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get deviceType => $composableBuilder(
column: $table.deviceType, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnFilters(column));
ColumnFilters<DateTime> get lastConnectedAt => $composableBuilder(
column: $table.lastConnectedAt,
builder: (column) => ColumnFilters(column));
}
class $$ConnectedDevicesTableOrderingComposer
extends Composer<_$AppDatabase, $ConnectedDevicesTable> {
$$ConnectedDevicesTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get deviceName => $composableBuilder(
column: $table.deviceName, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get deviceAddress => $composableBuilder(
column: $table.deviceAddress,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get deviceType => $composableBuilder(
column: $table.deviceType, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get lastConnectedAt => $composableBuilder(
column: $table.lastConnectedAt,
builder: (column) => ColumnOrderings(column));
}
class $$ConnectedDevicesTableAnnotationComposer
extends Composer<_$AppDatabase, $ConnectedDevicesTable> {
$$ConnectedDevicesTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<String> get deviceName => $composableBuilder(
column: $table.deviceName, builder: (column) => column);
GeneratedColumn<String> get deviceAddress => $composableBuilder(
column: $table.deviceAddress, builder: (column) => column);
GeneratedColumn<String> get deviceType => $composableBuilder(
column: $table.deviceType, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
GeneratedColumn<DateTime> get lastConnectedAt => $composableBuilder(
column: $table.lastConnectedAt, builder: (column) => column);
}
class $$ConnectedDevicesTableTableManager extends RootTableManager<
_$AppDatabase,
$ConnectedDevicesTable,
ConnectedDevice,
$$ConnectedDevicesTableFilterComposer,
$$ConnectedDevicesTableOrderingComposer,
$$ConnectedDevicesTableAnnotationComposer,
$$ConnectedDevicesTableCreateCompanionBuilder,
$$ConnectedDevicesTableUpdateCompanionBuilder,
(
ConnectedDevice,
BaseReferences<_$AppDatabase, $ConnectedDevicesTable, ConnectedDevice>
),
ConnectedDevice,
PrefetchHooks Function()> {
$$ConnectedDevicesTableTableManager(
_$AppDatabase db, $ConnectedDevicesTable table)
: super(TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
$$ConnectedDevicesTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
$$ConnectedDevicesTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () =>
$$ConnectedDevicesTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback: ({
Value<int> id = const Value.absent(),
Value<String> deviceName = const Value.absent(),
Value<String> deviceAddress = const Value.absent(),
Value<String> deviceType = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime?> lastConnectedAt = const Value.absent(),
}) =>
ConnectedDevicesCompanion(
id: id,
deviceName: deviceName,
deviceAddress: deviceAddress,
deviceType: deviceType,
createdAt: createdAt,
lastConnectedAt: lastConnectedAt,
),
createCompanionCallback: ({
Value<int> id = const Value.absent(),
required String deviceName,
required String deviceAddress,
required String deviceType,
Value<DateTime> createdAt = const Value.absent(),
Value<DateTime?> lastConnectedAt = const Value.absent(),
}) =>
ConnectedDevicesCompanion.insert(
id: id,
deviceName: deviceName,
deviceAddress: deviceAddress,
deviceType: deviceType,
createdAt: createdAt,
lastConnectedAt: lastConnectedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
));
}
typedef $$ConnectedDevicesTableProcessedTableManager = ProcessedTableManager<
_$AppDatabase,
$ConnectedDevicesTable,
ConnectedDevice,
$$ConnectedDevicesTableFilterComposer,
$$ConnectedDevicesTableOrderingComposer,
$$ConnectedDevicesTableAnnotationComposer,
$$ConnectedDevicesTableCreateCompanionBuilder,
$$ConnectedDevicesTableUpdateCompanionBuilder,
(
ConnectedDevice,
BaseReferences<_$AppDatabase, $ConnectedDevicesTable, ConnectedDevice>
),
ConnectedDevice,
PrefetchHooks Function()>;
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
$$ConnectedDevicesTableTableManager get connectedDevices =>
$$ConnectedDevicesTableTableManager(_db, _db.connectedDevices);
}
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$nConnectedDevicesHash() => r'022e744d950bb37c1016266064639e51ce6031b5';
/// See also [NConnectedDevices].
@ProviderFor(NConnectedDevices)
final nConnectedDevicesProvider = AutoDisposeAsyncNotifierProvider<
NConnectedDevices, List<ConnectedDevice>>.internal(
NConnectedDevices.new,
name: r'nConnectedDevicesProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$nConnectedDevicesHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$NConnectedDevices = AutoDisposeAsyncNotifier<List<ConnectedDevice>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,19 +1,23 @@
import 'package:abawo_bt_app/pages/devices_page.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
import 'package:abawo_bt_app/util/sharedPrefs.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nb_utils/nb_utils.dart';
import 'pages/home_page.dart';
import 'pages/settings_page.dart';
import 'package:abawo_bt_app/pages/device_details_page.dart';
Future<void> main() async {
Logger.root.level = Level.ALL; // defaults to Level.INFO
Logger.root.onRecord.listen((record) {
print('${record.level.name}: ${record.time}: ${record.message}');
});
await RustLib.init();
WidgetsFlutterBinding.ensureInitialized();
await initialize();
final prefs = await SharedPreferences.getInstance();
@ -49,6 +53,7 @@ class AbawoBtApp extends StatelessWidget {
// Configure GoRouter
final _router = GoRouter(
navigatorKey: navigatorKey,
initialLocation: '/',
routes: [
GoRoute(
@ -65,5 +70,42 @@ final _router = GoRouter(
)
],
),
GoRoute(
path: '/device/:deviceAddress',
builder: (context, state) {
final deviceAddress = state.pathParameters['deviceAddress']!;
return DeviceDetailsPage(deviceAddress: deviceAddress);
},
),
],
);
/*
import 'package:flutter/material.dart';
import 'package:abawo_bt_app/src/rust/api/simple.dart';
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
Future<void> main() async {
await RustLib.init();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('flutter_rust_bridge quickstart')),
body: Center(
child: Text(
'Action: Call Rust `greet("Tom")`\nResult: `${greet(name: "Tom")}`'),
),
),
);
}
}
*/

View File

@ -1,3 +1,6 @@
import 'package:abawo_bt_app/util/constants.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' show DeviceIdentifier;
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'bluetooth_device_model.freezed.dart';
@ -12,6 +15,24 @@ enum DeviceType {
other,
}
DeviceType deviceTypeFromUuids(List<Uuid> uuids) {
if (uuids.any((uuid) => isAbawoUniversalShiftersDeviceGuid(uuid))) {
return DeviceType.universalShifters;
}
return DeviceType.other;
}
DeviceType deviceTypeFromString(String type) {
return DeviceType.values.firstWhere(
(e) => e.toString().split('.').last == type,
orElse: () => DeviceType.other,
);
}
String deviceTypeToString(DeviceType type) {
return type.toString().split('.').last;
}
/// Model representing a Bluetooth device
@freezed
abstract class BluetoothDeviceModel with _$BluetoothDeviceModel {
@ -25,23 +46,28 @@ abstract class BluetoothDeviceModel with _$BluetoothDeviceModel {
/// MAC address of the device
required String address,
/// Signal strength indicator (RSSI)
int? rssi,
/// Type of the device
@Default(DeviceType.other) DeviceType type,
/// Whether the device is currently connected
@Default(false) bool isConnected,
/// Additional device information
Map<String, dynamic>? manufacturerData,
/// Service UUIDs advertised by the device
List<String>? serviceUuids,
/// Identifier of the device
@DeviceIdentJsonConverter() required DeviceIdentifier deviceIdent,
}) = _BluetoothDeviceModel;
/// Create a BluetoothDeviceModel from JSON
factory BluetoothDeviceModel.fromJson(Map<String, dynamic> json) =>
_$BluetoothDeviceModelFromJson(json);
}
class DeviceIdentJsonConverter
implements JsonConverter<DeviceIdentifier, String> {
const DeviceIdentJsonConverter();
@override
DeviceIdentifier fromJson(String json) => DeviceIdentifier(json);
@override
String toJson(DeviceIdentifier object) => object.str;
}

View File

@ -24,20 +24,15 @@ mixin _$BluetoothDeviceModel {
/// MAC address of the device
String get address;
/// Signal strength indicator (RSSI)
int? get rssi;
/// Type of the device
DeviceType get type;
/// Whether the device is currently connected
bool get isConnected;
/// Additional device information
Map<String, dynamic>? get manufacturerData;
/// Service UUIDs advertised by the device
List<String>? get serviceUuids;
/// Identifier of the device
@DeviceIdentJsonConverter()
DeviceIdentifier get deviceIdent;
/// Create a copy of BluetoothDeviceModel
/// with the given fields replaced by the non-null parameter values.
@ -58,32 +53,21 @@ mixin _$BluetoothDeviceModel {
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.address, address) || other.address == address) &&
(identical(other.rssi, rssi) || other.rssi == rssi) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.isConnected, isConnected) ||
other.isConnected == isConnected) &&
const DeepCollectionEquality()
.equals(other.manufacturerData, manufacturerData) &&
const DeepCollectionEquality()
.equals(other.serviceUuids, serviceUuids));
(identical(other.deviceIdent, deviceIdent) ||
other.deviceIdent == deviceIdent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
name,
address,
rssi,
type,
isConnected,
const DeepCollectionEquality().hash(manufacturerData),
const DeepCollectionEquality().hash(serviceUuids));
int get hashCode => Object.hash(runtimeType, id, name, address, type,
const DeepCollectionEquality().hash(manufacturerData), deviceIdent);
@override
String toString() {
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)';
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, type: $type, manufacturerData: $manufacturerData, deviceIdent: $deviceIdent)';
}
}
@ -97,11 +81,9 @@ abstract mixin class $BluetoothDeviceModelCopyWith<$Res> {
{String id,
String? name,
String address,
int? rssi,
DeviceType type,
bool isConnected,
Map<String, dynamic>? manufacturerData,
List<String>? serviceUuids});
@DeviceIdentJsonConverter() DeviceIdentifier deviceIdent});
}
/// @nodoc
@ -120,11 +102,9 @@ class _$BluetoothDeviceModelCopyWithImpl<$Res>
Object? id = null,
Object? name = freezed,
Object? address = null,
Object? rssi = freezed,
Object? type = null,
Object? isConnected = null,
Object? manufacturerData = freezed,
Object? serviceUuids = freezed,
Object? deviceIdent = null,
}) {
return _then(_self.copyWith(
id: null == id
@ -139,26 +119,18 @@ class _$BluetoothDeviceModelCopyWithImpl<$Res>
? _self.address
: address // ignore: cast_nullable_to_non_nullable
as String,
rssi: freezed == rssi
? _self.rssi
: rssi // ignore: cast_nullable_to_non_nullable
as int?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as DeviceType,
isConnected: null == isConnected
? _self.isConnected
: isConnected // ignore: cast_nullable_to_non_nullable
as bool,
manufacturerData: freezed == manufacturerData
? _self.manufacturerData
: manufacturerData // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
serviceUuids: freezed == serviceUuids
? _self.serviceUuids
: serviceUuids // ignore: cast_nullable_to_non_nullable
as List<String>?,
deviceIdent: null == deviceIdent
? _self.deviceIdent
: deviceIdent // ignore: cast_nullable_to_non_nullable
as DeviceIdentifier,
));
}
}
@ -170,13 +142,10 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
{required this.id,
this.name,
required this.address,
this.rssi,
this.type = DeviceType.other,
this.isConnected = false,
final Map<String, dynamic>? manufacturerData,
final List<String>? serviceUuids})
: _manufacturerData = manufacturerData,
_serviceUuids = serviceUuids;
@DeviceIdentJsonConverter() required this.deviceIdent})
: _manufacturerData = manufacturerData;
factory _BluetoothDeviceModel.fromJson(Map<String, dynamic> json) =>
_$BluetoothDeviceModelFromJson(json);
@ -192,20 +161,11 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
@override
final String address;
/// Signal strength indicator (RSSI)
@override
final int? rssi;
/// Type of the device
@override
@JsonKey()
final DeviceType type;
/// Whether the device is currently connected
@override
@JsonKey()
final bool isConnected;
/// Additional device information
final Map<String, dynamic>? _manufacturerData;
@ -219,18 +179,10 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
return EqualUnmodifiableMapView(value);
}
/// Service UUIDs advertised by the device
final List<String>? _serviceUuids;
/// Service UUIDs advertised by the device
/// Identifier of the device
@override
List<String>? get serviceUuids {
final value = _serviceUuids;
if (value == null) return null;
if (_serviceUuids is EqualUnmodifiableListView) return _serviceUuids;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@DeviceIdentJsonConverter()
final DeviceIdentifier deviceIdent;
/// Create a copy of BluetoothDeviceModel
/// with the given fields replaced by the non-null parameter values.
@ -256,32 +208,21 @@ class _BluetoothDeviceModel implements BluetoothDeviceModel {
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.address, address) || other.address == address) &&
(identical(other.rssi, rssi) || other.rssi == rssi) &&
(identical(other.type, type) || other.type == type) &&
(identical(other.isConnected, isConnected) ||
other.isConnected == isConnected) &&
const DeepCollectionEquality()
.equals(other._manufacturerData, _manufacturerData) &&
const DeepCollectionEquality()
.equals(other._serviceUuids, _serviceUuids));
(identical(other.deviceIdent, deviceIdent) ||
other.deviceIdent == deviceIdent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
name,
address,
rssi,
type,
isConnected,
const DeepCollectionEquality().hash(_manufacturerData),
const DeepCollectionEquality().hash(_serviceUuids));
int get hashCode => Object.hash(runtimeType, id, name, address, type,
const DeepCollectionEquality().hash(_manufacturerData), deviceIdent);
@override
String toString() {
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, rssi: $rssi, type: $type, isConnected: $isConnected, manufacturerData: $manufacturerData, serviceUuids: $serviceUuids)';
return 'BluetoothDeviceModel(id: $id, name: $name, address: $address, type: $type, manufacturerData: $manufacturerData, deviceIdent: $deviceIdent)';
}
}
@ -297,11 +238,9 @@ abstract mixin class _$BluetoothDeviceModelCopyWith<$Res>
{String id,
String? name,
String address,
int? rssi,
DeviceType type,
bool isConnected,
Map<String, dynamic>? manufacturerData,
List<String>? serviceUuids});
@DeviceIdentJsonConverter() DeviceIdentifier deviceIdent});
}
/// @nodoc
@ -320,11 +259,9 @@ class __$BluetoothDeviceModelCopyWithImpl<$Res>
Object? id = null,
Object? name = freezed,
Object? address = null,
Object? rssi = freezed,
Object? type = null,
Object? isConnected = null,
Object? manufacturerData = freezed,
Object? serviceUuids = freezed,
Object? deviceIdent = null,
}) {
return _then(_BluetoothDeviceModel(
id: null == id
@ -339,26 +276,18 @@ class __$BluetoothDeviceModelCopyWithImpl<$Res>
? _self.address
: address // ignore: cast_nullable_to_non_nullable
as String,
rssi: freezed == rssi
? _self.rssi
: rssi // ignore: cast_nullable_to_non_nullable
as int?,
type: null == type
? _self.type
: type // ignore: cast_nullable_to_non_nullable
as DeviceType,
isConnected: null == isConnected
? _self.isConnected
: isConnected // ignore: cast_nullable_to_non_nullable
as bool,
manufacturerData: freezed == manufacturerData
? _self._manufacturerData
: manufacturerData // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
serviceUuids: freezed == serviceUuids
? _self._serviceUuids
: serviceUuids // ignore: cast_nullable_to_non_nullable
as List<String>?,
deviceIdent: null == deviceIdent
? _self.deviceIdent
: deviceIdent // ignore: cast_nullable_to_non_nullable
as DeviceIdentifier,
));
}
}

View File

@ -12,14 +12,11 @@ _BluetoothDeviceModel _$BluetoothDeviceModelFromJson(
id: json['id'] as String,
name: json['name'] as String?,
address: json['address'] as String,
rssi: (json['rssi'] as num?)?.toInt(),
type: $enumDecodeNullable(_$DeviceTypeEnumMap, json['type']) ??
DeviceType.other,
isConnected: json['isConnected'] as bool? ?? false,
manufacturerData: json['manufacturerData'] as Map<String, dynamic>?,
serviceUuids: (json['serviceUuids'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
deviceIdent: const DeviceIdentJsonConverter()
.fromJson(json['deviceIdent'] as String),
);
Map<String, dynamic> _$BluetoothDeviceModelToJson(
@ -28,11 +25,10 @@ Map<String, dynamic> _$BluetoothDeviceModelToJson(
'id': instance.id,
'name': instance.name,
'address': instance.address,
'rssi': instance.rssi,
'type': _$DeviceTypeEnumMap[instance.type]!,
'isConnected': instance.isConnected,
'manufacturerData': instance.manufacturerData,
'serviceUuids': instance.serviceUuids,
'deviceIdent':
const DeviceIdentJsonConverter().toJson(instance.deviceIdent),
};
const _$DeviceTypeEnumMap = {

View File

@ -0,0 +1,299 @@
import 'package:cbor/simple.dart';
const String universalShifterControlServiceUuid =
'0993826f-0ee4-4b37-9614-d13ecba4ffc2';
const String universalShifterStatusCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40000';
const String universalShifterConnectToAddrCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40001';
const String universalShifterCommandCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40005';
const String universalShifterGearRatiosCharacteristicUuid =
'0993826f-0ee4-4b37-9614-d13ecba40006';
const String ftmsServiceUuid = '00001826-0000-1000-8000-00805f9b34fb';
const int errorSequence = 1;
const int errorFtmsMissing = 2;
const int errorPairingAuth = 3;
const int errorPairingEncrypt = 4;
const int errorFtmsRequiredCharMissing = 5;
class ShifterErrorInfo {
const ShifterErrorInfo({
required this.code,
required this.title,
required this.details,
});
final int code;
final String title;
final String details;
}
ShifterErrorInfo shifterErrorInfo(int code) {
switch (code) {
case errorSequence:
return const ShifterErrorInfo(
code: errorSequence,
title: 'Invalid command sequence',
details:
'The button received Connect without a fresh target address. The app must write connect_to_addr first, then the connect command.',
);
case errorFtmsMissing:
return const ShifterErrorInfo(
code: errorFtmsMissing,
title: 'FTMS service missing',
details:
'The selected bike does not expose the FTMS service (UUID 0x1826), so pairing cannot continue.',
);
case errorPairingAuth:
return const ShifterErrorInfo(
code: errorPairingAuth,
title: 'Pairing authentication failed',
details:
'Bonding authentication with the bike failed. Remove old bonds on both devices and try pairing again nearby.',
);
case errorPairingEncrypt:
return const ShifterErrorInfo(
code: errorPairingEncrypt,
title: 'Pairing/encryption failed',
details:
'The secure link to the bike could not be established. Retry close to the bike and ensure it is pairable.',
);
case errorFtmsRequiredCharMissing:
return const ShifterErrorInfo(
code: errorFtmsRequiredCharMissing,
title: 'Required FTMS characteristic missing',
details:
'The bike has FTMS but is missing required characteristics (for example Indoor Bike Data), so control cannot start.',
);
default:
return ShifterErrorInfo(
code: code,
title: 'Unknown error',
details: 'The button reported an unknown error code ($code).',
);
}
}
enum UniversalShifterCommand {
reset(0x00),
startScan(0x01),
stopScan(0x02),
connectToDevice(0x03),
disconnect(0x04),
turnOff(0x05);
const UniversalShifterCommand(this.value);
final int value;
}
enum ControlConnectionState {
disconnected,
connected;
static ControlConnectionState fromRaw(dynamic raw) {
if (raw is int) {
return raw == 1
? ControlConnectionState.connected
: ControlConnectionState.disconnected;
}
if (raw is String) {
final normalized = raw.toLowerCase();
if (normalized.contains('connected')) {
return ControlConnectionState.connected;
}
}
return ControlConnectionState.disconnected;
}
}
enum TrainerConnectionState {
idle,
connecting,
pairing,
discoveringFtms,
ftmsReady,
error,
}
class TrainerStatus {
const TrainerStatus({required this.state, this.errorCode});
final TrainerConnectionState state;
final int? errorCode;
String get label {
switch (state) {
case TrainerConnectionState.idle:
return 'Idle';
case TrainerConnectionState.connecting:
return 'Connecting';
case TrainerConnectionState.pairing:
return 'Pairing';
case TrainerConnectionState.discoveringFtms:
return 'Discovering FTMS';
case TrainerConnectionState.ftmsReady:
return 'FTMS Ready';
case TrainerConnectionState.error:
return 'Error${errorCode != null ? ' ($errorCode)' : ''}';
}
}
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.discoveringFtms);
case 4:
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
default:
return const TrainerStatus(state: TrainerConnectionState.idle);
}
}
if (raw is List && raw.isNotEmpty) {
final variant = raw.first;
final value = raw.length > 1 ? raw[1] : null;
if (variant is int && variant == 5) {
return TrainerStatus(
state: TrainerConnectionState.error,
errorCode: value is int ? value : null,
);
}
}
if (raw is Map) {
final entry = raw.entries.isNotEmpty ? raw.entries.first : null;
if (entry != null) {
final key = entry.key;
final value = entry.value;
if ((key is int && key == 5) ||
(key is String && key.toLowerCase().contains('error'))) {
return TrainerStatus(
state: TrainerConnectionState.error,
errorCode: value is int ? value : null,
);
}
}
}
if (raw is String) {
final normalized = raw.toLowerCase();
if (normalized.contains('connecting')) {
return const TrainerStatus(state: TrainerConnectionState.connecting);
}
if (normalized.contains('pairing')) {
return const TrainerStatus(state: TrainerConnectionState.pairing);
}
if (normalized.contains('discover')) {
return const TrainerStatus(
state: TrainerConnectionState.discoveringFtms);
}
if (normalized.contains('ready')) {
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
}
if (normalized.contains('error')) {
return const TrainerStatus(state: TrainerConnectionState.error);
}
}
return const TrainerStatus(state: TrainerConnectionState.idle);
}
}
class CentralStatus {
const CentralStatus({
required this.control,
required this.trainer,
required this.hasSavedBond,
required this.connectedTrainerAddr,
required this.lastFailure,
required this.raw,
});
final ControlConnectionState control;
final TrainerStatus trainer;
final bool hasSavedBond;
final List<int>? connectedTrainerAddr;
final int? lastFailure;
final dynamic raw;
String get statusLine =>
'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}';
static CentralStatus fromCborBytes(List<int> bytes) {
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,
);
}
final controlRaw = _readMapValue(decoded, [0, 'control']);
final trainerRaw = _readMapValue(decoded, [1, 'trainer']);
final hasSavedBondRaw = _readMapValue(decoded, [2, 'has_saved_bond']);
final connectedTrainerAddrRaw =
_readMapValue(decoded, [3, 'connected_trainer_addr']);
final lastFailureRaw = _readMapValue(decoded, [4, 'last_failure']);
return CentralStatus(
control: ControlConnectionState.fromRaw(controlRaw),
trainer: TrainerStatus.fromRaw(trainerRaw),
hasSavedBond: hasSavedBondRaw is bool ? hasSavedBondRaw : false,
connectedTrainerAddr: _toByteList(connectedTrainerAddrRaw),
lastFailure: lastFailureRaw is int ? lastFailureRaw : null,
raw: decoded,
);
}
}
dynamic _readMapValue(Map<dynamic, dynamic> map, List<dynamic> keys) {
for (final key in keys) {
if (map.containsKey(key)) {
return map[key];
}
}
return null;
}
List<int>? _toByteList(dynamic value) {
if (value == null) {
return null;
}
if (value is List) {
return value.whereType<int>().toList(growable: false);
}
return null;
}
List<int> parseMacToLittleEndianBytes(String macAddress) {
final compact = macAddress.replaceAll(':', '').replaceAll('-', '');
if (compact.length != 12) {
throw FormatException('Invalid MAC address format: $macAddress');
}
final bytes = <int>[];
for (int i = 0; i < compact.length; i += 2) {
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
}
return bytes.reversed.toList(growable: false);
}
String formatMacAddressFromLittleEndian(List<int> bytes) {
if (bytes.length != 6) {
return 'Unknown';
}
return bytes.reversed
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':');
}

View File

@ -0,0 +1,740 @@
import 'dart:async';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/shifter_service.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_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:nb_utils/nb_utils.dart';
import '../controller/bluetooth.dart';
import '../database/database.dart';
class DeviceDetailsPage extends ConsumerStatefulWidget {
const DeviceDetailsPage({
required this.deviceAddress,
super.key,
});
final String deviceAddress;
@override
ConsumerState<DeviceDetailsPage> createState() => _DeviceDetailsPageState();
}
class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
static const List<double> _keAntRatios = [
0.35,
0.40,
0.47,
0.54,
0.61,
0.69,
0.82,
0.95,
1.13,
1.29,
1.50,
1.71,
1.89,
2.12,
2.40,
2.77,
3.27,
];
bool _isReconnecting = false;
bool _wasConnectedToCurrentDevice = false;
bool _isExitingPage = false;
bool _hasRequestedDisconnect = false;
Timer? _reconnectTimeoutTimer;
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription;
ShifterService? _shifterService;
StreamSubscription<CentralStatus>? _statusSubscription;
CentralStatus? _latestStatus;
final List<_StatusHistoryEntry> _statusHistory = [];
bool _isGearRatiosLoading = false;
bool _hasLoadedGearRatios = false;
String? _gearRatiosError;
List<double> _gearRatios = const [];
@override
void initState() {
super.initState();
_connectionStatusSubscription =
ref.listenManual<AsyncValue<(ConnectionStatus, String?)>>(
connectionStatusProvider,
(_, next) {
final data = next.valueOrNull;
if (data == null) {
return;
}
_onConnectionStatusChanged(data);
},
fireImmediately: true,
);
}
@override
void dispose() {
unawaited(_disconnectOnClose());
_reconnectTimeoutTimer?.cancel();
_connectionStatusSubscription?.close();
_statusSubscription?.cancel();
_shifterService?.dispose();
super.dispose();
}
Future<void> _disconnectOnClose() async {
if (_hasRequestedDisconnect) {
return;
}
_hasRequestedDisconnect = true;
_isExitingPage = true;
_reconnectTimeoutTimer?.cancel();
final bluetooth = ref.read(bluetoothProvider).value;
await bluetooth?.disconnect();
await _stopStatusStreaming();
}
void _onConnectionStatusChanged((ConnectionStatus, String?) data) {
if (!mounted || _isExitingPage) {
return;
}
final (status, connectedDeviceId) = data;
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) {
_startReconnect();
}
if (!isCurrentDevice || status == ConnectionStatus.disconnected) {
_stopStatusStreaming();
}
}
Future<void> _startReconnect() async {
if (!mounted || _isExitingPage || _isReconnecting) {
return;
}
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) {
return;
}
_terminateConnectionAndGoHome(
'Connection lost. Could not reconnect in time.',
);
});
}
Future<void> _startStatusStreamingIfNeeded() async {
if (_shifterService != null) {
if (!_hasLoadedGearRatios && !_isGearRatiosLoading) {
unawaited(_loadGearRatios());
}
return;
}
final asyncBluetooth = ref.read(bluetoothProvider);
final BluetoothController bluetooth;
if (asyncBluetooth.hasValue) {
bluetooth = asyncBluetooth.requireValue;
} else {
bluetooth = await ref.read(bluetoothProvider.future);
}
final service = ShifterService(
bluetooth: bluetooth,
buttonDeviceId: widget.deviceAddress,
);
final initialStatusResult = await service.readStatus();
if (mounted && initialStatusResult.isOk()) {
_recordStatus(initialStatusResult.unwrap());
}
_statusSubscription = service.statusStream.listen((status) {
if (!mounted) {
return;
}
_recordStatus(status);
});
service.startStatusNotifications();
setState(() {
_shifterService = service;
});
unawaited(_loadGearRatios());
}
void _recordStatus(CentralStatus status) {
setState(() {
_latestStatus = status;
_statusHistory.insert(
0,
_StatusHistoryEntry(
timestamp: DateTime.now(),
status: status,
),
);
if (_statusHistory.length > 100) {
_statusHistory.removeRange(100, _statusHistory.length);
}
});
}
Future<void> _stopStatusStreaming() async {
await _statusSubscription?.cancel();
_statusSubscription = null;
await _shifterService?.dispose();
_shifterService = null;
}
Future<void> _loadGearRatios() async {
final shifter = _shifterService;
if (shifter == null || _isGearRatiosLoading) {
return;
}
setState(() {
_isGearRatiosLoading = true;
_gearRatiosError = null;
});
final result = await shifter.readGearRatios();
if (!mounted) {
return;
}
if (result.isErr()) {
setState(() {
_gearRatiosError = 'Failed to read gear ratios: ${result.unwrapErr()}';
_isGearRatiosLoading = false;
_hasLoadedGearRatios = false;
});
return;
}
setState(() {
_gearRatios = result.unwrap();
_isGearRatiosLoading = false;
_hasLoadedGearRatios = true;
_gearRatiosError = null;
});
}
Future<String?> _saveGearRatios(List<double> ratios) async {
final shifter = _shifterService;
if (shifter == null) {
return 'Status channel is not ready yet.';
}
final result = await shifter.writeGearRatios(ratios);
if (result.isErr()) {
return 'Could not save gear ratios: ${result.unwrapErr()}';
}
if (!mounted) {
return null;
}
setState(() {
_gearRatios = List<double>.from(ratios);
_hasLoadedGearRatios = true;
_gearRatiosError = null;
});
return null;
}
Future<void> _connectButtonToBike() async {
final selectedBike = await BikeScanDialog.show(
context,
excludedDeviceId: widget.deviceAddress,
);
if (selectedBike == null || !mounted) {
return;
}
await _startStatusStreamingIfNeeded();
final shifter = _shifterService;
if (shifter == null) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Status channel is not ready yet.')),
);
return;
}
final result = await shifter.connectButtonToBike(selectedBike.id);
if (!mounted) {
return;
}
if (result.isErr()) {
final err = result.unwrapErr();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connect request failed: $err')),
);
toast('Connect request failed.');
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sent connect request for ${selectedBike.id}.')),
);
}
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
await _disconnectOnClose();
if (!mounted) {
return;
}
toast(toastMessage);
context.replace('/');
}
Future<void> _cancelReconnect() async {
await _terminateConnectionAndGoHome('Reconnect cancelled.');
}
Future<void> _exitPage() async {
await _disconnectOnClose();
if (!mounted) {
return;
}
context.replace('/');
}
void _showStatusHistory() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.72,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
const Expanded(
child: Text(
'Status Console',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: _statusHistory.isEmpty
? const Center(child: Text('No status updates yet.'))
: ListView.builder(
itemCount: _statusHistory.length,
itemBuilder: (context, index) {
final item = _statusHistory[index];
final errorCode = _effectiveErrorCode(item.status);
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: Text(
item.status.statusLine,
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
_formatTimestamp(item.timestamp),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
trailing: errorCode == null
? null
: IconButton(
tooltip: 'Explain error',
onPressed: () {
_showErrorInfoDialog(errorCode);
},
icon: const Icon(Icons.info_outline),
),
onTap: errorCode == null
? null
: () {
_showErrorInfoDialog(errorCode);
},
);
},
),
),
],
),
);
},
);
}
String _formatTimestamp(DateTime time) {
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
final s = time.second.toString().padLeft(2, '0');
return '$h:$m:$s';
}
int? _effectiveErrorCode(CentralStatus status) {
if (status.trainer.state == TrainerConnectionState.error) {
return status.trainer.errorCode ?? status.lastFailure;
}
return status.lastFailure;
}
void _showErrorInfoDialog(int errorCode) {
final info = shifterErrorInfo(errorCode);
showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
icon: const Icon(Icons.info_outline),
title: Text('Error ${info.code}: ${info.title}'),
content: Text(info.details),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
final isCurrentConnected = connectionData != null &&
connectionData.$1 == ConnectionStatus.connected &&
connectionData.$2 == widget.deviceAddress;
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, bool? result) {
_exitPage();
},
child: Scaffold(
appBar: AppBar(
title: const Text('Device Details'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _exitPage,
),
),
body: Stack(
fit: StackFit.expand,
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDeviceInfo(context, ref, widget.deviceAddress),
const SizedBox(height: 16),
_buildConnectionStatus(context, ref, widget.deviceAddress),
const SizedBox(height: 16),
if (isCurrentConnected) ...[
_StatusBanner(
status: _latestStatus,
onTap: _showStatusHistory,
onErrorInfoTap: _latestStatus == null
? null
: () {
final code = _effectiveErrorCode(_latestStatus!);
if (code != null) {
_showErrorInfoDialog(code);
}
},
),
const SizedBox(height: 16),
GearRatioEditorCard(
ratios: _gearRatios,
isLoading: _isGearRatiosLoading,
errorText: _gearRatiosError,
onRetry: _loadGearRatios,
onSave: _saveGearRatios,
presets: const [
GearRatioPreset(
name: 'KeAnt Classic',
description:
'17-step baseline from KeAnt cross app gearing.',
ratios: _keAntRatios,
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _connectButtonToBike,
icon: const Icon(Icons.link),
label: const Text('Connect Button to Bike'),
),
),
],
],
),
),
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).cardColor,
borderRadius: BorderRadius.circular(16),
),
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'),
),
],
),
),
),
),
),
],
),
),
);
}
}
class _StatusHistoryEntry {
const _StatusHistoryEntry({
required this.timestamp,
required this.status,
});
final DateTime timestamp;
final CentralStatus status;
}
class _StatusBanner extends StatelessWidget {
const _StatusBanner({
required this.status,
required this.onTap,
this.onErrorInfoTap,
});
final CentralStatus? status;
final VoidCallback onTap;
final VoidCallback? onErrorInfoTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final color = _resolveColor(colorScheme);
final text = status?.statusLine ?? 'Waiting for status updates...';
return Material(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.memory, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (onErrorInfoTap != null &&
status != null &&
(status!.trainer.state == TrainerConnectionState.error ||
status!.lastFailure != null))
IconButton(
tooltip: 'Explain error',
onPressed: onErrorInfoTap,
icon: Icon(Icons.info_outline, color: color),
),
Icon(Icons.chevron_right, color: color),
],
),
),
),
);
}
Color _resolveColor(ColorScheme scheme) {
final current = status;
if (current == null) {
return scheme.primary;
}
if (current.trainer.state == TrainerConnectionState.error) {
return scheme.error;
}
if (current.trainer.state == TrainerConnectionState.ftmsReady) {
return Colors.green;
}
if (current.trainer.state == TrainerConnectionState.connecting ||
current.trainer.state == TrainerConnectionState.pairing ||
current.trainer.state == TrainerConnectionState.discoveringFtms) {
return Colors.orange;
}
return scheme.primary;
}
}
Widget _buildDeviceInfo(
BuildContext context,
WidgetRef ref,
String deviceAddress,
) {
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
return asyncSavedDevices.when(
data: (devices) {
ConnectedDevice? currentDeviceData;
try {
currentDeviceData = devices.firstWhere(
(d) => d.deviceAddress == deviceAddress,
);
} catch (_) {
currentDeviceData = null;
}
if (currentDeviceData == null) {
return Center(
child: Text('Device details not found for $deviceAddress.'),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Name: ${currentDeviceData.deviceName}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Address: ${currentDeviceData.deviceAddress}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text(
'Type: ${currentDeviceData.deviceType}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) =>
Center(child: Text('Error loading device info: $error')),
);
}
Widget _buildConnectionStatus(
BuildContext context,
WidgetRef ref,
String deviceAddress,
) {
final asyncConnectionStatus = ref.watch(connectionStatusProvider);
return asyncConnectionStatus.when(
data: (data) {
final (status, connectedDeviceId) = data;
String statusText;
final isCurrentDeviceConnected =
connectedDeviceId != null && connectedDeviceId == deviceAddress;
if (isCurrentDeviceConnected) {
switch (status) {
case ConnectionStatus.connected:
statusText = 'Status: Connected';
break;
case ConnectionStatus.connecting:
statusText = 'Status: Connecting...';
break;
case ConnectionStatus.disconnecting:
statusText = 'Status: Disconnecting...';
break;
case ConnectionStatus.disconnected:
statusText = 'Status: Disconnected';
break;
}
} else {
statusText = 'Status: Disconnected';
}
return Text(statusText, style: Theme.of(context).textTheme.titleMedium);
},
loading: () => const Text(
'Status: Unknown',
style: TextStyle(fontStyle: FontStyle.italic),
),
error: (error, stackTrace) => Text(
'Status: Error ($error)',
style: const TextStyle(color: Colors.red),
),
);
}

View File

@ -1,11 +1,17 @@
import 'dart:io';
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
import 'package:abawo_bt_app/util/constants.dart';
import 'package:anyhow/anyhow.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:abawo_bt_app/widgets/device_listitem.dart';
import 'package:abawo_bt_app/widgets/scanning_animation.dart'; // Import the new animation widget
import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart'; // Import the new horizontal animation
import 'package:abawo_bt_app/database/database.dart';
import 'package:drift/drift.dart' show Value;
const Duration _scanDuration = Duration(seconds: 10);
@ -18,25 +24,20 @@ class ConnectDevicePage extends ConsumerStatefulWidget {
class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
with TickerProviderStateMixin {
// Use TickerProviderStateMixin for multiple controllers if needed later, good practice
bool _initialScanStarted = false;
// TickerProviderStateMixin is no longer needed as animations are self-contained or handled by StreamBuilder
int _retryScanCounter = 0; // Used to force animation reset
bool _initialScanTriggered = false; // Track if the first scan was requested
bool _showOnlyAbawoDevices = true; // State for filtering devices
late AnimationController
_progressController; // Controller for scan duration progress
late AnimationController
_waveAnimationController; // Controller for wave animation
// Function to start scan safely after controller is ready
void _startScanIfNeeded(BluetoothController controller) {
// Use WidgetsBinding to schedule the scan start after the build phase
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialScanStarted && mounted) {
// Start scan only if it hasn't been triggered yet and the widget is mounted
if (!_initialScanTriggered && mounted) {
controller.startScan(timeout: _scanDuration);
_startScanProgressAnimation(); // Start scan duration progress animation
_startWaveAnimation(); // Start the wave animation
if (mounted) {
setState(() {
_initialScanStarted = true;
_initialScanTriggered = true;
});
}
}
@ -46,64 +47,17 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
@override
void initState() {
super.initState();
// Initialize scan progress controller
_progressController = AnimationController(
vsync: this,
duration: _scanDuration,
)..addListener(() {
// Trigger rebuild when animation value changes for the progress indicator
if (mounted) {
setState(() {});
}
});
// Initialize wave animation controller
_waveAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500), // New duration: 1.5 seconds
)..repeat(); // Make the wave animation repeat
super.initState();
// No animation controllers needed here anymore
}
@override
void dispose() {
// Stop animations before disposing
_progressController.stop();
_waveAnimationController.stop();
_progressController.dispose();
_waveAnimationController.dispose();
// Dispose controllers if they existed (they don't anymore)
super.dispose();
}
// Helper method to start/reset scan progress animation
void _startScanProgressAnimation() {
if (mounted) {
_progressController.reset();
_progressController.forward().whenCompleteOrCancel(() {
// Optional: Add logic if needed when scan progress completes or cancels
});
}
}
// Helper method to start the wave animation
void _startWaveAnimation() {
if (mounted && !_waveAnimationController.isAnimating) {
_waveAnimationController.reset();
_waveAnimationController.repeat();
}
}
// Helper method to stop animations when scan finishes/cancels
void _stopAnimations() {
if (mounted) {
if (_progressController.isAnimating) {
_progressController.stop();
}
if (_waveAnimationController.isAnimating) {
_waveAnimationController.stop();
}
}
}
// Removed _startScanProgressAnimation, _startWaveAnimation, _stopAnimations
@override
Widget build(BuildContext context) {
return Scaffold(
@ -134,155 +88,264 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage>
)
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
body: Column(
// Use Column instead of Center(Column(...))
children: [
const Padding(
padding: EdgeInsets.all(16.0), // Add padding around the title
child: Text(
'Available Devices',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// Use Consumer to react to bluetoothProvider changes
Expanded(
// Allow the Consumer content to expand
child: Consumer(builder: (context, ref, child) {
),
// Use Consumer to get the BluetoothController
Expanded(
// Allow the device list to take available space
child: Consumer(
builder: (context, ref, child) {
final btAsyncValue = ref.watch(bluetoothProvider);
final connectedDevices =
ref.watch(nConnectedDevicesProvider).valueOrNull ??
const <ConnectedDevice>[];
final connectedDeviceAddresses = connectedDevices
.map((device) => device.deviceAddress)
.toSet();
return btAsyncValue.when(
loading: () => const Center(
child:
CircularProgressIndicator()), // Center loading indicator
error: (err, stack) => Center(
child: Text(
'Error loading Bluetooth: $err')), // Center error
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (controller) {
// Start the initial scan once the controller is ready
// Start initial scan and animation
// Trigger the initial scan if needed
_startScanIfNeeded(controller);
// Use StreamBuilder to watch the scanning state
return StreamBuilder<bool>(
stream: FlutterBluePlus.isScanning,
initialData:
false, // Default to not scanning before check
// StreamBuilder for Scan Results (Device List)
return StreamBuilder<List<DiscoveredDevice>>(
stream: controller.scanResultsStream,
initialData: const [],
builder: (context, snapshot) {
final isScanning = snapshot.data ?? false;
final results = snapshot.data ?? [];
// Filter results based on the toggle state
final filteredResults = _showOnlyAbawoDevices
? results
.where((device) =>
device.serviceUuids.any(isAbawoDeviceGuid))
.toList()
: results;
if (isScanning && _initialScanStarted) {
_startWaveAnimation(); // Ensure wave animation is running
// Show the new scanning wave animation with the progress indicator value
return Center(
// Pass the wave animation controller. The progress value will be added
// to the ScanningWaveAnimation widget itself later.
child: ScanningWaveAnimation(
animation: _waveAnimationController,
progressValue: _progressController
.value, // Pass the scan progress
),
);
} else if (!_initialScanStarted) {
// Show placeholder or button to start initial scan if needed, or just empty space
return const SizedBox(
height: 50); // Placeholder before scan starts
} else {
// Scan finished, stop animations and show results
_stopAnimations();
final results = controller.scanResults;
// Filter results based on the toggle state
final filteredResults = _showOnlyAbawoDevices
? results
.where((device) => device
.advertisementData.serviceUuids
.contains(Guid(abawoServiceBtUUID)))
.toList()
: results;
// Use Column + Expanded for ListView + Button layout
return Column(
children: [
Expanded(
// Allow ListView to take available space
child: filteredResults
.isEmpty // Use filtered list check
? const Center(
child: Text(
'No devices found.')) // Center empty text
: ListView.builder(
itemCount: filteredResults
.length, // Use filtered list length
itemBuilder: (context, index) {
final device = filteredResults[
index]; // Use filtered list
final isAbawoDevice = device
.advertisementData.serviceUuids
.contains(
Guid(abawoServiceBtUUID));
final deviceName =
device.device.advName.isEmpty
? 'Unknown Device'
: device.device.advName;
// Use the custom DeviceListItem widget
return InkWell(
// Wrap with InkWell for tap feedback
onTap: () {
if (!isAbawoDevice) {
// Show a snackbar for non-Abawo devices
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(
content: Text(
'This app can only connect to abawo devices.')));
return;
}
// TODO: Implement connect logic
// controller.connectToDevice(device.device); // Pass the BluetoothDevice
// context.go('/control/${device.device.remoteId.str}');
print(
'Tapped on ${device.device.remoteId.str}');
},
child: DeviceListItem(
deviceName: deviceName,
deviceId:
device.device.remoteId.str,
isUnknownDevice:
device.device.advName.isEmpty,
),
);
} // End of itemBuilder
),
),
Padding(
// Add padding around the button
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: () {
// Retry scan by calling startScan on the controller
// Ensure _initialScanStarted is true so indicator shows
if (mounted) {
setState(() {
_initialScanStarted = true;
});
}
controller.startScan(
timeout: _scanDuration);
_startScanProgressAnimation(); // Restart scan progress animation
_startWaveAnimation(); // Ensure wave animation runs on retry
},
child: const Text('Retry Scan'),
),
),
],
);
if (!_initialScanTriggered && filteredResults.isEmpty) {
// Show a message or placeholder before the first scan starts or if no devices found initially
return const Center(
child: Text(
'Scanning for devices...')); // Or CircularProgressIndicator()
}
if (filteredResults.isEmpty && _initialScanTriggered) {
// Show 'No devices found' only after the initial scan was triggered
return const Center(child: Text('No devices found.'));
}
// Display the list
return ListView.builder(
itemCount: filteredResults.length,
itemBuilder: (context, index) {
final device = filteredResults[index];
final isAlreadyConnected =
connectedDeviceAddresses.contains(device.id);
final abawoDevice =
device.serviceUuids.any(isAbawoDeviceGuid);
final connectable = device.serviceUuids
.any(isConnectableAbawoDeviceGuid);
final deviceName = device.name.isEmpty
? 'Unknown Device'
: device.name;
return InkWell(
onTap: () async {
if (isAlreadyConnected) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'This device is already connected in the app.'),
),
);
return;
}
if (!abawoDevice) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'This app can only connect to abawo devices.')),
);
return;
} else if (!connectable) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'This device is not connectable with the app.')),
);
return;
} else {
final res = await controller.connect(device);
print('res: $res');
switch (res) {
case Ok():
// trigger pairing/permission prompt if needed
if (!Platform.isAndroid) {
controller.readCharacteristic(
device.id,
'0993826f-0ee4-4b37-9614-d13ecba4ffc2',
'0993826f-0ee4-4b37-9614-d13ecba40000');
}
// Save to DB and navigate
final notifier = ref.read(
nConnectedDevicesProvider.notifier);
final name = device.name.isNotEmpty
? device.name
: 'Unknown Device';
final deviceCompanion =
ConnectedDevicesCompanion(
deviceName: Value(name),
deviceAddress: Value(device.id),
deviceType: Value(deviceTypeToString(
deviceTypeFromUuids(
device.serviceUuids))),
lastConnectedAt: Value(DateTime.now()),
);
final addResult = await notifier
.addConnectedDevice(deviceCompanion);
// Check if mounted before using context
if (!context.mounted) break;
if (addResult.isErr()) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Failed to save device: ${addResult.unwrapErr()}')),
);
} else {
context.go('/device/${device.id}');
}
break;
case Err(:final v):
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
content: Text(
'Connection unsuccessful:\n${v.toString()}'),
));
break;
}
}
print('Tapped on ${device.id}');
},
child: DeviceListItem(
deviceName: deviceName,
deviceId: device.id,
type: deviceTypeFromUuids(device.serviceUuids),
),
);
},
);
},
);
},
);
}),
},
),
],
),
),
),
// Bottom section: Scanning Animation and Retry Button (visible only when scanning)
Consumer(
// Use Consumer to get the controller for the retry button action
builder: (context, ref, child) {
final btController = ref
.watch(bluetoothProvider)
.asData
?.value; // Get controller safely
return StreamBuilder<bool>(
stream: btController?.isScanningStream ?? Stream<bool>.empty(),
initialData: false,
builder: (context, snapshot) {
final isScanning = snapshot.data ?? false;
// Show bottom section only if scanning
if (!isScanning) {
// Show only the retry button when not scanning (optional, could be hidden)
// For now, let's keep the button always visible but disabled when not scannable.
// A better approach might be to hide the button when not scanning.
// Let's show the button but potentially disabled later if controller is null.
return Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
onPressed: btController != null
? () {
// Retry scan ONLY when NOT currently scanning
if (mounted) {
setState(() {
_initialScanTriggered =
true; // Ensure state reflects scan attempt
_retryScanCounter++; // Increment key counter
});
}
btController.startScan(timeout: _scanDuration);
}
: null, // Disable if controller not ready
child: const Text('Retry Scan'),
),
);
}
// If scanning, show animation and button
return Container(
padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 16.0),
decoration: BoxDecoration(
color: Theme.of(context)
.scaffoldBackgroundColor
.withValues(alpha: 0.9), // Slight overlay effect
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, -4), // Shadow upwards
)
],
),
child: Column(
mainAxisSize: MainAxisSize.min, // Keep column compact
children: [
// Pass isScanning and the ValueKey
HorizontalScanningAnimation(
key: ValueKey(
_retryScanCounter), // Force state rebuild on counter change
isScanning: isScanning,
height: 40,
),
const SizedBox(height: 8),
ElevatedButton(
// Button does nothing if pressed *while* scanning.
// It just indicates the status.
onPressed: null, // Disable button while scanning
style: ElevatedButton.styleFrom(
disabledBackgroundColor: Theme.of(context)
.primaryColor
.withValues(alpha: 0.5), // Custom disabled color
disabledForegroundColor: Colors.white70,
),
child:
const Text('Scanning...'), // Just indicate status
),
const SizedBox(height: 8), // Add some bottom padding
],
),
);
},
);
}),
], // End of outer Column children
), // End of Scaffold
);
}
}

View File

@ -1,4 +1,9 @@
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/widgets/device_listitem.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
class HomePage extends StatelessWidget {
@ -53,10 +58,7 @@ class HomePage extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Text(
'No devices connected yet',
style: TextStyle(color: Colors.grey),
),
child: DevicesList(),
),
),
],
@ -73,3 +75,148 @@ class HomePage extends StatelessWidget {
);
}
}
class DevicesList extends ConsumerStatefulWidget {
const DevicesList({super.key});
@override
ConsumerState<DevicesList> createState() => _DevicesListState();
}
class _DevicesListState extends ConsumerState<DevicesList> {
String? _connectingDeviceId; // ID of device currently being connected
Future<void> _removeDevice(ConnectedDevice device) async {
final shouldRemove = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove device?'),
content:
Text('Do you want to remove ${device.deviceName} from the app?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Remove'),
),
],
),
);
if (shouldRemove != true || !mounted) {
return;
}
final result = await ref
.read(nConnectedDevicesProvider.notifier)
.deleteConnectedDevice(device.id);
if (!mounted) {
return;
}
if (result.isErr()) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to remove device: ${result.unwrapErr()}')),
);
return;
}
if (_connectingDeviceId == device.deviceAddress) {
setState(() {
_connectingDeviceId = null;
});
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${device.deviceName} removed from the app.')),
);
}
@override
Widget build(BuildContext context) {
final asyncDevices = ref.watch(nConnectedDevicesProvider);
return asyncDevices.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: Text(
'Error loading devices: ${error.toString()}',
style: TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
data: (devices) {
if (devices.isEmpty) {
return const Center(
child: Text(
'No devices connected yet',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
itemCount: devices.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final device = devices[index];
return InkWell(
onTap: () async {
if (_connectingDeviceId != null) return;
setState(() {
_connectingDeviceId = device.deviceAddress;
});
try {
final controller = await ref.read(bluetoothProvider.future);
final result = await controller.connectById(
device.deviceAddress,
timeout: const Duration(seconds: 10),
);
if (result.isOk()) {
context.go('/device/${device.deviceAddress}');
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Connection failed. Is the device turned on and in range?'),
duration: const Duration(seconds: 3),
),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
} finally {
setState(() {
_connectingDeviceId = null;
});
}
},
child: DeviceListItem(
deviceName: device.deviceName,
deviceId: device.deviceAddress,
type: deviceTypeFromString(device.deviceType),
isConnecting: device.deviceAddress == _connectingDeviceId,
trailing: IconButton(
tooltip: 'Remove device',
icon: const Icon(Icons.delete_outline),
onPressed: () => _removeDevice(device),
),
),
);
},
);
},
);
}
}

View File

@ -0,0 +1,179 @@
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';
class ShifterService {
ShifterService({
required BluetoothController bluetooth,
required this.buttonDeviceId,
}) : _bluetooth = bluetooth;
final BluetoothController _bluetooth;
final String buttonDeviceId;
final StreamController<CentralStatus> _statusController =
StreamController<CentralStatus>.broadcast();
StreamSubscription<List<int>>? _statusSubscription;
Stream<CentralStatus> get statusStream => _statusController.stream;
static const int _gearRatioSlots = 32;
static const double _maxGearRatio = 255 / 64;
static const int _gearRatioWriteMtu = 64;
Future<Result<void>> writeConnectToAddress(String bikeDeviceId) async {
try {
final payload = parseMacToLittleEndianBytes(bikeDeviceId);
return _bluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterConnectToAddrCharacteristicUuid,
payload,
);
} on FormatException catch (e) {
return bail('Could not parse bike address "$bikeDeviceId": $e');
} catch (e) {
return bail('Failed writing connect address: $e');
}
}
Future<Result<void>> writeCommand(UniversalShifterCommand command) {
return _bluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterCommandCharacteristicUuid,
[command.value],
);
}
Future<Result<void>> connectButtonToBike(String bikeDeviceId) async {
final addrRes = await writeConnectToAddress(bikeDeviceId);
if (addrRes.isErr()) {
return addrRes;
}
return writeCommand(UniversalShifterCommand.connectToDevice);
}
Future<Result<List<double>>> readGearRatios() async {
final readRes = await _bluetooth.readCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid,
);
if (readRes.isErr()) {
return bail(readRes.unwrapErr());
}
final raw = readRes.unwrap();
if (raw.length > _gearRatioSlots) {
return bail(
'Invalid gear ratio payload length: expected at most $_gearRatioSlots, got ${raw.length}',
);
}
final normalizedRaw = List<int>.filled(_gearRatioSlots, 0, growable: false);
for (var i = 0; i < raw.length; i++) {
normalizedRaw[i] = raw[i];
}
final ratios = normalizedRaw
.where((v) => v > 0)
.map((v) => _decodeGearRatio(v))
.toList(growable: false);
return Ok(ratios);
}
Future<Result<void>> writeGearRatios(List<double> ratios) async {
final mtuResult =
await _bluetooth.requestMtu(buttonDeviceId, mtu: _gearRatioWriteMtu);
if (mtuResult.isErr()) {
return bail(
'Could not request MTU before writing gear ratios: ${mtuResult.unwrapErr()}');
}
final payload = List<int>.filled(_gearRatioSlots, 0, growable: false);
final limit =
ratios.length < _gearRatioSlots ? ratios.length : _gearRatioSlots;
for (var i = 0; i < limit; i++) {
payload[i] = _encodeGearRatio(ratios[i]);
}
return _bluetooth.writeCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterGearRatiosCharacteristicUuid,
payload,
);
}
Future<Result<CentralStatus>> readStatus() async {
final readRes = await _bluetooth.readCharacteristic(
buttonDeviceId,
universalShifterControlServiceUuid,
universalShifterStatusCharacteristicUuid,
);
if (readRes.isErr()) {
return bail(readRes.unwrapErr());
}
try {
return Ok(CentralStatus.fromCborBytes(readRes.unwrap()));
} catch (e) {
return bail('Failed to decode status payload: $e');
}
}
void startStatusNotifications() {
if (_statusSubscription != null) {
return;
}
_statusSubscription = _bluetooth
.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.
},
);
}
Future<void> stopStatusNotifications() async {
await _statusSubscription?.cancel();
_statusSubscription = null;
}
Future<void> dispose() async {
await stopStatusNotifications();
await _statusController.close();
}
int _encodeGearRatio(double value) {
if (value <= 0) {
return 0;
}
final clamped = value.clamp(0, _maxGearRatio);
final scaled = (clamped * 64).round();
if (scaled <= 0) {
return 1;
}
return scaled.clamp(1, 255);
}
double _decodeGearRatio(int raw) {
return raw / 64.0;
}
}

View File

@ -0,0 +1,10 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
String greet({required String name}) =>
RustLib.instance.api.crateApiSimpleGreet(name: name);

View File

@ -0,0 +1,240 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api/simple.dart';
import 'dart:async';
import 'dart:convert';
import 'frb_generated.dart';
import 'frb_generated.io.dart'
if (dart.library.js_interop) 'frb_generated.web.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
/// Main entrypoint of the Rust API
class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
@internal
static final instance = RustLib._();
RustLib._();
/// Initialize flutter_rust_bridge
static Future<void> init({
RustLibApi? api,
BaseHandler? handler,
ExternalLibrary? externalLibrary,
bool forceSameCodegenVersion = true,
}) async {
await instance.initImpl(
api: api,
handler: handler,
externalLibrary: externalLibrary,
forceSameCodegenVersion: forceSameCodegenVersion,
);
}
/// Initialize flutter_rust_bridge in mock mode.
/// No libraries for FFI are loaded.
static void initMock({
required RustLibApi api,
}) {
instance.initMockImpl(
api: api,
);
}
/// Dispose flutter_rust_bridge
///
/// The call to this function is optional, since flutter_rust_bridge (and everything else)
/// is automatically disposed when the app stops.
static void dispose() => instance.disposeImpl();
@override
ApiImplConstructor<RustLibApiImpl, RustLibWire> get apiImplConstructor =>
RustLibApiImpl.new;
@override
WireConstructor<RustLibWire> get wireConstructor =>
RustLibWire.fromExternalLibrary;
@override
Future<void> executeRustInitializers() async {
await api.crateApiSimpleInitApp();
}
@override
ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig =>
kDefaultExternalLibraryLoaderConfig;
@override
String get codegenVersion => '2.11.1';
@override
int get rustContentHash => -1918914929;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
stem: 'rust_lib_abawo_bt_app',
ioDirectory: 'rust/target/release/',
webPrefix: 'pkg/',
);
}
abstract class RustLibApi extends BaseApi {
String crateApiSimpleGreet({required String name});
Future<void> crateApiSimpleInitApp();
}
class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
RustLibApiImpl({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@override
String crateApiSimpleGreet({required String name}) {
return handler.executeSync(SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(name, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 1)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
decodeErrorData: null,
),
constMeta: kCrateApiSimpleGreetConstMeta,
argValues: [name],
apiImpl: this,
));
}
TaskConstMeta get kCrateApiSimpleGreetConstMeta => const TaskConstMeta(
debugName: "greet",
argNames: ["name"],
);
@override
Future<void> crateApiSimpleInitApp() {
return handler.executeNormal(NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(generalizedFrbRustBinding, serializer,
funcId: 2, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiSimpleInitAppConstMeta,
argValues: [],
apiImpl: this,
));
}
TaskConstMeta get kCrateApiSimpleInitAppConstMeta => const TaskConstMeta(
debugName: "init_app",
argNames: [],
);
@protected
String dco_decode_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as String;
}
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as Uint8List;
}
@protected
int dco_decode_u_8(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as int;
}
@protected
void dco_decode_unit(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return;
}
@protected
String sse_decode_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var inner = sse_decode_list_prim_u_8_strict(deserializer);
return utf8.decoder.convert(inner);
}
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var len_ = sse_decode_i_32(deserializer);
return deserializer.buffer.getUint8List(len_);
}
@protected
int sse_decode_u_8(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return deserializer.buffer.getUint8();
}
@protected
void sse_decode_unit(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
}
@protected
int sse_decode_i_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return deserializer.buffer.getInt32();
}
@protected
bool sse_decode_bool(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return deserializer.buffer.getUint8() != 0;
}
@protected
void sse_encode_String(String self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer);
}
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.length, serializer);
serializer.buffer.putUint8List(self);
}
@protected
void sse_encode_u_8(int self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
serializer.buffer.putUint8(self);
}
@protected
void sse_encode_unit(void self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
}
@protected
void sse_encode_i_32(int self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
serializer.buffer.putInt32(self);
}
@protected
void sse_encode_bool(bool self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
serializer.buffer.putUint8(self ? 1 : 0);
}
}

View File

@ -0,0 +1,84 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api/simple.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
RustLibApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
String dco_decode_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
}
// Section: wire_class
class RustLibWire implements BaseWire {
factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) =>
RustLibWire(lib.ffiDynamicLibrary);
/// Holds the symbol lookup function.
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
_lookup;
/// The symbols are looked up in [dynamicLibrary].
RustLibWire(ffi.DynamicLibrary dynamicLibrary)
: _lookup = dynamicLibrary.lookup;
}

View File

@ -0,0 +1,84 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.11.1.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
// Static analysis wrongly picks the IO variant, thus ignore this
// ignore_for_file: argument_type_not_assignable
import 'api/simple.dart';
import 'dart:async';
import 'dart:convert';
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
RustLibApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
String dco_decode_String(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
}
// Section: wire_class
class RustLibWire implements BaseWire {
RustLibWire.fromExternalLibrary(ExternalLibrary lib);
}
@JS('wasm_bindgen')
external RustLibWasmModule get wasmModule;
@JS()
@anonymous
extension type RustLibWasmModule._(JSObject _) implements JSObject {}

View File

@ -1 +1,21 @@
const abawoServiceBtUUID = '0993826f-0ee4-4b37-9614-d13ecba4ffc2';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
const abawoServiceBtUUIDPrefix = '0993826f-0ee4-4b37-9614';
const abawoUniversalShiftersServiceBtUUID =
'0993826f-0ee4-4b37-9614-d13ecba4ffc2';
bool isAbawoDeviceGuid(Uuid guid) {
return guid
.toString()
.toLowerCase()
.replaceAll('-', '')
.startsWith(abawoServiceBtUUIDPrefix.toLowerCase().replaceAll('-', ''));
}
bool isAbawoUniversalShiftersDeviceGuid(Uuid guid) {
return guid == Uuid.parse(abawoUniversalShiftersServiceBtUUID);
}
bool isConnectableAbawoDeviceGuid(Uuid guid) {
return isAbawoUniversalShiftersDeviceGuid(guid);
}

View File

@ -1,8 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'sharedPrefs.g.dart';
// part 'sharedPrefs.g.dart';
final sharedPreferencesProvider =
Provider<SharedPreferences>((ref) => throw UnimplementedError());

View File

@ -1,197 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sharedPrefs.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$sharedPrefValueHash() => r'6c78fac8d11d0df162d4d53f465c1c8535fcd150';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$SharedPrefValue extends BuildlessAutoDisposeNotifier<T> {
late final String key;
late final T defaultValue;
T build(
String key,
T defaultValue,
);
}
/// See also [SharedPrefValue].
@ProviderFor(SharedPrefValue)
const sharedPrefValueProvider = SharedPrefValueFamily();
/// See also [SharedPrefValue].
class SharedPrefValueFamily extends Family<T> {
/// See also [SharedPrefValue].
const SharedPrefValueFamily();
/// See also [SharedPrefValue].
SharedPrefValueProvider call(
String key,
T defaultValue,
) {
return SharedPrefValueProvider(
key,
defaultValue,
);
}
@override
SharedPrefValueProvider getProviderOverride(
covariant SharedPrefValueProvider provider,
) {
return call(
provider.key,
provider.defaultValue,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'sharedPrefValueProvider';
}
/// See also [SharedPrefValue].
class SharedPrefValueProvider
extends AutoDisposeNotifierProviderImpl<SharedPrefValue, T> {
/// See also [SharedPrefValue].
SharedPrefValueProvider(
String key,
T defaultValue,
) : this._internal(
() => SharedPrefValue()
..key = key
..defaultValue = defaultValue,
from: sharedPrefValueProvider,
name: r'sharedPrefValueProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$sharedPrefValueHash,
dependencies: SharedPrefValueFamily._dependencies,
allTransitiveDependencies:
SharedPrefValueFamily._allTransitiveDependencies,
key: key,
defaultValue: defaultValue,
);
SharedPrefValueProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.key,
required this.defaultValue,
}) : super.internal();
final String key;
final T defaultValue;
@override
T runNotifierBuild(
covariant SharedPrefValue notifier,
) {
return notifier.build(
key,
defaultValue,
);
}
@override
Override overrideWith(SharedPrefValue Function() create) {
return ProviderOverride(
origin: this,
override: SharedPrefValueProvider._internal(
() => create()
..key = key
..defaultValue = defaultValue,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
key: key,
defaultValue: defaultValue,
),
);
}
@override
AutoDisposeNotifierProviderElement<SharedPrefValue, T> createElement() {
return _SharedPrefValueProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is SharedPrefValueProvider &&
other.key == key &&
other.defaultValue == defaultValue;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, key.hashCode);
hash = _SystemHash.combine(hash, defaultValue.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin SharedPrefValueRef on AutoDisposeNotifierProviderRef<T> {
/// The parameter `key` of this provider.
String get key;
/// The parameter `defaultValue` of this provider.
T get defaultValue;
}
class _SharedPrefValueProviderElement
extends AutoDisposeNotifierProviderElement<SharedPrefValue, T>
with SharedPrefValueRef {
_SharedPrefValueProviderElement(super.provider);
@override
String get key => (origin as SharedPrefValueProvider).key;
@override
T get defaultValue => (origin as SharedPrefValueProvider).defaultValue;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -0,0 +1,214 @@
import 'package:abawo_bt_app/controller/bluetooth.dart';
import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:flutter/material.dart';
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class BikeScanDialog extends ConsumerStatefulWidget {
const BikeScanDialog({
required this.excludedDeviceId,
super.key,
});
final String excludedDeviceId;
static Future<DiscoveredDevice?> show(
BuildContext context, {
required String excludedDeviceId,
}) {
return showDialog<DiscoveredDevice>(
context: context,
barrierDismissible: true,
builder: (_) => BikeScanDialog(excludedDeviceId: excludedDeviceId),
);
}
@override
ConsumerState<BikeScanDialog> createState() => _BikeScanDialogState();
}
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
bool _showAll = false;
BluetoothController? _controller;
@override
void initState() {
super.initState();
_startScan();
}
Future<void> _startScan() async {
final controller = await ref.read(bluetoothProvider.future);
_controller = controller;
await controller.stopScan();
await controller.startScan();
}
@override
void dispose() {
_controller?.stopScan();
super.dispose();
}
@override
Widget build(BuildContext context) {
final btAsync = ref.watch(bluetoothProvider);
return Dialog(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: SizedBox(
width: 520,
height: 520,
child: btAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Bluetooth error: $err')),
data: (controller) {
_controller ??= controller;
return Column(
children: [
_buildHeader(context),
const Divider(height: 1),
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 Center(
child: Text('No matching devices nearby.'),
);
}
return ListView.separated(
itemCount: devices.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final device = devices[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: CircleAvatar(
backgroundColor: Theme.of(context)
.colorScheme
.primaryContainer,
child: const Icon(Icons.pedal_bike),
),
title: Text(
device.name.isEmpty
? 'Unknown Device'
: device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
device.id,
style: const TextStyle(fontFamily: 'monospace'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: _RssiBadge(rssi: device.rssi),
onTap: () {
Navigator.of(context).pop(device);
},
);
},
);
},
),
),
],
);
},
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: Row(
children: [
const Expanded(
child: Text(
'Select Bike',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
),
Row(
children: [
const Text('Show All'),
Switch(
value: _showAll,
onChanged: (value) {
setState(() {
_showAll = value;
});
},
),
],
),
IconButton(
tooltip: 'Rescan',
onPressed: _startScan,
icon: const Icon(Icons.refresh),
),
IconButton(
tooltip: 'Close',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
);
}
List<DiscoveredDevice> _filteredDevices(List<DiscoveredDevice> devices) {
final ftmsUuid = Uuid.parse(ftmsServiceUuid);
return devices.where((device) {
if (device.id == widget.excludedDeviceId) {
return false;
}
if (_showAll) {
return true;
}
return device.serviceUuids.contains(ftmsUuid);
}).toList(growable: false);
}
}
class _RssiBadge extends StatelessWidget {
const _RssiBadge({required this.rssi});
final int rssi;
@override
Widget build(BuildContext context) {
final color = rssi > -65
? Colors.green
: rssi > -80
? Colors.orange
: Colors.red;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$rssi dBm',
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
);
}
}

View File

@ -1,18 +1,23 @@
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
import 'package:flutter/material.dart';
import 'dart:ui'; // Required for ImageFilter
class DeviceListItem extends StatelessWidget {
final String deviceName;
final String deviceId; // Added for potential future use or subtitle
final bool isUnknownDevice;
final DeviceType type;
// final String? imageUrl; // Optional image URL - commented out for now
final bool isConnecting; // Add this line
final Widget? trailing;
const DeviceListItem({
super.key,
required this.deviceName,
required this.deviceId,
this.isUnknownDevice = false,
required this.type,
// this.imageUrl,
this.isConnecting = false, // Add this line
this.trailing,
});
@override
@ -22,11 +27,11 @@ class DeviceListItem extends StatelessWidget {
// Glassy effect colors - adjust transparency and base color as needed
final glassColor = isDarkMode
? Colors.white.withOpacity(0.1)
: Colors.black.withOpacity(0.05);
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.05);
final shadowColor = isDarkMode
? Colors.black.withOpacity(0.4)
: Colors.grey.withOpacity(0.5);
? Colors.black.withValues(alpha: 0.4)
: Colors.grey.withValues(alpha: 0.5);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
@ -57,21 +62,33 @@ class DeviceListItem extends StatelessWidget {
glassColor, // Semi-transparent color for glass effect
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.2), // Subtle border
color:
Colors.white.withValues(alpha: 0.2), // Subtle border
width: 0.5,
),
),
child: const Center(
// Placeholder '?' - replace with Image widget when imageUrl is available
child: Text(
'?',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.white70, // Adjust color as needed
),
),
),
child: type == DeviceType.universalShifters
// For Universal Shifters: Image fills the container, constrained by rounded borders
? ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/images/shifter-wireframe.png',
fit: BoxFit.cover, // Cover the entire container
width: 60,
height: 60,
),
)
// For other devices: Question mark with padding
: const Center(
child: Text(
'?',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
),
),
),
),
@ -84,14 +101,16 @@ class DeviceListItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isUnknownDevice ? 'Unknown Device' : deviceName,
deviceName.isEmpty ? 'Unknown Device' : deviceName,
style: TextStyle(
fontSize: 16,
fontWeight:
isUnknownDevice ? FontWeight.normal : FontWeight.w500,
fontStyle:
isUnknownDevice ? FontStyle.italic : FontStyle.normal,
color: isUnknownDevice
fontWeight: deviceName.isEmpty
? FontWeight.normal
: FontWeight.w500,
fontStyle: deviceName.isEmpty
? FontStyle.italic
: FontStyle.normal,
color: deviceName.isEmpty
? theme.hintColor
: theme.textTheme.bodyLarge?.color,
),
@ -108,6 +127,19 @@ class DeviceListItem extends StatelessWidget {
],
),
),
// Add spinner if connecting (Add this block)
if (isConnecting)
Padding(
padding: const EdgeInsets.only(left: 12.0), // Add some spacing
child: SizedBox(
width: 20, // Define spinner size
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.0), // Use a small spinner
),
)
else if (trailing != null)
trailing!,
// Optional: Add an icon or button on the far right if needed later
// Icon(Icons.chevron_right, color: theme.hintColor),
],

View File

@ -0,0 +1,892 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class GearRatioPreset {
const GearRatioPreset({
required this.name,
required this.description,
required this.ratios,
});
final String name;
final String description;
final List<double> ratios;
}
class GearRatioEditorCard extends StatefulWidget {
const GearRatioEditorCard({
required this.ratios,
required this.isLoading,
required this.onSave,
required this.presets,
this.errorText,
this.onRetry,
super.key,
});
final List<double> ratios;
final bool isLoading;
final Future<String?> Function(List<double> ratios) onSave;
final List<GearRatioPreset> presets;
final String? errorText;
final VoidCallback? onRetry;
@override
State<GearRatioEditorCard> createState() => _GearRatioEditorCardState();
}
class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
static const double _sliderMin = 0.10;
static const double _sliderMax = 3.90;
static const double _sliderPivotT = 0.50;
static const double _sliderPivotV = 1.00;
static const Duration _animDuration = Duration(milliseconds: 280);
static const Curve _animCurve = Cubic(0.2, 0.8, 0.2, 1.0);
bool _isExpanded = false;
bool _isEditing = false;
bool _sortAscending = true;
bool _isSaving = false;
double _stretchFactor = 1.0;
List<double>? _stretchBase;
int _gearLayoutVersion = 0;
List<double> _committed = const [];
List<double> _draft = const [];
@override
void initState() {
super.initState();
_committed = List<double>.from(widget.ratios);
_draft = List<double>.from(widget.ratios);
}
@override
void didUpdateWidget(covariant GearRatioEditorCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (!_isEditing && !_listEquals(oldWidget.ratios, widget.ratios)) {
_committed = List<double>.from(widget.ratios);
_draft = List<double>.from(widget.ratios);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color:
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
child: AnimatedSize(
duration: _animDuration,
curve: _animCurve,
alignment: Alignment.topCenter,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 10, 8),
child: Row(
children: [
const Expanded(
child: Text(
'Gear Ratios',
style:
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
IconButton(
tooltip: 'Edit ratios',
onPressed: (widget.isLoading || widget.errorText != null)
? null
: _enterEditMode,
icon: const Icon(Icons.edit_outlined),
),
],
),
),
if (widget.isLoading)
const Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 24),
child: Center(child: CircularProgressIndicator()),
)
else if (widget.errorText != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.errorText!,
style: TextStyle(color: theme.colorScheme.error),
),
if (widget.onRetry != null)
TextButton.icon(
onPressed: widget.onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
)
else if (_committed.isEmpty && !_isEditing)
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 18),
child: Text('No gear ratios found on device.'),
)
else ...[
if ((_isEditing ? _draft : _committed).isNotEmpty)
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: _isEditing
? null
: () {
setState(() {
_isExpanded = !_isExpanded;
});
},
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 8),
child: AnimatedContainer(
duration: _animDuration,
curve: _animCurve,
height: _isExpanded ? 210 : 130,
child: _GearRatioGraph(
ratios: _isEditing ? _draft : _committed,
compact: !_isExpanded,
),
),
),
),
if (!_isExpanded && !_isEditing && _committed.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
child: _compactRatioStrip(context, _committed),
),
AnimatedSize(
duration: _animDuration,
curve: _animCurve,
alignment: Alignment.topCenter,
child: _isExpanded && !_isEditing
? Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var i = 0; i < _committed.length; i++)
_ratioChip(context, i + 1, _committed[i]),
],
),
)
: const SizedBox.shrink(),
),
if (_isEditing) ...[
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Row(
children: [
const Text('Sort ascending'),
Switch(
value: _sortAscending,
onChanged: (value) {
setState(() {
_sortAscending = value;
if (_sortAscending) {
_sortDraft(animate: true);
}
});
},
),
const Spacer(),
OutlinedButton.icon(
onPressed: _openPresetPicker,
icon: const Icon(Icons.tune),
label: const Text('Load preset'),
),
],
),
),
if (_draft.isEmpty)
const Padding(
padding: EdgeInsets.fromLTRB(14, 0, 14, 10),
child:
Text('No ratios yet. Load a preset to start editing.'),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Stretch all: ${_stretchFactor.toStringAsFixed(2)}x',
style: theme.textTheme.bodyMedium,
),
Slider(
min: 0.0,
max: 1.0,
value: _stretchToSlider(_stretchFactor),
onChangeStart: (_) {
_stretchBase = List<double>.from(_draft);
},
onChanged: (value) {
final factor = _sliderToStretch(value);
final base = _stretchBase ?? _draft;
setState(() {
_stretchFactor = factor;
_draft = base
.map((ratio) => _quantizeRatio(ratio * factor))
.toList(growable: false);
});
},
onChangeEnd: (_) {
setState(() {
if (_sortAscending) {
_sortDraft(animate: true);
}
_stretchBase = null;
});
},
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 12),
child: AnimatedSwitcher(
duration: _animDuration,
switchInCurve: _animCurve,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: _snappyTransition,
child: Wrap(
key: ValueKey('editors-$_gearLayoutVersion'),
spacing: 8,
runSpacing: 8,
children: [
for (var i = 0; i < _draft.length; i++)
KeyedSubtree(
key: ValueKey('editor-${i + 1}'),
child: _buildGearEditor(context, i),
),
],
),
),
),
],
],
],
),
),
);
}
Widget _snappyTransition(Widget child, Animation<double> animation) {
final curved = CurvedAnimation(parent: animation, curve: _animCurve);
return FadeTransition(
opacity: curved,
child: SizeTransition(
sizeFactor: curved,
axisAlignment: -1,
child: child,
),
);
}
Widget _buildGearEditor(BuildContext context, int index) {
final ratio = _draft[index];
final sliderValue = _valueToSlider(ratio);
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: 230, maxWidth: 280),
child: Container(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.7),
border: Border.all(
color: Theme.of(context)
.colorScheme
.outlineVariant
.withValues(alpha: 0.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Gear ${index + 1}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
const Spacer(),
InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => _editRatioText(index),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
ratio.toStringAsFixed(2),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
Slider(
min: 0,
max: 1,
value: sliderValue,
onChanged: (value) {
setState(() {
_draft[index] = _quantizeRatio(_sliderToValue(value));
});
},
onChangeEnd: (_) {
setState(() {
if (_sortAscending) {
_sortDraft(animate: true);
}
});
},
),
],
),
),
);
}
Future<void> _editRatioText(int index) async {
final controller = TextEditingController(
text: _draft[index].toStringAsFixed(2),
);
final value = await showDialog<double>(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Set gear ${index + 1} ratio'),
content: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
autofocus: true,
decoration: const InputDecoration(hintText: 'e.g. 1.25'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final parsed = double.tryParse(controller.text.trim());
Navigator.of(context).pop(parsed);
},
child: const Text('Set'),
),
],
);
},
);
if (!mounted || value == null) {
return;
}
setState(() {
_draft[index] = _quantizeRatio(value);
if (_sortAscending) {
_sortDraft(animate: true);
}
});
}
Future<void> _openPresetPicker() async {
final selected = await showModalBottomSheet<GearRatioPreset>(
context: context,
showDragHandle: true,
builder: (context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
child: ListView.separated(
itemCount: widget.presets.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final preset = widget.presets[index];
return Material(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => Navigator.of(context).pop(preset),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
preset.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
preset.description,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 10),
SizedBox(
height: 90,
child: _GearRatioGraph(
ratios: preset.ratios, compact: true),
),
const SizedBox(height: 8),
_compactRatioStrip(
context,
preset.ratios,
showGearLabel: false,
),
],
),
),
),
);
},
),
);
},
);
if (!mounted || selected == null) {
return;
}
setState(() {
_draft = selected.ratios.map(_quantizeRatio).toList(growable: false);
if (_sortAscending) {
_sortDraft(animate: true);
}
});
}
void _sortDraft({bool animate = false}) {
final sorted = _sorted(_draft);
if (animate && !_listEquals(_draft, sorted)) {
_gearLayoutVersion++;
}
_draft = sorted;
}
void _enterEditMode() {
setState(() {
_isEditing = true;
_isExpanded = true;
_stretchFactor = 1.0;
_stretchBase = null;
_draft = List<double>.from(_committed);
});
}
void _onCancel() {
setState(() {
_isEditing = false;
_draft = List<double>.from(_committed);
_stretchFactor = 1.0;
_stretchBase = null;
});
}
Future<void> _onSave() async {
setState(() {
_isSaving = true;
});
final message = await widget.onSave(List<double>.from(_draft));
if (!mounted) {
return;
}
setState(() {
_isSaving = false;
if (message == null) {
_committed = List<double>.from(_draft);
_isEditing = false;
}
});
if (message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
Widget _ratioChip(BuildContext context, int gear, double ratio) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: theme.colorScheme.surface.withValues(alpha: 0.7),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
child: Text('G$gear ${ratio.toStringAsFixed(2)}'),
);
}
Widget _compactRatioStrip(
BuildContext context,
List<double> ratios, {
bool showGearLabel = true,
}) {
final theme = Theme.of(context);
return SizedBox(
height: 26,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var i = 0; i < ratios.length; i++)
Padding(
padding: EdgeInsets.only(right: i == ratios.length - 1 ? 0 : 6),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: theme.colorScheme.surface.withValues(alpha: 0.7),
border: Border.all(
color: theme.colorScheme.outlineVariant
.withValues(alpha: 0.55),
),
),
child: Text(
showGearLabel
? 'G${i + 1} ${ratios[i].toStringAsFixed(2)}'
: ratios[i].toStringAsFixed(2),
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
double _quantizeRatio(double raw) {
final clamped = raw.clamp(_sliderMin, _sliderMax);
return ((clamped * 64).round() / 64.0).clamp(_sliderMin, _sliderMax);
}
List<double> _sorted(List<double> values) {
final out = List<double>.from(values)..sort();
return out;
}
bool _listEquals(List<double> a, List<double> b) {
if (a.length != b.length) {
return false;
}
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
}
double _sliderToValue(double t) {
final normalized = t.clamp(0.0, 1.0);
if (normalized <= _sliderPivotT) {
final u = normalized / _sliderPivotT;
return _sliderMin * math.pow(_sliderPivotV / _sliderMin, u);
}
final u = (normalized - _sliderPivotT) / (1 - _sliderPivotT);
return _sliderPivotV * math.pow(_sliderMax / _sliderPivotV, u);
}
double _valueToSlider(double value) {
final clamped = value.clamp(_sliderMin, _sliderMax);
if (clamped <= _sliderPivotV) {
final u =
math.log(clamped / _sliderMin) / math.log(_sliderPivotV / _sliderMin);
return (u * _sliderPivotT).clamp(0.0, 1.0);
}
final u = math.log(clamped / _sliderPivotV) /
math.log(_sliderMax / _sliderPivotV);
return (_sliderPivotT + u * (1 - _sliderPivotT)).clamp(0.0, 1.0);
}
double _sliderToStretch(double t) {
return (0.6 + (1.6 - 0.6) * t).clamp(0.6, 1.6);
}
double _stretchToSlider(double factor) {
return ((factor - 0.6) / (1.6 - 0.6)).clamp(0.0, 1.0);
}
}
class _GearRatioGraph extends StatelessWidget {
const _GearRatioGraph({required this.ratios, required this.compact});
final List<double> ratios;
final bool compact;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.65),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!compact)
Text(
'Input RPM -> Output RPM',
style: textTheme.bodySmall,
),
Expanded(
child: CustomPaint(
painter: _GearRatioGraphPainter(
ratios: ratios,
axisColor: Theme.of(context).colorScheme.outline,
lineColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onSurface,
compact: compact,
),
),
),
],
),
),
);
}
}
class _GearRatioGraphPainter extends CustomPainter {
const _GearRatioGraphPainter({
required this.ratios,
required this.axisColor,
required this.lineColor,
required this.textColor,
required this.compact,
});
final List<double> ratios;
final Color axisColor;
final Color lineColor;
final Color textColor;
final bool compact;
@override
void paint(Canvas canvas, Size size) {
if (ratios.isEmpty) {
return;
}
const left = 28.0;
const right = 10.0;
const top = 8.0;
const bottom = 24.0;
final chart = Rect.fromLTWH(
left,
top,
size.width - left - right,
size.height - top - bottom,
);
final axisPaint = Paint()
..color = axisColor.withValues(alpha: 0.55)
..strokeWidth = 1.2;
canvas.drawRect(chart, axisPaint..style = PaintingStyle.stroke);
final maxRatio = ratios.reduce(math.max);
final xMax = 120.0;
final computedYMax = (xMax * math.max(maxRatio, 1.0)).toDouble();
final yMax = math.min(400.0, computedYMax);
for (var i = 1; i <= 3; i++) {
final y = chart.bottom - (chart.height * (i / 4));
canvas.drawLine(
Offset(chart.left, y),
Offset(chart.right, y),
axisPaint..color = axisColor.withValues(alpha: 0.2),
);
}
for (var i = 1; i <= 3; i++) {
final x = chart.left + (chart.width * (i / 4));
canvas.drawLine(
Offset(x, chart.top),
Offset(x, chart.bottom),
axisPaint..color = axisColor.withValues(alpha: 0.15),
);
}
for (var i = 0; i < ratios.length; i++) {
final ratio = ratios[i];
final p = i / math.max(1, ratios.length - 1);
final color = Color.lerp(
lineColor.withValues(alpha: 0.40),
lineColor,
p,
)!;
final endYValue = xMax * ratio;
final isClipped = endYValue > yMax;
final endX = isClipped
? chart.left + chart.width * (yMax / endYValue)
: chart.right;
final endY = isClipped
? chart.top
: chart.bottom - (endYValue / yMax) * chart.height;
final linePaint = Paint()
..color = color
..strokeWidth = 2;
canvas.drawLine(
Offset(chart.left, chart.bottom),
Offset(endX, endY),
linePaint,
);
if (!compact && i % 2 == 0) {
final tp = TextPainter(
text: TextSpan(
text: 'G${i + 1}',
style: TextStyle(
fontSize: 10, color: textColor.withValues(alpha: 0.75)),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(
canvas,
Offset(
(endX - tp.width - 2).clamp(chart.left, chart.right - tp.width),
(endY - 10).clamp(chart.top, chart.bottom - tp.height),
),
);
}
}
final xLabel = TextPainter(
text: TextSpan(
text: 'In RPM',
style:
TextStyle(fontSize: 10, color: textColor.withValues(alpha: 0.75)),
),
textDirection: TextDirection.ltr,
)..layout();
xLabel.paint(canvas, Offset(chart.right - xLabel.width, chart.bottom + 17));
final xMinValue = TextPainter(
text: TextSpan(
text: '0',
style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)),
),
textDirection: TextDirection.ltr,
)..layout();
xMinValue.paint(canvas, Offset(chart.left, chart.bottom + 5));
final xMaxValue = TextPainter(
text: TextSpan(
text: xMax.toStringAsFixed(0),
style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)),
),
textDirection: TextDirection.ltr,
)..layout();
xMaxValue.paint(
canvas,
Offset(chart.right - xMaxValue.width, chart.bottom + 5),
);
final yLabel = TextPainter(
text: TextSpan(
text: 'Out RPM',
style:
TextStyle(fontSize: 10, color: textColor.withValues(alpha: 0.75)),
),
textDirection: TextDirection.ltr,
)..layout();
yLabel.paint(canvas, Offset(chart.left - 28, chart.top - 14));
final yMinValue = TextPainter(
text: TextSpan(
text: '0',
style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)),
),
textDirection: TextDirection.ltr,
)..layout();
yMinValue.paint(
canvas,
Offset(chart.left - yMinValue.width - 4, chart.bottom - yMinValue.height),
);
final yMaxValue = TextPainter(
text: TextSpan(
text: yMax.toStringAsFixed(0),
style: TextStyle(fontSize: 9, color: textColor.withValues(alpha: 0.65)),
),
textDirection: TextDirection.ltr,
)..layout();
yMaxValue.paint(
canvas,
Offset(chart.left - yMaxValue.width - 4, chart.top),
);
}
@override
bool shouldRepaint(covariant _GearRatioGraphPainter oldDelegate) {
if (oldDelegate.compact != compact ||
oldDelegate.ratios.length != ratios.length) {
return true;
}
for (var i = 0; i < ratios.length; i++) {
if (ratios[i] != oldDelegate.ratios[i]) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,207 @@
import 'dart:math';
import 'package:flutter/material.dart';
class HorizontalScanningAnimation extends StatefulWidget {
final bool isScanning; // Add this to control the animation
final Color waveColor;
final double height;
const HorizontalScanningAnimation({
super.key,
required this.isScanning, // Make it required
this.waveColor = Colors.lightBlueAccent,
this.height = 50.0,
});
@override
_HorizontalScanningAnimationState createState() =>
_HorizontalScanningAnimationState();
}
class _HorizontalScanningAnimationState
extends State<HorizontalScanningAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
// Start repeating only if initially scanning
if (widget.isScanning) {
_controller.repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant HorizontalScanningAnimation oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isScanning != oldWidget.isScanning) {
if (widget.isScanning) {
// Start or resume repeating
if (!_controller.isAnimating) {
// If stopped previously, reset before repeating for a clean start
// Though, repeat() should handle restarting if stopped. Testing needed.
// _controller.reset(); // Optional: uncomment if repeat doesn't restart smoothly
_controller.repeat();
}
} else {
// Stop repeating, but let the current animation cycle finish visually
if (_controller.isAnimating) {
_controller.stop(
canceled:
false); // Use canceled: false to let it finish the current tick
}
}
}
}
@override
Widget build(BuildContext context) {
// Only build the painter if the controller is active or was recently stopped
// This prevents drawing when completely idle. Check if value is changing or non-zero.
// Or simply rely on the AnimatedBuilder which won't rebuild if controller is idle at 0.0
return SizedBox(
height: widget.height,
width: double.infinity,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: _HorizontalWavePainter(
progress: _controller.value,
waveColor: widget.waveColor,
),
);
},
),
);
}
}
class _HorizontalWavePainter extends CustomPainter {
final double progress; // Animation value from 0.0 to 1.0
final Color waveColor;
final int waveCount = 2; // Number of waves visible at once
final double waveAmplitude = 10.0; // Max height deviation of the wave
_HorizontalWavePainter({required this.progress, required this.waveColor});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = waveColor.withValues(alpha: 0.6) // Semi-transparent waves
..style = PaintingStyle.fill;
final centerY = size.height / 2;
final width = size.width;
// Draw multiple waves propagating outwards
for (int i = 0; i < waveCount; i++) {
// Calculate the phase offset for each wave based on progress and index
// This creates the effect of waves moving outwards
double waveProgress = (progress + i / waveCount) % 1.0;
// Use an easing curve for smoother expansion
double easedProgress = Curves.easeInOutSine.transform(waveProgress);
// Calculate the current horizontal position (expanding from center)
// The wave starts narrow and expands outwards
double currentWidth =
width * easedProgress * 0.8; // Max 80% width expansion
double startX = (width / 2) - (currentWidth / 2);
double endX = (width / 2) + (currentWidth / 2);
// Calculate opacity based on progress (fade in and out)
double opacity;
if (waveProgress < 0.1) {
opacity = waveProgress / 0.1; // Fade in
} else if (waveProgress > 0.8) {
opacity = (1.0 - waveProgress) / 0.2; // Fade out
} else {
opacity = 1.0;
}
opacity = max(0.0, opacity); // Clamp opacity
if (opacity <= 0.0 || currentWidth < 5)
continue; // Skip drawing if invisible or too small
// Create the wave path
final path = Path();
path.moveTo(startX, centerY);
// Calculate points for the sine wave shape within the current width
const int segments = 50; // Number of segments for the curve
for (int j = 0; j <= segments; j++) {
double segmentProgress = j / segments;
double x = startX + currentWidth * segmentProgress;
// Apply sine wave based on segment progress and overall animation progress
// Multiply by (1 - easedProgress) to reduce amplitude as it expands
double yOffset = waveAmplitude *
sin(segmentProgress * 2 * pi + progress * 4 * pi) *
(1 - easedProgress * 0.8) * // Reduce amplitude as it expands
opacity; // Apply opacity effect to amplitude too
path.lineTo(x, centerY + yOffset);
}
// Draw a filled shape (like a lens flare or horizontal bar)
// Adjust thickness based on easedProgress (thicker in the middle, thinner at ends)
double thickness =
waveAmplitude * (1 - easedProgress * 0.9) * opacity * 0.5;
paint.color = waveColor.withValues(
alpha: opacity * 0.5); // Update paint color with opacity
// Simplified: Draw a rectangle that pulses
// More complex shapes could be drawn here using path.arcTo or path.quadraticBezierTo
// For simplicity, let's use a slightly blurred rectangle effect
final rectPath = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(width / 2, centerY),
width: currentWidth,
height: thickness * 2),
Radius.circular(thickness)));
// Apply a blur effect
final blurPaint = Paint()
..color = waveColor.withValues(alpha: opacity * 0.4)
..maskFilter = MaskFilter.blur(
BlurStyle.normal, thickness * 1.5); // Blur based on thickness
// Draw the blurred shape
canvas.drawPath(rectPath, blurPaint);
// Draw a slightly smaller, less opaque shape on top for highlight
final highlightPaint = Paint()
..color = waveColor.withValues(alpha: opacity * 0.7)
..style = PaintingStyle.fill;
final highlightRectPath = Path()
..addRRect(RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(width / 2, centerY),
width: currentWidth * 0.95,
height: thickness * 1.5),
Radius.circular(thickness * 0.8)));
canvas.drawPath(highlightRectPath, highlightPaint);
// Old Path drawing - keep if rectangle isn't desired
// paint.color = waveColor.withValues(alpha: opacity * 0.5); // Apply opacity
// canvas.drawPath(path, paint);
}
}
@override
bool shouldRepaint(covariant _HorizontalWavePainter oldDelegate) {
// Repaint whenever the animation progress or color changes
return oldDelegate.progress != progress ||
oldDelegate.waveColor != waveColor;
}
}