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
|
### Company Color Theme
|
||||||
todo
|
todo
|
||||||
|
|
||||||
|
## Code
|
||||||
|
|
||||||
|
Always use `<color>.withValues(alpha: <alpha>)` instead of `<color>.withOpacity(<alpha>)` for colors.
|
||||||
@ -42,3 +42,7 @@ android {
|
|||||||
flutter {
|
flutter {
|
||||||
source = "../.."
|
source = "../.."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "io.reactivex.rxjava2:rxjava:2.2.21"
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,55 @@
|
|||||||
package com.example.abawo_bt_app
|
package com.example.abawo_bt_app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import io.reactivex.exceptions.UndeliverableException
|
||||||
|
import io.reactivex.plugins.RxJavaPlugins
|
||||||
|
|
||||||
class MainActivity: FlutterActivity()
|
class MainActivity: FlutterActivity() {
|
||||||
|
private val settingsChannel = "abawo/settings"
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
RxJavaPlugins.setErrorHandler { throwable ->
|
||||||
|
val error = if (throwable is UndeliverableException && throwable.cause != null) {
|
||||||
|
throwable.cause!!
|
||||||
|
} else {
|
||||||
|
throwable
|
||||||
|
}
|
||||||
|
val className = error.javaClass.name
|
||||||
|
val message = error.message.orEmpty()
|
||||||
|
if (className.contains("BleGatt") || message.contains("GATT exception")) {
|
||||||
|
return@setErrorHandler
|
||||||
|
}
|
||||||
|
Thread.currentThread().uncaughtExceptionHandler
|
||||||
|
?.uncaughtException(Thread.currentThread(), error)
|
||||||
|
}
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, settingsChannel)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"openBluetoothSettings" -> {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS))
|
||||||
|
result.success(true)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
try {
|
||||||
|
startActivity(Intent(Settings.ACTION_SETTINGS))
|
||||||
|
result.success(true)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
result.success(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:anyhow/anyhow.dart';
|
import 'package:anyhow/anyhow.dart';
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
||||||
hide ConnectionStatus, Result, Logger;
|
hide ConnectionStatus, Result, Logger;
|
||||||
@ -173,9 +175,6 @@ class BluetoothController {
|
|||||||
(currentState.$1 == ConnectionStatus.connected ||
|
(currentState.$1 == ConnectionStatus.connected ||
|
||||||
currentState.$1 == ConnectionStatus.connecting)) {
|
currentState.$1 == ConnectionStatus.connecting)) {
|
||||||
log.info('Already connected or connecting to $deviceId.');
|
log.info('Already connected or connecting to $deviceId.');
|
||||||
if (currentState.$1 == ConnectionStatus.connected) {
|
|
||||||
unawaited(_requestMtuOnConnect(deviceId));
|
|
||||||
}
|
|
||||||
return Ok(null);
|
return Ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +190,7 @@ class BluetoothController {
|
|||||||
try {
|
try {
|
||||||
await _connectionStateSubscription?.cancel();
|
await _connectionStateSubscription?.cancel();
|
||||||
_updateConnectionState(ConnectionStatus.connecting, deviceId);
|
_updateConnectionState(ConnectionStatus.connecting, deviceId);
|
||||||
|
final connectionResult = Completer<Result<void>>();
|
||||||
|
|
||||||
_connectionStateSubscription = _ble
|
_connectionStateSubscription = _ble
|
||||||
.connectToDevice(
|
.connectToDevice(
|
||||||
@ -199,12 +199,21 @@ class BluetoothController {
|
|||||||
servicesWithCharacteristicsToDiscover:
|
servicesWithCharacteristicsToDiscover:
|
||||||
servicesWithCharacteristicsToDiscover,
|
servicesWithCharacteristicsToDiscover,
|
||||||
)
|
)
|
||||||
.listen((update) {
|
.listen((update) async {
|
||||||
switch (update.connectionState) {
|
switch (update.connectionState) {
|
||||||
case DeviceConnectionState.connected:
|
case DeviceConnectionState.connected:
|
||||||
_connectedDeviceId = deviceId;
|
_connectedDeviceId = deviceId;
|
||||||
_updateConnectionState(ConnectionStatus.connected, deviceId);
|
_updateConnectionState(ConnectionStatus.connected, deviceId);
|
||||||
unawaited(_requestMtuOnConnect(deviceId));
|
if (!connectionResult.isCompleted) {
|
||||||
|
final mtuResult = await _requestInitialMtu(deviceId);
|
||||||
|
if (mtuResult.isErr()) {
|
||||||
|
log.warning(
|
||||||
|
'Initial MTU request failed for $deviceId: '
|
||||||
|
'${mtuResult.unwrapErr()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
connectionResult.complete(Ok(null));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case DeviceConnectionState.connecting:
|
case DeviceConnectionState.connecting:
|
||||||
_updateConnectionState(ConnectionStatus.connecting, deviceId);
|
_updateConnectionState(ConnectionStatus.connecting, deviceId);
|
||||||
@ -214,14 +223,31 @@ class BluetoothController {
|
|||||||
break;
|
break;
|
||||||
case DeviceConnectionState.disconnected:
|
case DeviceConnectionState.disconnected:
|
||||||
_cleanUpConnection();
|
_cleanUpConnection();
|
||||||
|
if (!connectionResult.isCompleted) {
|
||||||
|
connectionResult.complete(
|
||||||
|
bail('Failed to connect to $deviceId: disconnected'),
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, onError: (Object error, StackTrace st) {
|
}, onError: (Object error, StackTrace st) {
|
||||||
log.severe('Failed to connect to $deviceId: $error', error, st);
|
log.severe('Failed to connect to $deviceId: $error', error, st);
|
||||||
_cleanUpConnection();
|
_cleanUpConnection();
|
||||||
|
if (!connectionResult.isCompleted) {
|
||||||
|
connectionResult.complete(
|
||||||
|
bail('Failed to connect to $deviceId: $error'),
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok(null);
|
try {
|
||||||
|
return await connectionResult.future.timeout(timeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
await _connectionStateSubscription?.cancel();
|
||||||
|
_connectionStateSubscription = null;
|
||||||
|
_cleanUpConnection();
|
||||||
|
return bail('Timed out connecting to $deviceId');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_cleanUpConnection();
|
_cleanUpConnection();
|
||||||
return bail('Failed to connect to $deviceId: $e');
|
return bail('Failed to connect to $deviceId: $e');
|
||||||
@ -301,7 +327,7 @@ class BluetoothController {
|
|||||||
{int mtu = defaultMtu}) async {
|
{int mtu = defaultMtu}) async {
|
||||||
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
|
final result = await requestMtuAndGetValue(deviceId, mtu: mtu);
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
return bail(result.unwrapErr());
|
return Err(result.unwrapErr());
|
||||||
}
|
}
|
||||||
return Ok(null);
|
return Ok(null);
|
||||||
}
|
}
|
||||||
@ -310,6 +336,10 @@ class BluetoothController {
|
|||||||
{int mtu = defaultMtu}) async {
|
{int mtu = defaultMtu}) async {
|
||||||
try {
|
try {
|
||||||
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
|
final negotiatedMtu = await _ble.requestMtu(deviceId: deviceId, mtu: mtu);
|
||||||
|
if (negotiatedMtu <= 0) {
|
||||||
|
return bail(
|
||||||
|
'Error requesting MTU $mtu for $deviceId: negotiated invalid MTU $negotiatedMtu');
|
||||||
|
}
|
||||||
log.info(
|
log.info(
|
||||||
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
|
'MTU negotiated for $deviceId: requested $mtu, got $negotiatedMtu');
|
||||||
return Ok(negotiatedMtu);
|
return Ok(negotiatedMtu);
|
||||||
@ -318,12 +348,11 @@ class BluetoothController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _requestMtuOnConnect(String deviceId) async {
|
Future<Result<void>> _requestInitialMtu(String deviceId) async {
|
||||||
final mtuResult = await requestMtu(deviceId, mtu: defaultMtu);
|
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||||
if (mtuResult.isErr()) {
|
return Ok(null);
|
||||||
log.warning(
|
|
||||||
'MTU request after connect failed for $deviceId: ${mtuResult.unwrapErr()}');
|
|
||||||
}
|
}
|
||||||
|
return requestMtu(deviceId, mtu: defaultMtu);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<List<int>> subscribeToCharacteristic(
|
Stream<List<int>> subscribeToCharacteristic(
|
||||||
|
|||||||
@ -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;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> updateConnectedDeviceLastConnected(int id) async {
|
||||||
|
final db = ref.watch(databaseProvider);
|
||||||
|
final res = await db.updateConnectedDeviceLastConnected(id);
|
||||||
|
if (res.isOk()) {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for the [AppDatabase] instance
|
/// Provider for the [AppDatabase] instance
|
||||||
@ -137,6 +146,22 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Result<void>> updateConnectedDeviceLastConnected(int id) async {
|
||||||
|
try {
|
||||||
|
final count = await (update(connectedDevices)
|
||||||
|
..where((tbl) => tbl.id.equals(id)))
|
||||||
|
.write(ConnectedDevicesCompanion(
|
||||||
|
lastConnectedAt: Value(DateTime.now())));
|
||||||
|
if (count == 0) {
|
||||||
|
return bail('Device with id $id not found.');
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
} catch (e, st) {
|
||||||
|
return bail(
|
||||||
|
'Failed to update last connected time for device $id: $e', st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Stream<List<ConnectedDevice>> getAllConnectedDevicesStream() {
|
Stream<List<ConnectedDevice>> getAllConnectedDevicesStream() {
|
||||||
return select(connectedDevices).watch();
|
return select(connectedDevices).watch();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||||
import 'package:abawo_bt_app/pages/devices_page.dart';
|
import 'package:abawo_bt_app/pages/devices_page.dart';
|
||||||
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
|
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
|
||||||
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
|
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
|
||||||
@ -29,11 +32,47 @@ Future<void> main() async {
|
|||||||
], child: const AbawoBtApp()));
|
], child: const AbawoBtApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class AbawoBtApp extends ConsumerWidget {
|
class AbawoBtApp extends ConsumerStatefulWidget {
|
||||||
const AbawoBtApp({super.key});
|
const AbawoBtApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<AbawoBtApp> createState() => _AbawoBtAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AbawoBtAppState extends ConsumerState<AbawoBtApp>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.hidden ||
|
||||||
|
state == AppLifecycleState.paused) {
|
||||||
|
unawaited(_disconnectBluetoothForBackground());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _disconnectBluetoothForBackground() async {
|
||||||
|
final bluetooth = ref.read(bluetoothProvider).value;
|
||||||
|
if (bluetooth == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bluetooth.stopScan();
|
||||||
|
await bluetooth.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final themePreference = ref.watch(appThemePreferenceProvider);
|
final themePreference = ref.watch(appThemePreferenceProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:cbor/simple.dart';
|
import 'package:cbor/simple.dart';
|
||||||
|
|
||||||
const String universalShifterControlServiceUuid =
|
const String universalShifterControlServiceUuid =
|
||||||
@ -249,6 +251,9 @@ enum ControlConnectionState {
|
|||||||
}
|
}
|
||||||
if (raw is String) {
|
if (raw is String) {
|
||||||
final normalized = raw.toLowerCase();
|
final normalized = raw.toLowerCase();
|
||||||
|
if (normalized.contains('disconnected')) {
|
||||||
|
return ControlConnectionState.disconnected;
|
||||||
|
}
|
||||||
if (normalized.contains('connected')) {
|
if (normalized.contains('connected')) {
|
||||||
return ControlConnectionState.connected;
|
return ControlConnectionState.connected;
|
||||||
}
|
}
|
||||||
@ -294,32 +299,21 @@ class TrainerStatus {
|
|||||||
|
|
||||||
static TrainerStatus fromRaw(dynamic raw) {
|
static TrainerStatus fromRaw(dynamic raw) {
|
||||||
if (raw is int) {
|
if (raw is int) {
|
||||||
switch (raw) {
|
return _trainerStatusFromVariant(raw);
|
||||||
case 1:
|
|
||||||
return const TrainerStatus(state: TrainerConnectionState.connecting);
|
|
||||||
case 2:
|
|
||||||
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
|
||||||
case 3:
|
|
||||||
return const TrainerStatus(state: TrainerConnectionState.connected);
|
|
||||||
case 4:
|
|
||||||
return const TrainerStatus(
|
|
||||||
state: TrainerConnectionState.discoveringFtms);
|
|
||||||
case 5:
|
|
||||||
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
|
|
||||||
default:
|
|
||||||
return const TrainerStatus(state: TrainerConnectionState.idle);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw is List && raw.isNotEmpty) {
|
if (raw is List && raw.isNotEmpty) {
|
||||||
final variant = raw.first;
|
final variant = raw.first;
|
||||||
final value = raw.length > 1 ? raw[1] : null;
|
final value = raw.length > 1 ? raw[1] : null;
|
||||||
if (variant is int && (variant == 5 || variant == 6)) {
|
if (variant is int && variant == 6) {
|
||||||
return TrainerStatus(
|
return TrainerStatus(
|
||||||
state: TrainerConnectionState.error,
|
state: TrainerConnectionState.error,
|
||||||
errorCode: value is int ? value : null,
|
errorCode: value is int ? value : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (variant is int) {
|
||||||
|
return _trainerStatusFromVariant(variant);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw is Map) {
|
if (raw is Map) {
|
||||||
@ -327,13 +321,16 @@ class TrainerStatus {
|
|||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
final key = entry.key;
|
final key = entry.key;
|
||||||
final value = entry.value;
|
final value = entry.value;
|
||||||
if ((key is int && (key == 5 || key == 6)) ||
|
if ((key is int && key == 6) ||
|
||||||
(key is String && key.toLowerCase().contains('error'))) {
|
(key is String && key.toLowerCase().contains('error'))) {
|
||||||
return TrainerStatus(
|
return TrainerStatus(
|
||||||
state: TrainerConnectionState.error,
|
state: TrainerConnectionState.error,
|
||||||
errorCode: value is int ? value : null,
|
errorCode: value is int ? value : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (key is int) {
|
||||||
|
return _trainerStatusFromVariant(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,6 +359,26 @@ class TrainerStatus {
|
|||||||
|
|
||||||
return const TrainerStatus(state: TrainerConnectionState.idle);
|
return const TrainerStatus(state: TrainerConnectionState.idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TrainerStatus _trainerStatusFromVariant(int variant) {
|
||||||
|
switch (variant) {
|
||||||
|
case 1:
|
||||||
|
return const TrainerStatus(state: TrainerConnectionState.connecting);
|
||||||
|
case 2:
|
||||||
|
return const TrainerStatus(state: TrainerConnectionState.pairing);
|
||||||
|
case 3:
|
||||||
|
return const TrainerStatus(state: TrainerConnectionState.connected);
|
||||||
|
case 4:
|
||||||
|
return const TrainerStatus(
|
||||||
|
state: TrainerConnectionState.discoveringFtms);
|
||||||
|
case 5:
|
||||||
|
return const TrainerStatus(state: TrainerConnectionState.ftmsReady);
|
||||||
|
case 6:
|
||||||
|
return const TrainerStatus(state: TrainerConnectionState.error);
|
||||||
|
default:
|
||||||
|
return const TrainerStatus(state: TrainerConnectionState.idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CentralStatus {
|
class CentralStatus {
|
||||||
@ -384,16 +401,26 @@ class CentralStatus {
|
|||||||
String get statusLine =>
|
String get statusLine =>
|
||||||
'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}';
|
'Control: ${control.name}, Trainer: ${trainer.label}${lastFailure != null ? ', Last failure: $lastFailure' : ''}';
|
||||||
|
|
||||||
static CentralStatus fromCborBytes(List<int> bytes) {
|
static CentralStatus disconnected({dynamic raw}) {
|
||||||
final decoded = cbor.decode(bytes);
|
|
||||||
if (decoded is! Map) {
|
|
||||||
return CentralStatus(
|
return CentralStatus(
|
||||||
control: ControlConnectionState.disconnected,
|
control: ControlConnectionState.disconnected,
|
||||||
trainer: const TrainerStatus(state: TrainerConnectionState.idle),
|
trainer: const TrainerStatus(state: TrainerConnectionState.idle),
|
||||||
hasSavedBond: false,
|
hasSavedBond: false,
|
||||||
connectedTrainerAddr: null,
|
connectedTrainerAddr: null,
|
||||||
lastFailure: null,
|
lastFailure: null,
|
||||||
raw: decoded,
|
raw: raw,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static CentralStatus fromCborBytes(List<int> bytes) {
|
||||||
|
if (bytes.isEmpty) {
|
||||||
|
throw const FormatException('Status payload is empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = cbor.decode(bytes);
|
||||||
|
if (decoded is! Map) {
|
||||||
|
throw FormatException(
|
||||||
|
'Status payload must decode to a CBOR map, got ${decoded.runtimeType}.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -428,6 +455,9 @@ List<int>? _toByteList(dynamic value) {
|
|||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (value is Uint8List) {
|
||||||
|
return value.toList(growable: false);
|
||||||
|
}
|
||||||
if (value is List) {
|
if (value is List) {
|
||||||
return value.whereType<int>().toList(growable: false);
|
return value.whereType<int>().toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,9 +5,12 @@ import 'package:abawo_bt_app/model/shifter_types.dart';
|
|||||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||||
|
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||||
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
|
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
|
||||||
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
||||||
|
show DiscoveredDevice;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:nb_utils/nb_utils.dart';
|
import 'package:nb_utils/nb_utils.dart';
|
||||||
@ -48,11 +51,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
3.27,
|
3.27,
|
||||||
];
|
];
|
||||||
|
|
||||||
bool _isReconnecting = false;
|
|
||||||
bool _wasConnectedToCurrentDevice = false;
|
|
||||||
bool _isExitingPage = false;
|
bool _isExitingPage = false;
|
||||||
bool _hasRequestedDisconnect = false;
|
bool _hasRequestedDisconnect = false;
|
||||||
Timer? _reconnectTimeoutTimer;
|
bool _hasShownPairingRecoveryDialog = false;
|
||||||
|
bool _isAssignTrainerDialogOpen = false;
|
||||||
|
bool _isManualReconnectRunning = false;
|
||||||
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
|
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
|
||||||
_connectionStatusSubscription;
|
_connectionStatusSubscription;
|
||||||
|
|
||||||
@ -124,7 +127,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
unawaited(_disconnectOnClose());
|
unawaited(_disconnectOnClose());
|
||||||
_reconnectTimeoutTimer?.cancel();
|
|
||||||
_connectionStatusSubscription?.close();
|
_connectionStatusSubscription?.close();
|
||||||
_statusSubscription?.cancel();
|
_statusSubscription?.cancel();
|
||||||
_shifterService?.dispose();
|
_shifterService?.dispose();
|
||||||
@ -134,15 +136,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _disconnectOnClose() async {
|
Future<void> _disconnectOnClose() async {
|
||||||
|
if (_isFirmwareUpdateBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_hasRequestedDisconnect) {
|
if (_hasRequestedDisconnect) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_hasRequestedDisconnect = true;
|
_hasRequestedDisconnect = true;
|
||||||
_isExitingPage = true;
|
_isExitingPage = true;
|
||||||
_reconnectTimeoutTimer?.cancel();
|
|
||||||
|
|
||||||
await _firmwareUpdateService?.cancelUpdate();
|
|
||||||
await _disposeFirmwareUpdateService();
|
await _disposeFirmwareUpdateService();
|
||||||
|
|
||||||
final bluetooth = ref.read(bluetoothProvider).value;
|
final bluetooth = ref.read(bluetoothProvider).value;
|
||||||
@ -159,55 +163,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
|
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
|
||||||
|
|
||||||
if (isCurrentDevice && status == ConnectionStatus.connected) {
|
if (isCurrentDevice && status == ConnectionStatus.connected) {
|
||||||
_wasConnectedToCurrentDevice = true;
|
|
||||||
_startStatusStreamingIfNeeded();
|
_startStatusStreamingIfNeeded();
|
||||||
if (_isReconnecting) {
|
|
||||||
_reconnectTimeoutTimer?.cancel();
|
|
||||||
setState(() {
|
|
||||||
_isReconnecting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_wasConnectedToCurrentDevice &&
|
|
||||||
!_isReconnecting &&
|
|
||||||
status == ConnectionStatus.disconnected &&
|
|
||||||
!_isFirmwareUpdateBusy) {
|
|
||||||
_startReconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
|
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
|
||||||
!_isFirmwareUpdateBusy) {
|
!_isFirmwareUpdateBusy) {
|
||||||
_stopStatusStreaming();
|
_stopStatusStreaming();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startReconnect() async {
|
|
||||||
if (!mounted || _isExitingPage || _isReconnecting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isReconnecting = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
final bluetooth = ref.read(bluetoothProvider).value;
|
|
||||||
await bluetooth?.connectById(widget.deviceAddress);
|
|
||||||
|
|
||||||
_reconnectTimeoutTimer?.cancel();
|
|
||||||
_reconnectTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
|
||||||
if (!mounted || !_isReconnecting || _isExitingPage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_terminateConnectionAndGoHome(
|
|
||||||
'Connection lost. Could not reconnect in time.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _startStatusStreamingIfNeeded() async {
|
Future<void> _startStatusStreamingIfNeeded() async {
|
||||||
|
bool isCurrentDeviceConnected(BluetoothController bluetooth) {
|
||||||
|
final connectionState = bluetooth.currentConnectionState;
|
||||||
|
return connectionState.$1 == ConnectionStatus.connected &&
|
||||||
|
connectionState.$2 == widget.deviceAddress;
|
||||||
|
}
|
||||||
|
|
||||||
if (_shifterService != null) {
|
if (_shifterService != null) {
|
||||||
|
final bluetooth = ref.read(bluetoothProvider).value;
|
||||||
|
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
|
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@ -227,16 +204,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
} else {
|
} else {
|
||||||
bluetooth = await ref.read(bluetoothProvider.future);
|
bluetooth = await ref.read(bluetoothProvider.future);
|
||||||
}
|
}
|
||||||
|
if (!isCurrentDeviceConnected(bluetooth)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final service = ShifterService(
|
final service = ShifterService(
|
||||||
bluetooth: bluetooth,
|
bluetooth: bluetooth,
|
||||||
buttonDeviceId: widget.deviceAddress,
|
buttonDeviceId: widget.deviceAddress,
|
||||||
);
|
);
|
||||||
|
|
||||||
final initialStatusResult = await service.readStatus();
|
final initialStatusResult = await service.readStatus();
|
||||||
if (mounted && initialStatusResult.isOk()) {
|
if (!mounted) {
|
||||||
_recordStatus(initialStatusResult.unwrap());
|
await service.dispose();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (initialStatusResult.isErr()) {
|
||||||
|
await service.dispose();
|
||||||
|
await _showPairingRecoveryDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordStatus(initialStatusResult.unwrap());
|
||||||
|
|
||||||
_statusSubscription = service.statusStream.listen((status) {
|
_statusSubscription = service.statusStream.listen((status) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
@ -251,6 +240,15 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
unawaited(_loadGearRatios());
|
unawaited(_loadGearRatios());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showPairingRecoveryDialog() async {
|
||||||
|
if (!mounted || _hasShownPairingRecoveryDialog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasShownPairingRecoveryDialog = true;
|
||||||
|
await showBluetoothPairingRecoveryDialog(context);
|
||||||
|
}
|
||||||
|
|
||||||
void _recordStatus(CentralStatus status) {
|
void _recordStatus(CentralStatus status) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_latestStatus = status;
|
_latestStatus = status;
|
||||||
@ -360,6 +358,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connectButtonToBike() async {
|
Future<void> _connectButtonToBike() async {
|
||||||
|
if (_isAssignTrainerDialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_isFirmwareUpdateBusy) {
|
if (_isFirmwareUpdateBusy) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@ -369,10 +371,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final selectedBike = await BikeScanDialog.show(
|
_isAssignTrainerDialogOpen = true;
|
||||||
|
final DiscoveredDevice? selectedBike;
|
||||||
|
try {
|
||||||
|
selectedBike = await BikeScanDialog.show(
|
||||||
context,
|
context,
|
||||||
excludedDeviceId: widget.deviceAddress,
|
excludedDeviceId: widget.deviceAddress,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
_isAssignTrainerDialogOpen = false;
|
||||||
|
}
|
||||||
if (selectedBike == null || !mounted) {
|
if (selectedBike == null || !mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -533,17 +541,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cancelFirmwareUpdate() async {
|
|
||||||
final updater = _firmwareUpdateService;
|
|
||||||
if (updater == null || !_isFirmwareUpdateBusy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_firmwareUserMessage = 'Canceling firmware update...';
|
|
||||||
});
|
|
||||||
await updater.cancelUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _dfuPhaseText(DfuUpdateState state) {
|
String _dfuPhaseText(DfuUpdateState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case DfuUpdateState.idle:
|
case DfuUpdateState.idle:
|
||||||
@ -579,27 +576,68 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
|
Future<void> _manualReconnect() async {
|
||||||
await _disconnectOnClose();
|
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isManualReconnectRunning = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final bluetooth = await ref.read(bluetoothProvider.future);
|
||||||
|
final result = await bluetooth.connectById(
|
||||||
|
widget.deviceAddress,
|
||||||
|
timeout: const Duration(seconds: 10),
|
||||||
|
);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast(toastMessage);
|
if (result.isErr()) {
|
||||||
context.replace('/devices');
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Reconnect failed. Is the device turned on and in range?',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cancelReconnect() async {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
await _terminateConnectionAndGoHome('Reconnect cancelled.');
|
SnackBar(content: Text('Reconnect failed: $error')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isManualReconnectRunning = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _exitPage() async {
|
Future<void> _exitPage() async {
|
||||||
|
if (_isFirmwareUpdateBusy) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Firmware update is running. Keep this screen open until it completes.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _disconnectOnClose();
|
await _disconnectOnClose();
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.replace('/devices');
|
context.go('/devices');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showStatusHistory() {
|
void _showStatusHistory() {
|
||||||
@ -713,19 +751,18 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
||||||
final currentConnectionStatus = connectionData != null &&
|
final currentConnectionStatus =
|
||||||
connectionData.$2 == widget.deviceAddress
|
connectionData != null && connectionData.$2 == widget.deviceAddress
|
||||||
? connectionData.$1
|
? connectionData.$1
|
||||||
: ConnectionStatus.disconnected;
|
: ConnectionStatus.disconnected;
|
||||||
final isCurrentConnected = currentConnectionStatus == ConnectionStatus.connected;
|
final isCurrentConnected =
|
||||||
|
currentConnectionStatus == ConnectionStatus.connected;
|
||||||
final canSelectFirmware =
|
final canSelectFirmware =
|
||||||
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||||
final canStartFirmware = isCurrentConnected &&
|
final canStartFirmware = isCurrentConnected &&
|
||||||
!_isSelectingFirmware &&
|
!_isSelectingFirmware &&
|
||||||
!_isFirmwareUpdateBusy &&
|
!_isFirmwareUpdateBusy &&
|
||||||
_selectedFirmware != null;
|
_selectedFirmware != null;
|
||||||
final canCancelFirmware = _isFirmwareUpdateBusy;
|
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
onPopInvokedWithResult: (bool didPop, bool? result) {
|
onPopInvokedWithResult: (bool didPop, bool? result) {
|
||||||
@ -756,11 +793,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
if (isCurrentConnected) ...[
|
if (isCurrentConnected) ...[
|
||||||
_TrainerConnectionCard(
|
|
||||||
status: _latestStatus,
|
|
||||||
onAssign: _isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
|
||||||
onShowStatusConsole: _showStatusHistory,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_StatusBanner(
|
_StatusBanner(
|
||||||
status: _latestStatus,
|
status: _latestStatus,
|
||||||
@ -775,22 +807,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_FirmwareUpdateCard(
|
_TrainerConnectionCard(
|
||||||
selectedFirmware: _selectedFirmware,
|
status: _latestStatus,
|
||||||
progress: _dfuProgress,
|
onAssign:
|
||||||
isSelecting: _isSelectingFirmware,
|
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||||
isStarting: _isStartingFirmwareUpdate,
|
onShowStatusConsole: _showStatusHistory,
|
||||||
canSelect: canSelectFirmware,
|
|
||||||
canStart: canStartFirmware,
|
|
||||||
canCancel: canCancelFirmware,
|
|
||||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
|
||||||
statusText: _firmwareUserMessage,
|
|
||||||
formattedProgressBytes:
|
|
||||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
|
||||||
onSelectFirmware: _selectFirmwareFile,
|
|
||||||
onStartUpdate: _startFirmwareUpdate,
|
|
||||||
onCancelUpdate: _cancelFirmwareUpdate,
|
|
||||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Opacity(
|
Opacity(
|
||||||
@ -873,48 +894,30 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_FirmwareUpdateCard(
|
||||||
|
selectedFirmware: _selectedFirmware,
|
||||||
|
progress: _dfuProgress,
|
||||||
|
isSelecting: _isSelectingFirmware,
|
||||||
|
isStarting: _isStartingFirmwareUpdate,
|
||||||
|
canSelect: canSelectFirmware,
|
||||||
|
canStart: canStartFirmware,
|
||||||
|
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||||
|
statusText: _firmwareUserMessage,
|
||||||
|
formattedProgressBytes:
|
||||||
|
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||||
|
onSelectFirmware: _selectFirmwareFile,
|
||||||
|
onStartUpdate: _startFirmwareUpdate,
|
||||||
|
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
const _DisconnectedDetailCard(),
|
_DisconnectedDetailCard(
|
||||||
],
|
isReconnecting: _isManualReconnectRunning,
|
||||||
],
|
onReconnect: _manualReconnect,
|
||||||
),
|
onBackToDevices: _exitPage,
|
||||||
),
|
|
||||||
if (_isReconnecting)
|
|
||||||
Positioned.fill(
|
|
||||||
child: ColoredBox(
|
|
||||||
color: Colors.black.withValues(alpha: 0.55),
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.outlineVariant
|
|
||||||
.withValues(alpha: 0.55),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const CircularProgressIndicator(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Reconnecting...',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextButton(
|
|
||||||
onPressed: _cancelReconnect,
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -942,14 +945,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
|||||||
required this.isStarting,
|
required this.isStarting,
|
||||||
required this.canSelect,
|
required this.canSelect,
|
||||||
required this.canStart,
|
required this.canStart,
|
||||||
required this.canCancel,
|
|
||||||
required this.phaseText,
|
required this.phaseText,
|
||||||
required this.statusText,
|
required this.statusText,
|
||||||
required this.formattedProgressBytes,
|
required this.formattedProgressBytes,
|
||||||
required this.ackSequenceHex,
|
required this.ackSequenceHex,
|
||||||
required this.onSelectFirmware,
|
required this.onSelectFirmware,
|
||||||
required this.onStartUpdate,
|
required this.onStartUpdate,
|
||||||
required this.onCancelUpdate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final DfuV1PreparedFirmware? selectedFirmware;
|
final DfuV1PreparedFirmware? selectedFirmware;
|
||||||
@ -958,14 +959,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
|||||||
final bool isStarting;
|
final bool isStarting;
|
||||||
final bool canSelect;
|
final bool canSelect;
|
||||||
final bool canStart;
|
final bool canStart;
|
||||||
final bool canCancel;
|
|
||||||
final String phaseText;
|
final String phaseText;
|
||||||
final String? statusText;
|
final String? statusText;
|
||||||
final String formattedProgressBytes;
|
final String formattedProgressBytes;
|
||||||
final String ackSequenceHex;
|
final String ackSequenceHex;
|
||||||
final Future<void> Function() onSelectFirmware;
|
final Future<void> Function() onSelectFirmware;
|
||||||
final Future<void> Function() onStartUpdate;
|
final Future<void> Function() onStartUpdate;
|
||||||
final Future<void> Function() onCancelUpdate;
|
|
||||||
|
|
||||||
bool get _showProgress {
|
bool get _showProgress {
|
||||||
return progress.totalBytes > 0 ||
|
return progress.totalBytes > 0 ||
|
||||||
@ -991,7 +990,8 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.system_update_alt_rounded, color: colorScheme.primary),
|
Icon(Icons.system_update_alt_rounded,
|
||||||
|
color: colorScheme.primary),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Text(
|
const Text(
|
||||||
'Firmware Update',
|
'Firmware Update',
|
||||||
@ -1033,18 +1033,14 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
|||||||
: const Icon(Icons.system_update_alt),
|
: const Icon(Icons.system_update_alt),
|
||||||
label: const Text('Start Update'),
|
label: const Text('Start Update'),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
|
||||||
onPressed: canCancel ? onCancelUpdate : null,
|
|
||||||
icon: const Icon(Icons.stop_circle_outlined),
|
|
||||||
label: const Text('Cancel Update'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
color:
|
||||||
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -1207,12 +1203,10 @@ class _StatusBanner extends StatelessWidget {
|
|||||||
Widget _buildDeviceOverviewCard(
|
Widget _buildDeviceOverviewCard(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
String deviceAddress,
|
String deviceAddress, {
|
||||||
{
|
|
||||||
required ConnectionStatus connectionStatus,
|
required ConnectionStatus connectionStatus,
|
||||||
required CentralStatus? status,
|
required CentralStatus? status,
|
||||||
}
|
}) {
|
||||||
) {
|
|
||||||
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
|
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
|
||||||
|
|
||||||
return asyncSavedDevices.when(
|
return asyncSavedDevices.when(
|
||||||
@ -1301,7 +1295,8 @@ class _DeviceOverviewCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
device.deviceName,
|
device.deviceName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style:
|
||||||
|
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -1311,7 +1306,8 @@ class _DeviceOverviewCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
trainerAddress,
|
trainerAddress,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
color:
|
||||||
|
colorScheme.onSurface.withValues(alpha: 0.68),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -1395,7 +1391,8 @@ class _TrainerConnectionCard extends StatelessWidget {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: colorScheme.primary.withValues(alpha: 0.12),
|
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
),
|
),
|
||||||
child: Icon(Icons.pedal_bike_rounded, color: colorScheme.primary),
|
child: Icon(Icons.pedal_bike_rounded,
|
||||||
|
color: colorScheme.primary),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -1412,7 +1409,8 @@ class _TrainerConnectionCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
trainerText,
|
trainerText,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
color:
|
||||||
|
colorScheme.onSurface.withValues(alpha: 0.68),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -1424,12 +1422,14 @@ class _TrainerConnectionCard extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
color:
|
||||||
|
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.bluetooth_connected_rounded, color: colorScheme.primary),
|
Icon(Icons.bluetooth_connected_rounded,
|
||||||
|
color: colorScheme.primary),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -1470,21 +1470,86 @@ class _TrainerConnectionCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DisconnectedDetailCard extends StatelessWidget {
|
class _DisconnectedDetailCard extends StatelessWidget {
|
||||||
const _DisconnectedDetailCard();
|
const _DisconnectedDetailCard({
|
||||||
|
required this.isReconnecting,
|
||||||
|
required this.onReconnect,
|
||||||
|
required this.onBackToDevices,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isReconnecting;
|
||||||
|
final VoidCallback onReconnect;
|
||||||
|
final VoidCallback onBackToDevices;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
child: Text(
|
child: Column(
|
||||||
'This device is not currently connected. Reopen it from Devices to reconnect and manage trainer pairing, firmware, and gear ratios.',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
children: [
|
||||||
color: Theme.of(context)
|
Row(
|
||||||
.colorScheme
|
children: [
|
||||||
.onSurface
|
Container(
|
||||||
.withValues(alpha: 0.68),
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colorScheme.error.withValues(alpha: 0.12),
|
||||||
),
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.bluetooth_disabled_rounded,
|
||||||
|
color: colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'No connection',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'This device is not currently connected. Turn it on and keep it nearby, then reconnect when you are ready.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: isReconnecting ? null : onReconnect,
|
||||||
|
icon: isReconnecting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.bluetooth_connected_rounded),
|
||||||
|
label:
|
||||||
|
Text(isReconnecting ? 'Reconnecting...' : 'Reconnect'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: isReconnecting ? null : onBackToDevices,
|
||||||
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
|
label: const Text('Devices'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -1501,9 +1566,14 @@ class _DetailStatusChip extends StatelessWidget {
|
|||||||
final (label, color) = switch (status) {
|
final (label, color) = switch (status) {
|
||||||
ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)),
|
ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)),
|
||||||
ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)),
|
ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)),
|
||||||
ConnectionStatus.disconnecting => ('Disconnecting', const Color(0xFFFFB649)),
|
ConnectionStatus.disconnecting => (
|
||||||
ConnectionStatus.disconnected =>
|
'Disconnecting',
|
||||||
('Disconnected', Theme.of(context).colorScheme.primary),
|
const Color(0xFFFFB649)
|
||||||
|
),
|
||||||
|
ConnectionStatus.disconnected => (
|
||||||
|
'Disconnected',
|
||||||
|
Theme.of(context).colorScheme.primary
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||||
import 'package:abawo_bt_app/database/database.dart';
|
import 'package:abawo_bt_app/database/database.dart';
|
||||||
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
||||||
|
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||||
import 'package:abawo_bt_app/util/constants.dart';
|
import 'package:abawo_bt_app/util/constants.dart';
|
||||||
import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart';
|
import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart';
|
||||||
import 'package:anyhow/anyhow.dart';
|
import 'package:anyhow/anyhow.dart';
|
||||||
@ -63,9 +64,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isAbawoDevice = isAbawoDeviceIdent(device.manufacturerData);
|
final isAbawoDevice = device.serviceUuids.any(isAbawoDeviceGuid);
|
||||||
final isConnectable =
|
final isConnectable = device.serviceUuids.any(isConnectableAbawoDeviceGuid);
|
||||||
device.serviceUuids.any(isConnectableAbawoDeviceGuid);
|
|
||||||
|
|
||||||
if (!isAbawoDevice) {
|
if (!isAbawoDevice) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@ -122,13 +122,18 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
context.push('/device/${device.id}');
|
context.go('/device/${device.id}');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case Err(:final v):
|
case Err(:final v):
|
||||||
|
final error = v.toString();
|
||||||
|
if (error.toLowerCase().contains('disconnected')) {
|
||||||
|
await showBluetoothPairingRecoveryDialog(context);
|
||||||
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Connection unsuccessful:\n${v.toString()}')),
|
SnackBar(content: Text('Connection unsuccessful:\n$error')),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -224,7 +229,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
|||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
return btAsyncValue.when(
|
return btAsyncValue.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
error: (err, stack) => Padding(
|
error: (err, stack) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: _ScanMessageCard(
|
child: _ScanMessageCard(
|
||||||
@ -275,15 +281,16 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
|||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||||
itemCount: filteredResults.length,
|
itemCount: filteredResults.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
separatorBuilder: (_, __) =>
|
||||||
|
const SizedBox(height: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final device = filteredResults[index];
|
final device = filteredResults[index];
|
||||||
final isAlreadyConnected =
|
final isAlreadyConnected =
|
||||||
connectedDeviceAddresses.contains(device.id);
|
connectedDeviceAddresses.contains(device.id);
|
||||||
final tone = _ScanResultTone.resolve(
|
final tone = _ScanResultTone.resolve(
|
||||||
isAlreadyConnected: isAlreadyConnected,
|
isAlreadyConnected: isAlreadyConnected,
|
||||||
isAbawoDevice:
|
isAbawoDevice: hasConnectableAbawoDeviceGuid(
|
||||||
isAbawoDeviceIdent(device.manufacturerData),
|
device.serviceUuids),
|
||||||
isConnectable: device.serviceUuids
|
isConnectable: device.serviceUuids
|
||||||
.any(isConnectableAbawoDeviceGuid),
|
.any(isConnectableAbawoDeviceGuid),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -168,6 +168,14 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.isOk()) {
|
if (result.isOk()) {
|
||||||
|
await ref
|
||||||
|
.read(nConnectedDevicesProvider.notifier)
|
||||||
|
.updateConnectedDeviceLastConnected(device.id);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
context.push('/device/${device.deviceAddress}');
|
context.push('/device/${device.deviceAddress}');
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@ -256,21 +264,29 @@ class _ActiveDeviceCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (devices.isEmpty) {
|
final shifterDevices = devices
|
||||||
return _MessageCard(
|
.where(
|
||||||
title: 'No connected devices yet',
|
(device) =>
|
||||||
message: 'Your saved shifters will show up here with status and shortcuts.',
|
deviceTypeFromString(device.deviceType) ==
|
||||||
actionLabel: 'Connect Device',
|
DeviceType.universalShifters,
|
||||||
onAction: () => context.push('/connect_device'),
|
)
|
||||||
);
|
.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aLastConnected = a.lastConnectedAt ?? a.createdAt;
|
||||||
|
final bLastConnected = b.lastConnectedAt ?? b.createdAt;
|
||||||
|
return bLastConnected.compareTo(aLastConnected);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shifterDevices.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final connectedId = connectionData?.$2;
|
final connectedId = connectionData?.$2;
|
||||||
final primaryDevice = connectedId == null
|
final primaryDevice = connectedId == null
|
||||||
? devices.first
|
? shifterDevices.first
|
||||||
: devices.firstWhere(
|
: shifterDevices.firstWhere(
|
||||||
(device) => device.deviceAddress == connectedId,
|
(device) => device.deviceAddress == connectedId,
|
||||||
orElse: () => devices.first,
|
orElse: () => shifterDevices.first,
|
||||||
);
|
);
|
||||||
final isConnected = connectedId == primaryDevice.deviceAddress &&
|
final isConnected = connectedId == primaryDevice.deviceAddress &&
|
||||||
connectionData?.$1 == ConnectionStatus.connected;
|
connectionData?.$1 == ConnectionStatus.connected;
|
||||||
@ -306,8 +322,7 @@ class _ActiveDeviceCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
primaryDevice.deviceName,
|
primaryDevice.deviceName,
|
||||||
style:
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -412,14 +427,16 @@ class _SavedDeviceTile extends StatelessWidget {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: isConnected
|
color: isConnected
|
||||||
? colorScheme.primary.withValues(alpha: 0.14)
|
? colorScheme.primary.withValues(alpha: 0.14)
|
||||||
: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
deviceTypeFromString(device.deviceType) ==
|
deviceTypeFromString(device.deviceType) ==
|
||||||
DeviceType.universalShifters
|
DeviceType.universalShifters
|
||||||
? Icons.bluetooth_rounded
|
? Icons.bluetooth_rounded
|
||||||
: Icons.memory_rounded,
|
: Icons.memory_rounded,
|
||||||
color: isConnected ? colorScheme.primary : colorScheme.onSurface,
|
color:
|
||||||
|
isConnected ? colorScheme.primary : colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
@ -447,7 +464,8 @@ class _SavedDeviceTile extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
device.deviceAddress,
|
device.deviceAddress,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.62),
|
color:
|
||||||
|
colorScheme.onSurface.withValues(alpha: 0.62),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -186,6 +186,14 @@ class _DevicesListState extends ConsumerState<DevicesList> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.isOk()) {
|
if (result.isOk()) {
|
||||||
|
await ref
|
||||||
|
.read(nConnectedDevicesProvider.notifier)
|
||||||
|
.updateConnectedDeviceLastConnected(device.id);
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
context.go('/device/${device.deviceAddress}');
|
context.go('/device/${device.deviceAddress}');
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@ -629,7 +629,7 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
final connectResult =
|
final connectResult =
|
||||||
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
|
await bluetoothController.connectById(buttonDeviceId, timeout: timeout);
|
||||||
if (connectResult.isErr()) {
|
if (connectResult.isErr()) {
|
||||||
return bail(connectResult.unwrapErr());
|
return Err(connectResult.unwrapErr());
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentState = bluetoothController.currentConnectionState;
|
final currentState = bluetoothController.currentConnectionState;
|
||||||
@ -663,7 +663,7 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
try {
|
try {
|
||||||
final statusResult = await shifterService.readStatus().timeout(timeout);
|
final statusResult = await shifterService.readStatus().timeout(timeout);
|
||||||
if (statusResult.isErr()) {
|
if (statusResult.isErr()) {
|
||||||
return bail(statusResult.unwrapErr());
|
return Err(statusResult.unwrapErr());
|
||||||
}
|
}
|
||||||
return Ok(null);
|
return Ok(null);
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
|
|||||||
@ -3,6 +3,9 @@ import 'dart:async';
|
|||||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||||
import 'package:anyhow/anyhow.dart';
|
import 'package:anyhow/anyhow.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('ShifterService');
|
||||||
|
|
||||||
class ShifterService {
|
class ShifterService {
|
||||||
ShifterService({
|
ShifterService({
|
||||||
@ -83,7 +86,7 @@ class ShifterService {
|
|||||||
universalShifterGearRatiosCharacteristicUuid,
|
universalShifterGearRatiosCharacteristicUuid,
|
||||||
);
|
);
|
||||||
if (readRes.isErr()) {
|
if (readRes.isErr()) {
|
||||||
return bail(readRes.unwrapErr());
|
return Err(readRes.unwrapErr());
|
||||||
}
|
}
|
||||||
|
|
||||||
final raw = readRes.unwrap();
|
final raw = readRes.unwrap();
|
||||||
@ -159,7 +162,7 @@ class ShifterService {
|
|||||||
universalShifterStatusCharacteristicUuid,
|
universalShifterStatusCharacteristicUuid,
|
||||||
);
|
);
|
||||||
if (readRes.isErr()) {
|
if (readRes.isErr()) {
|
||||||
return bail(readRes.unwrapErr());
|
return Err(readRes.unwrapErr());
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -240,6 +243,7 @@ class ShifterService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
_statusSubscription = _requireBluetooth
|
_statusSubscription = _requireBluetooth
|
||||||
.subscribeToCharacteristic(
|
.subscribeToCharacteristic(
|
||||||
buttonDeviceId,
|
buttonDeviceId,
|
||||||
@ -251,14 +255,22 @@ class ShifterService {
|
|||||||
try {
|
try {
|
||||||
final status = CentralStatus.fromCborBytes(data);
|
final status = CentralStatus.fromCborBytes(data);
|
||||||
_statusController.add(status);
|
_statusController.add(status);
|
||||||
} catch (_) {
|
} catch (error, st) {
|
||||||
// Ignore malformed payloads but keep stream alive.
|
_log.warning(
|
||||||
|
'Failed to decode status notification from $buttonDeviceId: '
|
||||||
|
'bytes=${_formatBytes(data)}',
|
||||||
|
error,
|
||||||
|
st,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (_) {
|
onError: (Object error, StackTrace st) {
|
||||||
// Keep UI running; reconnection logic is handled elsewhere.
|
_log.warning('Status notification stream failed', error, st);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} catch (error, st) {
|
||||||
|
_log.warning('Could not start status notifications', error, st);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopStatusNotifications() async {
|
Future<void> stopStatusNotifications() async {
|
||||||
@ -286,6 +298,12 @@ class ShifterService {
|
|||||||
double _decodeGearRatio(int raw) {
|
double _decodeGearRatio(int raw) {
|
||||||
return raw / 64.0;
|
return raw / 64.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatBytes(List<int> bytes) {
|
||||||
|
return bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract interface class DfuPreflightBluetoothAdapter {
|
abstract interface class DfuPreflightBluetoothAdapter {
|
||||||
|
|||||||
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);
|
return isAbawoUniversalShiftersDeviceGuid(guid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasConnectableAbawoDeviceGuid(List<Uuid> guid) => guid
|
||||||
|
.map((id) => isConnectableAbawoDeviceGuid(id))
|
||||||
|
.fold(false, (v, e) => v || e);
|
||||||
|
|
||||||
bool isAbawoDeviceIdent(List<int> manuData) {
|
bool isAbawoDeviceIdent(List<int> manuData) {
|
||||||
if (manuData.length < abawoManuIdentData.length) return false;
|
if (manuData.length < abawoManuIdentData.length) return false;
|
||||||
for (int i = 0; i < abawoManuIdentData.length; i++) {
|
for (int i = 0; i < abawoManuIdentData.length; i++) {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@ -29,19 +31,36 @@ class BikeScanDialog extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
||||||
bool _showAll = false;
|
bool _showAll = false;
|
||||||
|
bool _isStartingScan = true;
|
||||||
|
String? _scanError;
|
||||||
BluetoothController? _controller;
|
BluetoothController? _controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_startScan();
|
unawaited(_startScan());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startScan() async {
|
Future<void> _startScan() async {
|
||||||
|
setState(() {
|
||||||
|
_isStartingScan = true;
|
||||||
|
_scanError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
final controller = await ref.read(bluetoothProvider.future);
|
final controller = await ref.read(bluetoothProvider.future);
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
await controller.stopScan();
|
await controller.stopScan();
|
||||||
await controller.startScan();
|
await controller.startScan();
|
||||||
|
} catch (error) {
|
||||||
|
_scanError = error.toString();
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isStartingScan = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -73,6 +92,7 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
|||||||
children: [
|
children: [
|
||||||
_DialogHeader(
|
_DialogHeader(
|
||||||
showAll: _showAll,
|
showAll: _showAll,
|
||||||
|
isScanning: _isStartingScan,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showAll = value;
|
_showAll = value;
|
||||||
@ -81,37 +101,51 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
|||||||
onRescan: _startScan,
|
onRescan: _startScan,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StreamBuilder<List<DiscoveredDevice>>(
|
child: _scanError != null
|
||||||
|
? _ScanMessage(
|
||||||
|
message: 'Could not start trainer scan: $_scanError',
|
||||||
|
action: TextButton.icon(
|
||||||
|
onPressed: _startScan,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: StreamBuilder<List<DiscoveredDevice>>(
|
||||||
stream: controller.scanResultsStream,
|
stream: controller.scanResultsStream,
|
||||||
initialData: controller.scanResults,
|
initialData: controller.scanResults,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final devices = _filteredDevices(snapshot.data ?? const []);
|
if (_isStartingScan &&
|
||||||
|
(snapshot.data == null ||
|
||||||
|
snapshot.data!.isEmpty)) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
final devices =
|
||||||
|
_filteredDevices(snapshot.data ?? const []);
|
||||||
if (devices.isEmpty) {
|
if (devices.isEmpty) {
|
||||||
return const Padding(
|
return const _ScanMessage(
|
||||||
padding: EdgeInsets.all(20),
|
message:
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
'No matching trainers nearby. Keep scanning or enable Show All to inspect every device in range.',
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||||
itemCount: devices.length,
|
itemCount: devices.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
separatorBuilder: (_, __) =>
|
||||||
|
const SizedBox(height: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final device = devices[index];
|
final device = devices[index];
|
||||||
final isFtms =
|
final isFtms = device.serviceUuids
|
||||||
device.serviceUuids.contains(Uuid.parse(ftmsServiceUuid));
|
.contains(Uuid.parse(ftmsServiceUuid));
|
||||||
return Material(
|
return Material(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
onTap: () => Navigator.of(context).pop(device),
|
onTap: () =>
|
||||||
|
Navigator.of(context).pop(device),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -153,17 +187,21 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
|||||||
? 'Unknown Device'
|
? 'Unknown Device'
|
||||||
: device.name,
|
: device.name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow:
|
||||||
|
TextOverflow.ellipsis,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleMedium
|
.titleMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight:
|
||||||
|
FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
isFtms ? 'FTMS' : 'Nearby trainer',
|
isFtms
|
||||||
|
? 'FTMS'
|
||||||
|
: 'Nearby trainer',
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodyMedium
|
.bodyMedium
|
||||||
@ -171,14 +209,16 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
|||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.primary,
|
.primary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight:
|
||||||
|
FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
device.id,
|
device.id,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow:
|
||||||
|
TextOverflow.ellipsis,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall
|
.bodySmall
|
||||||
@ -186,7 +226,8 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
|||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.onSurface
|
.onSurface
|
||||||
.withValues(alpha: 0.62),
|
.withValues(
|
||||||
|
alpha: 0.62),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -194,7 +235,8 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
_RssiBadge(rssi: device.rssi),
|
_RssiBadge(rssi: device.rssi),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@ -242,11 +284,13 @@ class _BikeScanDialogState extends ConsumerState<BikeScanDialog> {
|
|||||||
class _DialogHeader extends StatelessWidget {
|
class _DialogHeader extends StatelessWidget {
|
||||||
const _DialogHeader({
|
const _DialogHeader({
|
||||||
required this.showAll,
|
required this.showAll,
|
||||||
|
required this.isScanning,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
required this.onRescan,
|
required this.onRescan,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool showAll;
|
final bool showAll;
|
||||||
|
final bool isScanning;
|
||||||
final ValueChanged<bool> onChanged;
|
final ValueChanged<bool> onChanged;
|
||||||
final VoidCallback onRescan;
|
final VoidCallback onRescan;
|
||||||
|
|
||||||
@ -265,7 +309,8 @@ class _DialogHeader extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Assign Trainer',
|
'Assign Trainer',
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style:
|
||||||
|
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -306,11 +351,23 @@ class _DialogHeader extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
SizedBox(
|
||||||
onPressed: onRescan,
|
width: 132,
|
||||||
icon: const Icon(Icons.refresh),
|
child: OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
),
|
||||||
|
onPressed: isScanning ? null : onRescan,
|
||||||
|
icon: isScanning
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
label: const Text('Rescan'),
|
label: const Text('Rescan'),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -319,6 +376,38 @@ class _DialogHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ScanMessage extends StatelessWidget {
|
||||||
|
const _ScanMessage({
|
||||||
|
required this.message,
|
||||||
|
this.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (action != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(width: 132, child: action!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _RssiBadge extends StatelessWidget {
|
class _RssiBadge extends StatelessWidget {
|
||||||
const _RssiBadge({required this.rssi});
|
const _RssiBadge({required this.rssi});
|
||||||
|
|
||||||
|
|||||||
@ -165,22 +165,7 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isEditing) ...[
|
if (!_isEditing)
|
||||||
TextButton(
|
|
||||||
onPressed: _isSaving ? null : _onCancel,
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: _isSaving ? null : _onSave,
|
|
||||||
child: _isSaving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('Save'),
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Edit ratios',
|
tooltip: 'Edit ratios',
|
||||||
onPressed: (widget.isLoading || widget.errorText != null)
|
onPressed: (widget.isLoading || widget.errorText != null)
|
||||||
@ -191,6 +176,11 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_isEditing)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||||
|
child: _buildEditActions(),
|
||||||
|
),
|
||||||
if (widget.isLoading)
|
if (widget.isLoading)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 24),
|
padding: EdgeInsets.fromLTRB(16, 20, 16, 24),
|
||||||
@ -365,6 +355,9 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
duration: _animDuration,
|
duration: _animDuration,
|
||||||
switchInCurve: _animCurve,
|
switchInCurve: _animCurve,
|
||||||
switchOutCurve: Curves.easeInCubic,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
layoutBuilder: (currentChild, previousChildren) {
|
||||||
|
return currentChild ?? const SizedBox.shrink();
|
||||||
|
},
|
||||||
transitionBuilder: _snappyTransition,
|
transitionBuilder: _snappyTransition,
|
||||||
child: Column(
|
child: Column(
|
||||||
key: ValueKey('editors-$_gearLayoutVersion'),
|
key: ValueKey('editors-$_gearLayoutVersion'),
|
||||||
@ -373,6 +366,10 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 4, 14, 14),
|
||||||
|
child: _buildEditActions(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -381,6 +378,32 @@ class _GearRatioEditorCardState extends State<GearRatioEditorCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildEditActions() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isSaving ? null : _onCancel,
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size(88, 52),
|
||||||
|
),
|
||||||
|
onPressed: _isSaving ? null : _onSave,
|
||||||
|
child: _isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Done'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _snappyTransition(Widget child, Animation<double> animation) {
|
Widget _snappyTransition(Widget child, Animation<double> animation) {
|
||||||
final curved = CurvedAnimation(parent: animation, curve: _animCurve);
|
final curved = CurvedAnimation(parent: animation, curve: _animCurve);
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
|
|||||||
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.
|
import 'package:abawo_bt_app/widgets/app_shell.dart';
|
||||||
//
|
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:abawo_bt_app/main.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('renders app shell', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
await tester.pumpWidget(
|
||||||
await tester.pumpWidget(const AbawoBtApp());
|
const MaterialApp(
|
||||||
|
home: AppShell(
|
||||||
|
currentLocation: '/devices',
|
||||||
|
child: Text('Devices'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
expect(find.text('Devices'), findsWidgets);
|
||||||
expect(find.text('0'), findsOneWidget);
|
|
||||||
expect(find.text('1'), findsNothing);
|
|
||||||
|
|
||||||
// Tap the '+' icon and trigger a frame.
|
|
||||||
await tester.tap(find.byIcon(Icons.add));
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
// Verify that our counter has incremented.
|
|
||||||
expect(find.text('0'), findsNothing);
|
|
||||||
expect(find.text('1'), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user