feat: fw-update recovery flow
This commit is contained in:
@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
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_page.dart';
|
||||||
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
|
import 'package:abawo_bt_app/pages/devices_tab_page.dart';
|
||||||
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
|
import 'package:abawo_bt_app/src/rust/frb_generated.dart';
|
||||||
@ -125,6 +126,18 @@ final _router = GoRouter(
|
|||||||
return DeviceDetailsPage(deviceAddress: deviceAddress);
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
196
lib/pages/bootloader_recovery_update_page.dart
Normal file
196
lib/pages/bootloader_recovery_update_page.dart
Normal file
@ -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<BootloaderRecoveryUpdatePage> createState() =>
|
||||||
|
_BootloaderRecoveryUpdatePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BootloaderRecoveryUpdatePageState
|
||||||
|
extends ConsumerState<BootloaderRecoveryUpdatePage> {
|
||||||
|
FirmwareUpdateService? _firmwareUpdateService;
|
||||||
|
StreamSubscription<DfuUpdateProgress>? _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<void>.value());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FirmwareUpdateService?> _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<void> _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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/service/shifter_service.dart';
|
||||||
import 'package:abawo_bt_app/util/bluetooth_settings.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/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:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@ -904,7 +905,7 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
(_dfuProgress.state != DfuUpdateState.idle &&
|
(_dfuProgress.state != DfuUpdateState.idle &&
|
||||||
_dfuProgress.state != DfuUpdateState.completed &&
|
_dfuProgress.state != DfuUpdateState.completed &&
|
||||||
_dfuProgress.state != DfuUpdateState.failed)) {
|
_dfuProgress.state != DfuUpdateState.failed)) {
|
||||||
return _FirmwareUpdateFullscreen(
|
return FirmwareUpdateFullscreen(
|
||||||
progress: _dfuProgress,
|
progress: _dfuProgress,
|
||||||
selectedFirmware: _selectedFirmware,
|
selectedFirmware: _selectedFirmware,
|
||||||
phaseText: _dfuPhaseText(_dfuProgress.state),
|
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,18 +1,145 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
import 'package:abawo_bt_app/controller/bluetooth.dart';
|
||||||
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
|
import 'package:abawo_bt_app/controller/shifter_device_telemetry.dart';
|
||||||
import 'package:abawo_bt_app/database/database.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/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/material.dart';
|
||||||
|
import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
||||||
|
show DiscoveredDevice, ScanMode, Uuid;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class DevicesTabPage extends ConsumerWidget {
|
class DevicesTabPage extends ConsumerStatefulWidget {
|
||||||
const DevicesTabPage({super.key});
|
const DevicesTabPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<DevicesTabPage> createState() => _DevicesTabPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DevicesTabPageState extends ConsumerState<DevicesTabPage> {
|
||||||
|
static const Duration _bootloaderScanTimeout = Duration(seconds: 10);
|
||||||
|
|
||||||
|
StreamSubscription<List<DiscoveredDevice>>? _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<void> _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<void> _stopBootloaderScan([BluetoothController? bluetooth]) async {
|
||||||
|
await _scanSubscription?.cancel();
|
||||||
|
_scanSubscription = null;
|
||||||
|
|
||||||
|
await bluetooth?.stopScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateBootloaderDevice(List<DiscoveredDevice> devices) {
|
||||||
|
final dfuDevice = devices.cast<DiscoveredDevice?>().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<void> _openBootloaderRecovery() async {
|
||||||
|
final device = _dfuDevice;
|
||||||
|
if (device == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final firmware =
|
||||||
|
await Navigator.of(context).push<BootloaderDfuPreparedFirmware>(
|
||||||
|
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 devicesAsync = ref.watch(nConnectedDevicesProvider);
|
||||||
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
final connectionData = ref.watch(connectionStatusProvider).valueOrNull;
|
||||||
|
final dfuDevice = _dfuDevice;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||||
@ -50,6 +177,13 @@ class DevicesTabPage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
if (dfuDevice != null) ...[
|
||||||
|
_BootloaderRecoveryCard(
|
||||||
|
device: dfuDevice,
|
||||||
|
onStartRecovery: _openBootloaderRecovery,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
devicesAsync.when(
|
devicesAsync.when(
|
||||||
loading: () => const _LoadingCard(),
|
loading: () => const _LoadingCard(),
|
||||||
error: (error, _) => _MessageCard(
|
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<void> _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 {
|
class _SavedDevicesList extends ConsumerStatefulWidget {
|
||||||
const _SavedDevicesList();
|
const _SavedDevicesList();
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,6 @@ class HomePage extends StatelessWidget {
|
|||||||
style: TextStyle(fontSize: 20),
|
style: TextStyle(fontSize: 20),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// Devices Section
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -84,7 +83,7 @@ class DevicesList extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DevicesListState extends ConsumerState<DevicesList> {
|
class _DevicesListState extends ConsumerState<DevicesList> {
|
||||||
String? _connectingDeviceId; // ID of device currently being connected
|
String? _connectingDeviceId;
|
||||||
|
|
||||||
Future<void> _removeDevice(ConnectedDevice device) async {
|
Future<void> _removeDevice(ConnectedDevice device) async {
|
||||||
final shouldRemove = await showDialog<bool>(
|
final shouldRemove = await showDialog<bool>(
|
||||||
@ -197,10 +196,10 @@ class _DevicesListState extends ConsumerState<DevicesList> {
|
|||||||
context.go('/device/${device.deviceAddress}');
|
context.go('/device/${device.deviceAddress}');
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
'Connection failed. Is the device turned on and in range?'),
|
'Connection failed. Is the device turned on and in range?'),
|
||||||
duration: const Duration(seconds: 3),
|
duration: Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'
|
|||||||
class FirmwareUpdateService {
|
class FirmwareUpdateService {
|
||||||
FirmwareUpdateService({
|
FirmwareUpdateService({
|
||||||
required FirmwareUpdateTransport transport,
|
required FirmwareUpdateTransport transport,
|
||||||
|
this.verifyAfterFinish = true,
|
||||||
this.defaultStatusTimeout = const Duration(seconds: 2),
|
this.defaultStatusTimeout = const Duration(seconds: 2),
|
||||||
this.defaultBootloaderConnectTimeout = const Duration(seconds: 60),
|
this.defaultBootloaderConnectTimeout = const Duration(seconds: 60),
|
||||||
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
|
this.defaultPostFinishResetTimeout = const Duration(seconds: 8),
|
||||||
@ -22,6 +23,7 @@ class FirmwareUpdateService {
|
|||||||
}) : _transport = transport;
|
}) : _transport = transport;
|
||||||
|
|
||||||
final FirmwareUpdateTransport _transport;
|
final FirmwareUpdateTransport _transport;
|
||||||
|
final bool verifyAfterFinish;
|
||||||
final Duration defaultStatusTimeout;
|
final Duration defaultStatusTimeout;
|
||||||
final Duration defaultBootloaderConnectTimeout;
|
final Duration defaultBootloaderConnectTimeout;
|
||||||
final Duration defaultPostFinishResetTimeout;
|
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);
|
_emitProgress(state: DfuUpdateState.verifying);
|
||||||
final reconnectResult = await _transport.reconnectForVerification(
|
final reconnectResult = await _transport.reconnectForVerification(
|
||||||
timeout: effectiveReconnectTimeout,
|
timeout: effectiveReconnectTimeout,
|
||||||
@ -831,6 +842,14 @@ class ShifterFirmwareUpdateTransport implements FirmwareUpdateTransport {
|
|||||||
return Ok(null);
|
return Ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shifterService == null) {
|
||||||
|
_bootloaderDeviceId = buttonDeviceId;
|
||||||
|
return bluetoothController.connectById(
|
||||||
|
buttonDeviceId,
|
||||||
|
timeout: timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final scanResult = await _scanForBootloader(timeout: timeout);
|
final scanResult = await _scanForBootloader(timeout: timeout);
|
||||||
if (scanResult.isErr()) {
|
if (scanResult.isErr()) {
|
||||||
return Err(scanResult.unwrapErr());
|
return Err(scanResult.unwrapErr());
|
||||||
|
|||||||
247
lib/widgets/firmware_update_fullscreen.dart
Normal file
247
lib/widgets/firmware_update_fullscreen.dart
Normal file
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user