diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index e9c201d..993366e 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -735,7 +735,7 @@ class _DeviceDetailsPageState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( - 'Firmware update is running. Keep this screen open until it completes.'), + 'Firmware update is running. Do not close this screen or the app until it completes.'), ), ); return; @@ -748,6 +748,21 @@ class _DeviceDetailsPageState extends ConsumerState { context.go('/devices'); } + void _dismissFirmwareFullscreen() { + setState(() { + _dfuProgress = const DfuUpdateProgress( + state: DfuUpdateState.idle, + totalBytes: 0, + sentBytes: 0, + expectedOffset: 0, + sessionId: 0, + flags: DfuUpdateFlags(), + ); + _firmwareUserMessage = null; + _selectedFirmware = null; + }); + } + void _showStatusHistory() { showModalBottomSheet( context: context, @@ -874,6 +889,23 @@ class _DeviceDetailsPageState extends ConsumerState { !_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) { @@ -938,96 +970,88 @@ class _DeviceDetailsPageState extends ConsumerState { const SizedBox(height: 16), _TrainerConnectionCard( status: _latestStatus, - onAssign: - _isFirmwareUpdateBusy ? null : _connectButtonToBike, + onAssign: _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, - ), + 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: _isFirmwareUpdateBusy ? null : _retryPairing, + onRetry: _retryPairing, onOpenBluetoothSettings: _openPairingSettings, ), ] else ...[ @@ -1921,3 +1945,244 @@ class _OverviewMetricTile extends StatelessWidget { ); } } + +class _FirmwareUpdateFullscreen extends StatelessWidget { + const _FirmwareUpdateFullscreen({ + required this.progress, + required this.selectedFirmware, + required this.phaseText, + required this.statusText, + required this.formattedProgressBytes, + required this.expectedOffsetHex, + required this.onDismiss, + }); + + final DfuUpdateProgress progress; + final BootloaderDfuPreparedFirmware? selectedFirmware; + final String phaseText; + final String? statusText; + final String formattedProgressBytes; + final String expectedOffsetHex; + final VoidCallback onDismiss; + + bool get _isTerminal => + progress.state == DfuUpdateState.completed || + progress.state == DfuUpdateState.failed; + + bool get _isRunning => !_isTerminal && progress.state != DfuUpdateState.idle; + + 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 '$codeLabel • session ${status.sessionId} • offset 0x${status.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isFailed = progress.state == DfuUpdateState.failed; + + return PopScope( + canPop: false, + child: Scaffold( + backgroundColor: colorScheme.surface, + body: SafeArea( + child: Column( + children: [ + if (_isRunning) + Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + color: colorScheme.errorContainer, + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, + color: colorScheme.onErrorContainer, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Do not close the app, lock the phone, or move away from the button.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + _isTerminal + ? (isFailed + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded) + : Icons.system_update_alt_rounded, + size: 56, + color: _isTerminal + ? (isFailed + ? colorScheme.error + : colorScheme.primary) + : colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + _isTerminal + ? (isFailed ? 'Update failed' : 'Update completed') + : 'Updating firmware', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + phaseText, + style: theme.textTheme.bodyLarge?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.72), + ), + ), + if (selectedFirmware != null) ...[ + const SizedBox(height: 12), + Text( + '${selectedFirmware!.fileName} • ${_formatBytes(selectedFirmware!.fileBytes.length)}', + style: theme.textTheme.bodySmall, + ), + ], + const SizedBox(height: 24), + if (_isRunning) ...[ + LinearProgressIndicator( + value: progress.totalBytes > 0 + ? progress.fractionComplete + : null, + minHeight: 12, + borderRadius: BorderRadius.circular(999), + ), + const SizedBox(height: 12), + Text( + '${progress.percentComplete}% • $formattedProgressBytes', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + if (progress.state == DfuUpdateState.finishing || + progress.state == DfuUpdateState.rebooting || + progress.state == DfuUpdateState.verifying) ...[ + const SizedBox(height: 20), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.primaryContainer + .withValues(alpha: 0.36), + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: colorScheme.primary), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Bootloader is verifying, resetting, and booting the new app. Keep the screen open.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + if (_bootloaderStatusText != null) ...[ + const SizedBox(height: 12), + Text( + _bootloaderStatusText!, + style: theme.textTheme.bodySmall?.copyWith( + color: + colorScheme.onSurface.withValues(alpha: 0.56), + ), + ), + ], + if (statusText != null && + statusText!.trim().isNotEmpty) ...[ + const SizedBox(height: 20), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isFailed + ? colorScheme.errorContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + child: Text( + statusText!, + style: theme.textTheme.bodyMedium?.copyWith( + color: isFailed + ? colorScheme.onErrorContainer + : null, + ), + ), + ), + ], + if (_isTerminal) ...[ + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onDismiss, + icon: Icon(isFailed + ? Icons.arrow_back_rounded + : Icons.check_rounded), + label: Text( + isFailed ? 'Back to device' : 'Done', + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + 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'; + } +}