import 'dart:async'; 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/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'; import '../controller/bluetooth.dart'; import '../database/database.dart'; class DeviceDetailsPage extends ConsumerStatefulWidget { const DeviceDetailsPage({ required this.deviceAddress, super.key, }); final String deviceAddress; @override ConsumerState createState() => _DeviceDetailsPageState(); } class _DeviceDetailsPageState extends ConsumerState { static const List _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, ]; bool _isExitingPage = false; bool _hasRequestedDisconnect = false; bool _hasShownPairingRecoveryDialog = false; bool _isAssignTrainerDialogOpen = false; bool _isManualReconnectRunning = false; ProviderSubscription>? _connectionStatusSubscription; ShifterService? _shifterService; StreamSubscription? _statusSubscription; CentralStatus? _latestStatus; final List<_StatusHistoryEntry> _statusHistory = []; bool _isGearRatiosLoading = false; bool _hasLoadedGearRatios = false; String? _gearRatiosError; List _gearRatios = const []; int _defaultGearIndex = 0; late final FirmwareFileSelectionService _firmwareFileSelectionService; FirmwareUpdateService? _firmwareUpdateService; StreamSubscription? _firmwareProgressSubscription; DfuV1PreparedFirmware? _selectedFirmware; DfuUpdateProgress _dfuProgress = const DfuUpdateProgress( state: DfuUpdateState.idle, totalBytes: 0, sentBytes: 0, lastAckedSequence: 0xFF, 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.waitingForAck: case DfuUpdateState.transferring: case DfuUpdateState.finishing: 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>( connectionStatusProvider, (_, next) { final data = next.valueOrNull; if (data == null) { return; } _onConnectionStatusChanged(data); }, fireImmediately: true, ); } @override void dispose() { unawaited(_disconnectOnClose()); _connectionStatusSubscription?.close(); _statusSubscription?.cancel(); _shifterService?.dispose(); _firmwareProgressSubscription?.cancel(); unawaited(_firmwareUpdateService?.dispose() ?? Future.value()); super.dispose(); } Future _disconnectOnClose() async { if (_isFirmwareUpdateBusy) { return; } if (_hasRequestedDisconnect) { 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 (isCurrentDevice && status == ConnectionStatus.connected) { _startStatusStreamingIfNeeded(); return; } if ((!isCurrentDevice || status == ConnectionStatus.disconnected) && !_isFirmwareUpdateBusy) { _stopStatusStreaming(); } } Future _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()); } 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, ); final initialStatusResult = await service.readStatus(); 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; } _recordStatus(status); }); service.startStatusNotifications(); setState(() { _shifterService = service; }); unawaited(_loadGearRatios()); } Future _showPairingRecoveryDialog() async { if (!mounted || _hasShownPairingRecoveryDialog) { return; } _hasShownPairingRecoveryDialog = true; await showBluetoothPairingRecoveryDialog(context); } 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 _stopStatusStreaming() async { await _statusSubscription?.cancel(); _statusSubscription = null; if (_isFirmwareUpdateBusy) { return; } await _disposeFirmwareUpdateService(); await _shifterService?.dispose(); _shifterService = null; } Future _disposeFirmwareUpdateService() async { await _firmwareProgressSubscription?.cancel(); _firmwareProgressSubscription = null; await _firmwareUpdateService?.dispose(); _firmwareUpdateService = null; } Future _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 _saveGearRatios( List 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.from(ratios); _defaultGearIndex = ratios.isEmpty ? 0 : defaultGearIndex.clamp(0, ratios.length - 1).toInt(); _hasLoadedGearRatios = true; _gearRatiosError = null; }); return null; } Future _connectButtonToBike() async { if (_isAssignTrainerDialogOpen) { return; } if (_isFirmwareUpdateBusy) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Connect to bike is disabled during firmware updates.'), ), ); return; } _isAssignTrainerDialogOpen = true; final DiscoveredDevice? selectedBike; try { selectedBike = await BikeScanDialog.show( context, excludedDeviceId: widget.deviceAddress, ); } finally { _isAssignTrainerDialogOpen = false; } if (selectedBike == null || !mounted) { 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; } final result = await shifter.connectButtonToBike(selectedBike.id); 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('Sent connect request for ${selectedBike.id}.')), ); } Future _ensureFirmwareUpdateService() async { final shifter = _shifterService; if (shifter == null) { return null; } 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: shifter, bluetoothController: bluetooth, buttonDeviceId: widget.deviceAddress, ), ); _firmwareProgressSubscription = service.progressStream.listen((progress) { if (!mounted) { return; } setState(() { _dfuProgress = progress; if (progress.state == DfuUpdateState.failed && progress.errorMessage != null) { _firmwareUserMessage = progress.errorMessage; } if (progress.state == DfuUpdateState.completed) { _firmwareUserMessage = 'Firmware update completed. The button rebooted and reconnected.'; } if (progress.state == DfuUpdateState.aborted) { _firmwareUserMessage = 'Firmware update canceled.'; } }); }); _firmwareUpdateService = service; return service; } Future _selectFirmwareFile() async { if (_isFirmwareUpdateBusy || _isSelectingFirmware) { return; } setState(() { _isSelectingFirmware = true; _firmwareUserMessage = null; }); final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1(); if (!mounted) { return; } setState(() { _isSelectingFirmware = false; if (result.isSuccess) { _selectedFirmware = result.firmware; _firmwareUserMessage = 'Selected ${result.firmware!.fileName}. Ready to start update.'; } else if (!result.isCanceled) { _firmwareUserMessage = result.failure?.message; } }); } Future _startFirmwareUpdate() async { if (_isFirmwareUpdateBusy || _isSelectingFirmware) { return; } final firmware = _selectedFirmware; if (firmware == null) { setState(() { _firmwareUserMessage = 'Select a firmware .bin file before starting the update.'; }); return; } await _startStatusStreamingIfNeeded(); 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 = 'Starting update. Keep this screen open and stay near the button.'; }); final result = await updater.startUpdate( imageBytes: firmware.fileBytes, sessionId: firmware.metadata.sessionId, flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags), ); if (!mounted) { return; } setState(() { _isStartingFirmwareUpdate = false; if (result.isErr()) { _firmwareUserMessage = result.unwrapErr().toString(); } }); } String _dfuPhaseText(DfuUpdateState state) { switch (state) { case DfuUpdateState.idle: return 'Idle'; case DfuUpdateState.starting: return 'Sending START command'; case DfuUpdateState.waitingForAck: return 'Waiting for ACK from button'; case DfuUpdateState.transferring: return 'Transferring firmware frames'; case DfuUpdateState.finishing: return 'Finalizing update and waiting for reboot/reconnect'; 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'; } String _hexByte(int value) { return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}'; } Future _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()) { 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 _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.go('/devices'); } void _showStatusHistory() { showModalBottomSheet( 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( 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 canSelectFirmware = isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy; final canStartFirmware = isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy && _selectedFirmware != null; 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 (isCurrentConnected) ...[ const SizedBox(height: 16), _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: _isFirmwareUpdateBusy ? null : _connectButtonToBike, onShowStatusConsole: _showStatusHistory, ), const SizedBox(height: 16), Opacity( opacity: _isFirmwareUpdateBusy ? 0.6 : 1, child: AbsorbPointer( absorbing: _isFirmwareUpdateBusy, child: GearRatioEditorCard( ratios: _gearRatios, defaultGearIndex: _defaultGearIndex, isLoading: _isGearRatiosLoading, errorText: _gearRatiosError, onRetry: _isFirmwareUpdateBusy ? null : _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, ), ], ), ), ), 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 ...[ _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.ackSequenceHex, required this.onSelectFirmware, required this.onStartUpdate, }); final DfuV1PreparedFirmware? 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 ackSequenceHex; final Future Function() onSelectFirmware; final Future 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.completed; } @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 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'), ), ], ), 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, ), ), ], ], ), ), ); } } 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); 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.'), ), ); } // TODO(yannik): Replace these overview placeholder metrics with actual // battery, signal, and firmware values once the device exposes them. return _DeviceOverviewCard( device: currentDeviceData, connectionStatus: connectionStatus, status: status, ); }, 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, }); final ConnectedDevice device; final ConnectionStatus connectionStatus; final CentralStatus? status; @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: const [ Expanded( child: _OverviewMetricTile( label: 'Battery', value: '--', icon: Icons.battery_charging_full_rounded, ), ), SizedBox(width: 10), Expanded( child: _OverviewMetricTile( label: 'Signal', value: 'Ready', icon: Icons.signal_cellular_alt_rounded, ), ), SizedBox(width: 10), Expanded( child: _OverviewMetricTile( label: 'Firmware', value: '--', 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 _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, ), ), ], ), ); } }