diff --git a/lib/main.dart b/lib/main.dart index 5b65f3e..0a69bdb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:abawo_bt_app/controller/bluetooth.dart'; +import 'package:abawo_bt_app/pages/bootloader_recovery_update_page.dart'; import 'package:abawo_bt_app/pages/devices_page.dart'; import 'package:abawo_bt_app/pages/devices_tab_page.dart'; import 'package:abawo_bt_app/src/rust/frb_generated.dart'; @@ -125,6 +126,18 @@ final _router = GoRouter( return DeviceDetailsPage(deviceAddress: deviceAddress); }, ), + GoRoute( + path: '/bootloader_recovery_update', + builder: (context, state) { + final args = state.extra; + if (args is! BootloaderRecoveryUpdateArgs) { + return const Scaffold( + body: Center(child: Text('Missing bootloader recovery data.')), + ); + } + return BootloaderRecoveryUpdatePage(args: args); + }, + ), ], ); diff --git a/lib/pages/bootloader_recovery_update_page.dart b/lib/pages/bootloader_recovery_update_page.dart new file mode 100644 index 0000000..566df8c --- /dev/null +++ b/lib/pages/bootloader_recovery_update_page.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'package:abawo_bt_app/controller/bluetooth.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_update_service.dart'; +import 'package:abawo_bt_app/widgets/firmware_update_fullscreen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +class BootloaderRecoveryUpdateArgs { + const BootloaderRecoveryUpdateArgs({ + required this.bootloaderDeviceId, + required this.firmware, + }); + + final String bootloaderDeviceId; + final BootloaderDfuPreparedFirmware firmware; +} + +class BootloaderRecoveryUpdatePage extends ConsumerStatefulWidget { + const BootloaderRecoveryUpdatePage({ + required this.args, + super.key, + }); + + final BootloaderRecoveryUpdateArgs args; + + @override + ConsumerState createState() => + _BootloaderRecoveryUpdatePageState(); +} + +class _BootloaderRecoveryUpdatePageState + extends ConsumerState { + FirmwareUpdateService? _firmwareUpdateService; + StreamSubscription? _firmwareProgressSubscription; + DfuUpdateProgress _dfuProgress = const DfuUpdateProgress( + state: DfuUpdateState.idle, + totalBytes: 0, + sentBytes: 0, + expectedOffset: 0, + sessionId: 0, + flags: DfuUpdateFlags(), + ); + String? _firmwareUserMessage = 'Preparing US-DFU recovery update...'; + bool _hasStarted = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + unawaited(_startUpdate()); + } + }); + } + + @override + void dispose() { + unawaited(_firmwareProgressSubscription?.cancel()); + unawaited(_firmwareUpdateService?.dispose() ?? Future.value()); + super.dispose(); + } + + Future _ensureFirmwareUpdateService() async { + if (_firmwareUpdateService != null) { + return _firmwareUpdateService; + } + + final bluetooth = ref.read(bluetoothProvider).valueOrNull; + if (bluetooth == null) { + return null; + } + + final service = FirmwareUpdateService( + verifyAfterFinish: false, + transport: ShifterFirmwareUpdateTransport( + shifterService: null, + bluetoothController: bluetooth, + buttonDeviceId: widget.args.bootloaderDeviceId, + ), + ); + _firmwareProgressSubscription = service.progressStream.listen((progress) { + if (!mounted) { + return; + } + setState(() { + _dfuProgress = progress; + if (progress.state == DfuUpdateState.failed && + progress.errorMessage != null) { + _firmwareUserMessage = progress.errorMessage; + } else if (progress.state == DfuUpdateState.completed) { + _firmwareUserMessage = + 'Firmware update completed. The bootloader accepted FINISH and reset; reconnect to the device when it starts advertising again.'; + } else if (progress.state == DfuUpdateState.aborted) { + _firmwareUserMessage = 'Firmware update canceled.'; + } else if (progress.errorMessage != null) { + _firmwareUserMessage = progress.errorMessage; + } + }); + }); + _firmwareUpdateService = service; + return service; + } + + Future _startUpdate() async { + if (_hasStarted) { + return; + } + _hasStarted = true; + + final updater = await _ensureFirmwareUpdateService(); + if (!mounted) { + return; + } + if (updater == null) { + setState(() { + _dfuProgress = DfuUpdateProgress( + state: DfuUpdateState.failed, + totalBytes: widget.args.firmware.fileBytes.length, + sentBytes: 0, + expectedOffset: 0, + sessionId: widget.args.firmware.metadata.sessionId, + flags: DfuUpdateFlags.fromRaw(widget.args.firmware.metadata.flags), + errorMessage: + 'Firmware updater is not ready. Reconnect to US-DFU and retry.', + ); + _firmwareUserMessage = _dfuProgress.errorMessage; + }); + return; + } + + final firmware = widget.args.firmware; + 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 || result.isOk()) { + return; + } + setState(() { + _firmwareUserMessage = result.unwrapErr().toString(); + }); + } + + String _dfuPhaseText(DfuUpdateState state) { + return switch (state) { + DfuUpdateState.idle => 'Preparing recovery update', + DfuUpdateState.starting => 'Preparing update', + DfuUpdateState.enteringBootloader => 'Checking bootloader mode', + DfuUpdateState.connectingBootloader => 'Connecting to bootloader', + DfuUpdateState.waitingForStatus => 'Waiting for bootloader status', + DfuUpdateState.erasing => 'Starting destructive bootloader update', + DfuUpdateState.transferring => 'Transferring firmware image', + DfuUpdateState.finishing => 'Finalizing bootloader update', + DfuUpdateState.rebooting => 'Waiting for bootloader reset', + DfuUpdateState.verifying => 'Verifying updated app', + DfuUpdateState.completed => 'Update completed', + DfuUpdateState.aborted => 'Update canceled', + DfuUpdateState.failed => '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'; + } + + @override + Widget build(BuildContext context) { + return FirmwareUpdateFullscreen( + progress: _dfuProgress, + selectedFirmware: widget.args.firmware, + phaseText: _dfuPhaseText(_dfuProgress.state), + statusText: _firmwareUserMessage, + formattedProgressBytes: + '${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}', + expectedOffsetHex: + '0x${_dfuProgress.expectedOffset.toRadixString(16).padLeft(8, '0').toUpperCase()}', + doneLabel: 'Back to devices', + failedLabel: 'Back to devices', + onDismiss: () => context.go('/devices'), + ); + } +} diff --git a/lib/pages/device_details_page.dart b/lib/pages/device_details_page.dart index abed2ff..638d2f7 100644 --- a/lib/pages/device_details_page.dart +++ b/lib/pages/device_details_page.dart @@ -8,6 +8,7 @@ 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'; @@ -904,7 +905,7 @@ class _DeviceDetailsPageState extends ConsumerState { (_dfuProgress.state != DfuUpdateState.idle && _dfuProgress.state != DfuUpdateState.completed && _dfuProgress.state != DfuUpdateState.failed)) { - return _FirmwareUpdateFullscreen( + return FirmwareUpdateFullscreen( progress: _dfuProgress, selectedFirmware: _selectedFirmware, phaseText: _dfuPhaseText(_dfuProgress.state), @@ -1956,244 +1957,3 @@ 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'; - } -} diff --git a/lib/pages/devices_tab_page.dart b/lib/pages/devices_tab_page.dart index 5d73842..35f6ad5 100644 --- a/lib/pages/devices_tab_page.dart +++ b/lib/pages/devices_tab_page.dart @@ -1,18 +1,145 @@ +import 'dart:async'; + import 'package:abawo_bt_app/controller/bluetooth.dart'; import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart'; import 'package:abawo_bt_app/database/database.dart'; import 'package:abawo_bt_app/model/bluetooth_device_model.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/pages/bootloader_recovery_update_page.dart'; +import 'package:abawo_bt_app/service/firmware_file_selection_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' + show DiscoveredDevice, ScanMode, Uuid; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -class DevicesTabPage extends ConsumerWidget { +class DevicesTabPage extends ConsumerStatefulWidget { const DevicesTabPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DevicesTabPageState(); +} + +class _DevicesTabPageState extends ConsumerState { + static const Duration _bootloaderScanTimeout = Duration(seconds: 10); + + StreamSubscription>? _scanSubscription; + DiscoveredDevice? _dfuDevice; + bool _isBootloaderScanStarting = false; + + @override + void initState() { + super.initState(); + unawaited(_startBootloaderBackgroundScan()); + } + + @override + void dispose() { + final bluetooth = ref.read(bluetoothProvider).valueOrNull; + unawaited(_scanSubscription?.cancel()); + unawaited(_stopBootloaderScan(bluetooth)); + super.dispose(); + } + + Future _startBootloaderBackgroundScan() async { + if (_isBootloaderScanStarting || _scanSubscription != null) { + return; + } + _isBootloaderScanStarting = true; + + try { + final bluetooth = await ref.read(bluetoothProvider.future); + if (!mounted) { + return; + } + + final scanResult = await bluetooth.startScan( + timeout: _bootloaderScanTimeout, + scanMode: ScanMode.lowLatency, + ); + if (scanResult.isErr()) { + return; + } + + _updateBootloaderDevice(bluetooth.scanResults); + _scanSubscription = bluetooth.scanResultsStream.listen( + _updateBootloaderDevice, + ); + } finally { + _isBootloaderScanStarting = false; + } + } + + Future _stopBootloaderScan([BluetoothController? bluetooth]) async { + await _scanSubscription?.cancel(); + _scanSubscription = null; + + await bluetooth?.stopScan(); + } + + void _updateBootloaderDevice(List devices) { + final dfuDevice = devices.cast().firstWhere( + (device) => device != null && _isBootloaderAdvertisement(device), + orElse: () => null, + ); + if (!mounted || dfuDevice == null || dfuDevice.id == _dfuDevice?.id) { + return; + } + + setState(() { + _dfuDevice = dfuDevice; + }); + } + + bool _isBootloaderAdvertisement(DiscoveredDevice device) { + final name = device.name.trim(); + if (name == 'US-DFU' || name == 'UniversalShifters DFU') { + return true; + } + return name.toLowerCase().contains('dfu') && + device.serviceUuids.any( + (uuid) => + uuid.expanded == + Uuid.parse(universalShifterControlServiceUuid).expanded, + ); + } + + Future _openBootloaderRecovery() async { + final device = _dfuDevice; + if (device == null) { + return; + } + + final firmware = + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => _BootloaderRecoverySetupPage(device: device), + ), + ); + if (!mounted || firmware == null) { + return; + } + + await _stopBootloaderScan(); + if (!mounted) { + return; + } + context.push( + '/bootloader_recovery_update', + extra: BootloaderRecoveryUpdateArgs( + bootloaderDeviceId: device.id, + firmware: firmware, + ), + ); + } + + @override + Widget build(BuildContext context) { final devicesAsync = ref.watch(nConnectedDevicesProvider); final connectionData = ref.watch(connectionStatusProvider).valueOrNull; + final dfuDevice = _dfuDevice; return ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), @@ -50,6 +177,13 @@ class DevicesTabPage extends ConsumerWidget { ], ), const SizedBox(height: 20), + if (dfuDevice != null) ...[ + _BootloaderRecoveryCard( + device: dfuDevice, + onStartRecovery: _openBootloaderRecovery, + ), + const SizedBox(height: 20), + ], devicesAsync.when( loading: () => const _LoadingCard(), error: (error, _) => _MessageCard( @@ -86,6 +220,259 @@ class DevicesTabPage extends ConsumerWidget { } } +class _BootloaderRecoveryCard extends StatelessWidget { + const _BootloaderRecoveryCard({ + required this.device, + required this.onStartRecovery, + }); + + final DiscoveredDevice device; + final VoidCallback onStartRecovery; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + color: colorScheme.errorContainer.withValues(alpha: 0.45), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.system_update_alt, color: colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'US-DFU Device Detected', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + const Text( + 'US-DFU (Universal Shifters Firmware Update) device detected. Maybe a previous update failed?', + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + device.name.isEmpty ? device.id : '${device.name} - ${device.id}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.68), + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onStartRecovery, + icon: const Icon(Icons.build_circle_outlined), + label: const Text('Start Recovery'), + ), + ], + ), + ), + ); + } +} + +class _BootloaderRecoverySetupPage extends ConsumerStatefulWidget { + const _BootloaderRecoverySetupPage({required this.device}); + + final DiscoveredDevice device; + + @override + ConsumerState<_BootloaderRecoverySetupPage> createState() => + _BootloaderRecoverySetupPageState(); +} + +class _BootloaderRecoverySetupPageState + extends ConsumerState<_BootloaderRecoverySetupPage> { + final FirmwareFileSelectionService _firmwareFileSelectionService = + FirmwareFileSelectionService(filePicker: LocalFirmwareFilePicker()); + + BootloaderDfuPreparedFirmware? _selectedFirmware; + bool _isSelectingFirmware = false; + String? _message; + + Future _selectFirmwareFile() async { + if (_isSelectingFirmware) { + return; + } + + setState(() { + _isSelectingFirmware = true; + _message = 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; + _message = + 'Validated ${result.firmware!.fileName}. Ready to start recovery.'; + } else if (!result.isCanceled) { + _message = result.failure?.message; + } + }); + } + + void _startRecovery() { + final firmware = _selectedFirmware; + if (firmware == null) { + setState(() { + _message = 'Select a firmware .bin file before starting recovery.'; + }); + return; + } + + Navigator.of(context).pop(firmware); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final selectedFirmware = _selectedFirmware; + + return Scaffold( + appBar: AppBar( + title: const Text('US-DFU Recovery'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.system_update_alt_rounded, + color: colorScheme.primary), + const SizedBox(width: 10), + Text( + 'Recover Firmware Update', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + 'Select a raw app image for the detected US-DFU bootloader. Starting recovery opens the firmware update screen.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.68), + ), + ), + const SizedBox(height: 14), + Text( + widget.device.name.isEmpty + ? widget.device.id + : '${widget.device.name} - ${widget.device.id}', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(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: 6), + 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), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: + _isSelectingFirmware ? null : _selectFirmwareFile, + icon: _isSelectingFirmware + ? const SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload_file), + label: const Text('Select Firmware'), + ), + FilledButton.icon( + onPressed: + selectedFirmware == null ? null : _startRecovery, + icon: const Icon(Icons.system_update_alt), + label: const Text('Start Update'), + ), + ], + ), + if (_message != null && _message!.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(_message!), + ], + ], + ), + ), + ), + ], + ), + ), + ); + } +} + class _SavedDevicesList extends ConsumerStatefulWidget { const _SavedDevicesList(); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 9bbb99c..8de9bcf 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -30,7 +30,6 @@ class HomePage extends StatelessWidget { style: TextStyle(fontSize: 20), ), const SizedBox(height: 20), - // Devices Section Container( padding: const EdgeInsets.only(left: 16, right: 16), child: Column( @@ -84,7 +83,7 @@ class DevicesList extends ConsumerStatefulWidget { } class _DevicesListState extends ConsumerState { - String? _connectingDeviceId; // ID of device currently being connected + String? _connectingDeviceId; Future _removeDevice(ConnectedDevice device) async { final shouldRemove = await showDialog( @@ -197,10 +196,10 @@ class _DevicesListState extends ConsumerState { context.go('/device/${device.deviceAddress}'); } else { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( + const SnackBar( content: Text( 'Connection failed. Is the device turned on and in range?'), - duration: const Duration(seconds: 3), + duration: Duration(seconds: 3), ), ); } diff --git a/lib/service/firmware_update_service.dart b/lib/service/firmware_update_service.dart index 0bfd65c..08c008c 100644 --- a/lib/service/firmware_update_service.dart +++ b/lib/service/firmware_update_service.dart @@ -11,6 +11,7 @@ import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' class FirmwareUpdateService { FirmwareUpdateService({ required FirmwareUpdateTransport transport, + this.verifyAfterFinish = true, this.defaultStatusTimeout = const Duration(seconds: 2), this.defaultBootloaderConnectTimeout = const Duration(seconds: 60), this.defaultPostFinishResetTimeout = const Duration(seconds: 8), @@ -22,6 +23,7 @@ class FirmwareUpdateService { }) : _transport = transport; final FirmwareUpdateTransport _transport; + final bool verifyAfterFinish; final Duration defaultStatusTimeout; final Duration defaultBootloaderConnectTimeout; final Duration defaultPostFinishResetTimeout; @@ -220,6 +222,15 @@ class FirmwareUpdateService { ); } + if (!verifyAfterFinish) { + _emitProgress( + state: DfuUpdateState.completed, + sentBytes: imageBytes.length, + expectedOffset: imageBytes.length, + ); + return Ok(null); + } + _emitProgress(state: DfuUpdateState.verifying); final reconnectResult = await _transport.reconnectForVerification( timeout: effectiveReconnectTimeout, @@ -831,6 +842,14 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport { return Ok(null); } + if (shifterService == null) { + _bootloaderDeviceId = buttonDeviceId; + return bluetoothController.connectById( + buttonDeviceId, + timeout: timeout, + ); + } + final scanResult = await _scanForBootloader(timeout: timeout); if (scanResult.isErr()) { return Err(scanResult.unwrapErr()); diff --git a/lib/widgets/firmware_update_fullscreen.dart b/lib/widgets/firmware_update_fullscreen.dart new file mode 100644 index 0000000..4f18a53 --- /dev/null +++ b/lib/widgets/firmware_update_fullscreen.dart @@ -0,0 +1,247 @@ +import 'package:abawo_bt_app/model/firmware_file_selection.dart'; +import 'package:abawo_bt_app/model/shifter_types.dart'; +import 'package:flutter/material.dart'; + +class FirmwareUpdateFullscreen extends StatelessWidget { + const FirmwareUpdateFullscreen({ + super.key, + required this.progress, + required this.selectedFirmware, + required this.phaseText, + required this.statusText, + required this.formattedProgressBytes, + required this.expectedOffsetHex, + required this.onDismiss, + this.doneLabel = 'Done', + this.failedLabel = 'Back to device', + }); + + final DfuUpdateProgress progress; + final BootloaderDfuPreparedFirmware? selectedFirmware; + final String phaseText; + final String? statusText; + final String formattedProgressBytes; + final String expectedOffsetHex; + final VoidCallback onDismiss; + final String doneLabel; + final String failedLabel; + + 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 ? failedLabel : doneLabel), + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + 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'; + } +}