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/widgets/bike_scan_dialog.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: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 _isReconnecting = false; bool _wasConnectedToCurrentDevice = false; bool _isExitingPage = false; bool _hasRequestedDisconnect = false; Timer? _reconnectTimeoutTimer; 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()); _reconnectTimeoutTimer?.cancel(); _connectionStatusSubscription?.close(); _statusSubscription?.cancel(); _shifterService?.dispose(); _firmwareProgressSubscription?.cancel(); unawaited(_firmwareUpdateService?.dispose() ?? Future.value()); super.dispose(); } Future _disconnectOnClose() async { if (_hasRequestedDisconnect) { return; } _hasRequestedDisconnect = true; _isExitingPage = true; _reconnectTimeoutTimer?.cancel(); await _firmwareUpdateService?.cancelUpdate(); 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) { _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 _startReconnect() async { if (!mounted || _isExitingPage || _isReconnecting) { return; } setState(() { _isReconnecting = true; }); final bluetooth = ref.read(bluetoothProvider).value; await bluetooth?.connectById(widget.deviceAddress); _reconnectTimeoutTimer?.cancel(); _reconnectTimeoutTimer = Timer(const Duration(seconds: 10), () { if (!mounted || !_isReconnecting || _isExitingPage) { return; } _terminateConnectionAndGoHome( 'Connection lost. Could not reconnect in time.', ); }); } Future _startStatusStreamingIfNeeded() async { if (_shifterService != null) { _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); } final service = ShifterService( bluetooth: bluetooth, buttonDeviceId: widget.deviceAddress, ); final initialStatusResult = await service.readStatus(); if (mounted && initialStatusResult.isOk()) { _recordStatus(initialStatusResult.unwrap()); } _statusSubscription = service.statusStream.listen((status) { if (!mounted) { return; } _recordStatus(status); }); service.startStatusNotifications(); setState(() { _shifterService = service; }); unawaited(_loadGearRatios()); } 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 (_isFirmwareUpdateBusy) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Connect to bike is disabled during firmware updates.'), ), ); return; } final selectedBike = await BikeScanDialog.show( context, excludedDeviceId: widget.deviceAddress, ); 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(); } }); } Future _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: 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 _terminateConnectionAndGoHome(String toastMessage) async { await _disconnectOnClose(); if (!mounted) { return; } toast(toastMessage); context.replace('/'); } Future _cancelReconnect() async { await _terminateConnectionAndGoHome('Reconnect cancelled.'); } Future _exitPage() async { await _disconnectOnClose(); if (!mounted) { return; } context.replace('/'); } 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 isCurrentConnected = connectionData != null && connectionData.$1 == ConnectionStatus.connected && connectionData.$2 == widget.deviceAddress; final canSelectFirmware = isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy; final canStartFirmware = isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy && _selectedFirmware != null; final canCancelFirmware = _isFirmwareUpdateBusy; return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, bool? result) { _exitPage(); }, child: Scaffold( appBar: AppBar( title: const Text('Device Details'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: _exitPage, ), ), body: Stack( fit: StackFit.expand, children: [ SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildDeviceInfo(context, ref, widget.deviceAddress), const SizedBox(height: 16), _buildConnectionStatus(context, ref, widget.deviceAddress), const SizedBox(height: 16), if (isCurrentConnected) ...[ _StatusBanner( status: _latestStatus, onTap: _showStatusHistory, onErrorInfoTap: _latestStatus == null ? null : () { final code = _effectiveErrorCode(_latestStatus!); if (code != null) { _showErrorInfoDialog(code); } }, ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _isFirmwareUpdateBusy ? null : _connectButtonToBike, icon: const Icon(Icons.link), label: const Text('Connect Button to Bike'), ), ), 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), ), 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: 'KeAnt Classic', description: '17-step baseline from KeAnt cross app gearing.', ratios: _keAntRatios, ), ], ), ), ), ], ], ), ), 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).cardColor, borderRadius: BorderRadius.circular(16), ), 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'), ), ], ), ), ), ), ), ], ), ), ); } } 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.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; final DfuUpdateProgress progress; final bool isSelecting; 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 Function() onSelectFirmware; final Future Function() onStartUpdate; final Future Function() onCancelUpdate; 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 Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), border: Border.all( color: colorScheme.outlineVariant.withValues(alpha: 0.6), ), ), padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Firmware Update', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), ), const SizedBox(height: 10), 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: 10), Text( selectedFirmware == null ? 'Selected file: none' : 'Selected file: ${selectedFirmware!.fileName}', style: theme.textTheme.bodyMedium, ), 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: 10), Text('Phase: $phaseText', style: theme.textTheme.bodyMedium), if (_showProgress) ...[ const SizedBox(height: 8), LinearProgressIndicator( value: progress.totalBytes > 0 ? progress.fractionComplete : 0, ), 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: color.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(12), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: onTap, child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ Icon(Icons.memory, color: color), const SizedBox(width: 10), Expanded( child: 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 _buildDeviceInfo( BuildContext context, WidgetRef ref, String deviceAddress, ) { 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 Center( child: Text('Device details not found for $deviceAddress.'), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Name: ${currentDeviceData.deviceName}', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( 'Address: ${currentDeviceData.deviceAddress}', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 8), Text( 'Type: ${currentDeviceData.deviceType}', style: Theme.of(context).textTheme.bodyMedium, ), ], ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, stackTrace) => Center(child: Text('Error loading device info: $error')), ); } Widget _buildConnectionStatus( BuildContext context, WidgetRef ref, String deviceAddress, ) { final asyncConnectionStatus = ref.watch(connectionStatusProvider); return asyncConnectionStatus.when( data: (data) { final (status, connectedDeviceId) = data; String statusText; final isCurrentDeviceConnected = connectedDeviceId != null && connectedDeviceId == deviceAddress; if (isCurrentDeviceConnected) { switch (status) { case ConnectionStatus.connected: statusText = 'Status: Connected'; break; case ConnectionStatus.connecting: statusText = 'Status: Connecting...'; break; case ConnectionStatus.disconnecting: statusText = 'Status: Disconnecting...'; break; case ConnectionStatus.disconnected: statusText = 'Status: Disconnected'; break; } } else { statusText = 'Status: Disconnected'; } return Text(statusText, style: Theme.of(context).textTheme.titleMedium); }, loading: () => const Text( 'Status: Unknown', style: TextStyle(fontStyle: FontStyle.italic), ), error: (error, stackTrace) => Text( 'Status: Error ($error)', style: const TextStyle(color: Colors.red), ), ); }