feat: redesign and lots of progress
This commit is contained in:
@ -5,9 +5,12 @@ import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
||||
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||
import 'package:abawo_bt_app/widgets/bike_scan_dialog.dart';
|
||||
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
||||
show DiscoveredDevice;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nb_utils/nb_utils.dart';
|
||||
@ -48,11 +51,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
3.27,
|
||||
];
|
||||
|
||||
bool _isReconnecting = false;
|
||||
bool _wasConnectedToCurrentDevice = false;
|
||||
bool _isExitingPage = false;
|
||||
bool _hasRequestedDisconnect = false;
|
||||
Timer? _reconnectTimeoutTimer;
|
||||
bool _hasShownPairingRecoveryDialog = false;
|
||||
bool _isAssignTrainerDialogOpen = false;
|
||||
bool _isManualReconnectRunning = false;
|
||||
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
|
||||
_connectionStatusSubscription;
|
||||
|
||||
@ -124,7 +127,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_disconnectOnClose());
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
_connectionStatusSubscription?.close();
|
||||
_statusSubscription?.cancel();
|
||||
_shifterService?.dispose();
|
||||
@ -134,15 +136,17 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
Future<void> _disconnectOnClose() async {
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hasRequestedDisconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
_hasRequestedDisconnect = true;
|
||||
_isExitingPage = true;
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
|
||||
await _firmwareUpdateService?.cancelUpdate();
|
||||
await _disposeFirmwareUpdateService();
|
||||
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
@ -159,55 +163,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
|
||||
|
||||
if (isCurrentDevice && status == ConnectionStatus.connected) {
|
||||
_wasConnectedToCurrentDevice = true;
|
||||
_startStatusStreamingIfNeeded();
|
||||
if (_isReconnecting) {
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
setState(() {
|
||||
_isReconnecting = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_wasConnectedToCurrentDevice &&
|
||||
!_isReconnecting &&
|
||||
status == ConnectionStatus.disconnected &&
|
||||
!_isFirmwareUpdateBusy) {
|
||||
_startReconnect();
|
||||
}
|
||||
|
||||
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
|
||||
!_isFirmwareUpdateBusy) {
|
||||
_stopStatusStreaming();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startReconnect() async {
|
||||
if (!mounted || _isExitingPage || _isReconnecting) {
|
||||
return;
|
||||
Future<void> _startStatusStreamingIfNeeded() async {
|
||||
bool isCurrentDeviceConnected(BluetoothController bluetooth) {
|
||||
final connectionState = bluetooth.currentConnectionState;
|
||||
return connectionState.$1 == ConnectionStatus.connected &&
|
||||
connectionState.$2 == widget.deviceAddress;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isReconnecting = true;
|
||||
});
|
||||
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
await bluetooth?.connectById(widget.deviceAddress);
|
||||
|
||||
_reconnectTimeoutTimer?.cancel();
|
||||
_reconnectTimeoutTimer = Timer(const Duration(seconds: 10), () {
|
||||
if (!mounted || !_isReconnecting || _isExitingPage) {
|
||||
if (_shifterService != null) {
|
||||
final bluetooth = ref.read(bluetoothProvider).value;
|
||||
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
|
||||
return;
|
||||
}
|
||||
_terminateConnectionAndGoHome(
|
||||
'Connection lost. Could not reconnect in time.',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startStatusStreamingIfNeeded() async {
|
||||
if (_shifterService != null) {
|
||||
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -227,16 +204,28 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
} else {
|
||||
bluetooth = await ref.read(bluetoothProvider.future);
|
||||
}
|
||||
if (!isCurrentDeviceConnected(bluetooth)) {
|
||||
return;
|
||||
}
|
||||
final service = ShifterService(
|
||||
bluetooth: bluetooth,
|
||||
buttonDeviceId: widget.deviceAddress,
|
||||
);
|
||||
|
||||
final initialStatusResult = await service.readStatus();
|
||||
if (mounted && initialStatusResult.isOk()) {
|
||||
_recordStatus(initialStatusResult.unwrap());
|
||||
if (!mounted) {
|
||||
await service.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialStatusResult.isErr()) {
|
||||
await service.dispose();
|
||||
await _showPairingRecoveryDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
_recordStatus(initialStatusResult.unwrap());
|
||||
|
||||
_statusSubscription = service.statusStream.listen((status) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
@ -251,6 +240,15 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
unawaited(_loadGearRatios());
|
||||
}
|
||||
|
||||
Future<void> _showPairingRecoveryDialog() async {
|
||||
if (!mounted || _hasShownPairingRecoveryDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
_hasShownPairingRecoveryDialog = true;
|
||||
await showBluetoothPairingRecoveryDialog(context);
|
||||
}
|
||||
|
||||
void _recordStatus(CentralStatus status) {
|
||||
setState(() {
|
||||
_latestStatus = status;
|
||||
@ -360,6 +358,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
}
|
||||
|
||||
Future<void> _connectButtonToBike() async {
|
||||
if (_isAssignTrainerDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@ -369,10 +371,16 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final selectedBike = await BikeScanDialog.show(
|
||||
context,
|
||||
excludedDeviceId: widget.deviceAddress,
|
||||
);
|
||||
_isAssignTrainerDialogOpen = true;
|
||||
final DiscoveredDevice? selectedBike;
|
||||
try {
|
||||
selectedBike = await BikeScanDialog.show(
|
||||
context,
|
||||
excludedDeviceId: widget.deviceAddress,
|
||||
);
|
||||
} finally {
|
||||
_isAssignTrainerDialogOpen = false;
|
||||
}
|
||||
if (selectedBike == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
@ -533,17 +541,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _cancelFirmwareUpdate() async {
|
||||
final updater = _firmwareUpdateService;
|
||||
if (updater == null || !_isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_firmwareUserMessage = 'Canceling firmware update...';
|
||||
});
|
||||
await updater.cancelUpdate();
|
||||
}
|
||||
|
||||
String _dfuPhaseText(DfuUpdateState state) {
|
||||
switch (state) {
|
||||
case DfuUpdateState.idle:
|
||||
@ -579,27 +576,68 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
||||
}
|
||||
|
||||
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
|
||||
await _disconnectOnClose();
|
||||
|
||||
if (!mounted) {
|
||||
Future<void> _manualReconnect() async {
|
||||
if (_isManualReconnectRunning || _isFirmwareUpdateBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast(toastMessage);
|
||||
context.replace('/devices');
|
||||
}
|
||||
setState(() {
|
||||
_isManualReconnectRunning = true;
|
||||
});
|
||||
|
||||
Future<void> _cancelReconnect() async {
|
||||
await _terminateConnectionAndGoHome('Reconnect cancelled.');
|
||||
try {
|
||||
final bluetooth = await ref.read(bluetoothProvider.future);
|
||||
final result = await bluetooth.connectById(
|
||||
widget.deviceAddress,
|
||||
timeout: const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.isErr()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Reconnect failed. Is the device turned on and in range?',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Reconnect failed: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isManualReconnectRunning = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _exitPage() async {
|
||||
if (_isFirmwareUpdateBusy) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Firmware update is running. Keep this screen open until it completes.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _disconnectOnClose();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.replace('/devices');
|
||||
context.go('/devices');
|
||||
}
|
||||
|
||||
void _showStatusHistory() {
|
||||
@ -713,19 +751,18 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
||||
final currentConnectionStatus = connectionData != null &&
|
||||
connectionData.$2 == widget.deviceAddress
|
||||
? connectionData.$1
|
||||
: ConnectionStatus.disconnected;
|
||||
final isCurrentConnected = currentConnectionStatus == ConnectionStatus.connected;
|
||||
final currentConnectionStatus =
|
||||
connectionData != null && connectionData.$2 == widget.deviceAddress
|
||||
? connectionData.$1
|
||||
: ConnectionStatus.disconnected;
|
||||
final isCurrentConnected =
|
||||
currentConnectionStatus == ConnectionStatus.connected;
|
||||
final canSelectFirmware =
|
||||
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||
final canStartFirmware = isCurrentConnected &&
|
||||
!_isSelectingFirmware &&
|
||||
!_isFirmwareUpdateBusy &&
|
||||
_selectedFirmware != null;
|
||||
final canCancelFirmware = _isFirmwareUpdateBusy;
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (bool didPop, bool? result) {
|
||||
@ -756,11 +793,6 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (isCurrentConnected) ...[
|
||||
_TrainerConnectionCard(
|
||||
status: _latestStatus,
|
||||
onAssign: _isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||
onShowStatusConsole: _showStatusHistory,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_StatusBanner(
|
||||
status: _latestStatus,
|
||||
@ -775,22 +807,11 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FirmwareUpdateCard(
|
||||
selectedFirmware: _selectedFirmware,
|
||||
progress: _dfuProgress,
|
||||
isSelecting: _isSelectingFirmware,
|
||||
isStarting: _isStartingFirmwareUpdate,
|
||||
canSelect: canSelectFirmware,
|
||||
canStart: canStartFirmware,
|
||||
canCancel: canCancelFirmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
onSelectFirmware: _selectFirmwareFile,
|
||||
onStartUpdate: _startFirmwareUpdate,
|
||||
onCancelUpdate: _cancelFirmwareUpdate,
|
||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||
_TrainerConnectionCard(
|
||||
status: _latestStatus,
|
||||
onAssign:
|
||||
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||
onShowStatusConsole: _showStatusHistory,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Opacity(
|
||||
@ -873,50 +894,32 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_FirmwareUpdateCard(
|
||||
selectedFirmware: _selectedFirmware,
|
||||
progress: _dfuProgress,
|
||||
isSelecting: _isSelectingFirmware,
|
||||
isStarting: _isStartingFirmwareUpdate,
|
||||
canSelect: canSelectFirmware,
|
||||
canStart: canStartFirmware,
|
||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||
statusText: _firmwareUserMessage,
|
||||
formattedProgressBytes:
|
||||
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||
onSelectFirmware: _selectFirmwareFile,
|
||||
onStartUpdate: _startFirmwareUpdate,
|
||||
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||
),
|
||||
] else ...[
|
||||
const _DisconnectedDetailCard(),
|
||||
_DisconnectedDetailCard(
|
||||
isReconnecting: _isManualReconnectRunning,
|
||||
onReconnect: _manualReconnect,
|
||||
onBackToDevices: _exitPage,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isReconnecting)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
child: Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Reconnecting...',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _cancelReconnect,
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -942,14 +945,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
required this.isStarting,
|
||||
required this.canSelect,
|
||||
required this.canStart,
|
||||
required this.canCancel,
|
||||
required this.phaseText,
|
||||
required this.statusText,
|
||||
required this.formattedProgressBytes,
|
||||
required this.ackSequenceHex,
|
||||
required this.onSelectFirmware,
|
||||
required this.onStartUpdate,
|
||||
required this.onCancelUpdate,
|
||||
});
|
||||
|
||||
final DfuV1PreparedFirmware? selectedFirmware;
|
||||
@ -958,14 +959,12 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
final bool isStarting;
|
||||
final bool canSelect;
|
||||
final bool canStart;
|
||||
final bool canCancel;
|
||||
final String phaseText;
|
||||
final String? statusText;
|
||||
final String formattedProgressBytes;
|
||||
final String ackSequenceHex;
|
||||
final Future<void> Function() onSelectFirmware;
|
||||
final Future<void> Function() onStartUpdate;
|
||||
final Future<void> Function() onCancelUpdate;
|
||||
|
||||
bool get _showProgress {
|
||||
return progress.totalBytes > 0 ||
|
||||
@ -986,126 +985,123 @@ class _FirmwareUpdateCard extends StatelessWidget {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.system_update_alt_rounded, color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Firmware Update',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Select a firmware image, review the transfer state, and start the update when ready.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: canSelect ? onSelectFirmware : null,
|
||||
icon: isSelecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: const Text('Select Firmware'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: canStart ? onStartUpdate : null,
|
||||
icon: isStarting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt),
|
||||
label: const Text('Start Update'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: canCancel ? onCancelUpdate : null,
|
||||
icon: const Icon(Icons.stop_circle_outlined),
|
||||
label: const Text('Cancel Update'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
selectedFirmware == null
|
||||
? 'Selected file: none'
|
||||
: 'Selected file: ${selectedFirmware!.fileName}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
Icon(Icons.system_update_alt_rounded,
|
||||
color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'Firmware Update',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
if (selectedFirmware != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
|
||||
if (_showProgress) ...[
|
||||
const SizedBox(height: 10),
|
||||
LinearProgressIndicator(
|
||||
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
|
||||
minHeight: 10,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (_showRebootExpectation) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
'Select a firmware image, review the transfer state, and start the update when ready.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (statusText != null && statusText!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
statusText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: progress.state == DfuUpdateState.failed
|
||||
? colorScheme.error
|
||||
: theme.textTheme.bodySmall?.color,
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: canSelect ? onSelectFirmware : null,
|
||||
icon: isSelecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.upload_file),
|
||||
label: const Text('Select Firmware'),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: canStart ? onStartUpdate : null,
|
||||
icon: isStarting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt),
|
||||
label: const Text('Start Update'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
selectedFirmware == null
|
||||
? 'Selected file: none'
|
||||
: 'Selected file: ${selectedFirmware!.fileName}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (selectedFirmware != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
|
||||
if (_showProgress) ...[
|
||||
const SizedBox(height: 10),
|
||||
LinearProgressIndicator(
|
||||
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
|
||||
minHeight: 10,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
if (_showRebootExpectation) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (statusText != null && statusText!.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
statusText!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: progress.state == DfuUpdateState.failed
|
||||
? colorScheme.error
|
||||
: theme.textTheme.bodySmall?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -1207,12 +1203,10 @@ class _StatusBanner extends StatelessWidget {
|
||||
Widget _buildDeviceOverviewCard(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String deviceAddress,
|
||||
{
|
||||
String deviceAddress, {
|
||||
required ConnectionStatus connectionStatus,
|
||||
required CentralStatus? status,
|
||||
}
|
||||
) {
|
||||
}) {
|
||||
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
|
||||
|
||||
return asyncSavedDevices.when(
|
||||
@ -1301,9 +1295,10 @@ class _DeviceOverviewCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
device.deviceName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DetailStatusChip(status: connectionStatus),
|
||||
@ -1311,7 +1306,8 @@ class _DeviceOverviewCard extends StatelessWidget {
|
||||
Text(
|
||||
trainerAddress,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1395,7 +1391,8 @@ class _TrainerConnectionCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Icon(Icons.pedal_bike_rounded, color: colorScheme.primary),
|
||||
child: Icon(Icons.pedal_bike_rounded,
|
||||
color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@ -1412,7 +1409,8 @@ class _TrainerConnectionCard extends StatelessWidget {
|
||||
Text(
|
||||
trainerText,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -1424,12 +1422,14 @@ class _TrainerConnectionCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.bluetooth_connected_rounded, color: colorScheme.primary),
|
||||
Icon(Icons.bluetooth_connected_rounded,
|
||||
color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@ -1470,21 +1470,86 @@ class _TrainerConnectionCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _DisconnectedDetailCard extends StatelessWidget {
|
||||
const _DisconnectedDetailCard();
|
||||
const _DisconnectedDetailCard({
|
||||
required this.isReconnecting,
|
||||
required this.onReconnect,
|
||||
required this.onBackToDevices,
|
||||
});
|
||||
|
||||
final bool isReconnecting;
|
||||
final VoidCallback onReconnect;
|
||||
final VoidCallback onBackToDevices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Text(
|
||||
'This device is not currently connected. Reopen it from Devices to reconnect and manage trainer pairing, firmware, and gear ratios.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.68),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.error.withValues(alpha: 0.12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.bluetooth_disabled_rounded,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'No connection',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'This device is not currently connected. Turn it on and keep it nearby, then reconnect when you are ready.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: isReconnecting ? null : onReconnect,
|
||||
icon: isReconnecting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.bluetooth_connected_rounded),
|
||||
label:
|
||||
Text(isReconnecting ? 'Reconnecting...' : 'Reconnect'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isReconnecting ? null : onBackToDevices,
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
label: const Text('Devices'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -1501,9 +1566,14 @@ class _DetailStatusChip extends StatelessWidget {
|
||||
final (label, color) = switch (status) {
|
||||
ConnectionStatus.connected => ('Connected', const Color(0xFF40C979)),
|
||||
ConnectionStatus.connecting => ('Connecting', const Color(0xFFFFB649)),
|
||||
ConnectionStatus.disconnecting => ('Disconnecting', const Color(0xFFFFB649)),
|
||||
ConnectionStatus.disconnected =>
|
||||
('Disconnected', Theme.of(context).colorScheme.primary),
|
||||
ConnectionStatus.disconnecting => (
|
||||
'Disconnecting',
|
||||
const Color(0xFFFFB649)
|
||||
),
|
||||
ConnectionStatus.disconnected => (
|
||||
'Disconnected',
|
||||
Theme.of(context).colorScheme.primary
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
|
||||
@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||
import 'package:abawo_bt_app/database/database.dart';
|
||||
import 'package:abawo_bt_app/model/bluetooth_device_model.dart';
|
||||
import 'package:abawo_bt_app/util/bluetooth_settings.dart';
|
||||
import 'package:abawo_bt_app/util/constants.dart';
|
||||
import 'package:abawo_bt_app/widgets/horizontal_scanning_animation.dart';
|
||||
import 'package:anyhow/anyhow.dart';
|
||||
@ -63,9 +64,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final isAbawoDevice = isAbawoDeviceIdent(device.manufacturerData);
|
||||
final isConnectable =
|
||||
device.serviceUuids.any(isConnectableAbawoDeviceGuid);
|
||||
final isAbawoDevice = device.serviceUuids.any(isAbawoDeviceGuid);
|
||||
final isConnectable = device.serviceUuids.any(isConnectableAbawoDeviceGuid);
|
||||
|
||||
if (!isAbawoDevice) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@ -122,13 +122,18 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
),
|
||||
);
|
||||
} else {
|
||||
context.push('/device/${device.id}');
|
||||
context.go('/device/${device.id}');
|
||||
}
|
||||
break;
|
||||
case Err(:final v):
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Connection unsuccessful:\n${v.toString()}')),
|
||||
);
|
||||
final error = v.toString();
|
||||
if (error.toLowerCase().contains('disconnected')) {
|
||||
await showBluetoothPairingRecoveryDialog(context);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Connection unsuccessful:\n$error')),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -224,7 +229,8 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
.toSet();
|
||||
|
||||
return btAsyncValue.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (err, stack) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _ScanMessageCard(
|
||||
@ -275,15 +281,16 @@ class _ConnectDevicePageState extends ConsumerState<ConnectDevicePage> {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
itemCount: filteredResults.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final device = filteredResults[index];
|
||||
final isAlreadyConnected =
|
||||
connectedDeviceAddresses.contains(device.id);
|
||||
final tone = _ScanResultTone.resolve(
|
||||
isAlreadyConnected: isAlreadyConnected,
|
||||
isAbawoDevice:
|
||||
isAbawoDeviceIdent(device.manufacturerData),
|
||||
isAbawoDevice: hasConnectableAbawoDeviceGuid(
|
||||
device.serviceUuids),
|
||||
isConnectable: device.serviceUuids
|
||||
.any(isConnectableAbawoDeviceGuid),
|
||||
);
|
||||
|
||||
@ -168,6 +168,14 @@ class _SavedDevicesListState extends ConsumerState<_SavedDevicesList> {
|
||||
}
|
||||
|
||||
if (result.isOk()) {
|
||||
await ref
|
||||
.read(nConnectedDevicesProvider.notifier)
|
||||
.updateConnectedDeviceLastConnected(device.id);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.push('/device/${device.deviceAddress}');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@ -256,21 +264,29 @@ class _ActiveDeviceCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (devices.isEmpty) {
|
||||
return _MessageCard(
|
||||
title: 'No connected devices yet',
|
||||
message: 'Your saved shifters will show up here with status and shortcuts.',
|
||||
actionLabel: 'Connect Device',
|
||||
onAction: () => context.push('/connect_device'),
|
||||
);
|
||||
final shifterDevices = devices
|
||||
.where(
|
||||
(device) =>
|
||||
deviceTypeFromString(device.deviceType) ==
|
||||
DeviceType.universalShifters,
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) {
|
||||
final aLastConnected = a.lastConnectedAt ?? a.createdAt;
|
||||
final bLastConnected = b.lastConnectedAt ?? b.createdAt;
|
||||
return bLastConnected.compareTo(aLastConnected);
|
||||
});
|
||||
|
||||
if (shifterDevices.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final connectedId = connectionData?.$2;
|
||||
final primaryDevice = connectedId == null
|
||||
? devices.first
|
||||
: devices.firstWhere(
|
||||
? shifterDevices.first
|
||||
: shifterDevices.firstWhere(
|
||||
(device) => device.deviceAddress == connectedId,
|
||||
orElse: () => devices.first,
|
||||
orElse: () => shifterDevices.first,
|
||||
);
|
||||
final isConnected = connectedId == primaryDevice.deviceAddress &&
|
||||
connectionData?.$1 == ConnectionStatus.connected;
|
||||
@ -306,10 +322,9 @@ class _ActiveDeviceCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
primaryDevice.deviceName,
|
||||
style:
|
||||
Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_StatusChip(
|
||||
@ -412,14 +427,16 @@ class _SavedDeviceTile extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
color: isConnected
|
||||
? colorScheme.primary.withValues(alpha: 0.14)
|
||||
: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
: colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.7),
|
||||
),
|
||||
child: Icon(
|
||||
deviceTypeFromString(device.deviceType) ==
|
||||
DeviceType.universalShifters
|
||||
? Icons.bluetooth_rounded
|
||||
: Icons.memory_rounded,
|
||||
color: isConnected ? colorScheme.primary : colorScheme.onSurface,
|
||||
color:
|
||||
isConnected ? colorScheme.primary : colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
@ -447,7 +464,8 @@ class _SavedDeviceTile extends StatelessWidget {
|
||||
Text(
|
||||
device.deviceAddress,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.62),
|
||||
color:
|
||||
colorScheme.onSurface.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -186,6 +186,14 @@ class _DevicesListState extends ConsumerState<DevicesList> {
|
||||
}
|
||||
|
||||
if (result.isOk()) {
|
||||
await ref
|
||||
.read(nConnectedDevicesProvider.notifier)
|
||||
.updateConnectedDeviceLastConnected(device.id);
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.go('/device/${device.deviceAddress}');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
Reference in New Issue
Block a user