Files
abawo-bt-app/lib/pages/device_details_page.dart

2003 lines
63 KiB
Dart

import 'dart:async';
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
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/firmware_update_fullscreen.dart';
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:nb_utils/nb_utils.dart';
import '../controller/bluetooth.dart';
import '../database/database.dart';
final _log = Logger('DeviceDetailsPage');
class DeviceDetailsPage extends ConsumerStatefulWidget {
const DeviceDetailsPage({
required this.deviceAddress,
super.key,
});
final String deviceAddress;
@override
ConsumerState<DeviceDetailsPage> createState() => _DeviceDetailsPageState();
}
class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
static const List<double> _keAntRatios = [
0.35,
0.40,
0.47,
0.54,
0.61,
0.69,
0.82,
0.95,
1.13,
1.29,
1.50,
1.71,
1.89,
2.12,
2.40,
2.77,
3.27,
];
static const List<Duration> _initialStatusRetryDelays = [
Duration(milliseconds: 500),
Duration(milliseconds: 1500),
Duration(seconds: 3),
];
bool _isExitingPage = false;
bool _hasRequestedDisconnect = false;
bool _isAssignTrainerDialogOpen = false;
bool _isManualReconnectRunning = false;
bool _isPairingCheckRunning = false;
ProviderSubscription<AsyncValue<(ConnectionStatus, String?)>>?
_connectionStatusSubscription;
ShifterService? _shifterService;
StreamSubscription<CentralStatus>? _statusSubscription;
CentralStatus? _latestStatus;
String? _pairingError;
final List<_StatusHistoryEntry> _statusHistory = [];
bool _isGearRatiosLoading = false;
bool _hasLoadedGearRatios = false;
String? _gearRatiosError;
List<double> _gearRatios = const [];
int _defaultGearIndex = 0;
bool _isDeviceTelemetryLoading = false;
bool _hasLoadedDeviceTelemetry = false;
late final FirmwareFileSelectionService _firmwareFileSelectionService;
FirmwareUpdateService? _firmwareUpdateService;
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
BootloaderDfuPreparedFirmware? _selectedFirmware;
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
expectedOffset: 0,
sessionId: 0,
flags: DfuUpdateFlags(),
);
bool _isSelectingFirmware = false;
bool _isStartingFirmwareUpdate = false;
String? _firmwareUserMessage;
bool get _isFirmwareUpdateBusy {
if (_isStartingFirmwareUpdate) {
return true;
}
switch (_dfuProgress.state) {
case DfuUpdateState.starting:
case DfuUpdateState.enteringBootloader:
case DfuUpdateState.connectingBootloader:
case DfuUpdateState.waitingForStatus:
case DfuUpdateState.erasing:
case DfuUpdateState.transferring:
case DfuUpdateState.finishing:
case DfuUpdateState.rebooting:
case DfuUpdateState.verifying:
return true;
case DfuUpdateState.idle:
case DfuUpdateState.completed:
case DfuUpdateState.aborted:
case DfuUpdateState.failed:
return false;
}
}
@override
void initState() {
super.initState();
_firmwareFileSelectionService = FirmwareFileSelectionService(
filePicker: LocalFirmwareFilePicker(),
);
_connectionStatusSubscription =
ref.listenManual<AsyncValue<(ConnectionStatus, String?)>>(
connectionStatusProvider,
(_, next) {
final data = next.valueOrNull;
if (data == null) {
return;
}
_onConnectionStatusChanged(data);
},
fireImmediately: true,
);
}
@override
void dispose() {
_log.info(
'Disposing device details page for ${widget.deviceAddress}; '
'dfuState=${_dfuProgress.state}, isFirmwareUpdateBusy=$_isFirmwareUpdateBusy',
);
unawaited(_disconnectOnClose());
_connectionStatusSubscription?.close();
_statusSubscription?.cancel();
_shifterService?.dispose();
_firmwareProgressSubscription?.cancel();
unawaited(_firmwareUpdateService?.dispose() ?? Future<void>.value());
super.dispose();
}
Future<void> _disconnectOnClose() async {
if (_isFirmwareUpdateBusy) {
_log.info('Skipping disconnect on close because firmware update is busy');
return;
}
if (_hasRequestedDisconnect) {
_log.fine('Disconnect on close already requested');
return;
}
_hasRequestedDisconnect = true;
_isExitingPage = true;
await _disposeFirmwareUpdateService();
final bluetooth = ref.read(bluetoothProvider).value;
await bluetooth?.disconnect();
await _stopStatusStreaming();
}
void _onConnectionStatusChanged((ConnectionStatus, String?) data) {
if (!mounted || _isExitingPage) {
return;
}
final (status, connectedDeviceId) = data;
final isCurrentDevice = connectedDeviceId == widget.deviceAddress;
if (_isFirmwareUpdateBusy || _dfuProgress.state != DfuUpdateState.idle) {
_log.info(
'Connection update during firmware flow: status=$status, '
'connectedDevice=$connectedDeviceId, expected=${widget.deviceAddress}, '
'isCurrentDevice=$isCurrentDevice, dfuState=${_dfuProgress.state}',
);
}
if (isCurrentDevice && status == ConnectionStatus.connected) {
_startStatusStreamingIfNeeded();
return;
}
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
!_isFirmwareUpdateBusy) {
_stopStatusStreaming();
}
}
Future<void> _startStatusStreamingIfNeeded() async {
bool isCurrentDeviceConnected(BluetoothController bluetooth) {
final connectionState = bluetooth.currentConnectionState;
return connectionState.$1 == ConnectionStatus.connected &&
connectionState.$2 == widget.deviceAddress;
}
if (_shifterService != null) {
final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
return;
}
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
if (!mounted) {
return;
}
_recordStatus(status);
});
_shifterService!.startStatusNotifications();
if (!_hasLoadedGearRatios && !_isGearRatiosLoading) {
unawaited(_loadGearRatios());
}
if (!_hasLoadedDeviceTelemetry && !_isDeviceTelemetryLoading) {
unawaited(_loadDeviceTelemetry());
}
return;
}
if (_isPairingCheckRunning) {
return;
}
final asyncBluetooth = ref.read(bluetoothProvider);
final BluetoothController bluetooth;
if (asyncBluetooth.hasValue) {
bluetooth = asyncBluetooth.requireValue;
} else {
bluetooth = await ref.read(bluetoothProvider.future);
}
if (!isCurrentDeviceConnected(bluetooth)) {
return;
}
final service = ShifterService(
bluetooth: bluetooth,
buttonDeviceId: widget.deviceAddress,
);
setState(() {
_isPairingCheckRunning = true;
_pairingError = null;
});
var initialStatusResult = await service.readStatus();
for (final delay in _initialStatusRetryDelays) {
if (initialStatusResult.isOk() || !mounted) {
break;
}
await Future<void>.delayed(delay);
if (!mounted) {
break;
}
final bluetooth = ref.read(bluetoothProvider).value;
if (bluetooth == null || !isCurrentDeviceConnected(bluetooth)) {
break;
}
initialStatusResult = await service.readStatus();
}
if (!mounted) {
await service.dispose();
return;
}
if (initialStatusResult.isErr()) {
final error = initialStatusResult.unwrapErr();
await service.dispose();
setState(() {
_isPairingCheckRunning = false;
_pairingError = error.toString();
_latestStatus = null;
});
return;
}
_recordStatus(initialStatusResult.unwrap());
_statusSubscription = service.statusStream.listen((status) {
if (!mounted) {
return;
}
_recordStatus(status);
});
service.startStatusNotifications();
setState(() {
_shifterService = service;
_isPairingCheckRunning = false;
_pairingError = null;
});
unawaited(_loadGearRatios());
unawaited(_loadDeviceTelemetry());
}
void _recordStatus(CentralStatus status) {
setState(() {
_latestStatus = status;
_statusHistory.insert(
0,
_StatusHistoryEntry(
timestamp: DateTime.now(),
status: status,
),
);
if (_statusHistory.length > 100) {
_statusHistory.removeRange(100, _statusHistory.length);
}
});
}
Future<void> _stopStatusStreaming() async {
await _statusSubscription?.cancel();
_statusSubscription = null;
if (_isFirmwareUpdateBusy) {
return;
}
await _disposeFirmwareUpdateService();
await _shifterService?.dispose();
_shifterService = null;
_isPairingCheckRunning = false;
_isDeviceTelemetryLoading = false;
_hasLoadedDeviceTelemetry = false;
}
Future<void> _disposeFirmwareUpdateService() async {
await _firmwareProgressSubscription?.cancel();
_firmwareProgressSubscription = null;
await _firmwareUpdateService?.dispose();
_firmwareUpdateService = null;
}
Future<void> _loadDeviceTelemetry({bool force = false}) async {
final shifter = _shifterService;
if (shifter == null || _isDeviceTelemetryLoading) {
return;
}
if (_hasLoadedDeviceTelemetry && !force) {
return;
}
_isDeviceTelemetryLoading = true;
final result = await shifter.readDeviceTelemetry();
if (!mounted) {
return;
}
_isDeviceTelemetryLoading = false;
if (result.isErr()) {
_hasLoadedDeviceTelemetry = false;
return;
}
ref.read(shifterDeviceTelemetryCacheProvider.notifier).upsert(
widget.deviceAddress,
result.unwrap(),
);
_hasLoadedDeviceTelemetry = true;
}
Future<void> _loadGearRatios() async {
final shifter = _shifterService;
if (shifter == null || _isGearRatiosLoading || _isFirmwareUpdateBusy) {
return;
}
setState(() {
_isGearRatiosLoading = true;
_gearRatiosError = null;
});
final result = await shifter.readGearRatios();
if (!mounted) {
return;
}
if (result.isErr()) {
setState(() {
_gearRatiosError = 'Failed to read gear ratios: ${result.unwrapErr()}';
_isGearRatiosLoading = false;
_hasLoadedGearRatios = false;
});
return;
}
setState(() {
final data = result.unwrap();
_gearRatios = data.ratios;
_defaultGearIndex = data.defaultGearIndex;
_isGearRatiosLoading = false;
_hasLoadedGearRatios = true;
_gearRatiosError = null;
});
}
Future<String?> _saveGearRatios(
List<double> ratios, int defaultGearIndex) async {
if (_isFirmwareUpdateBusy) {
return 'Gear ratio changes are disabled during firmware update.';
}
final shifter = _shifterService;
if (shifter == null) {
return 'Status channel is not ready yet.';
}
final result = await shifter.writeGearRatios(
GearRatiosData(
ratios: ratios,
defaultGearIndex: defaultGearIndex,
),
);
if (result.isErr()) {
return 'Could not save gear ratios: ${result.unwrapErr()}';
}
if (!mounted) {
return null;
}
setState(() {
_gearRatios = List<double>.from(ratios);
_defaultGearIndex = ratios.isEmpty
? 0
: defaultGearIndex.clamp(0, ratios.length - 1).toInt();
_hasLoadedGearRatios = true;
_gearRatiosError = null;
});
return null;
}
Future<void> _connectButtonToBike() async {
if (_isAssignTrainerDialogOpen) {
return;
}
if (_isFirmwareUpdateBusy) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect to bike is disabled during firmware updates.'),
),
);
return;
}
await _startStatusStreamingIfNeeded();
final shifter = _shifterService;
if (shifter == null) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Status channel is not ready yet.')),
);
return;
}
if (!mounted) {
return;
}
_isAssignTrainerDialogOpen = true;
final TrainerScanResult? selectedTrainer;
try {
selectedTrainer = await BikeScanDialog.show(
context,
shifter: shifter,
);
} finally {
_isAssignTrainerDialogOpen = false;
}
if (selectedTrainer == null || !mounted) {
return;
}
final result =
await shifter.connectButtonToTrainer(selectedTrainer.address);
if (!mounted) {
return;
}
if (result.isErr()) {
final err = result.unwrapErr();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Connect request failed: $err')),
);
toast('Connect request failed.');
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
selectedTrainer.name.isEmpty
? 'Sent connect request for trainer.'
: 'Sent connect request for ${selectedTrainer.name}.',
),
),
);
}
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
if (_firmwareUpdateService != null) {
return _firmwareUpdateService;
}
final asyncBluetooth = ref.read(bluetoothProvider);
final bluetooth = asyncBluetooth.valueOrNull;
if (bluetooth == null) {
return null;
}
final service = FirmwareUpdateService(
transport: ShifterFirmwareUpdateTransport(
shifterService: _shifterService,
bluetoothController: bluetooth,
buttonDeviceId: widget.deviceAddress,
),
);
_firmwareProgressSubscription = service.progressStream.listen((progress) {
if (!mounted) {
return;
}
_log.info(
'Firmware progress: state=${progress.state}, '
'sent=${progress.sentBytes}/${progress.totalBytes}, '
'expectedOffset=${progress.expectedOffset}, error=${progress.errorMessage}',
);
setState(() {
_dfuProgress = progress;
if (progress.state == DfuUpdateState.failed &&
progress.errorMessage != null) {
_firmwareUserMessage = progress.errorMessage;
}
if (progress.state == DfuUpdateState.completed) {
_firmwareUserMessage =
'Firmware update completed. The bootloader rebooted into the updated app and verification passed.';
}
if (progress.state == DfuUpdateState.aborted) {
_firmwareUserMessage = 'Firmware update canceled.';
}
});
});
_firmwareUpdateService = service;
return service;
}
Future<void> _selectFirmwareFile() async {
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
return;
}
setState(() {
_isSelectingFirmware = true;
_firmwareUserMessage = null;
});
final suppressionCount = ref.read(
backgroundBluetoothDisconnectSuppressionCountProvider.notifier,
);
suppressionCount.state += 1;
final FirmwareFileSelectionResult result;
try {
result =
await _firmwareFileSelectionService.selectAndPrepareBootloaderDfu();
} finally {
suppressionCount.state =
suppressionCount.state <= 0 ? 0 : suppressionCount.state - 1;
}
if (!mounted) {
return;
}
setState(() {
_isSelectingFirmware = false;
if (result.isSuccess) {
_selectedFirmware = result.firmware;
_firmwareUserMessage =
'Validated ${result.firmware!.fileName}. Ready for bootloader update.';
} else if (!result.isCanceled) {
_firmwareUserMessage = result.failure?.message;
}
});
}
Future<void> _startFirmwareUpdate() async {
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
return;
}
final firmware = _selectedFirmware;
if (firmware == null) {
setState(() {
_firmwareUserMessage =
'Select a firmware .bin file before starting the update.';
});
return;
}
final updater = await _ensureFirmwareUpdateService();
if (!mounted) {
return;
}
if (updater == null) {
setState(() {
_firmwareUserMessage =
'Firmware updater is not ready. Ensure the button is connected.';
});
return;
}
setState(() {
_isStartingFirmwareUpdate = true;
_firmwareUserMessage =
'Requesting bootloader mode. Keep this screen open and stay near the button.';
});
final result = await updater.startUpdate(
imageBytes: firmware.fileBytes,
sessionId: firmware.metadata.sessionId,
appStart: firmware.metadata.appStart,
imageVersion: firmware.metadata.imageVersion,
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
);
if (!mounted) {
return;
}
setState(() {
_isStartingFirmwareUpdate = false;
if (result.isErr()) {
_firmwareUserMessage = result.unwrapErr().toString();
}
});
if (result.isErr() &&
result.unwrapErr().toString().startsWith(
universalShifterBootMetadataWarningMessage,
)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'WARNING: The device failed writing to its internal storage. This might be a temporary problem. If it continues happening, the internal storage may be broken. If so, please contact abawo support',
),
),
);
}
if (result.isOk()) {
_hasLoadedDeviceTelemetry = false;
unawaited(_loadDeviceTelemetry(force: true));
}
}
String _dfuPhaseText(DfuUpdateState state) {
switch (state) {
case DfuUpdateState.idle:
return 'Idle';
case DfuUpdateState.starting:
return 'Preparing update';
case DfuUpdateState.enteringBootloader:
return 'Requesting bootloader mode';
case DfuUpdateState.connectingBootloader:
return 'Connecting to bootloader';
case DfuUpdateState.waitingForStatus:
return 'Waiting for bootloader status';
case DfuUpdateState.erasing:
return 'Starting destructive bootloader update';
case DfuUpdateState.transferring:
return 'Transferring firmware image';
case DfuUpdateState.finishing:
return 'Finalizing bootloader update';
case DfuUpdateState.rebooting:
return 'Waiting for updated app reboot';
case DfuUpdateState.verifying:
return 'Verifying updated app';
case DfuUpdateState.completed:
return 'Update completed';
case DfuUpdateState.aborted:
return 'Update canceled';
case DfuUpdateState.failed:
return 'Update failed';
}
}
String _formatBytes(int bytes) {
if (bytes < 1024) {
return '$bytes B';
}
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
Future<void> _manualReconnect() async {
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) {
return;
}
if (result.isErr()) {
if (isBluetoothPairingRecoveryError(result.unwrapErr())) {
await showBluetoothPairingRecoveryDialog(context);
return;
}
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> _retryPairing() async {
if (_isPairingCheckRunning || _isFirmwareUpdateBusy) {
return;
}
await _startStatusStreamingIfNeeded();
}
Future<void> _openPairingSettings() async {
final opened = await openBluetoothSettings();
if (!mounted || opened) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open Bluetooth settings.')),
);
}
Future<void> _exitPage() async {
if (_isFirmwareUpdateBusy) {
_log.warning('Blocked page exit while firmware update is busy');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Firmware update is running. Do not close this screen or the app until it completes.'),
),
);
return;
}
_log.info('Exiting device details page to /devices');
await _disconnectOnClose();
if (!mounted) {
return;
}
context.go('/devices');
}
void _dismissFirmwareFullscreen() {
_log.info(
'Dismissing firmware fullscreen from state ${_dfuProgress.state}');
setState(() {
_dfuProgress = const DfuUpdateProgress(
state: DfuUpdateState.idle,
totalBytes: 0,
sentBytes: 0,
expectedOffset: 0,
sessionId: 0,
flags: DfuUpdateFlags(),
);
_firmwareUserMessage = null;
_selectedFirmware = null;
});
}
void _showStatusHistory() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.72,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
const Expanded(
child: Text(
'Status Console',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: _statusHistory.isEmpty
? const Center(child: Text('No status updates yet.'))
: ListView.builder(
itemCount: _statusHistory.length,
itemBuilder: (context, index) {
final item = _statusHistory[index];
final errorCode = _effectiveErrorCode(item.status);
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: Text(
item.status.statusLine,
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
_formatTimestamp(item.timestamp),
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
trailing: errorCode == null
? null
: IconButton(
tooltip: 'Explain error',
onPressed: () {
_showErrorInfoDialog(errorCode);
},
icon: const Icon(Icons.info_outline),
),
onTap: errorCode == null
? null
: () {
_showErrorInfoDialog(errorCode);
},
);
},
),
),
],
),
);
},
);
}
String _formatTimestamp(DateTime time) {
final h = time.hour.toString().padLeft(2, '0');
final m = time.minute.toString().padLeft(2, '0');
final s = time.second.toString().padLeft(2, '0');
return '$h:$m:$s';
}
int? _effectiveErrorCode(CentralStatus status) {
if (status.trainer.state == TrainerConnectionState.error) {
return status.trainer.errorCode ?? status.lastFailure;
}
return status.lastFailure;
}
void _showErrorInfoDialog(int errorCode) {
final info = shifterErrorInfo(errorCode);
showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
icon: const Icon(Icons.info_outline),
title: Text('Error ${info.code}: ${info.title}'),
content: Text(info.details),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
},
);
}
@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 hasDeviceAccess =
isCurrentConnected && _shifterService != null && _latestStatus != null;
final canUseFirmwareUpdate = hasDeviceAccess;
final canSelectFirmware =
canUseFirmwareUpdate && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
final canStartFirmware = canUseFirmwareUpdate &&
!_isSelectingFirmware &&
!_isFirmwareUpdateBusy &&
_selectedFirmware != null;
if (_isFirmwareUpdateBusy ||
(_dfuProgress.state != DfuUpdateState.idle &&
_dfuProgress.state != DfuUpdateState.completed &&
_dfuProgress.state != DfuUpdateState.failed)) {
return FirmwareUpdateFullscreen(
progress: _dfuProgress,
selectedFirmware: _selectedFirmware,
phaseText: _dfuPhaseText(_dfuProgress.state),
statusText: _firmwareUserMessage,
formattedProgressBytes:
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
expectedOffsetHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
onDismiss: _dismissFirmwareFullscreen,
);
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, bool? result) {
_exitPage();
},
child: Scaffold(
appBar: AppBar(
title: const Text('Device'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _exitPage,
),
),
body: Stack(
fit: StackFit.expand,
children: [
SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDeviceOverviewCard(
context,
ref,
widget.deviceAddress,
connectionStatus: currentConnectionStatus,
status: _latestStatus,
),
const SizedBox(height: 20),
if (canUseFirmwareUpdate) ...[
_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,
expectedOffsetHex:
'0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}',
),
const SizedBox(height: 16),
],
if (hasDeviceAccess) ...[
_StatusBanner(
status: _latestStatus,
onTap: _showStatusHistory,
onErrorInfoTap: _latestStatus == null
? null
: () {
final code = _effectiveErrorCode(_latestStatus!);
if (code != null) {
_showErrorInfoDialog(code);
}
},
),
const SizedBox(height: 16),
_TrainerConnectionCard(
status: _latestStatus,
onAssign: _connectButtonToBike,
onShowStatusConsole: _showStatusHistory,
),
const SizedBox(height: 16),
GearRatioEditorCard(
ratios: _gearRatios,
defaultGearIndex: _defaultGearIndex,
isLoading: _isGearRatiosLoading,
errorText: _gearRatiosError,
onRetry: _loadGearRatios,
onSave: _saveGearRatios,
presets: const [
GearRatioPreset(
name: 'Road',
description:
'Balanced 12-speed road gearing for steady cadence steps.',
ratios: [
0.50,
0.58,
0.67,
0.76,
0.86,
0.97,
1.09,
1.22,
1.36,
1.51,
1.67,
1.84,
],
),
GearRatioPreset(
name: 'Gravel',
description:
'Slightly lower gearing with smooth jumps for mixed terrain rides.',
ratios: [
0.46,
0.54,
0.62,
0.70,
0.79,
0.89,
1.00,
1.12,
1.25,
1.40,
1.57,
1.76,
],
),
GearRatioPreset(
name: 'MTB',
description:
'Lower climbing gears with wider top-end spacing for steep trails.',
ratios: [
0.42,
0.49,
0.57,
0.66,
0.76,
0.87,
1.00,
1.15,
1.32,
1.52,
1.75,
2.02,
],
),
GearRatioPreset(
name: 'KeAnt Classic',
description:
'17-step baseline from KeAnt cross app gearing.',
ratios: _keAntRatios,
),
],
),
] else if (isCurrentConnected) ...[
_PairingRequiredCard(
isChecking: _isPairingCheckRunning,
errorText: _pairingError,
onRetry: _retryPairing,
onOpenBluetoothSettings: _openPairingSettings,
),
] else ...[
_DisconnectedDetailCard(
isReconnecting: _isManualReconnectRunning,
onReconnect: _manualReconnect,
onBackToDevices: _exitPage,
),
],
],
),
),
],
),
),
);
}
}
class _StatusHistoryEntry {
const _StatusHistoryEntry({
required this.timestamp,
required this.status,
});
final DateTime timestamp;
final CentralStatus status;
}
class _FirmwareUpdateCard extends StatelessWidget {
const _FirmwareUpdateCard({
required this.selectedFirmware,
required this.progress,
required this.isSelecting,
required this.isStarting,
required this.canSelect,
required this.canStart,
required this.phaseText,
required this.statusText,
required this.formattedProgressBytes,
required this.expectedOffsetHex,
required this.onSelectFirmware,
required this.onStartUpdate,
});
final BootloaderDfuPreparedFirmware? selectedFirmware;
final DfuUpdateProgress progress;
final bool isSelecting;
final bool isStarting;
final bool canSelect;
final bool canStart;
final String phaseText;
final String? statusText;
final String formattedProgressBytes;
final String expectedOffsetHex;
final Future<void> Function() onSelectFirmware;
final Future<void> Function() onStartUpdate;
bool get _showProgress {
return progress.totalBytes > 0 ||
progress.sentBytes > 0 ||
progress.state != DfuUpdateState.idle;
}
bool get _showRebootExpectation {
return progress.state == DfuUpdateState.finishing ||
progress.state == DfuUpdateState.rebooting ||
progress.state == DfuUpdateState.verifying ||
progress.state == DfuUpdateState.completed;
}
String? get _bootloaderStatusText {
final status = progress.bootloaderStatus;
if (status == null) {
return null;
}
final codeLabel = switch (status.code) {
DfuBootloaderStatusCode.ok => 'OK',
DfuBootloaderStatusCode.parseError => 'parse error',
DfuBootloaderStatusCode.stateError => 'state error',
DfuBootloaderStatusCode.boundsError => 'bounds error',
DfuBootloaderStatusCode.crcError => 'CRC error',
DfuBootloaderStatusCode.flashError => 'flash error',
DfuBootloaderStatusCode.unsupportedError => 'unsupported flags',
DfuBootloaderStatusCode.vectorError => 'vector table error',
DfuBootloaderStatusCode.queueFull => 'queue full',
DfuBootloaderStatusCode.bootMetadataError => 'boot metadata error',
DfuBootloaderStatusCode.unknown =>
'unknown 0x${status.rawCode.toRadixString(16).padLeft(2, '0').toUpperCase()}',
};
return 'Bootloader status: $codeLabel, session ${status.sessionId}, expected offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
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 raw app image for the single-slot bootloader. Once START is accepted, the active app slot is erased.',
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'),
),
],
),
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: 2),
Text(
'App start: 0x${selectedFirmware!.metadata.appStart.toRadixString(16).padLeft(8, '0').toUpperCase()} | Image version: ${selectedFirmware!.metadata.imageVersion} | Reset: 0x${selectedFirmware!.metadata.vectorReset.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 • Expected offset $expectedOffsetHex',
style: theme.textTheme.bodySmall,
),
if (_bootloaderStatusText != null) ...[
const SizedBox(height: 4),
Text(
_bootloaderStatusText!,
style: theme.textTheme.bodySmall,
),
],
],
if (_showRebootExpectation) ...[
const SizedBox(height: 8),
Text(
'Expected behavior: after FINISH, the bootloader verifies the image, resets, and the updated app confirms itself before reconnecting.',
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,
),
),
],
],
),
),
);
}
}
class _StatusBanner extends StatelessWidget {
const _StatusBanner({
required this.status,
required this.onTap,
this.onErrorInfoTap,
});
final CentralStatus? status;
final VoidCallback onTap;
final VoidCallback? onErrorInfoTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final color = _resolveColor(colorScheme);
final text = status?.statusLine ?? 'Waiting for status updates...';
return Material(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(22),
child: InkWell(
borderRadius: BorderRadius.circular(22),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: color.withValues(alpha: 0.32)),
color: color.withValues(alpha: 0.08),
),
padding: const EdgeInsets.all(14),
child: Row(
children: [
Icon(Icons.memory_rounded, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Live Status',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (onErrorInfoTap != null &&
status != null &&
(status!.trainer.state == TrainerConnectionState.error ||
status!.lastFailure != null))
IconButton(
tooltip: 'Explain error',
onPressed: onErrorInfoTap,
icon: Icon(Icons.info_outline, color: color),
),
Icon(Icons.chevron_right, color: color),
],
),
),
),
),
);
}
Color _resolveColor(ColorScheme scheme) {
final current = status;
if (current == null) {
return scheme.primary;
}
if (current.trainer.state == TrainerConnectionState.error) {
return scheme.error;
}
if (current.trainer.state == TrainerConnectionState.ftmsReady) {
return Colors.green;
}
if (current.trainer.state == TrainerConnectionState.connecting ||
current.trainer.state == TrainerConnectionState.pairing ||
current.trainer.state == TrainerConnectionState.discoveringFtms) {
return Colors.orange;
}
return scheme.primary;
}
}
Widget _buildDeviceOverviewCard(
BuildContext context,
WidgetRef ref,
String deviceAddress, {
required ConnectionStatus connectionStatus,
required CentralStatus? status,
}) {
final asyncSavedDevices = ref.watch(nConnectedDevicesProvider);
final telemetry = ref.watch(
shifterDeviceTelemetryCacheProvider.select(
(cache) => cache[deviceAddress],
),
);
return asyncSavedDevices.when(
data: (devices) {
ConnectedDevice? currentDeviceData;
try {
currentDeviceData = devices.firstWhere(
(d) => d.deviceAddress == deviceAddress,
);
} catch (_) {
currentDeviceData = null;
}
if (currentDeviceData == null) {
return Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Text('Device details not found for $deviceAddress.'),
),
);
}
return _DeviceOverviewCard(
device: currentDeviceData,
connectionStatus: connectionStatus,
status: status,
telemetry: telemetry,
);
},
loading: () => const Card(
child: Padding(
padding: EdgeInsets.all(18),
child: Center(child: CircularProgressIndicator()),
),
),
error: (error, stackTrace) => Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Text('Error loading device info: $error'),
),
),
);
}
class _DeviceOverviewCard extends StatelessWidget {
const _DeviceOverviewCard({
required this.device,
required this.connectionStatus,
required this.status,
required this.telemetry,
});
final ConnectedDevice device;
final ConnectionStatus connectionStatus;
final CentralStatus? status;
final ShifterDeviceTelemetry? telemetry;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final trainerAddress = status?.connectedTrainerAddr == null
? 'No trainer assigned yet'
: formatMacAddressFromLittleEndian(status!.connectedTrainerAddr!);
return Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/images/shifter-wireframe.png',
width: 104,
height: 78,
fit: BoxFit.cover,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.deviceName,
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
_DetailStatusChip(status: connectionStatus),
const SizedBox(height: 10),
Text(
trainerAddress,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color:
colorScheme.onSurface.withValues(alpha: 0.68),
),
),
],
),
),
],
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: _OverviewMetricTile(
label: 'Battery',
value: telemetry?.batteryLabel ?? '--',
icon: Icons.battery_charging_full_rounded,
),
),
const SizedBox(width: 10),
const Expanded(
child: _OverviewMetricTile(
label: 'Signal',
value: 'Ready',
icon: Icons.signal_cellular_alt_rounded,
),
),
const SizedBox(width: 10),
Expanded(
child: _OverviewMetricTile(
label: 'Firmware',
value: telemetry?.firmwareLabel ?? '--',
icon: Icons.memory_rounded,
),
),
],
),
const SizedBox(height: 14),
Text(
device.deviceAddress,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.62),
),
),
],
),
),
);
}
}
class _TrainerConnectionCard extends StatelessWidget {
const _TrainerConnectionCard({
required this.status,
required this.onAssign,
required this.onShowStatusConsole,
});
final CentralStatus? status;
final VoidCallback? onAssign;
final VoidCallback onShowStatusConsole;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final trainerText = status?.connectedTrainerAddr == null
? 'No trainer assigned yet.'
: 'Assigned: ${formatMacAddressFromLittleEndian(status!.connectedTrainerAddr!)}';
final readinessText = status?.trainer.label ?? 'Waiting for trainer status';
return Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.12),
),
child: Icon(Icons.pedal_bike_rounded,
color: colorScheme.primary),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trainer Assignment',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
trainerText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color:
colorScheme.onSurface.withValues(alpha: 0.68),
),
),
],
),
),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color:
colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
Icon(Icons.bluetooth_connected_rounded,
color: colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
readinessText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: onAssign,
icon: const Icon(Icons.link_rounded),
label: const Text('Assign Trainer'),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: onShowStatusConsole,
icon: const Icon(Icons.subject_rounded),
label: const Text('Status Console'),
),
),
],
),
],
),
),
);
}
}
class _PairingRequiredCard extends StatelessWidget {
const _PairingRequiredCard({
required this.isChecking,
required this.errorText,
required this.onRetry,
required this.onOpenBluetoothSettings,
});
final bool isChecking;
final String? errorText;
final VoidCallback? onRetry;
final VoidCallback onOpenBluetoothSettings;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final trimmedError = errorText?.trim();
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primary.withValues(alpha: 0.12),
),
child: Icon(
Icons.bluetooth_searching_rounded,
color: colorScheme.primary,
size: 28,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isChecking ? 'Checking pairing...' : 'Pair this device',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Device controls need Bluetooth pairing before they can be used. Follow any system pairing prompts, keep the button nearby, then retry.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
),
),
],
),
),
],
),
if (trimmedError != null && trimmedError.isNotEmpty) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(18),
),
child: Text(
'Last attempt failed: $trimmedError',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: isChecking ? null : onRetry,
icon: isChecking
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh_rounded),
label:
Text(isChecking ? 'Checking Pairing...' : 'Retry Pairing'),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: isChecking ? null : onOpenBluetoothSettings,
icon: const Icon(Icons.settings_bluetooth_rounded),
label: const Text('Open Bluetooth Settings'),
),
),
],
),
),
);
}
}
class _DisconnectedDetailCard extends StatelessWidget {
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: 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'),
),
),
],
),
],
),
),
);
}
}
class _DetailStatusChip extends StatelessWidget {
const _DetailStatusChip({required this.status});
final ConnectionStatus status;
@override
Widget build(BuildContext context) {
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
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.24)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline, size: 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _OverviewMetricTile extends StatelessWidget {
const _OverviewMetricTile({
required this.label,
required this.value,
required this.icon,
});
final String label;
final String value;
final IconData icon;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: colorScheme.primary),
const SizedBox(height: 10),
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.62),
),
),
const SizedBox(height: 2),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}