diff --git a/.beads/dolt-monitor.pid b/.beads/dolt-monitor.pid index 01f1dc4..955f7cb 100644 --- a/.beads/dolt-monitor.pid +++ b/.beads/dolt-monitor.pid @@ -1 +1 @@ -48179 \ No newline at end of file +1720108 \ No newline at end of file diff --git a/.beads/dolt-server.activity b/.beads/dolt-server.activity deleted file mode 100644 index 336bdbf..0000000 --- a/.beads/dolt-server.activity +++ /dev/null @@ -1 +0,0 @@ -1772550918 \ No newline at end of file diff --git a/.beads/dolt-server.port b/.beads/dolt-server.port deleted file mode 100644 index 3d112cd..0000000 --- a/.beads/dolt-server.port +++ /dev/null @@ -1 +0,0 @@ -13365 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index f923f4b..e69de29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,150 +0,0 @@ -# Agent Instructions - -This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. - -## Quick Reference - -```bash -bd ready # Find available work -bd show # View issue details -bd update --claim # Claim work atomically -bd close # Complete work -bd sync # Sync with git -``` - -## Non-Interactive Shell Commands - -**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. - -Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. - -**Use these forms instead:** -```bash -# Force overwrite without prompting -cp -f source dest # NOT: cp source dest -mv -f source dest # NOT: mv source dest -rm -f file # NOT: rm file - -# For recursive operations -rm -rf directory # NOT: rm -r directory -cp -rf source dest # NOT: cp -r source dest -``` - -**Other commands that may prompt:** -- `scp` - use `-o BatchMode=yes` for non-interactive -- `ssh` - use `-o BatchMode=yes` to fail instead of prompting -- `apt-get` - use `-y` flag -- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var - - -## Issue Tracking with bd (beads) - -**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. - -### Why bd? - -- Dependency-aware: Track blockers and relationships between issues -- Git-friendly: Auto-syncs to JSONL for version control -- Agent-optimized: JSON output, ready work detection, discovered-from links -- Prevents duplicate tracking systems and confusion - -### Quick Start - -**Check for ready work:** - -```bash -bd ready --json -``` - -**Create new issues:** - -```bash -bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json -bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json -``` - -**Claim and update:** - -```bash -bd update --claim --json -bd update bd-42 --priority 1 --json -``` - -**Complete work:** - -```bash -bd close bd-42 --reason "Completed" --json -``` - -### Issue Types - -- `bug` - Something broken -- `feature` - New functionality -- `task` - Work item (tests, docs, refactoring) -- `epic` - Large feature with subtasks -- `chore` - Maintenance (dependencies, tooling) - -### Priorities - -- `0` - Critical (security, data loss, broken builds) -- `1` - High (major features, important bugs) -- `2` - Medium (default, nice-to-have) -- `3` - Low (polish, optimization) -- `4` - Backlog (future ideas) - -### Workflow for AI Agents - -1. **Check ready work**: `bd ready` shows unblocked issues -2. **Claim your task atomically**: `bd update --claim` -3. **Work on it**: Implement, test, document -4. **Discover new work?** Create linked issue: - - `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:` -5. **Complete**: `bd close --reason "Done"` - -### Auto-Sync - -bd automatically syncs with git: - -- Exports to `.beads/issues.jsonl` after changes (5s debounce) -- Imports from JSONL when newer (e.g., after `git pull`) -- No manual export/import needed! - -### Important Rules - -- ✅ Use bd for ALL task tracking -- ✅ Always use `--json` flag for programmatic use -- ✅ Link discovered work with `discovered-from` dependencies -- ✅ Check `bd ready` before asking "what should I work on?" -- ❌ Do NOT create markdown TODO lists -- ❌ Do NOT use external issue trackers -- ❌ Do NOT duplicate tracking systems - -For more details, see README.md and docs/QUICKSTART.md. - - - -## Landing the Plane (Session Completion) - -**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. - -**MANDATORY WORKFLOW:** - -1. **File issues for remaining work** - Create issues for anything that needs follow-up -2. **Run quality gates** (if code changed) - Tests, linters, builds -3. **Update issue status** - Close finished work, update in-progress items -4. **PUSH TO REMOTE** - This is MANDATORY: - ```bash - git pull --rebase - bd sync - git push - git status # MUST show "up to date with origin" - ``` -5. **Clean up** - Clear stashes, prune remote branches -6. **Verify** - All changes committed AND pushed -7. **Hand off** - Provide context for next session - -**CRITICAL RULES:** -- Work is NOT complete until `git push` succeeds -- NEVER stop before pushing - that leaves work stranded locally -- NEVER say "ready to push when you are" - YOU must push -- If push fails, resolve and retry until it succeeds diff --git a/DesignRequirements.md b/DesignRequirements.md index c7a9a20..c67e344 100644 --- a/DesignRequirements.md +++ b/DesignRequirements.md @@ -41,3 +41,6 @@ Still mostly material design. ### Company Color Theme todo +## Code + +Always use `.withValues(alpha: )` instead of `.withOpacity()` for colors. \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index f1570c5..dbe579c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -42,3 +42,7 @@ android { flutter { source = "../.." } + +dependencies { + implementation "io.reactivex.rxjava2:rxjava:2.2.21" +} diff --git a/android/app/src/main/kotlin/com/example/abawo_bt_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/abawo_bt_app/MainActivity.kt index 1005a24..f45a606 100644 --- a/android/app/src/main/kotlin/com/example/abawo_bt_app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/abawo_bt_app/MainActivity.kt @@ -1,5 +1,55 @@ package com.example.abawo_bt_app +import android.content.Intent +import android.os.Bundle +import android.provider.Settings import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import io.reactivex.exceptions.UndeliverableException +import io.reactivex.plugins.RxJavaPlugins -class MainActivity: FlutterActivity() +class MainActivity: FlutterActivity() { + private val settingsChannel = "abawo/settings" + + override fun onCreate(savedInstanceState: Bundle?) { + RxJavaPlugins.setErrorHandler { throwable -> + val error = if (throwable is UndeliverableException && throwable.cause != null) { + throwable.cause!! + } else { + throwable + } + val className = error.javaClass.name + val message = error.message.orEmpty() + if (className.contains("BleGatt") || message.contains("GATT exception")) { + return@setErrorHandler + } + Thread.currentThread().uncaughtExceptionHandler + ?.uncaughtException(Thread.currentThread(), error) + } + super.onCreate(savedInstanceState) + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, settingsChannel) + .setMethodCallHandler { call, result -> + when (call.method) { + "openBluetoothSettings" -> { + try { + startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) + result.success(true) + } catch (_: Exception) { + try { + startActivity(Intent(Settings.ACTION_SETTINGS)) + result.success(true) + } catch (_: Exception) { + result.success(false) + } + } + } + else -> result.notImplemented() + } + } + } +} diff --git a/lib/controller/bluetooth.dart b/lib/controller/bluetooth.dart index c63b091..c54844b 100644 --- a/lib/controller/bluetooth.dart +++ b/lib/controller/bluetooth.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:anyhow/anyhow.dart'; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' hide ConnectionStatus, Result, Logger; @@ -173,9 +175,6 @@ class BluetoothController { (currentState.$1 == ConnectionStatus.connected || currentState.$1 == ConnectionStatus.connecting)) { log.info('Already connected or connecting to $deviceId.'); - if (currentState.$1 == ConnectionStatus.connected) { - unawaited(_requestMtuOnConnect(deviceId)); - } return Ok(null); } @@ -191,6 +190,7 @@ class BluetoothController { try { await _connectionStateSubscription?.cancel(); _updateConnectionState(ConnectionStatus.connecting, deviceId); + final connectionResult = Completer>(); _connectionStateSubscription = _ble .connectToDevice( @@ -199,12 +199,21 @@ class BluetoothController { servicesWithCharacteristicsToDiscover: servicesWithCharacteristicsToDiscover, ) - .listen((update) { + .listen((update) async { switch (update.connectionState) { case DeviceConnectionState.connected: _connectedDeviceId = deviceId; _updateConnectionState(ConnectionStatus.connected, deviceId); - unawaited(_requestMtuOnConnect(deviceId)); + if (!connectionResult.isCompleted) { + final mtuResult = await _requestInitialMtu(deviceId); + if (mtuResult.isErr()) { + log.warning( + 'Initial MTU request failed for $deviceId: ' + '${mtuResult.unwrapErr()}', + ); + } + connectionResult.complete(Ok(null)); + } break; case DeviceConnectionState.connecting: _updateConnectionState(ConnectionStatus.connecting, deviceId); @@ -214,14 +223,31 @@ class BluetoothController { break; case DeviceConnectionState.disconnected: _cleanUpConnection(); + if (!connectionResult.isCompleted) { + connectionResult.complete( + bail('Failed to connect to $deviceId: disconnected'), + ); + } break; } }, onError: (Object error, StackTrace st) { log.severe('Failed to connect to $deviceId: $error', error, st); _cleanUpConnection(); + if (!connectionResult.isCompleted) { + connectionResult.complete( + bail('Failed to connect to $deviceId: $error'), + ); + } }); - return Ok(null); + try { + return await connectionResult.future.timeout(timeout); + } on TimeoutException { + await _connectionStateSubscription?.cancel(); + _connectionStateSubscription = null; + _cleanUpConnection(); + return bail('Timed out connecting to $deviceId'); + } } catch (e) { _cleanUpConnection(); return bail('Failed to connect to $deviceId: $e'); @@ -301,7 +327,7 @@ class BluetoothController { {int mtu = defaultMtu}) async { final result = await requestMtuAndGetValue(deviceId, mtu: mtu); if (result.isErr()) { - return bail(result.unwrapErr()); + return Err(result.unwrapErr()); } return Ok(null); } @@ -310,6 +336,10 @@ class BluetoothController { {int mtu = defaultMtu}) async { try { final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu); + if (negotiatedMtu <= 0) { + return bail( + 'Error requesting MTU $mtu for $deviceId: negotiated invalid MTU $negotiatedMtu'); + } log.info( 'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu'); return Ok(negotiatedMtu); @@ -318,12 +348,11 @@ class BluetoothController { } } - Future _requestMtuOnConnect(String deviceId) async { - final mtuResult = await requestMtu(deviceId, mtu: defaultMtu); - if (mtuResult.isErr()) { - log.warning( - 'MTU request after connect failed for $deviceId: ${mtuResult.unwrapErr()}'); + Future> _requestInitialMtu(String deviceId) async { + if (defaultTargetPlatform != TargetPlatform.android) { + return Ok(null); } + return requestMtu(deviceId, mtu: defaultMtu); } Stream> subscribeToCharacteristic( diff --git a/lib/controller/bluetooth.old.dart b/lib/controller/bluetooth.old.dart deleted file mode 100644 index cca19cd..0000000 --- a/lib/controller/bluetooth.old.dart +++ /dev/null @@ -1,627 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:anyhow/anyhow.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'bluetooth.g.dart'; - -final log = Logger('BluetoothController'); - -@Riverpod(keepAlive: true) -Future 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? _btStateSubscription; - StreamSubscription>? _scanResultsSubscription; - List _latestScanResults = []; - StreamSubscription? _servicesResetSubscription; - final Map> _servicesByDevice = {}; - final Map> - _characteristicsByDevice = {}; - // Connection State - BluetoothDevice? _connectedDevice; - StreamSubscription? _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> 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> startScan({ - List? withServices, - List? 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> 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 get scanResults => _latestScanResults; - - /// Wait for the current scan to complete - Future> 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 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> 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> 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> 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>> discoverServices( - BluetoothDevice device, { - bool force = false, - }) async { - return _discoverAndCacheServices(device, force: force); - } - - Future> writeCharacteristic( - BluetoothDevice device, - String serviceUuid, - String characteristicUuid, - List 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>>> subscribeToNotifications( - BluetoothDevice device, - String serviceUuid, - String characteristicUuid, { - void Function(List)? onValue, - bool useLastValueStream = false, - int timeout = 15, - }) async { - return _subscribeToCharacteristic( - device, - serviceUuid, - characteristicUuid, - useLastValueStream: useLastValueStream, - timeout: timeout, - forceIndications: false, - onValue: onValue, - ); - } - - Future>>> subscribeToIndications( - BluetoothDevice device, - String serviceUuid, - String characteristicUuid, { - void Function(List)? onValue, - bool useLastValueStream = false, - int timeout = 15, - }) async { - return _subscribeToCharacteristic( - device, - serviceUuid, - characteristicUuid, - useLastValueStream: useLastValueStream, - timeout: timeout, - forceIndications: true, - onValue: onValue, - ); - } - - Future> 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 _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> dispose() async { - await _scanResultsSubscription?.cancel(); - await _btStateSubscription?.cancel(); - await disconnect(); // Ensure disconnection on dispose - await _connectionStateSubject.close(); - return Ok(null); - } - - Future>> 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 services) { - final serviceMap = {}; - final characteristicMap = {}; - - 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>> _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> _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>>> _subscribeToCharacteristic( - BluetoothDevice device, - String serviceUuid, - String characteristicUuid, { - required bool forceIndications, - required bool useLastValueStream, - required int timeout, - void Function(List)? 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'); - } - } -} diff --git a/lib/database/database.dart b/lib/database/database.dart index 10ca118..082787c 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -32,6 +32,15 @@ class NConnectedDevices extends _$NConnectedDevices { } return res; } + + Future> updateConnectedDeviceLastConnected(int id) async { + final db = ref.watch(databaseProvider); + final res = await db.updateConnectedDeviceLastConnected(id); + if (res.isOk()) { + ref.invalidateSelf(); + } + return res; + } } /// Provider for the [AppDatabase] instance @@ -137,6 +146,22 @@ class AppDatabase extends _$AppDatabase { } } + Future> updateConnectedDeviceLastConnected(int id) async { + try { + final count = await (update(connectedDevices) + ..where((tbl) => tbl.id.equals(id))) + .write(ConnectedDevicesCompanion( + lastConnectedAt: Value(DateTime.now()))); + if (count == 0) { + return bail('Device with id $id not found.'); + } + return Ok(()); + } catch (e, st) { + return bail( + 'Failed to update last connected time for device $id: $e', st); + } + } + Stream> getAllConnectedDevicesStream() { return select(connectedDevices).watch(); } diff --git a/lib/main.dart b/lib/main.dart index 37f0d5b..8b7e407 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/pages/devices_page.dart'; import 'package:abawo_bt_app/pages/devices_tab_page.dart'; import 'package:abawo_bt_app/src/rust/frb_generated.dart'; @@ -29,11 +32,47 @@ Future main() async { ], child: const AbawoBtApp())); } -class AbawoBtApp extends ConsumerWidget { +class AbawoBtApp extends ConsumerStatefulWidget { const AbawoBtApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _AbawoBtAppState(); +} + +class _AbawoBtAppState extends ConsumerState + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.hidden || + state == AppLifecycleState.paused) { + unawaited(_disconnectBluetoothForBackground()); + } + } + + Future _disconnectBluetoothForBackground() async { + final bluetooth = ref.read(bluetoothProvider).value; + if (bluetooth == null) { + return; + } + + await bluetooth.stopScan(); + await bluetooth.disconnect(); + } + + @override + Widget build(BuildContext context) { final themePreference = ref.watch(appThemePreferenceProvider); return MaterialApp.router( diff --git a/lib/model/shifter_types.dart b/lib/model/shifter_types.dart index f0930f6..c7138c2 100644 --- a/lib/model/shifter_types.dart +++ b/lib/model/shifter_types.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:cbor/simple.dart'; const String universalShifterControlServiceUuid = @@ -249,6 +251,9 @@ enum ControlConnectionState { } if (raw is String) { final normalized = raw.toLowerCase(); + if (normalized.contains('disconnected')) { + return ControlConnectionState.disconnected; + } if (normalized.contains('connected')) { return ControlConnectionState.connected; } @@ -294,32 +299,21 @@ class TrainerStatus { static TrainerStatus fromRaw(dynamic raw) { if (raw is int) { - switch (raw) { - case 1: - return const TrainerStatus(state: TrainerConnectionState.connecting); - case 2: - return const TrainerStatus(state: TrainerConnectionState.pairing); - case 3: - return const TrainerStatus(state: TrainerConnectionState.connected); - case 4: - return const TrainerStatus( - state: TrainerConnectionState.discoveringFtms); - case 5: - return const TrainerStatus(state: TrainerConnectionState.ftmsReady); - default: - return const TrainerStatus(state: TrainerConnectionState.idle); - } + return _trainerStatusFromVariant(raw); } if (raw is List && raw.isNotEmpty) { final variant = raw.first; final value = raw.length > 1 ? raw[1] : null; - if (variant is int && (variant == 5 || variant == 6)) { + if (variant is int && variant == 6) { return TrainerStatus( state: TrainerConnectionState.error, errorCode: value is int ? value : null, ); } + if (variant is int) { + return _trainerStatusFromVariant(variant); + } } if (raw is Map) { @@ -327,13 +321,16 @@ class TrainerStatus { if (entry != null) { final key = entry.key; final value = entry.value; - if ((key is int && (key == 5 || key == 6)) || + if ((key is int && key == 6) || (key is String && key.toLowerCase().contains('error'))) { return TrainerStatus( state: TrainerConnectionState.error, errorCode: value is int ? value : null, ); } + if (key is int) { + return _trainerStatusFromVariant(key); + } } } @@ -362,6 +359,26 @@ class TrainerStatus { return const TrainerStatus(state: TrainerConnectionState.idle); } + + static TrainerStatus _trainerStatusFromVariant(int variant) { + switch (variant) { + case 1: + return const TrainerStatus(state: TrainerConnectionState.connecting); + case 2: + return const TrainerStatus(state: TrainerConnectionState.pairing); + case 3: + return const TrainerStatus(state: TrainerConnectionState.connected); + case 4: + return const TrainerStatus( + state: TrainerConnectionState.discoveringFtms); + case 5: + return const TrainerStatus(state: TrainerConnectionState.ftmsReady); + case 6: + return const TrainerStatus(state: TrainerConnectionState.error); + default: + return const TrainerStatus(state: TrainerConnectionState.idle); + } + } } class CentralStatus { @@ -384,16 +401,26 @@ class CentralStatus { String get statusLine => 'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}'; + static CentralStatus disconnected({dynamic raw}) { + return CentralStatus( + control: ControlConnectionState.disconnected, + trainer: const TrainerStatus(state: TrainerConnectionState.idle), + hasSavedBond: false, + connectedTrainerAddr: null, + lastFailure: null, + raw: raw, + ); + } + static CentralStatus fromCborBytes(List bytes) { + if (bytes.isEmpty) { + throw const FormatException('Status payload is empty.'); + } + final decoded = cbor.decode(bytes); if (decoded is! Map) { - return CentralStatus( - control: ControlConnectionState.disconnected, - trainer: const TrainerStatus(state: TrainerConnectionState.idle), - hasSavedBond: false, - connectedTrainerAddr: null, - lastFailure: null, - raw: decoded, + throw FormatException( + 'Status payload must decode to a CBOR map, got ${decoded.runtimeType}.', ); } @@ -428,6 +455,9 @@ List? _toByteList(dynamic value) { if (value == null) { return null; } + if (value is Uint8List) { + return value.toList(growable: false); + } if (value is List) { return value.whereType().toList(growable: false); } diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index b0f6001..2a3f056 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -5,9 +5,12 @@ import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:abawo_bt_app/service/firmware_file_selection_service.dart'; import 'package:abawo_bt_app/service/firmware_update_service.dart'; import 'package:abawo_bt_app/service/shifter_service.dart'; +import 'package:abawo_bt_app/util/bluetooth_settings.dart'; import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart'; import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' + show DiscoveredDevice; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:nb_utils/nb_utils.dart'; @@ -48,11 +51,11 @@ class _DeviceDetailsPageState extends ConsumerState { 3.27, ]; - bool _isReconnecting = false; - bool _wasConnectedToCurrentDevice = false; bool _isExitingPage = false; bool _hasRequestedDisconnect = false; - Timer? _reconnectTimeoutTimer; + bool _hasShownPairingRecoveryDialog = false; + bool _isAssignTrainerDialogOpen = false; + bool _isManualReconnectRunning = false; ProviderSubscription>? _connectionStatusSubscription; @@ -124,7 +127,6 @@ class _DeviceDetailsPageState extends ConsumerState { @override void dispose() { unawaited(_disconnectOnClose()); - _reconnectTimeoutTimer?.cancel(); _connectionStatusSubscription?.close(); _statusSubscription?.cancel(); _shifterService?.dispose(); @@ -134,15 +136,17 @@ class _DeviceDetailsPageState extends ConsumerState { } Future _disconnectOnClose() async { + if (_isFirmwareUpdateBusy) { + return; + } + if (_hasRequestedDisconnect) { return; } _hasRequestedDisconnect = true; _isExitingPage = true; - _reconnectTimeoutTimer?.cancel(); - await _firmwareUpdateService?.cancelUpdate(); await _disposeFirmwareUpdateService(); final bluetooth = ref.read(bluetoothProvider).value; @@ -159,55 +163,28 @@ class _DeviceDetailsPageState extends ConsumerState { final isCurrentDevice = connectedDeviceId == widget.deviceAddress; if (isCurrentDevice && status == ConnectionStatus.connected) { - _wasConnectedToCurrentDevice = true; _startStatusStreamingIfNeeded(); - if (_isReconnecting) { - _reconnectTimeoutTimer?.cancel(); - setState(() { - _isReconnecting = false; - }); - } return; } - if (_wasConnectedToCurrentDevice && - !_isReconnecting && - status == ConnectionStatus.disconnected && - !_isFirmwareUpdateBusy) { - _startReconnect(); - } - if ((!isCurrentDevice || status == ConnectionStatus.disconnected) && !_isFirmwareUpdateBusy) { _stopStatusStreaming(); } } - Future _startReconnect() async { - if (!mounted || _isExitingPage || _isReconnecting) { - return; + Future _startStatusStreamingIfNeeded() async { + bool isCurrentDeviceConnected(BluetoothController bluetooth) { + final connectionState = bluetooth.currentConnectionState; + return connectionState.$1 == ConnectionStatus.connected && + connectionState.$2 == widget.deviceAddress; } - setState(() { - _isReconnecting = true; - }); - - final bluetooth = ref.read(bluetoothProvider).value; - await bluetooth?.connectById(widget.deviceAddress); - - _reconnectTimeoutTimer?.cancel(); - _reconnectTimeoutTimer = Timer(const Duration(seconds: 10), () { - if (!mounted || !_isReconnecting || _isExitingPage) { + if (_shifterService != null) { + final bluetooth = ref.read(bluetoothProvider).value; + if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) { return; } - _terminateConnectionAndGoHome( - 'Connection lost. Could not reconnect in time.', - ); - }); - } - - Future _startStatusStreamingIfNeeded() async { - if (_shifterService != null) { _statusSubscription ??= _shifterService!.statusStream.listen((status) { if (!mounted) { return; @@ -227,16 +204,28 @@ class _DeviceDetailsPageState extends ConsumerState { } else { bluetooth = await ref.read(bluetoothProvider.future); } + if (!isCurrentDeviceConnected(bluetooth)) { + return; + } final service = ShifterService( bluetooth: bluetooth, buttonDeviceId: widget.deviceAddress, ); final initialStatusResult = await service.readStatus(); - if (mounted && initialStatusResult.isOk()) { - _recordStatus(initialStatusResult.unwrap()); + if (!mounted) { + await service.dispose(); + return; } + if (initialStatusResult.isErr()) { + await service.dispose(); + await _showPairingRecoveryDialog(); + return; + } + + _recordStatus(initialStatusResult.unwrap()); + _statusSubscription = service.statusStream.listen((status) { if (!mounted) { return; @@ -251,6 +240,15 @@ class _DeviceDetailsPageState extends ConsumerState { unawaited(_loadGearRatios()); } + Future _showPairingRecoveryDialog() async { + if (!mounted || _hasShownPairingRecoveryDialog) { + return; + } + + _hasShownPairingRecoveryDialog = true; + await showBluetoothPairingRecoveryDialog(context); + } + void _recordStatus(CentralStatus status) { setState(() { _latestStatus = status; @@ -360,6 +358,10 @@ class _DeviceDetailsPageState extends ConsumerState { } Future _connectButtonToBike() async { + if (_isAssignTrainerDialogOpen) { + return; + } + if (_isFirmwareUpdateBusy) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -369,10 +371,16 @@ class _DeviceDetailsPageState extends ConsumerState { return; } - final selectedBike = await BikeScanDialog.show( - context, - excludedDeviceId: widget.deviceAddress, - ); + _isAssignTrainerDialogOpen = true; + final DiscoveredDevice? selectedBike; + try { + selectedBike = await BikeScanDialog.show( + context, + excludedDeviceId: widget.deviceAddress, + ); + } finally { + _isAssignTrainerDialogOpen = false; + } if (selectedBike == null || !mounted) { return; } @@ -533,17 +541,6 @@ class _DeviceDetailsPageState extends ConsumerState { }); } - Future _cancelFirmwareUpdate() async { - final updater = _firmwareUpdateService; - if (updater == null || !_isFirmwareUpdateBusy) { - return; - } - setState(() { - _firmwareUserMessage = 'Canceling firmware update...'; - }); - await updater.cancelUpdate(); - } - String _dfuPhaseText(DfuUpdateState state) { switch (state) { case DfuUpdateState.idle: @@ -579,27 +576,68 @@ class _DeviceDetailsPageState extends ConsumerState { return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}'; } - Future _terminateConnectionAndGoHome(String toastMessage) async { - await _disconnectOnClose(); - - if (!mounted) { + Future _manualReconnect() async { + if (_isManualReconnectRunning || _isFirmwareUpdateBusy) { return; } - toast(toastMessage); - context.replace('/devices'); - } + setState(() { + _isManualReconnectRunning = true; + }); - Future _cancelReconnect() async { - await _terminateConnectionAndGoHome('Reconnect cancelled.'); + try { + final bluetooth = await ref.read(bluetoothProvider.future); + final result = await bluetooth.connectById( + widget.deviceAddress, + timeout: const Duration(seconds: 10), + ); + + if (!mounted) { + return; + } + + if (result.isErr()) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Reconnect failed. Is the device turned on and in range?', + ), + ), + ); + } + } catch (error) { + if (!mounted) { + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Reconnect failed: $error')), + ); + } finally { + if (mounted) { + setState(() { + _isManualReconnectRunning = false; + }); + } + } } Future _exitPage() async { + if (_isFirmwareUpdateBusy) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Firmware update is running. Keep this screen open until it completes.'), + ), + ); + return; + } + await _disconnectOnClose(); if (!mounted) { return; } - context.replace('/devices'); + context.go('/devices'); } void _showStatusHistory() { @@ -713,19 +751,18 @@ class _DeviceDetailsPageState extends ConsumerState { @override Widget build(BuildContext context) { final connectionData = ref.watch(connectionStatusProvider).valueOrNull; - final currentConnectionStatus = connectionData != null && - connectionData.$2 == widget.deviceAddress - ? connectionData.$1 - : ConnectionStatus.disconnected; - final isCurrentConnected = currentConnectionStatus == ConnectionStatus.connected; + final currentConnectionStatus = + connectionData != null && connectionData.$2 == widget.deviceAddress + ? connectionData.$1 + : ConnectionStatus.disconnected; + final isCurrentConnected = + currentConnectionStatus == ConnectionStatus.connected; final canSelectFirmware = isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy; final canStartFirmware = isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy && _selectedFirmware != null; - final canCancelFirmware = _isFirmwareUpdateBusy; - return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, bool? result) { @@ -756,11 +793,6 @@ class _DeviceDetailsPageState extends ConsumerState { ), const SizedBox(height: 20), if (isCurrentConnected) ...[ - _TrainerConnectionCard( - status: _latestStatus, - onAssign: _isFirmwareUpdateBusy ? null : _connectButtonToBike, - onShowStatusConsole: _showStatusHistory, - ), const SizedBox(height: 16), _StatusBanner( status: _latestStatus, @@ -775,22 +807,11 @@ class _DeviceDetailsPageState extends ConsumerState { }, ), const SizedBox(height: 16), - _FirmwareUpdateCard( - selectedFirmware: _selectedFirmware, - progress: _dfuProgress, - isSelecting: _isSelectingFirmware, - isStarting: _isStartingFirmwareUpdate, - canSelect: canSelectFirmware, - canStart: canStartFirmware, - canCancel: canCancelFirmware, - phaseText: _dfuPhaseText(_dfuProgress.state), - statusText: _firmwareUserMessage, - formattedProgressBytes: - '${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}', - onSelectFirmware: _selectFirmwareFile, - onStartUpdate: _startFirmwareUpdate, - onCancelUpdate: _cancelFirmwareUpdate, - ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence), + _TrainerConnectionCard( + status: _latestStatus, + onAssign: + _isFirmwareUpdateBusy ? null : _connectButtonToBike, + onShowStatusConsole: _showStatusHistory, ), const SizedBox(height: 16), Opacity( @@ -873,50 +894,32 @@ class _DeviceDetailsPageState extends ConsumerState { ), ), ), + const SizedBox(height: 16), + _FirmwareUpdateCard( + selectedFirmware: _selectedFirmware, + progress: _dfuProgress, + isSelecting: _isSelectingFirmware, + isStarting: _isStartingFirmwareUpdate, + canSelect: canSelectFirmware, + canStart: canStartFirmware, + phaseText: _dfuPhaseText(_dfuProgress.state), + statusText: _firmwareUserMessage, + formattedProgressBytes: + '${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}', + onSelectFirmware: _selectFirmwareFile, + onStartUpdate: _startFirmwareUpdate, + ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence), + ), ] else ...[ - const _DisconnectedDetailCard(), + _DisconnectedDetailCard( + isReconnecting: _isManualReconnectRunning, + onReconnect: _manualReconnect, + onBackToDevices: _exitPage, + ), ], ], ), ), - if (_isReconnecting) - Positioned.fill( - child: ColoredBox( - color: Colors.black.withValues(alpha: 0.55), - child: Center( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 24), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.55), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - 'Reconnecting...', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - TextButton( - onPressed: _cancelReconnect, - child: const Text('Cancel'), - ), - ], - ), - ), - ), - ), - ), ], ), ), @@ -942,14 +945,12 @@ class _FirmwareUpdateCard extends StatelessWidget { required this.isStarting, required this.canSelect, required this.canStart, - required this.canCancel, required this.phaseText, required this.statusText, required this.formattedProgressBytes, required this.ackSequenceHex, required this.onSelectFirmware, required this.onStartUpdate, - required this.onCancelUpdate, }); final DfuV1PreparedFirmware? selectedFirmware; @@ -958,14 +959,12 @@ class _FirmwareUpdateCard extends StatelessWidget { final bool isStarting; final bool canSelect; final bool canStart; - final bool canCancel; final String phaseText; final String? statusText; final String formattedProgressBytes; final String ackSequenceHex; final Future Function() onSelectFirmware; final Future Function() onStartUpdate; - final Future Function() onCancelUpdate; bool get _showProgress { return progress.totalBytes > 0 || @@ -986,126 +985,123 @@ class _FirmwareUpdateCard extends StatelessWidget { return Card( child: Padding( padding: const EdgeInsets.fromLTRB(18, 18, 18, 18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.system_update_alt_rounded, color: colorScheme.primary), - const SizedBox(width: 10), - const Text( - 'Firmware Update', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), - ), - ], - ), - const SizedBox(height: 8), - Text( - 'Select a firmware image, review the transfer state, and start the update when ready.', - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.68), - ), - ), - const SizedBox(height: 14), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - OutlinedButton.icon( - onPressed: canSelect ? onSelectFirmware : null, - icon: isSelecting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.upload_file), - label: const Text('Select Firmware'), - ), - FilledButton.icon( - onPressed: canStart ? onStartUpdate : null, - icon: isStarting - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.system_update_alt), - label: const Text('Start Update'), - ), - TextButton.icon( - onPressed: canCancel ? onCancelUpdate : null, - icon: const Icon(Icons.stop_circle_outlined), - label: const Text('Cancel Update'), - ), - ], - ), - const SizedBox(height: 14), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54), - borderRadius: BorderRadius.circular(18), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Text( - selectedFirmware == null - ? 'Selected file: none' - : 'Selected file: ${selectedFirmware!.fileName}', - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + Icon(Icons.system_update_alt_rounded, + color: colorScheme.primary), + const SizedBox(width: 10), + const Text( + 'Firmware Update', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), ), - if (selectedFirmware != null) ...[ - const SizedBox(height: 4), - Text( - 'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}', - style: theme.textTheme.bodySmall, - ), - ], ], ), - ), - const SizedBox(height: 14), - Text('Phase: $phaseText', style: theme.textTheme.bodyMedium), - if (_showProgress) ...[ - const SizedBox(height: 10), - LinearProgressIndicator( - value: progress.totalBytes > 0 ? progress.fractionComplete : 0, - minHeight: 10, - borderRadius: BorderRadius.circular(999), - ), - const SizedBox(height: 6), - Text( - '${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex', - style: theme.textTheme.bodySmall, - ), - ], - if (_showRebootExpectation) ...[ const SizedBox(height: 8), Text( - 'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w600, + 'Select a firmware image, review the transfer state, and start the update when ready.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.68), ), ), - ], - if (statusText != null && statusText!.trim().isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - statusText!, - style: theme.textTheme.bodySmall?.copyWith( - color: progress.state == DfuUpdateState.failed - ? colorScheme.error - : theme.textTheme.bodySmall?.color, + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: canSelect ? onSelectFirmware : null, + icon: isSelecting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload_file), + label: const Text('Select Firmware'), ), + FilledButton.icon( + onPressed: canStart ? onStartUpdate : null, + icon: isStarting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.system_update_alt), + label: const Text('Start Update'), + ), + ], ), + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: + colorScheme.surfaceContainerHighest.withValues(alpha: 0.54), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedFirmware == null + ? 'Selected file: none' + : 'Selected file: ${selectedFirmware!.fileName}', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (selectedFirmware != null) ...[ + const SizedBox(height: 4), + Text( + 'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}', + style: theme.textTheme.bodySmall, + ), + ], + ], + ), + ), + const SizedBox(height: 14), + Text('Phase: $phaseText', style: theme.textTheme.bodyMedium), + if (_showProgress) ...[ + const SizedBox(height: 10), + LinearProgressIndicator( + value: progress.totalBytes > 0 ? progress.fractionComplete : 0, + minHeight: 10, + borderRadius: BorderRadius.circular(999), + ), + const SizedBox(height: 6), + Text( + '${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex', + style: theme.textTheme.bodySmall, + ), + ], + if (_showRebootExpectation) ...[ + const SizedBox(height: 8), + Text( + 'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + if (statusText != null && statusText!.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + statusText!, + style: theme.textTheme.bodySmall?.copyWith( + color: progress.state == DfuUpdateState.failed + ? colorScheme.error + : theme.textTheme.bodySmall?.color, + ), + ), + ], ], - ], - ), + ), ), ); } @@ -1207,12 +1203,10 @@ class _StatusBanner extends StatelessWidget { Widget _buildDeviceOverviewCard( BuildContext context, WidgetRef ref, - String deviceAddress, - { + String deviceAddress, { required ConnectionStatus connectionStatus, required CentralStatus? status, -} -) { +}) { final asyncSavedDevices = ref.watch(nConnectedDevicesProvider); return asyncSavedDevices.when( @@ -1301,9 +1295,10 @@ class _DeviceOverviewCard extends StatelessWidget { children: [ Text( device.deviceName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 8), _DetailStatusChip(status: connectionStatus), @@ -1311,7 +1306,8 @@ class _DeviceOverviewCard extends StatelessWidget { Text( trainerAddress, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.68), + color: + colorScheme.onSurface.withValues(alpha: 0.68), ), ), ], @@ -1395,7 +1391,8 @@ class _TrainerConnectionCard extends StatelessWidget { shape: BoxShape.circle, color: colorScheme.primary.withValues(alpha: 0.12), ), - child: Icon(Icons.pedal_bike_rounded, color: colorScheme.primary), + child: Icon(Icons.pedal_bike_rounded, + color: colorScheme.primary), ), const SizedBox(width: 14), Expanded( @@ -1412,7 +1409,8 @@ class _TrainerConnectionCard extends StatelessWidget { Text( trainerText, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.68), + color: + colorScheme.onSurface.withValues(alpha: 0.68), ), ), ], @@ -1424,12 +1422,14 @@ class _TrainerConnectionCard extends StatelessWidget { Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54), + color: + colorScheme.surfaceContainerHighest.withValues(alpha: 0.54), borderRadius: BorderRadius.circular(18), ), child: Row( children: [ - Icon(Icons.bluetooth_connected_rounded, color: colorScheme.primary), + Icon(Icons.bluetooth_connected_rounded, + color: colorScheme.primary), const SizedBox(width: 10), Expanded( child: Text( @@ -1470,21 +1470,86 @@ class _TrainerConnectionCard extends StatelessWidget { } class _DisconnectedDetailCard extends StatelessWidget { - const _DisconnectedDetailCard(); + const _DisconnectedDetailCard({ + required this.isReconnecting, + required this.onReconnect, + required this.onBackToDevices, + }); + + final bool isReconnecting; + final VoidCallback onReconnect; + final VoidCallback onBackToDevices; @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( child: Padding( padding: const EdgeInsets.all(18), - child: Text( - 'This device is not currently connected. Reopen it from Devices to reconnect and manage trainer pairing, firmware, and gear ratios.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.68), - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.error.withValues(alpha: 0.12), + ), + child: Icon( + Icons.bluetooth_disabled_rounded, + color: colorScheme.error, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + 'No connection', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + 'This device is not currently connected. Turn it on and keep it nearby, then reconnect when you are ready.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.68), + ), + ), + const SizedBox(height: 18), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: isReconnecting ? null : onReconnect, + icon: isReconnecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.bluetooth_connected_rounded), + label: + Text(isReconnecting ? 'Reconnecting...' : 'Reconnect'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: isReconnecting ? null : onBackToDevices, + icon: const Icon(Icons.arrow_back_rounded), + label: const Text('Devices'), + ), + ), + ], + ), + ], ), ), ); @@ -1501,9 +1566,14 @@ class _DetailStatusChip extends StatelessWidget { final (label, color) = switch (status) { ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)), ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)), - ConnectionStatus.disconnecting => ('Disconnecting', const Color(0xFFFFB649)), - ConnectionStatus.disconnected => - ('Disconnected', Theme.of(context).colorScheme.primary), + ConnectionStatus.disconnecting => ( + 'Disconnecting', + const Color(0xFFFFB649) + ), + ConnectionStatus.disconnected => ( + 'Disconnected', + Theme.of(context).colorScheme.primary + ), }; return Container( diff --git a/lib/pages/devices_page.dart b/lib/pages/devices_page.dart index c8644d7..e4206c0 100644 --- a/lib/pages/devices_page.dart +++ b/lib/pages/devices_page.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/database/database.dart'; import 'package:abawo_bt_app/model/bluetooth_device_model.dart'; +import 'package:abawo_bt_app/util/bluetooth_settings.dart'; import 'package:abawo_bt_app/util/constants.dart'; import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart'; import 'package:anyhow/anyhow.dart'; @@ -63,9 +64,8 @@ class _ConnectDevicePageState extends ConsumerState { return; } - final isAbawoDevice = isAbawoDeviceIdent(device.manufacturerData); - final isConnectable = - device.serviceUuids.any(isConnectableAbawoDeviceGuid); + final isAbawoDevice = device.serviceUuids.any(isAbawoDeviceGuid); + final isConnectable = device.serviceUuids.any(isConnectableAbawoDeviceGuid); if (!isAbawoDevice) { ScaffoldMessenger.of(context).showSnackBar( @@ -122,13 +122,18 @@ class _ConnectDevicePageState extends ConsumerState { ), ); } else { - context.push('/device/${device.id}'); + context.go('/device/${device.id}'); } break; case Err(:final v): - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Connection unsuccessful:\n${v.toString()}')), - ); + final error = v.toString(); + if (error.toLowerCase().contains('disconnected')) { + await showBluetoothPairingRecoveryDialog(context); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Connection unsuccessful:\n$error')), + ); + } break; } } @@ -224,7 +229,8 @@ class _ConnectDevicePageState extends ConsumerState { .toSet(); return btAsyncValue.when( - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => + const Center(child: CircularProgressIndicator()), error: (err, stack) => Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: _ScanMessageCard( @@ -275,15 +281,16 @@ class _ConnectDevicePageState extends ConsumerState { return ListView.separated( padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), itemCount: filteredResults.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), + separatorBuilder: (_, __) => + const SizedBox(height: 12), itemBuilder: (context, index) { final device = filteredResults[index]; final isAlreadyConnected = connectedDeviceAddresses.contains(device.id); final tone = _ScanResultTone.resolve( isAlreadyConnected: isAlreadyConnected, - isAbawoDevice: - isAbawoDeviceIdent(device.manufacturerData), + isAbawoDevice: hasConnectableAbawoDeviceGuid( + device.serviceUuids), isConnectable: device.serviceUuids .any(isConnectableAbawoDeviceGuid), ); diff --git a/lib/pages/devices_tab_page.dart b/lib/pages/devices_tab_page.dart index 6246efb..65bfc86 100644 --- a/lib/pages/devices_tab_page.dart +++ b/lib/pages/devices_tab_page.dart @@ -168,6 +168,14 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> { } if (result.isOk()) { + await ref + .read(nConnectedDevicesProvider.notifier) + .updateConnectedDeviceLastConnected(device.id); + + if (!mounted) { + return; + } + context.push('/device/${device.deviceAddress}'); } else { ScaffoldMessenger.of(context).showSnackBar( @@ -256,21 +264,29 @@ class _ActiveDeviceCard extends StatelessWidget { @override Widget build(BuildContext context) { - if (devices.isEmpty) { - return _MessageCard( - title: 'No connected devices yet', - message: 'Your saved shifters will show up here with status and shortcuts.', - actionLabel: 'Connect Device', - onAction: () => context.push('/connect_device'), - ); + final shifterDevices = devices + .where( + (device) => + deviceTypeFromString(device.deviceType) == + DeviceType.universalShifters, + ) + .toList() + ..sort((a, b) { + final aLastConnected = a.lastConnectedAt ?? a.createdAt; + final bLastConnected = b.lastConnectedAt ?? b.createdAt; + return bLastConnected.compareTo(aLastConnected); + }); + + if (shifterDevices.isEmpty) { + return const SizedBox.shrink(); } final connectedId = connectionData?.$2; final primaryDevice = connectedId == null - ? devices.first - : devices.firstWhere( + ? shifterDevices.first + : shifterDevices.firstWhere( (device) => device.deviceAddress == connectedId, - orElse: () => devices.first, + orElse: () => shifterDevices.first, ); final isConnected = connectedId == primaryDevice.deviceAddress && connectionData?.$1 == ConnectionStatus.connected; @@ -306,10 +322,9 @@ class _ActiveDeviceCard extends StatelessWidget { children: [ Text( primaryDevice.deviceName, - style: - Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 8), _StatusChip( @@ -412,14 +427,16 @@ class _SavedDeviceTile extends StatelessWidget { shape: BoxShape.circle, color: isConnected ? colorScheme.primary.withValues(alpha: 0.14) - : colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), + : colorScheme.surfaceContainerHighest + .withValues(alpha: 0.7), ), child: Icon( deviceTypeFromString(device.deviceType) == DeviceType.universalShifters ? Icons.bluetooth_rounded : Icons.memory_rounded, - color: isConnected ? colorScheme.primary : colorScheme.onSurface, + color: + isConnected ? colorScheme.primary : colorScheme.onSurface, ), ), const SizedBox(width: 14), @@ -447,7 +464,8 @@ class _SavedDeviceTile extends StatelessWidget { Text( device.deviceAddress, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.62), + color: + colorScheme.onSurface.withValues(alpha: 0.62), ), ), ], diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 4a62369..9bbb99c 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -186,6 +186,14 @@ class _DevicesListState extends ConsumerState { } if (result.isOk()) { + await ref + .read(nConnectedDevicesProvider.notifier) + .updateConnectedDeviceLastConnected(device.id); + + if (!context.mounted) { + return; + } + context.go('/device/${device.deviceAddress}'); } else { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/service/firmware_update_service.dart b/lib/service/firmware_update_service.dart index 399d891..84a54c0 100644 --- a/lib/service/firmware_update_service.dart +++ b/lib/service/firmware_update_service.dart @@ -629,7 +629,7 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { final connectResult = await bluetoothController.connectById(buttonDeviceId, timeout: timeout); if (connectResult.isErr()) { - return bail(connectResult.unwrapErr()); + return Err(connectResult.unwrapErr()); } final currentState = bluetoothController.currentConnectionState; @@ -663,7 +663,7 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { try { final statusResult = await shifterService.readStatus().timeout(timeout); if (statusResult.isErr()) { - return bail(statusResult.unwrapErr()); + return Err(statusResult.unwrapErr()); } return Ok(null); } on TimeoutException { diff --git a/lib/service/shifter_service.dart b/lib/service/shifter_service.dart index 26eeb95..f3c1eb1 100644 --- a/lib/service/shifter_service.dart +++ b/lib/service/shifter_service.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:anyhow/anyhow.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('ShifterService'); class ShifterService { ShifterService({ @@ -83,7 +86,7 @@ class ShifterService { universalShifterGearRatiosCharacteristicUuid, ); if (readRes.isErr()) { - return bail(readRes.unwrapErr()); + return Err(readRes.unwrapErr()); } final raw = readRes.unwrap(); @@ -159,7 +162,7 @@ class ShifterService { universalShifterStatusCharacteristicUuid, ); if (readRes.isErr()) { - return bail(readRes.unwrapErr()); + return Err(readRes.unwrapErr()); } try { @@ -240,25 +243,34 @@ class ShifterService { return; } - _statusSubscription = _requireBluetooth - .subscribeToCharacteristic( - buttonDeviceId, - universalShifterControlServiceUuid, - universalShifterStatusCharacteristicUuid, - ) - .listen( - (data) { - try { - final status = CentralStatus.fromCborBytes(data); - _statusController.add(status); - } catch (_) { - // Ignore malformed payloads but keep stream alive. - } - }, - onError: (_) { - // Keep UI running; reconnection logic is handled elsewhere. - }, - ); + try { + _statusSubscription = _requireBluetooth + .subscribeToCharacteristic( + buttonDeviceId, + universalShifterControlServiceUuid, + universalShifterStatusCharacteristicUuid, + ) + .listen( + (data) { + try { + final status = CentralStatus.fromCborBytes(data); + _statusController.add(status); + } catch (error, st) { + _log.warning( + 'Failed to decode status notification from $buttonDeviceId: ' + 'bytes=${_formatBytes(data)}', + error, + st, + ); + } + }, + onError: (Object error, StackTrace st) { + _log.warning('Status notification stream failed', error, st); + }, + ); + } catch (error, st) { + _log.warning('Could not start status notifications', error, st); + } } Future stopStatusNotifications() async { @@ -286,6 +298,12 @@ class ShifterService { double _decodeGearRatio(int raw) { return raw / 64.0; } + + String _formatBytes(List bytes) { + return bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(' '); + } } abstract interface class DfuPreflightBluetoothAdapter { diff --git a/lib/util/bluetooth_settings.dart b/lib/util/bluetooth_settings.dart new file mode 100644 index 0000000..2becda8 --- /dev/null +++ b/lib/util/bluetooth_settings.dart @@ -0,0 +1,44 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +const MethodChannel _settingsChannel = MethodChannel('abawo/settings'); + +Future openBluetoothSettings() async { + if (!Platform.isAndroid) { + return false; + } + + try { + return await _settingsChannel.invokeMethod('openBluetoothSettings') ?? + false; + } on PlatformException { + return false; + } +} + +Future showBluetoothPairingRecoveryDialog(BuildContext context) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Bluetooth pairing may be broken'), + content: const Text( + 'The connection opened, then broke while reading the device. This is probably a pairing problem.\n\nOpen Bluetooth settings, remove/forget this device, then come back and connect again.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Not now'), + ), + FilledButton( + onPressed: () async { + Navigator.of(context).pop(); + await openBluetoothSettings(); + }, + child: const Text('Open Bluetooth settings'), + ), + ], + ), + ); +} diff --git a/lib/util/constants.dart b/lib/util/constants.dart index 2bb47a3..949bc8d 100644 --- a/lib/util/constants.dart +++ b/lib/util/constants.dart @@ -22,6 +22,10 @@ bool isConnectableAbawoDeviceGuid(Uuid guid) { return isAbawoUniversalShiftersDeviceGuid(guid); } +bool hasConnectableAbawoDeviceGuid(List guid) => guid + .map((id) => isConnectableAbawoDeviceGuid(id)) + .fold(false, (v, e) => v || e); + bool isAbawoDeviceIdent(List manuData) { if (manuData.length < abawoManuIdentData.length) return false; for (int i = 0; i < abawoManuIdentData.length; i++) { diff --git a/lib/widgets/bike_scan_dialog.dart b/lib/widgets/bike_scan_dialog.dart index 7f5e705..aa03ccb 100644 --- a/lib/widgets/bike_scan_dialog.dart +++ b/lib/widgets/bike_scan_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/model/shifter_types.dart'; import 'package:flutter/material.dart'; @@ -29,19 +31,36 @@ class BikeScanDialog extends ConsumerStatefulWidget { class _BikeScanDialogState extends ConsumerState { bool _showAll = false; + bool _isStartingScan = true; + String? _scanError; BluetoothController? _controller; @override void initState() { super.initState(); - _startScan(); + unawaited(_startScan()); } Future _startScan() async { - final controller = await ref.read(bluetoothProvider.future); - _controller = controller; - await controller.stopScan(); - await controller.startScan(); + setState(() { + _isStartingScan = true; + _scanError = null; + }); + + try { + final controller = await ref.read(bluetoothProvider.future); + _controller = controller; + await controller.stopScan(); + await controller.startScan(); + } catch (error) { + _scanError = error.toString(); + } finally { + if (mounted) { + setState(() { + _isStartingScan = false; + }); + } + } } @override @@ -73,6 +92,7 @@ class _BikeScanDialogState extends ConsumerState { children: [ _DialogHeader( showAll: _showAll, + isScanning: _isStartingScan, onChanged: (value) { setState(() { _showAll = value; @@ -81,141 +101,163 @@ class _BikeScanDialogState extends ConsumerState { onRescan: _startScan, ), Expanded( - child: StreamBuilder>( - stream: controller.scanResultsStream, - initialData: controller.scanResults, - builder: (context, snapshot) { - final devices = _filteredDevices(snapshot.data ?? const []); - if (devices.isEmpty) { - return const Padding( - padding: EdgeInsets.all(20), - child: Center( - child: Text( - 'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.', - textAlign: TextAlign.center, - ), + child: _scanError != null + ? _ScanMessage( + message: 'Could not start trainer scan: $_scanError', + action: TextButton.icon( + onPressed: _startScan, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), ), - ); - } + ) + : StreamBuilder>( + stream: controller.scanResultsStream, + initialData: controller.scanResults, + builder: (context, snapshot) { + if (_isStartingScan && + (snapshot.data == null || + snapshot.data!.isEmpty)) { + return const Center( + child: CircularProgressIndicator()); + } - return ListView.separated( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - itemCount: devices.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final device = devices[index]; - final isFtms = - device.serviceUuids.contains(Uuid.parse(ftmsServiceUuid)); - return Material( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(22), - child: InkWell( - borderRadius: BorderRadius.circular(22), - onTap: () => Navigator.of(context).pop(device), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( + final devices = + _filteredDevices(snapshot.data ?? const []); + if (devices.isEmpty) { + return const _ScanMessage( + message: + 'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + itemCount: devices.length, + separatorBuilder: (_, __) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final device = devices[index]; + final isFtms = device.serviceUuids + .contains(Uuid.parse(ftmsServiceUuid)); + return Material( + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(22), - border: Border.all( - color: Theme.of(context) - .colorScheme - .outlineVariant - .withValues(alpha: 0.55), - ), - ), - child: Row( - children: [ - Container( - width: 50, - height: 50, + child: InkWell( + borderRadius: BorderRadius.circular(22), + onTap: () => + Navigator.of(context).pop(device), + child: Container( + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context) - .colorScheme - .primary - .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: Theme.of(context) + .colorScheme + .outlineVariant + .withValues(alpha: 0.55), + ), ), - child: Icon( - Icons.pedal_bike_rounded, - color: Theme.of(context) - .colorScheme - .primary, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, + child: Row( children: [ - Text( - device.name.isEmpty - ? 'Unknown Device' - : device.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.w700, - ), + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.12), + ), + child: Icon( + Icons.pedal_bike_rounded, + color: Theme.of(context) + .colorScheme + .primary, + ), ), - const SizedBox(height: 4), - Text( - isFtms ? 'FTMS' : 'Nearby trainer', - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: Theme.of(context) - .colorScheme - .primary, - fontWeight: FontWeight.w600, + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + device.name.isEmpty + ? 'Unknown Device' + : device.name, + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: + FontWeight.w700, + ), ), + const SizedBox(height: 4), + Text( + isFtms + ? 'FTMS' + : 'Nearby trainer', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .primary, + fontWeight: + FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + device.id, + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues( + alpha: 0.62), + ), + ), + ], + ), ), - const SizedBox(height: 4), - Text( - device.id, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .bodySmall - ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.62), - ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + _RssiBadge(rssi: device.rssi), + const SizedBox(height: 12), + Icon( + Icons.chevron_right_rounded, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.55), + ), + ], ), ], ), ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - _RssiBadge(rssi: device.rssi), - const SizedBox(height: 12), - Icon( - Icons.chevron_right_rounded, - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.55), - ), - ], - ), - ], - ), - ), - ), - ); - }, - ); - }, - ), + ), + ); + }, + ); + }, + ), ), ], ); @@ -242,11 +284,13 @@ class _BikeScanDialogState extends ConsumerState { class _DialogHeader extends StatelessWidget { const _DialogHeader({ required this.showAll, + required this.isScanning, required this.onChanged, required this.onRescan, }); final bool showAll; + final bool isScanning; final ValueChanged onChanged; final VoidCallback onRescan; @@ -265,9 +309,10 @@ class _DialogHeader extends StatelessWidget { children: [ Text( 'Assign Trainer', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(height: 6), Text( @@ -306,10 +351,22 @@ class _DialogHeader extends StatelessWidget { ], ), ), - OutlinedButton.icon( - onPressed: onRescan, - icon: const Icon(Icons.refresh), - label: const Text('Rescan'), + SizedBox( + width: 132, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + ), + onPressed: isScanning ? null : onRescan, + icon: isScanning + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + label: const Text('Rescan'), + ), ), ], ), @@ -319,6 +376,38 @@ class _DialogHeader extends StatelessWidget { } } +class _ScanMessage extends StatelessWidget { + const _ScanMessage({ + required this.message, + this.action, + }); + + final String message; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + textAlign: TextAlign.center, + ), + if (action != null) ...[ + const SizedBox(height: 12), + SizedBox(width: 132, child: action!), + ], + ], + ), + ), + ); + } +} + class _RssiBadge extends StatelessWidget { const _RssiBadge({required this.rssi}); diff --git a/lib/widgets/gear_ratio_editor_card.dart b/lib/widgets/gear_ratio_editor_card.dart index f3f9382..a1d8ad2 100644 --- a/lib/widgets/gear_ratio_editor_card.dart +++ b/lib/widgets/gear_ratio_editor_card.dart @@ -165,22 +165,7 @@ class _GearRatioEditorCardState extends State { TextStyle(fontSize: 17, fontWeight: FontWeight.w700), ), ), - if (_isEditing) ...[ - TextButton( - onPressed: _isSaving ? null : _onCancel, - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _isSaving ? null : _onSave, - child: _isSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Save'), - ), - ] else + if (!_isEditing) IconButton( tooltip: 'Edit ratios', onPressed: (widget.isLoading || widget.errorText != null) @@ -191,6 +176,11 @@ class _GearRatioEditorCardState extends State { ], ), ), + if (_isEditing) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: _buildEditActions(), + ), if (widget.isLoading) const Padding( padding: EdgeInsets.fromLTRB(16, 20, 16, 24), @@ -365,6 +355,9 @@ class _GearRatioEditorCardState extends State { duration: _animDuration, switchInCurve: _animCurve, switchOutCurve: Curves.easeInCubic, + layoutBuilder: (currentChild, previousChildren) { + return currentChild ?? const SizedBox.shrink(); + }, transitionBuilder: _snappyTransition, child: Column( key: ValueKey('editors-$_gearLayoutVersion'), @@ -373,6 +366,10 @@ class _GearRatioEditorCardState extends State { ), ), ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 4, 14, 14), + child: _buildEditActions(), + ), ], ], ], @@ -381,6 +378,32 @@ class _GearRatioEditorCardState extends State { ); } + Widget _buildEditActions() { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isSaving ? null : _onCancel, + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size(88, 52), + ), + onPressed: _isSaving ? null : _onSave, + child: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Done'), + ), + ], + ); + } + Widget _snappyTransition(Widget child, Animation animation) { final curved = CurvedAnimation(parent: animation, curve: _animCurve); return FadeTransition( diff --git a/test/model/shifter_types_test.dart b/test/model/shifter_types_test.dart new file mode 100644 index 0000000..0e66294 --- /dev/null +++ b/test/model/shifter_types_test.dart @@ -0,0 +1,110 @@ +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:cbor/simple.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CentralStatus.fromCborBytes', () { + test('decodes firmware packed status with FTMS ready', () { + final status = CentralStatus.fromCborBytes( + _packedStatusBytes( + controlVariant: 1, + trainerVariant: 5, + hasSavedBond: true, + connectedTrainerAddr: [1, 2, 3, 4, 5, 6], + ), + ); + + expect(status.control, ControlConnectionState.connected); + expect(status.trainer.state, TrainerConnectionState.ftmsReady); + expect(status.trainer.errorCode, isNull); + expect(status.hasSavedBond, isTrue); + expect(status.connectedTrainerAddr, [1, 2, 3, 4, 5, 6]); + expect(status.lastFailure, isNull); + }); + + test('decodes all firmware packed trainer unit variants', () { + final expectedStates = { + 0: TrainerConnectionState.idle, + 1: TrainerConnectionState.connecting, + 2: TrainerConnectionState.pairing, + 3: TrainerConnectionState.connected, + 4: TrainerConnectionState.discoveringFtms, + 5: TrainerConnectionState.ftmsReady, + 6: TrainerConnectionState.error, + }; + + for (final entry in expectedStates.entries) { + final status = CentralStatus.fromCborBytes( + _packedStatusBytes( + controlVariant: 1, + trainerVariant: entry.key, + ), + ); + + expect( + status.trainer.state, + entry.value, + reason: 'trainer variant ${entry.key} should decode correctly', + ); + } + }); + + test('decodes firmware packed trainer error newtype variant', () { + final status = CentralStatus.fromCborBytes( + _packedStatusBytes( + controlVariant: 1, + trainerRaw: [6, errorFtmsRequiredCharMissing], + lastFailure: errorFtmsRequiredCharMissing, + ), + ); + + expect(status.trainer.state, TrainerConnectionState.error); + expect(status.trainer.errorCode, errorFtmsRequiredCharMissing); + expect(status.lastFailure, errorFtmsRequiredCharMissing); + }); + + test('decodes non-packed status maps with text keys', () { + final status = CentralStatus.fromCborBytes( + cbor.encode({ + 'control': 'Connected', + 'trainer': 'FtmsReady', + 'has_saved_bond': false, + 'connected_trainer_addr': [10, 11, 12, 13, 14, 15], + 'last_failure': null, + }), + ); + + expect(status.control, ControlConnectionState.connected); + expect(status.trainer.state, TrainerConnectionState.ftmsReady); + expect(status.connectedTrainerAddr, [10, 11, 12, 13, 14, 15]); + }); + + test('throws for invalid status payloads instead of hiding them', () { + expect( + () => CentralStatus.fromCborBytes(const []), + throwsFormatException, + ); + expect( + () => CentralStatus.fromCborBytes(cbor.encode([1, 2, 3])), + throwsFormatException, + ); + }); + }); +} + +List _packedStatusBytes({ + required int controlVariant, + int? trainerVariant, + Object? trainerRaw, + bool hasSavedBond = false, + List? connectedTrainerAddr, + int? lastFailure, +}) { + return cbor.encode({ + 0: controlVariant, + 1: trainerRaw ?? trainerVariant, + 2: hasSavedBond, + 3: connectedTrainerAddr, + 4: lastFailure, + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 59a46fe..48ba083 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,18 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - +import 'package:abawo_bt_app/widgets/app_shell.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:abawo_bt_app/main.dart'; - void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const AbawoBtApp()); + testWidgets('renders app shell', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: AppShell( + currentLocation: '/devices', + child: Text('Devices'), + ), + ), + ); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('Devices'), findsWidgets); }); }