feat: redesign and lots of progress

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

View File

@ -1 +1 @@
48179 1720108

View File

@ -1 +0,0 @@
1772550918

View File

@ -1 +0,0 @@
13365

150
AGENTS.md
View File

@ -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 <id> # View issue details
bd update <id> --claim # Claim work atomically
bd close <id> # 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
<!-- BEGIN BEADS INTEGRATION -->
## 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 <id> --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 <id> --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:<parent-id>`
5. **Complete**: `bd close <id> --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.
<!-- END BEADS INTEGRATION -->
## 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

View File

@ -41,3 +41,6 @@ Still mostly material design.
### Company Color Theme ### Company Color Theme
todo todo
## Code
Always use `<color>.withValues(alpha: <alpha>)` instead of `<color>.withOpacity(<alpha>)` for colors.

View File

@ -42,3 +42,7 @@ android {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
implementation "io.reactivex.rxjava2:rxjava:2.2.21"
}

View File

@ -1,5 +1,55 @@
package com.example.abawo_bt_app 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.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()
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,9 +5,12 @@ import 'package:abawo_bt_app/model/shifter_types.dart';
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart'; import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
import 'package:abawo_bt_app/service/firmware_update_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/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/bike_scan_dialog.dart';
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart'; import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
import 'package:flutter/material.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:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:nb_utils/nb_utils.dart'; import 'package:nb_utils/nb_utils.dart';
@ -48,11 +51,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
3.27, 3.27,
]; ];
bool _isReconnecting = false;
bool _wasConnectedToCurrentDevice = false;
bool _isExitingPage = false; bool _isExitingPage = false;
bool _hasRequestedDisconnect = false; bool _hasRequestedDisconnect = false;
Timer? _reconnectTimeoutTimer; bool _hasShownPairingRecoveryDialog = false;
bool _isAssignTrainerDialogOpen = false;
bool _isManualReconnectRunning = false;
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>? ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription; _connectionStatusSubscription;
@ -124,7 +127,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
@override @override
void dispose() { void dispose() {
unawaited(_disconnectOnClose()); unawaited(_disconnectOnClose());
_reconnectTimeoutTimer?.cancel();
_connectionStatusSubscription?.close(); _connectionStatusSubscription?.close();
_statusSubscription?.cancel(); _statusSubscription?.cancel();
_shifterService?.dispose(); _shifterService?.dispose();
@ -134,15 +136,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
Future<void> _disconnectOnClose() async { Future<void> _disconnectOnClose() async {
if (_isFirmwareUpdateBusy) {
return;
}
if (_hasRequestedDisconnect) { if (_hasRequestedDisconnect) {
return; return;
} }
_hasRequestedDisconnect = true; _hasRequestedDisconnect = true;
_isExitingPage = true; _isExitingPage = true;
_reconnectTimeoutTimer?.cancel();
await _firmwareUpdateService?.cancelUpdate();
await _disposeFirmwareUpdateService(); await _disposeFirmwareUpdateService();
final bluetooth = ref.read(bluetoothProvider).value; final bluetooth = ref.read(bluetoothProvider).value;
@ -159,55 +163,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
final isCurrentDevice = connectedDeviceId == widget.deviceAddress; final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
if (isCurrentDevice && status == ConnectionStatus.connected) { if (isCurrentDevice && status == ConnectionStatus.connected) {
_wasConnectedToCurrentDevice = true;
_startStatusStreamingIfNeeded(); _startStatusStreamingIfNeeded();
if (_isReconnecting) {
_reconnectTimeoutTimer?.cancel();
setState(() {
_isReconnecting = false;
});
}
return; return;
} }
if (_wasConnectedToCurrentDevice &&
!_isReconnecting &&
status == ConnectionStatus.disconnected &&
!_isFirmwareUpdateBusy) {
_startReconnect();
}
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) && if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
!_isFirmwareUpdateBusy) { !_isFirmwareUpdateBusy) {
_stopStatusStreaming(); _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 { Future<void> _startStatusStreamingIfNeeded() async {
bool isCurrentDeviceConnected(BluetoothController bluetooth) {
final connectionState = bluetooth.currentConnectionState;
return connectionState.$1 == ConnectionStatus.connected &&
connectionState.$2 == widget.deviceAddress;
}
if (_shifterService != null) { if (_shifterService != null) {
final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
return;
}
_statusSubscription ??= _shifterService!.statusStream.listen((status) { _statusSubscription ??= _shifterService!.statusStream.listen((status) {
if (!mounted) { if (!mounted) {
return; return;
@ -227,16 +204,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} else { } else {
bluetooth = await ref.read(bluetoothProvider.future); bluetooth = await ref.read(bluetoothProvider.future);
} }
if (!isCurrentDeviceConnected(bluetooth)) {
return;
}
final service = ShifterService( final service = ShifterService(
bluetooth: bluetooth, bluetooth: bluetooth,
buttonDeviceId: widget.deviceAddress, buttonDeviceId: widget.deviceAddress,
); );
final initialStatusResult = await service.readStatus(); final initialStatusResult = await service.readStatus();
if (mounted && initialStatusResult.isOk()) { if (!mounted) {
_recordStatus(initialStatusResult.unwrap()); await service.dispose();
return;
} }
if (initialStatusResult.isErr()) {
await service.dispose();
await _showPairingRecoveryDialog();
return;
}
_recordStatus(initialStatusResult.unwrap());
_statusSubscription = service.statusStream.listen((status) { _statusSubscription = service.statusStream.listen((status) {
if (!mounted) { if (!mounted) {
return; return;
@ -251,6 +240,15 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
unawaited(_loadGearRatios()); unawaited(_loadGearRatios());
} }
Future<void> _showPairingRecoveryDialog() async {
if (!mounted || _hasShownPairingRecoveryDialog) {
return;
}
_hasShownPairingRecoveryDialog = true;
await showBluetoothPairingRecoveryDialog(context);
}
void _recordStatus(CentralStatus status) { void _recordStatus(CentralStatus status) {
setState(() { setState(() {
_latestStatus = status; _latestStatus = status;
@ -360,6 +358,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
} }
Future<void> _connectButtonToBike() async { Future<void> _connectButtonToBike() async {
if (_isAssignTrainerDialogOpen) {
return;
}
if (_isFirmwareUpdateBusy) { if (_isFirmwareUpdateBusy) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -369,10 +371,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return; return;
} }
final selectedBike = await BikeScanDialog.show( _isAssignTrainerDialogOpen = true;
final DiscoveredDevice? selectedBike;
try {
selectedBike = await BikeScanDialog.show(
context, context,
excludedDeviceId: widget.deviceAddress, excludedDeviceId: widget.deviceAddress,
); );
} finally {
_isAssignTrainerDialogOpen = false;
}
if (selectedBike == null || !mounted) { if (selectedBike == null || !mounted) {
return; return;
} }
@ -533,17 +541,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}); });
} }
Future<void> _cancelFirmwareUpdate() async {
final updater = _firmwareUpdateService;
if (updater == null || !_isFirmwareUpdateBusy) {
return;
}
setState(() {
_firmwareUserMessage = 'Canceling firmware update...';
});
await updater.cancelUpdate();
}
String _dfuPhaseText(DfuUpdateState state) { String _dfuPhaseText(DfuUpdateState state) {
switch (state) { switch (state) {
case DfuUpdateState.idle: case DfuUpdateState.idle:
@ -579,27 +576,68 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}'; return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
} }
Future<void> _terminateConnectionAndGoHome(String toastMessage) async { Future<void> _manualReconnect() async {
await _disconnectOnClose(); if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
return;
}
setState(() {
_isManualReconnectRunning = true;
});
try {
final bluetooth = await ref.read(bluetoothProvider.future);
final result = await bluetooth.connectById(
widget.deviceAddress,
timeout: const Duration(seconds: 10),
);
if (!mounted) { if (!mounted) {
return; return;
} }
toast(toastMessage); if (result.isErr()) {
context.replace('/devices'); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Reconnect failed. Is the device turned on and in range?',
),
),
);
}
} catch (error) {
if (!mounted) {
return;
} }
Future<void> _cancelReconnect() async { ScaffoldMessenger.of(context).showSnackBar(
await _terminateConnectionAndGoHome('Reconnect cancelled.'); SnackBar(content: Text('Reconnect failed: $error')),
);
} finally {
if (mounted) {
setState(() {
_isManualReconnectRunning = false;
});
}
}
} }
Future<void> _exitPage() async { Future<void> _exitPage() async {
if (_isFirmwareUpdateBusy) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Firmware update is running. Keep this screen open until it completes.'),
),
);
return;
}
await _disconnectOnClose(); await _disconnectOnClose();
if (!mounted) { if (!mounted) {
return; return;
} }
context.replace('/devices'); context.go('/devices');
} }
void _showStatusHistory() { void _showStatusHistory() {
@ -713,19 +751,18 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connectionData = ref.watch(connectionStatusProvider).valueOrNull; final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
final currentConnectionStatus = connectionData != null && final currentConnectionStatus =
connectionData.$2 == widget.deviceAddress connectionData != null && connectionData.$2 == widget.deviceAddress
? connectionData.$1 ? connectionData.$1
: ConnectionStatus.disconnected; : ConnectionStatus.disconnected;
final isCurrentConnected = currentConnectionStatus == ConnectionStatus.connected; final isCurrentConnected =
currentConnectionStatus == ConnectionStatus.connected;
final canSelectFirmware = final canSelectFirmware =
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy; isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = isCurrentConnected && final canStartFirmware = isCurrentConnected &&
!_isSelectingFirmware && !_isSelectingFirmware &&
!_isFirmwareUpdateBusy && !_isFirmwareUpdateBusy &&
_selectedFirmware != null; _selectedFirmware != null;
final canCancelFirmware = _isFirmwareUpdateBusy;
return PopScope( return PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (bool didPop, bool? result) { onPopInvokedWithResult: (bool didPop, bool? result) {
@ -756,11 +793,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
if (isCurrentConnected) ...[ if (isCurrentConnected) ...[
_TrainerConnectionCard(
status: _latestStatus,
onAssign: _isFirmwareUpdateBusy ? null : _connectButtonToBike,
onShowStatusConsole: _showStatusHistory,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_StatusBanner( _StatusBanner(
status: _latestStatus, status: _latestStatus,
@ -775,22 +807,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_FirmwareUpdateCard( _TrainerConnectionCard(
selectedFirmware: _selectedFirmware, status: _latestStatus,
progress: _dfuProgress, onAssign:
isSelecting: _isSelectingFirmware, _isFirmwareUpdateBusy ? null : _connectButtonToBike,
isStarting: _isStartingFirmwareUpdate, onShowStatusConsole: _showStatusHistory,
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),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Opacity( Opacity(
@ -873,48 +894,30 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
), ),
), ),
), ),
const SizedBox(height: 16),
_FirmwareUpdateCard(
selectedFirmware: _selectedFirmware,
progress: _dfuProgress,
isSelecting: _isSelectingFirmware,
isStarting: _isStartingFirmwareUpdate,
canSelect: canSelectFirmware,
canStart: canStartFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
onSelectFirmware: _selectFirmwareFile,
onStartUpdate: _startFirmwareUpdate,
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
),
] else ...[ ] 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.isStarting,
required this.canSelect, required this.canSelect,
required this.canStart, required this.canStart,
required this.canCancel,
required this.phaseText, required this.phaseText,
required this.statusText, required this.statusText,
required this.formattedProgressBytes, required this.formattedProgressBytes,
required this.ackSequenceHex, required this.ackSequenceHex,
required this.onSelectFirmware, required this.onSelectFirmware,
required this.onStartUpdate, required this.onStartUpdate,
required this.onCancelUpdate,
}); });
final DfuV1PreparedFirmware? selectedFirmware; final DfuV1PreparedFirmware? selectedFirmware;
@ -958,14 +959,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
final bool isStarting; final bool isStarting;
final bool canSelect; final bool canSelect;
final bool canStart; final bool canStart;
final bool canCancel;
final String phaseText; final String phaseText;
final String? statusText; final String? statusText;
final String formattedProgressBytes; final String formattedProgressBytes;
final String ackSequenceHex; final String ackSequenceHex;
final Future<void> Function() onSelectFirmware; final Future<void> Function() onSelectFirmware;
final Future<void> Function() onStartUpdate; final Future<void> Function() onStartUpdate;
final Future<void> Function() onCancelUpdate;
bool get _showProgress { bool get _showProgress {
return progress.totalBytes > 0 || return progress.totalBytes > 0 ||
@ -991,7 +990,8 @@ class _FirmwareUpdateCard extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.system_update_alt_rounded, color: colorScheme.primary), Icon(Icons.system_update_alt_rounded,
color: colorScheme.primary),
const SizedBox(width: 10), const SizedBox(width: 10),
const Text( const Text(
'Firmware Update', 'Firmware Update',
@ -1033,18 +1033,14 @@ class _FirmwareUpdateCard extends StatelessWidget {
: const Icon(Icons.system_update_alt), : const Icon(Icons.system_update_alt),
label: const Text('Start Update'), 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), const SizedBox(height: 14),
Container( Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54), color:
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
child: Column( child: Column(
@ -1207,12 +1203,10 @@ class _StatusBanner extends StatelessWidget {
Widget _buildDeviceOverviewCard( Widget _buildDeviceOverviewCard(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
String deviceAddress, String deviceAddress, {
{
required ConnectionStatus connectionStatus, required ConnectionStatus connectionStatus,
required CentralStatus? status, required CentralStatus? status,
} }) {
) {
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider); final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
return asyncSavedDevices.when( return asyncSavedDevices.when(
@ -1301,7 +1295,8 @@ class _DeviceOverviewCard extends StatelessWidget {
children: [ children: [
Text( Text(
device.deviceName, device.deviceName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@ -1311,7 +1306,8 @@ class _DeviceOverviewCard extends StatelessWidget {
Text( Text(
trainerAddress, trainerAddress,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( 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, shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.12), 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), const SizedBox(width: 14),
Expanded( Expanded(
@ -1412,7 +1409,8 @@ class _TrainerConnectionCard extends StatelessWidget {
Text( Text(
trainerText, trainerText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( 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( Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54), color:
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.bluetooth_connected_rounded, color: colorScheme.primary), Icon(Icons.bluetooth_connected_rounded,
color: colorScheme.primary),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
@ -1470,21 +1470,86 @@ class _TrainerConnectionCard extends StatelessWidget {
} }
class _DisconnectedDetailCard 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
child: Text( child: Column(
'This device is not currently connected. Reopen it from Devices to reconnect and manage trainer pairing, firmware, and gear ratios.', crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( children: [
color: Theme.of(context) Row(
.colorScheme children: [
.onSurface Container(
.withValues(alpha: 0.68), 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) { final (label, color) = switch (status) {
ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)), ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)),
ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)), ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)),
ConnectionStatus.disconnecting => ('Disconnecting', const Color(0xFFFFB649)), ConnectionStatus.disconnecting => (
ConnectionStatus.disconnected => 'Disconnecting',
('Disconnected', Theme.of(context).colorScheme.primary), const Color(0xFFFFB649)
),
ConnectionStatus.disconnected => (
'Disconnected',
Theme.of(context).colorScheme.primary
),
}; };
return Container( return Container(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = <int, TrainerConnectionState>{
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<int> _packedStatusBytes({
required int controlVariant,
int? trainerVariant,
Object? trainerRaw,
bool hasSavedBond = false,
List<int>? connectedTrainerAddr,
int? lastFailure,
}) {
return cbor.encode({
0: controlVariant,
1: trainerRaw ?? trainerVariant,
2: hasSavedBond,
3: connectedTrainerAddr,
4: lastFailure,
});
}

View File

@ -1,30 +1,18 @@
// This is a basic Flutter widget test. import 'package:abawo_bt_app/widgets/app_shell.dart';
//
// 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:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:abawo_bt_app/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async { testWidgets('renders app shell', (WidgetTester tester) async {
// Build our app and trigger a frame. await tester.pumpWidget(
await tester.pumpWidget(const AbawoBtApp()); const MaterialApp(
home: AppShell(
currentLocation: '/devices',
child: Text('Devices'),
),
),
);
// Verify that our counter starts at 0. expect(find.text('Devices'), findsWidgets);
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);
}); });
} }