feat: working connection, conn setting, and gear ratio setting for universal shifters
This commit is contained in:
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
627
lib/controller/bluetooth.old.dart
Normal file
627
lib/controller/bluetooth.old.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
1
lib/controller/connected_device.dart
Normal file
1
lib/controller/connected_device.dart
Normal file
@ -0,0 +1 @@
|
||||
|
||||
143
lib/database/database.dart
Normal file
143
lib/database/database.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
586
lib/database/database.g.dart
Normal file
586
lib/database/database.g.dart
Normal 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
|
||||
@ -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")}`'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
299
lib/model/shifter_types.dart
Normal file
299
lib/model/shifter_types.dart
Normal 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(':');
|
||||
}
|
||||
740
lib/pages/device_details_page.dart
Normal file
740
lib/pages/device_details_page.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
179
lib/service/shifter_service.dart
Normal file
179
lib/service/shifter_service.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
10
lib/src/rust/api/simple.dart
Normal file
10
lib/src/rust/api/simple.dart
Normal 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);
|
||||
240
lib/src/rust/frb_generated.dart
Normal file
240
lib/src/rust/frb_generated.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
84
lib/src/rust/frb_generated.io.dart
Normal file
84
lib/src/rust/frb_generated.io.dart
Normal 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;
|
||||
}
|
||||
84
lib/src/rust/frb_generated.web.dart
Normal file
84
lib/src/rust/frb_generated.web.dart
Normal 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 {}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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
|
||||
214
lib/widgets/bike_scan_dialog.dart
Normal file
214
lib/widgets/bike_scan_dialog.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
],
|
||||
|
||||
892
lib/widgets/gear_ratio_editor_card.dart
Normal file
892
lib/widgets/gear_ratio_editor_card.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
207
lib/widgets/horizontal_scanning_animation.dart
Normal file
207
lib/widgets/horizontal_scanning_animation.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user