feat: redesign and lots of progress

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

View File

@ -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(