feat: redesign and lots of progress
This commit is contained in:
@ -1 +1 @@
|
||||
48179
|
||||
1720108
|
||||
@ -1 +0,0 @@
|
||||
1772550918
|
||||
@ -1 +0,0 @@
|
||||
13365
|
||||
150
AGENTS.md
150
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 <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
|
||||
|
||||
@ -41,3 +41,6 @@ Still mostly material design.
|
||||
### Company Color Theme
|
||||
todo
|
||||
|
||||
## Code
|
||||
|
||||
Always use `<color>.withValues(alpha: <alpha>)` instead of `<color>.withOpacity(<alpha>)` for colors.
|
||||
@ -42,3 +42,7 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "io.reactivex.rxjava2:rxjava:2.2.21"
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:flutter/foundation.dart'
|
||||
show TargetPlatform, defaultTargetPlatform;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
||||
hide ConnectionStatus, Result, Logger;
|
||||
@ -173,9 +175,6 @@ class BluetoothController {
|
||||
(currentState.$1 == ConnectionStatus.connected ||
|
||||
currentState.$1 == ConnectionStatus.connecting)) {
|
||||
log.info('Already connected or connecting to $deviceId.');
|
||||
if (currentState.$1 == ConnectionStatus.connected) {
|
||||
unawaited(_requestMtuOnConnect(deviceId));
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
|
||||
@ -191,6 +190,7 @@ class BluetoothController {
|
||||
try {
|
||||
await _connectionStateSubscription?.cancel();
|
||||
_updateConnectionState(ConnectionStatus.connecting, deviceId);
|
||||
final connectionResult = Completer<Result<void>>();
|
||||
|
||||
_connectionStateSubscription = _ble
|
||||
.connectToDevice(
|
||||
@ -199,12 +199,21 @@ class BluetoothController {
|
||||
servicesWithCharacteristicsToDiscover:
|
||||
servicesWithCharacteristicsToDiscover,
|
||||
)
|
||||
.listen((update) {
|
||||
.listen((update) async {
|
||||
switch (update.connectionState) {
|
||||
case DeviceConnectionState.connected:
|
||||
_connectedDeviceId = deviceId;
|
||||
_updateConnectionState(ConnectionStatus.connected, deviceId);
|
||||
unawaited(_requestMtuOnConnect(deviceId));
|
||||
if (!connectionResult.isCompleted) {
|
||||
final mtuResult = await _requestInitialMtu(deviceId);
|
||||
if (mtuResult.isErr()) {
|
||||
log.warning(
|
||||
'Initial MTU request failed for $deviceId: '
|
||||
'${mtuResult.unwrapErr()}',
|
||||
);
|
||||
}
|
||||
connectionResult.complete(Ok(null));
|
||||
}
|
||||
break;
|
||||
case DeviceConnectionState.connecting:
|
||||
_updateConnectionState(ConnectionStatus.connecting, deviceId);
|
||||
@ -214,14 +223,31 @@ class BluetoothController {
|
||||
break;
|
||||
case DeviceConnectionState.disconnected:
|
||||
_cleanUpConnection();
|
||||
if (!connectionResult.isCompleted) {
|
||||
connectionResult.complete(
|
||||
bail('Failed to connect to $deviceId: disconnected'),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, onError: (Object error, StackTrace st) {
|
||||
log.severe('Failed to connect to $deviceId: $error', error, st);
|
||||
_cleanUpConnection();
|
||||
if (!connectionResult.isCompleted) {
|
||||
connectionResult.complete(
|
||||
bail('Failed to connect to $deviceId: $error'),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(null);
|
||||
try {
|
||||
return await connectionResult.future.timeout(timeout);
|
||||
} on TimeoutException {
|
||||
await _connectionStateSubscription?.cancel();
|
||||
_connectionStateSubscription = null;
|
||||
_cleanUpConnection();
|
||||
return bail('Timed out connecting to $deviceId');
|
||||
}
|
||||
} catch (e) {
|
||||
_cleanUpConnection();
|
||||
return bail('Failed to connect to $deviceId: $e');
|
||||
@ -301,7 +327,7 @@ class BluetoothController {
|
||||
{int mtu = defaultMtu}) async {
|
||||
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
|
||||
if (result.isErr()) {
|
||||
return bail(result.unwrapErr());
|
||||
return Err(result.unwrapErr());
|
||||
}
|
||||
return Ok(null);
|
||||
}
|
||||
@ -310,6 +336,10 @@ class BluetoothController {
|
||||
{int mtu = defaultMtu}) async {
|
||||
try {
|
||||
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
|
||||
if (negotiatedMtu <= 0) {
|
||||
return bail(
|
||||
'Error requesting MTU $mtu for $deviceId: negotiated invalid MTU $negotiatedMtu');
|
||||
}
|
||||
log.info(
|
||||
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
|
||||
return Ok(negotiatedMtu);
|
||||
@ -318,12 +348,11 @@ class BluetoothController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestMtuOnConnect(String deviceId) async {
|
||||
final mtuResult = await requestMtu(deviceId, mtu: defaultMtu);
|
||||
if (mtuResult.isErr()) {
|
||||
log.warning(
|
||||
'MTU request after connect failed for $deviceId: ${mtuResult.unwrapErr()}');
|
||||
Future<Result<void>> _requestInitialMtu(String deviceId) async {
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
return Ok(null);
|
||||
}
|
||||
return requestMtu(deviceId, mtu: defaultMtu);
|
||||
}
|
||||
|
||||
Stream<List<int>> subscribeToCharacteristic(
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,15 @@ class NConnectedDevices extends _$NConnectedDevices {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<Result<void>> updateConnectedDeviceLastConnected(int id) async {
|
||||
final db = ref.watch(databaseProvider);
|
||||
final res = await db.updateConnectedDeviceLastConnected(id);
|
||||
if (res.isOk()) {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for the [AppDatabase] instance
|
||||
@ -137,6 +146,22 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<void>> updateConnectedDeviceLastConnected(int id) async {
|
||||
try {
|
||||
final count = await (update(connectedDevices)
|
||||
..where((tbl) => tbl.id.equals(id)))
|
||||
.write(ConnectedDevicesCompanion(
|
||||
lastConnectedAt: Value(DateTime.now())));
|
||||
if (count == 0) {
|
||||
return bail('Device with id $id not found.');
|
||||
}
|
||||
return Ok(());
|
||||
} catch (e, st) {
|
||||
return bail(
|
||||
'Failed to update last connected time for device $id: $e', st);
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<ConnectedDevice>> getAllConnectedDevicesStream() {
|
||||
return select(connectedDevices).watch();
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/pages/devices_page.dart';
|
||||
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
|
||||
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
|
||||
@ -29,11 +32,47 @@ Future<void> main() async {
|
||||
], child: const AbawoBtApp()));
|
||||
}
|
||||
|
||||
class AbawoBtApp extends ConsumerWidget {
|
||||
class AbawoBtApp extends ConsumerStatefulWidget {
|
||||
const AbawoBtApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<AbawoBtApp> createState() => _AbawoBtAppState();
|
||||
}
|
||||
|
||||
class _AbawoBtAppState extends ConsumerState<AbawoBtApp>
|
||||
with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.hidden ||
|
||||
state == AppLifecycleState.paused) {
|
||||
unawaited(_disconnectBluetoothForBackground());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectBluetoothForBackground() async {
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
if (bluetooth == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bluetooth.stopScan();
|
||||
await bluetooth.disconnect();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themePreference = ref.watch(appThemePreferenceProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cbor/simple.dart';
|
||||
|
||||
const String universalShifterControlServiceUuid =
|
||||
@ -249,6 +251,9 @@ enum ControlConnectionState {
|
||||
}
|
||||
if (raw is String) {
|
||||
final normalized = raw.toLowerCase();
|
||||
if (normalized.contains('disconnected')) {
|
||||
return ControlConnectionState.disconnected;
|
||||
}
|
||||
if (normalized.contains('connected')) {
|
||||
return ControlConnectionState.connected;
|
||||
}
|
||||
@ -294,32 +299,21 @@ class TrainerStatus {
|
||||
|
||||
static TrainerStatus fromRaw(dynamic raw) {
|
||||
if (raw is int) {
|
||||
switch (raw) {
|
||||
case 1:
|
||||
return const TrainerStatus(state: TrainerConnectionState.connecting);
|
||||
case 2:
|
||||
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
||||
case 3:
|
||||
return const TrainerStatus(state: TrainerConnectionState.connected);
|
||||
case 4:
|
||||
return const TrainerStatus(
|
||||
state: TrainerConnectionState.discoveringFtms);
|
||||
case 5:
|
||||
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
|
||||
default:
|
||||
return const TrainerStatus(state: TrainerConnectionState.idle);
|
||||
}
|
||||
return _trainerStatusFromVariant(raw);
|
||||
}
|
||||
|
||||
if (raw is List && raw.isNotEmpty) {
|
||||
final variant = raw.first;
|
||||
final value = raw.length > 1 ? raw[1] : null;
|
||||
if (variant is int && (variant == 5 || variant == 6)) {
|
||||
if (variant is int && variant == 6) {
|
||||
return TrainerStatus(
|
||||
state: TrainerConnectionState.error,
|
||||
errorCode: value is int ? value : null,
|
||||
);
|
||||
}
|
||||
if (variant is int) {
|
||||
return _trainerStatusFromVariant(variant);
|
||||
}
|
||||
}
|
||||
|
||||
if (raw is Map) {
|
||||
@ -327,13 +321,16 @@ class TrainerStatus {
|
||||
if (entry != null) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
if ((key is int && (key == 5 || key == 6)) ||
|
||||
if ((key is int && key == 6) ||
|
||||
(key is String && key.toLowerCase().contains('error'))) {
|
||||
return TrainerStatus(
|
||||
state: TrainerConnectionState.error,
|
||||
errorCode: value is int ? value : null,
|
||||
);
|
||||
}
|
||||
if (key is int) {
|
||||
return _trainerStatusFromVariant(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -362,6 +359,26 @@ class TrainerStatus {
|
||||
|
||||
return const TrainerStatus(state: TrainerConnectionState.idle);
|
||||
}
|
||||
|
||||
static TrainerStatus _trainerStatusFromVariant(int variant) {
|
||||
switch (variant) {
|
||||
case 1:
|
||||
return const TrainerStatus(state: TrainerConnectionState.connecting);
|
||||
case 2:
|
||||
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
||||
case 3:
|
||||
return const TrainerStatus(state: TrainerConnectionState.connected);
|
||||
case 4:
|
||||
return const TrainerStatus(
|
||||
state: TrainerConnectionState.discoveringFtms);
|
||||
case 5:
|
||||
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
|
||||
case 6:
|
||||
return const TrainerStatus(state: TrainerConnectionState.error);
|
||||
default:
|
||||
return const TrainerStatus(state: TrainerConnectionState.idle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CentralStatus {
|
||||
@ -384,16 +401,26 @@ class CentralStatus {
|
||||
String get statusLine =>
|
||||
'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}';
|
||||
|
||||
static CentralStatus disconnected({dynamic raw}) {
|
||||
return CentralStatus(
|
||||
control: ControlConnectionState.disconnected,
|
||||
trainer: const TrainerStatus(state: TrainerConnectionState.idle),
|
||||
hasSavedBond: false,
|
||||
connectedTrainerAddr: null,
|
||||
lastFailure: null,
|
||||
raw: raw,
|
||||
);
|
||||
}
|
||||
|
||||
static CentralStatus fromCborBytes(List<int> bytes) {
|
||||
if (bytes.isEmpty) {
|
||||
throw const FormatException('Status payload is empty.');
|
||||
}
|
||||
|
||||
final decoded = cbor.decode(bytes);
|
||||
if (decoded is! Map) {
|
||||
return CentralStatus(
|
||||
control: ControlConnectionState.disconnected,
|
||||
trainer: const TrainerStatus(state: TrainerConnectionState.idle),
|
||||
hasSavedBond: false,
|
||||
connectedTrainerAddr: null,
|
||||
lastFailure: null,
|
||||
raw: decoded,
|
||||
throw FormatException(
|
||||
'Status payload must decode to a CBOR map, got ${decoded.runtimeType}.',
|
||||
);
|
||||
}
|
||||
|
||||
@ -428,6 +455,9 @@ List<int>? _toByteList(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value is Uint8List) {
|
||||
return value.toList(growable: false);
|
||||
}
|
||||
if (value is List) {
|
||||
return value.whereType<int>().toList(growable: false);
|
||||
}
|
||||
|
||||
@ -5,9 +5,12 @@ import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
|
||||
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
||||
show DiscoveredDevice;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nb_utils/nb_utils.dart';
|
||||
@ -48,11 +51,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
3.27,
|
||||
];
|
||||
|
||||
bool _isReconnecting = false;
|
||||
bool _wasConnectedToCurrentDevice = false;
|
||||
bool _isExitingPage = false;
|
||||
bool _hasRequestedDisconnect = false;
|
||||
Timer? _reconnectTimeoutTimer;
|
||||
bool _hasShownPairingRecoveryDialog = false;
|
||||
bool _isAssignTrainerDialogOpen = false;
|
||||
bool _isManualReconnectRunning = false;
|
||||
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
|
||||
_connectionStatusSubscription;
|
||||
|
||||
@ -124,7 +127,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_disconnectOnClose());
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
_connectionStatusSubscription?.close();
|
||||
_statusSubscription?.cancel();
|
||||
_shifterService?.dispose();
|
||||
@ -134,15 +136,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
Future<void> _disconnectOnClose() async {
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hasRequestedDisconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
_hasRequestedDisconnect = true;
|
||||
_isExitingPage = true;
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
|
||||
await _firmwareUpdateService?.cancelUpdate();
|
||||
await _disposeFirmwareUpdateService();
|
||||
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
@ -159,55 +163,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
|
||||
|
||||
if (isCurrentDevice && status == ConnectionStatus.connected) {
|
||||
_wasConnectedToCurrentDevice = true;
|
||||
_startStatusStreamingIfNeeded();
|
||||
if (_isReconnecting) {
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
setState(() {
|
||||
_isReconnecting = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_wasConnectedToCurrentDevice &&
|
||||
!_isReconnecting &&
|
||||
status == ConnectionStatus.disconnected &&
|
||||
!_isFirmwareUpdateBusy) {
|
||||
_startReconnect();
|
||||
}
|
||||
|
||||
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
|
||||
!_isFirmwareUpdateBusy) {
|
||||
_stopStatusStreaming();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startReconnect() async {
|
||||
if (!mounted || _isExitingPage || _isReconnecting) {
|
||||
return;
|
||||
Future<void> _startStatusStreamingIfNeeded() async {
|
||||
bool isCurrentDeviceConnected(BluetoothController bluetooth) {
|
||||
final connectionState = bluetooth.currentConnectionState;
|
||||
return connectionState.$1 == ConnectionStatus.connected &&
|
||||
connectionState.$2 == widget.deviceAddress;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isReconnecting = true;
|
||||
});
|
||||
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
await bluetooth?.connectById(widget.deviceAddress);
|
||||
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
_reconnectTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
||||
if (!mounted || !_isReconnecting || _isExitingPage) {
|
||||
if (_shifterService != null) {
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
|
||||
return;
|
||||
}
|
||||
_terminateConnectionAndGoHome(
|
||||
'Connection lost. Could not reconnect in time.',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startStatusStreamingIfNeeded() async {
|
||||
if (_shifterService != null) {
|
||||
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -227,16 +204,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
} else {
|
||||
bluetooth = await ref.read(bluetoothProvider.future);
|
||||
}
|
||||
if (!isCurrentDeviceConnected(bluetooth)) {
|
||||
return;
|
||||
}
|
||||
final service = ShifterService(
|
||||
bluetooth: bluetooth,
|
||||
buttonDeviceId: widget.deviceAddress,
|
||||
);
|
||||
|
||||
final initialStatusResult = await service.readStatus();
|
||||
if (mounted && initialStatusResult.isOk()) {
|
||||
_recordStatus(initialStatusResult.unwrap());
|
||||
if (!mounted) {
|
||||
await service.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialStatusResult.isErr()) {
|
||||
await service.dispose();
|
||||
await _showPairingRecoveryDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
_recordStatus(initialStatusResult.unwrap());
|
||||
|
||||
_statusSubscription = service.statusStream.listen((status) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -251,6 +240,15 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
unawaited(_loadGearRatios());
|
||||
}
|
||||
|
||||
Future<void> _showPairingRecoveryDialog() async {
|
||||
if (!mounted || _hasShownPairingRecoveryDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
_hasShownPairingRecoveryDialog = true;
|
||||
await showBluetoothPairingRecoveryDialog(context);
|
||||
}
|
||||
|
||||
void _recordStatus(CentralStatus status) {
|
||||
setState(() {
|
||||
_latestStatus = status;
|
||||
@ -360,6 +358,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
Future<void> _connectButtonToBike() async {
|
||||
if (_isAssignTrainerDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@ -369,10 +371,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedBike = await BikeScanDialog.show(
|
||||
context,
|
||||
excludedDeviceId: widget.deviceAddress,
|
||||
);
|
||||
_isAssignTrainerDialogOpen = true;
|
||||
final DiscoveredDevice? selectedBike;
|
||||
try {
|
||||
selectedBike = await BikeScanDialog.show(
|
||||
context,
|
||||
excludedDeviceId: widget.deviceAddress,
|
||||
);
|
||||
} finally {
|
||||
_isAssignTrainerDialogOpen = false;
|
||||
}
|
||||
if (selectedBike == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
@ -533,17 +541,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cancelFirmwareUpdate() async {
|
||||
final updater = _firmwareUpdateService;
|
||||
if (updater == null || !_isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_firmwareUserMessage = 'Canceling firmware update...';
|
||||
});
|
||||
await updater.cancelUpdate();
|
||||
}
|
||||
|
||||
String _dfuPhaseText(DfuUpdateState state) {
|
||||
switch (state) {
|
||||
case DfuUpdateState.idle:
|
||||
@ -579,27 +576,68 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
|
||||
await _disconnectOnClose();
|
||||
|
||||
if (!mounted) {
|
||||
Future<void> _manualReconnect() async {
|
||||
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast(toastMessage);
|
||||
context.replace('/devices');
|
||||
}
|
||||
setState(() {
|
||||
_isManualReconnectRunning = true;
|
||||
});
|
||||
|
||||
Future<void> _cancelReconnect() async {
|
||||
await _terminateConnectionAndGoHome('Reconnect cancelled.');
|
||||
try {
|
||||
final bluetooth = await ref.read(bluetoothProvider.future);
|
||||
final result = await bluetooth.connectById(
|
||||
widget.deviceAddress,
|
||||
timeout: const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isErr()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Reconnect failed. Is the device turned on and in range?',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Reconnect failed: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isManualReconnectRunning = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exitPage() async {
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Firmware update is running. Keep this screen open until it completes.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _disconnectOnClose();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.replace('/devices');
|
||||
context.go('/devices');
|
||||
}
|
||||
|
||||
void _showStatusHistory() {
|
||||
@ -713,19 +751,18 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
||||
final currentConnectionStatus = connectionData != null &&
|
||||
connectionData.$2 == widget.deviceAddress
|
||||
? connectionData.$1
|
||||
: ConnectionStatus.disconnected;
|
||||
final isCurrentConnected = currentConnectionStatus == ConnectionStatus.connected;
|
||||
final currentConnectionStatus =
|
||||
connectionData != null && connectionData.$2 == widget.deviceAddress
|
||||
? connectionData.$1
|
||||
: ConnectionStatus.disconnected;
|
||||
final isCurrentConnected =
|
||||
currentConnectionStatus == ConnectionStatus.connected;
|
||||
final canSelectFirmware =
|
||||
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||
final canStartFirmware = isCurrentConnected &&
|
||||
!_isSelectingFirmware &&
|
||||
!_isFirmwareUpdateBusy &&
|
||||
_selectedFirmware != null;
|
||||
final canCancelFirmware = _isFirmwareUpdateBusy;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (bool didPop, bool? result) {
|
||||
@ -756,11 +793,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (isCurrentConnected) ...[
|
||||
_TrainerConnectionCard(
|
||||
status: _latestStatus,
|
||||
onAssign: _isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||
onShowStatusConsole: _showStatusHistory,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_StatusBanner(
|
||||
status: _latestStatus,
|
||||
@ -775,22 +807,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FirmwareUpdateCard(
|
||||
selectedFirmware: _selectedFirmware,
|
||||
progress: _dfuProgress,
|
||||
isSelecting: _isSelectingFirmware,
|
||||
isStarting: _isStartingFirmwareUpdate,
|
||||
canSelect: canSelectFirmware,
|
||||
canStart: canStartFirmware,
|
||||
canCancel: canCancelFirmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
onSelectFirmware: _selectFirmwareFile,
|
||||
onStartUpdate: _startFirmwareUpdate,
|
||||
onCancelUpdate: _cancelFirmwareUpdate,
|
||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||
_TrainerConnectionCard(
|
||||
status: _latestStatus,
|
||||
onAssign:
|
||||
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||
onShowStatusConsole: _showStatusHistory,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Opacity(
|
||||
@ -873,50 +894,32 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FirmwareUpdateCard(
|
||||
selectedFirmware: _selectedFirmware,
|
||||
progress: _dfuProgress,
|
||||
isSelecting: _isSelectingFirmware,
|
||||
isStarting: _isStartingFirmwareUpdate,
|
||||
canSelect: canSelectFirmware,
|
||||
canStart: canStartFirmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
onSelectFirmware: _selectFirmwareFile,
|
||||
onStartUpdate: _startFirmwareUpdate,
|
||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||
),
|
||||
] else ...[
|
||||
const _DisconnectedDetailCard(),
|
||||
_DisconnectedDetailCard(
|
||||
isReconnecting: _isManualReconnectRunning,
|
||||
onReconnect: _manualReconnect,
|
||||
onBackToDevices: _exitPage,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isReconnecting)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Reconnecting...',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _cancelReconnect,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -942,14 +945,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
required this.isStarting,
|
||||
required this.canSelect,
|
||||
required this.canStart,
|
||||
required this.canCancel,
|
||||
required this.phaseText,
|
||||
required this.statusText,
|
||||
required this.formattedProgressBytes,
|
||||
required this.ackSequenceHex,
|
||||
required this.onSelectFirmware,
|
||||
required this.onStartUpdate,
|
||||
required this.onCancelUpdate,
|
||||
});
|
||||
|
||||
final DfuV1PreparedFirmware? selectedFirmware;
|
||||
@ -958,14 +959,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
final bool isStarting;
|
||||
final bool canSelect;
|
||||
final bool canStart;
|
||||
final bool canCancel;
|
||||
final String phaseText;
|
||||
final String? statusText;
|
||||
final String formattedProgressBytes;
|
||||
final String ackSequenceHex;
|
||||
final Future<void> Function() onSelectFirmware;
|
||||
final Future<void> Function() onStartUpdate;
|
||||
final Future<void> Function() onCancelUpdate;
|
||||
|
||||
bool get _showProgress {
|
||||
return progress.totalBytes > 0 ||
|
||||
@ -986,126 +985,123 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.system_update_alt_rounded, color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Firmware Update',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select a firmware image, review the transfer state, and start the update when ready.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: canSelect ? onSelectFirmware : null,
|
||||
icon: isSelecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: const Text('Select Firmware'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: canStart ? onStartUpdate : null,
|
||||
icon: isStarting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt),
|
||||
label: const Text('Start Update'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: canCancel ? onCancelUpdate : null,
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
label: const Text('Cancel Update'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
selectedFirmware == null
|
||||
? 'Selected file: none'
|
||||
: 'Selected file: ${selectedFirmware!.fileName}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Icon(Icons.system_update_alt_rounded,
|
||||
color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Firmware Update',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (selectedFirmware != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
|
||||
if (_showProgress) ...[
|
||||
const SizedBox(height: 10),
|
||||
LinearProgressIndicator(
|
||||
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
|
||||
minHeight: 10,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (_showRebootExpectation) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
'Select a firmware image, review the transfer state, and start the update when ready.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (statusText != null && statusText!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
statusText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: progress.state == DfuUpdateState.failed
|
||||
? colorScheme.error
|
||||
: theme.textTheme.bodySmall?.color,
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: canSelect ? onSelectFirmware : null,
|
||||
icon: isSelecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: const Text('Select Firmware'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: canStart ? onStartUpdate : null,
|
||||
icon: isStarting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt),
|
||||
label: const Text('Start Update'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selectedFirmware == null
|
||||
? 'Selected file: none'
|
||||
: 'Selected file: ${selectedFirmware!.fileName}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (selectedFirmware != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
|
||||
if (_showProgress) ...[
|
||||
const SizedBox(height: 10),
|
||||
LinearProgressIndicator(
|
||||
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
|
||||
minHeight: 10,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (_showRebootExpectation) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (statusText != null && statusText!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
statusText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: progress.state == DfuUpdateState.failed
|
||||
? colorScheme.error
|
||||
: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -1207,12 +1203,10 @@ class _StatusBanner extends StatelessWidget {
|
||||
Widget _buildDeviceOverviewCard(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String deviceAddress,
|
||||
{
|
||||
String deviceAddress, {
|
||||
required ConnectionStatus connectionStatus,
|
||||
required CentralStatus? status,
|
||||
}
|
||||
) {
|
||||
}) {
|
||||
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
|
||||
|
||||
return asyncSavedDevices.when(
|
||||
@ -1301,9 +1295,10 @@ class _DeviceOverviewCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
device.deviceName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DetailStatusChip(status: connectionStatus),
|
||||
@ -1311,7 +1306,8 @@ class _DeviceOverviewCard extends StatelessWidget {
|
||||
Text(
|
||||
trainerAddress,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1395,7 +1391,8 @@ class _TrainerConnectionCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Icon(Icons.pedal_bike_rounded, color: colorScheme.primary),
|
||||
child: Icon(Icons.pedal_bike_rounded,
|
||||
color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@ -1412,7 +1409,8 @@ class _TrainerConnectionCard extends StatelessWidget {
|
||||
Text(
|
||||
trainerText,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1424,12 +1422,14 @@ class _TrainerConnectionCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.bluetooth_connected_rounded, color: colorScheme.primary),
|
||||
Icon(Icons.bluetooth_connected_rounded,
|
||||
color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@ -1470,21 +1470,86 @@ class _TrainerConnectionCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DisconnectedDetailCard extends StatelessWidget {
|
||||
const _DisconnectedDetailCard();
|
||||
const _DisconnectedDetailCard({
|
||||
required this.isReconnecting,
|
||||
required this.onReconnect,
|
||||
required this.onBackToDevices,
|
||||
});
|
||||
|
||||
final bool isReconnecting;
|
||||
final VoidCallback onReconnect;
|
||||
final VoidCallback onBackToDevices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Text(
|
||||
'This device is not currently connected. Reopen it from Devices to reconnect and manage trainer pairing, firmware, and gear ratios.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.68),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.error.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.bluetooth_disabled_rounded,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'No connection',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'This device is not currently connected. Turn it on and keep it nearby, then reconnect when you are ready.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: isReconnecting ? null : onReconnect,
|
||||
icon: isReconnecting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_connected_rounded),
|
||||
label:
|
||||
Text(isReconnecting ? 'Reconnecting...' : 'Reconnect'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isReconnecting ? null : onBackToDevices,
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
label: const Text('Devices'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -1501,9 +1566,14 @@ class _DetailStatusChip extends StatelessWidget {
|
||||
final (label, color) = switch (status) {
|
||||
ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)),
|
||||
ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)),
|
||||
ConnectionStatus.disconnecting => ('Disconnecting', const Color(0xFFFFB649)),
|
||||
ConnectionStatus.disconnected =>
|
||||
('Disconnected', Theme.of(context).colorScheme.primary),
|
||||
ConnectionStatus.disconnecting => (
|
||||
'Disconnecting',
|
||||
const Color(0xFFFFB649)
|
||||
),
|
||||
ConnectionStatus.disconnected => (
|
||||
'Disconnected',
|
||||
Theme.of(context).colorScheme.primary
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
|
||||
@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/database/database.dart';
|
||||
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
||||
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||
import 'package:abawo_bt_app/util/constants.dart';
|
||||
import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
@ -63,9 +64,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isAbawoDevice = isAbawoDeviceIdent(device.manufacturerData);
|
||||
final isConnectable =
|
||||
device.serviceUuids.any(isConnectableAbawoDeviceGuid);
|
||||
final isAbawoDevice = device.serviceUuids.any(isAbawoDeviceGuid);
|
||||
final isConnectable = device.serviceUuids.any(isConnectableAbawoDeviceGuid);
|
||||
|
||||
if (!isAbawoDevice) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@ -122,13 +122,18 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
context.push('/device/${device.id}');
|
||||
context.go('/device/${device.id}');
|
||||
}
|
||||
break;
|
||||
case Err(:final v):
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Connection unsuccessful:\n${v.toString()}')),
|
||||
);
|
||||
final error = v.toString();
|
||||
if (error.toLowerCase().contains('disconnected')) {
|
||||
await showBluetoothPairingRecoveryDialog(context);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Connection unsuccessful:\n$error')),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -224,7 +229,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
.toSet();
|
||||
|
||||
return btAsyncValue.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _ScanMessageCard(
|
||||
@ -275,15 +281,16 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
itemCount: filteredResults.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final device = filteredResults[index];
|
||||
final isAlreadyConnected =
|
||||
connectedDeviceAddresses.contains(device.id);
|
||||
final tone = _ScanResultTone.resolve(
|
||||
isAlreadyConnected: isAlreadyConnected,
|
||||
isAbawoDevice:
|
||||
isAbawoDeviceIdent(device.manufacturerData),
|
||||
isAbawoDevice: hasConnectableAbawoDeviceGuid(
|
||||
device.serviceUuids),
|
||||
isConnectable: device.serviceUuids
|
||||
.any(isConnectableAbawoDeviceGuid),
|
||||
);
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -186,6 +186,14 @@ class _DevicesListState extends ConsumerState<DevicesList> {
|
||||
}
|
||||
|
||||
if (result.isOk()) {
|
||||
await ref
|
||||
.read(nConnectedDevicesProvider.notifier)
|
||||
.updateConnectedDeviceLastConnected(device.id);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.go('/device/${device.deviceAddress}');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -3,6 +3,9 @@ import 'dart:async';
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
final _log = Logger('ShifterService');
|
||||
|
||||
class ShifterService {
|
||||
ShifterService({
|
||||
@ -83,7 +86,7 @@ class ShifterService {
|
||||
universalShifterGearRatiosCharacteristicUuid,
|
||||
);
|
||||
if (readRes.isErr()) {
|
||||
return bail(readRes.unwrapErr());
|
||||
return Err(readRes.unwrapErr());
|
||||
}
|
||||
|
||||
final raw = readRes.unwrap();
|
||||
@ -159,7 +162,7 @@ class ShifterService {
|
||||
universalShifterStatusCharacteristicUuid,
|
||||
);
|
||||
if (readRes.isErr()) {
|
||||
return bail(readRes.unwrapErr());
|
||||
return Err(readRes.unwrapErr());
|
||||
}
|
||||
|
||||
try {
|
||||
@ -240,25 +243,34 @@ class ShifterService {
|
||||
return;
|
||||
}
|
||||
|
||||
_statusSubscription = _requireBluetooth
|
||||
.subscribeToCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterStatusCharacteristicUuid,
|
||||
)
|
||||
.listen(
|
||||
(data) {
|
||||
try {
|
||||
final status = CentralStatus.fromCborBytes(data);
|
||||
_statusController.add(status);
|
||||
} catch (_) {
|
||||
// Ignore malformed payloads but keep stream alive.
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
// Keep UI running; reconnection logic is handled elsewhere.
|
||||
},
|
||||
);
|
||||
try {
|
||||
_statusSubscription = _requireBluetooth
|
||||
.subscribeToCharacteristic(
|
||||
buttonDeviceId,
|
||||
universalShifterControlServiceUuid,
|
||||
universalShifterStatusCharacteristicUuid,
|
||||
)
|
||||
.listen(
|
||||
(data) {
|
||||
try {
|
||||
final status = CentralStatus.fromCborBytes(data);
|
||||
_statusController.add(status);
|
||||
} catch (error, st) {
|
||||
_log.warning(
|
||||
'Failed to decode status notification from $buttonDeviceId: '
|
||||
'bytes=${_formatBytes(data)}',
|
||||
error,
|
||||
st,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace st) {
|
||||
_log.warning('Status notification stream failed', error, st);
|
||||
},
|
||||
);
|
||||
} catch (error, st) {
|
||||
_log.warning('Could not start status notifications', error, st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopStatusNotifications() async {
|
||||
@ -286,6 +298,12 @@ class ShifterService {
|
||||
double _decodeGearRatio(int raw) {
|
||||
return raw / 64.0;
|
||||
}
|
||||
|
||||
String _formatBytes(List<int> bytes) {
|
||||
return bytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
abstract interface class DfuPreflightBluetoothAdapter {
|
||||
|
||||
44
lib/util/bluetooth_settings.dart
Normal file
44
lib/util/bluetooth_settings.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -22,6 +22,10 @@ bool isConnectableAbawoDeviceGuid(Uuid guid) {
|
||||
return isAbawoUniversalShiftersDeviceGuid(guid);
|
||||
}
|
||||
|
||||
bool hasConnectableAbawoDeviceGuid(List<Uuid> guid) => guid
|
||||
.map((id) => isConnectableAbawoDeviceGuid(id))
|
||||
.fold(false, (v, e) => v || e);
|
||||
|
||||
bool isAbawoDeviceIdent(List<int> manuData) {
|
||||
if (manuData.length < abawoManuIdentData.length) return false;
|
||||
for (int i = 0; i < abawoManuIdentData.length; i++) {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -29,19 +31,36 @@ class BikeScanDialog extends ConsumerStatefulWidget {
|
||||
|
||||
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
bool _showAll = false;
|
||||
bool _isStartingScan = true;
|
||||
String? _scanError;
|
||||
BluetoothController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startScan();
|
||||
unawaited(_startScan());
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
final controller = await ref.read(bluetoothProvider.future);
|
||||
_controller = controller;
|
||||
await controller.stopScan();
|
||||
await controller.startScan();
|
||||
setState(() {
|
||||
_isStartingScan = true;
|
||||
_scanError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final controller = await ref.read(bluetoothProvider.future);
|
||||
_controller = controller;
|
||||
await controller.stopScan();
|
||||
await controller.startScan();
|
||||
} catch (error) {
|
||||
_scanError = error.toString();
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isStartingScan = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@ -73,6 +92,7 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
children: [
|
||||
_DialogHeader(
|
||||
showAll: _showAll,
|
||||
isScanning: _isStartingScan,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_showAll = value;
|
||||
@ -81,141 +101,163 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
onRescan: _startScan,
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<DiscoveredDevice>>(
|
||||
stream: controller.scanResultsStream,
|
||||
initialData: controller.scanResults,
|
||||
builder: (context, snapshot) {
|
||||
final devices = _filteredDevices(snapshot.data ?? const []);
|
||||
if (devices.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
child: _scanError != null
|
||||
? _ScanMessage(
|
||||
message: 'Could not start trainer scan: $_scanError',
|
||||
action: TextButton.icon(
|
||||
onPressed: _startScan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
);
|
||||
}
|
||||
)
|
||||
: StreamBuilder<List<DiscoveredDevice>>(
|
||||
stream: controller.scanResultsStream,
|
||||
initialData: controller.scanResults,
|
||||
builder: (context, snapshot) {
|
||||
if (_isStartingScan &&
|
||||
(snapshot.data == null ||
|
||||
snapshot.data!.isEmpty)) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
final isFtms =
|
||||
device.serviceUuids.contains(Uuid.parse(ftmsServiceUuid));
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: () => Navigator.of(context).pop(device),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
final devices =
|
||||
_filteredDevices(snapshot.data ?? const []);
|
||||
if (devices.isEmpty) {
|
||||
return const _ScanMessage(
|
||||
message:
|
||||
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
itemCount: devices.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
final isFtms = device.serviceUuids
|
||||
.contains(Uuid.parse(ftmsServiceUuid));
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pop(device),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.pedal_bike_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
device.name.isEmpty
|
||||
? 'Unknown Device'
|
||||
: device.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.pedal_bike_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isFtms ? 'FTMS' : 'Nearby trainer',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
device.name.isEmpty
|
||||
? 'Unknown Device'
|
||||
: device.name,
|
||||
maxLines: 1,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight:
|
||||
FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isFtms
|
||||
? 'FTMS'
|
||||
: 'Nearby trainer',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
device.id,
|
||||
maxLines: 1,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(
|
||||
alpha: 0.62),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
device.id,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.62),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end,
|
||||
children: [
|
||||
_RssiBadge(rssi: device.rssi),
|
||||
const SizedBox(height: 12),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_RssiBadge(rssi: device.rssi),
|
||||
const SizedBox(height: 12),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -242,11 +284,13 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||
class _DialogHeader extends StatelessWidget {
|
||||
const _DialogHeader({
|
||||
required this.showAll,
|
||||
required this.isScanning,
|
||||
required this.onChanged,
|
||||
required this.onRescan,
|
||||
});
|
||||
|
||||
final bool showAll;
|
||||
final bool isScanning;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final VoidCallback onRescan;
|
||||
|
||||
@ -265,9 +309,10 @@ class _DialogHeader extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Assign Trainer',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
@ -306,10 +351,22 @@ class _DialogHeader extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRescan,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Rescan'),
|
||||
SizedBox(
|
||||
width: 132,
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 48),
|
||||
),
|
||||
onPressed: isScanning ? null : onRescan,
|
||||
icon: isScanning
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
label: const Text('Rescan'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -319,6 +376,38 @@ class _DialogHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanMessage extends StatelessWidget {
|
||||
const _ScanMessage({
|
||||
required this.message,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(width: 132, child: action!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RssiBadge extends StatelessWidget {
|
||||
const _RssiBadge({required this.rssi});
|
||||
|
||||
|
||||
@ -165,22 +165,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (_isEditing) ...[
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _onCancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _isSaving ? null : _onSave,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
] else
|
||||
if (!_isEditing)
|
||||
IconButton(
|
||||
tooltip: 'Edit ratios',
|
||||
onPressed: (widget.isLoading || widget.errorText != null)
|
||||
@ -191,6 +176,11 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isEditing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: _buildEditActions(),
|
||||
),
|
||||
if (widget.isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 24),
|
||||
@ -365,6 +355,9 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
duration: _animDuration,
|
||||
switchInCurve: _animCurve,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return currentChild ?? const SizedBox.shrink();
|
||||
},
|
||||
transitionBuilder: _snappyTransition,
|
||||
child: Column(
|
||||
key: ValueKey('editors-$_gearLayoutVersion'),
|
||||
@ -373,6 +366,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 4, 14, 14),
|
||||
child: _buildEditActions(),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
@ -381,6 +378,32 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditActions() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _isSaving ? null : _onCancel,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(88, 52),
|
||||
),
|
||||
onPressed: _isSaving ? null : _onSave,
|
||||
child: _isSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Done'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _snappyTransition(Widget child, Animation<double> animation) {
|
||||
final curved = CurvedAnimation(parent: animation, curve: _animCurve);
|
||||
return FadeTransition(
|
||||
|
||||
110
test/model/shifter_types_test.dart
Normal file
110
test/model/shifter_types_test.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user