feat(dfu): add firmware update controls to device page
This commit is contained in:
@ -1,6 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:abawo_bt_app/model/firmware_file_selection.dart';
|
||||||
import 'package:abawo_bt_app/model/shifter_types.dart';
|
import 'package:abawo_bt_app/model/shifter_types.dart';
|
||||||
|
import 'package:abawo_bt_app/service/firmware_file_selection_service.dart';
|
||||||
|
import 'package:abawo_bt_app/service/firmware_update_service.dart';
|
||||||
import 'package:abawo_bt_app/service/shifter_service.dart';
|
import 'package:abawo_bt_app/service/shifter_service.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/gear_ratio_editor_card.dart';
|
import 'package:abawo_bt_app/widgets/gear_ratio_editor_card.dart';
|
||||||
@ -64,9 +67,46 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
List<double> _gearRatios = const [];
|
List<double> _gearRatios = const [];
|
||||||
int _defaultGearIndex = 0;
|
int _defaultGearIndex = 0;
|
||||||
|
|
||||||
|
late final FirmwareFileSelectionService _firmwareFileSelectionService;
|
||||||
|
FirmwareUpdateService? _firmwareUpdateService;
|
||||||
|
StreamSubscription<DfuUpdateProgress>? _firmwareProgressSubscription;
|
||||||
|
DfuV1PreparedFirmware? _selectedFirmware;
|
||||||
|
DfuUpdateProgress _dfuProgress = const DfuUpdateProgress(
|
||||||
|
state: DfuUpdateState.idle,
|
||||||
|
totalBytes: 0,
|
||||||
|
sentBytes: 0,
|
||||||
|
lastAckedSequence: 0xFF,
|
||||||
|
sessionId: 0,
|
||||||
|
flags: DfuUpdateFlags(),
|
||||||
|
);
|
||||||
|
bool _isSelectingFirmware = false;
|
||||||
|
bool _isStartingFirmwareUpdate = false;
|
||||||
|
String? _firmwareUserMessage;
|
||||||
|
|
||||||
|
bool get _isFirmwareUpdateBusy {
|
||||||
|
if (_isStartingFirmwareUpdate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
switch (_dfuProgress.state) {
|
||||||
|
case DfuUpdateState.starting:
|
||||||
|
case DfuUpdateState.waitingForAck:
|
||||||
|
case DfuUpdateState.transferring:
|
||||||
|
case DfuUpdateState.finishing:
|
||||||
|
return true;
|
||||||
|
case DfuUpdateState.idle:
|
||||||
|
case DfuUpdateState.completed:
|
||||||
|
case DfuUpdateState.aborted:
|
||||||
|
case DfuUpdateState.failed:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_firmwareFileSelectionService = FirmwareFileSelectionService(
|
||||||
|
filePicker: LocalFirmwareFilePicker(),
|
||||||
|
);
|
||||||
_connectionStatusSubscription =
|
_connectionStatusSubscription =
|
||||||
ref.listenManual<AsyncValue<(ConnectionStatus, String?)>>(
|
ref.listenManual<AsyncValue<(ConnectionStatus, String?)>>(
|
||||||
connectionStatusProvider,
|
connectionStatusProvider,
|
||||||
@ -88,6 +128,8 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
_connectionStatusSubscription?.close();
|
_connectionStatusSubscription?.close();
|
||||||
_statusSubscription?.cancel();
|
_statusSubscription?.cancel();
|
||||||
_shifterService?.dispose();
|
_shifterService?.dispose();
|
||||||
|
_firmwareProgressSubscription?.cancel();
|
||||||
|
unawaited(_firmwareUpdateService?.dispose() ?? Future<void>.value());
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +142,9 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
_isExitingPage = true;
|
_isExitingPage = true;
|
||||||
_reconnectTimeoutTimer?.cancel();
|
_reconnectTimeoutTimer?.cancel();
|
||||||
|
|
||||||
|
await _firmwareUpdateService?.cancelUpdate();
|
||||||
|
await _disposeFirmwareUpdateService();
|
||||||
|
|
||||||
final bluetooth = ref.read(bluetoothProvider).value;
|
final bluetooth = ref.read(bluetoothProvider).value;
|
||||||
await bluetooth?.disconnect();
|
await bluetooth?.disconnect();
|
||||||
await _stopStatusStreaming();
|
await _stopStatusStreaming();
|
||||||
@ -127,11 +172,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
|
|
||||||
if (_wasConnectedToCurrentDevice &&
|
if (_wasConnectedToCurrentDevice &&
|
||||||
!_isReconnecting &&
|
!_isReconnecting &&
|
||||||
status == ConnectionStatus.disconnected) {
|
status == ConnectionStatus.disconnected &&
|
||||||
|
!_isFirmwareUpdateBusy) {
|
||||||
_startReconnect();
|
_startReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCurrentDevice || status == ConnectionStatus.disconnected) {
|
if ((!isCurrentDevice || status == ConnectionStatus.disconnected) &&
|
||||||
|
!_isFirmwareUpdateBusy) {
|
||||||
_stopStatusStreaming();
|
_stopStatusStreaming();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -161,6 +208,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
|
|
||||||
Future<void> _startStatusStreamingIfNeeded() async {
|
Future<void> _startStatusStreamingIfNeeded() async {
|
||||||
if (_shifterService != null) {
|
if (_shifterService != null) {
|
||||||
|
_statusSubscription ??= _shifterService!.statusStream.listen((status) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recordStatus(status);
|
||||||
|
});
|
||||||
|
_shifterService!.startStatusNotifications();
|
||||||
if (!_hasLoadedGearRatios && !_isGearRatiosLoading) {
|
if (!_hasLoadedGearRatios && !_isGearRatiosLoading) {
|
||||||
unawaited(_loadGearRatios());
|
unawaited(_loadGearRatios());
|
||||||
}
|
}
|
||||||
@ -216,13 +270,26 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
Future<void> _stopStatusStreaming() async {
|
Future<void> _stopStatusStreaming() async {
|
||||||
await _statusSubscription?.cancel();
|
await _statusSubscription?.cancel();
|
||||||
_statusSubscription = null;
|
_statusSubscription = null;
|
||||||
|
|
||||||
|
if (_isFirmwareUpdateBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _disposeFirmwareUpdateService();
|
||||||
await _shifterService?.dispose();
|
await _shifterService?.dispose();
|
||||||
_shifterService = null;
|
_shifterService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _disposeFirmwareUpdateService() async {
|
||||||
|
await _firmwareProgressSubscription?.cancel();
|
||||||
|
_firmwareProgressSubscription = null;
|
||||||
|
await _firmwareUpdateService?.dispose();
|
||||||
|
_firmwareUpdateService = null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadGearRatios() async {
|
Future<void> _loadGearRatios() async {
|
||||||
final shifter = _shifterService;
|
final shifter = _shifterService;
|
||||||
if (shifter == null || _isGearRatiosLoading) {
|
if (shifter == null || _isGearRatiosLoading || _isFirmwareUpdateBusy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -257,6 +324,10 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
|
|
||||||
Future<String?> _saveGearRatios(
|
Future<String?> _saveGearRatios(
|
||||||
List<double> ratios, int defaultGearIndex) async {
|
List<double> ratios, int defaultGearIndex) async {
|
||||||
|
if (_isFirmwareUpdateBusy) {
|
||||||
|
return 'Gear ratio changes are disabled during firmware update.';
|
||||||
|
}
|
||||||
|
|
||||||
final shifter = _shifterService;
|
final shifter = _shifterService;
|
||||||
if (shifter == null) {
|
if (shifter == null) {
|
||||||
return 'Status channel is not ready yet.';
|
return 'Status channel is not ready yet.';
|
||||||
@ -289,6 +360,15 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _connectButtonToBike() async {
|
Future<void> _connectButtonToBike() async {
|
||||||
|
if (_isFirmwareUpdateBusy) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Connect to bike is disabled during firmware updates.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final selectedBike = await BikeScanDialog.show(
|
final selectedBike = await BikeScanDialog.show(
|
||||||
context,
|
context,
|
||||||
excludedDeviceId: widget.deviceAddress,
|
excludedDeviceId: widget.deviceAddress,
|
||||||
@ -328,6 +408,177 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<FirmwareUpdateService?> _ensureFirmwareUpdateService() async {
|
||||||
|
final shifter = _shifterService;
|
||||||
|
if (shifter == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_firmwareUpdateService != null) {
|
||||||
|
return _firmwareUpdateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
final asyncBluetooth = ref.read(bluetoothProvider);
|
||||||
|
final bluetooth = asyncBluetooth.valueOrNull;
|
||||||
|
if (bluetooth == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final service = FirmwareUpdateService(
|
||||||
|
transport: ShifterFirmwareUpdateTransport(
|
||||||
|
shifterService: shifter,
|
||||||
|
bluetoothController: bluetooth,
|
||||||
|
buttonDeviceId: widget.deviceAddress,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_firmwareProgressSubscription = service.progressStream.listen((progress) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_dfuProgress = progress;
|
||||||
|
if (progress.state == DfuUpdateState.failed &&
|
||||||
|
progress.errorMessage != null) {
|
||||||
|
_firmwareUserMessage = progress.errorMessage;
|
||||||
|
}
|
||||||
|
if (progress.state == DfuUpdateState.completed) {
|
||||||
|
_firmwareUserMessage =
|
||||||
|
'Firmware update completed. The button rebooted and reconnected.';
|
||||||
|
}
|
||||||
|
if (progress.state == DfuUpdateState.aborted) {
|
||||||
|
_firmwareUserMessage = 'Firmware update canceled.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
_firmwareUpdateService = service;
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectFirmwareFile() async {
|
||||||
|
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSelectingFirmware = true;
|
||||||
|
_firmwareUserMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await _firmwareFileSelectionService.selectAndPrepareDfuV1();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSelectingFirmware = false;
|
||||||
|
if (result.isSuccess) {
|
||||||
|
_selectedFirmware = result.firmware;
|
||||||
|
_firmwareUserMessage =
|
||||||
|
'Selected ${result.firmware!.fileName}. Ready to start update.';
|
||||||
|
} else if (!result.isCanceled) {
|
||||||
|
_firmwareUserMessage = result.failure?.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startFirmwareUpdate() async {
|
||||||
|
if (_isFirmwareUpdateBusy || _isSelectingFirmware) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final firmware = _selectedFirmware;
|
||||||
|
if (firmware == null) {
|
||||||
|
setState(() {
|
||||||
|
_firmwareUserMessage =
|
||||||
|
'Select a firmware .bin file before starting the update.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _startStatusStreamingIfNeeded();
|
||||||
|
final updater = await _ensureFirmwareUpdateService();
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (updater == null) {
|
||||||
|
setState(() {
|
||||||
|
_firmwareUserMessage =
|
||||||
|
'Firmware updater is not ready. Ensure the button is connected.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isStartingFirmwareUpdate = true;
|
||||||
|
_firmwareUserMessage =
|
||||||
|
'Starting update. Keep this screen open and stay near the button.';
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await updater.startUpdate(
|
||||||
|
imageBytes: firmware.fileBytes,
|
||||||
|
sessionId: firmware.metadata.sessionId,
|
||||||
|
flags: DfuUpdateFlags.fromRaw(firmware.metadata.flags),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isStartingFirmwareUpdate = false;
|
||||||
|
if (result.isErr()) {
|
||||||
|
_firmwareUserMessage = result.unwrapErr().toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelFirmwareUpdate() async {
|
||||||
|
final updater = _firmwareUpdateService;
|
||||||
|
if (updater == null || !_isFirmwareUpdateBusy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_firmwareUserMessage = 'Canceling firmware update...';
|
||||||
|
});
|
||||||
|
await updater.cancelUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _dfuPhaseText(DfuUpdateState state) {
|
||||||
|
switch (state) {
|
||||||
|
case DfuUpdateState.idle:
|
||||||
|
return 'Idle';
|
||||||
|
case DfuUpdateState.starting:
|
||||||
|
return 'Sending START command';
|
||||||
|
case DfuUpdateState.waitingForAck:
|
||||||
|
return 'Waiting for ACK from button';
|
||||||
|
case DfuUpdateState.transferring:
|
||||||
|
return 'Transferring firmware frames';
|
||||||
|
case DfuUpdateState.finishing:
|
||||||
|
return 'Finalizing update and waiting for reboot/reconnect';
|
||||||
|
case DfuUpdateState.completed:
|
||||||
|
return 'Update completed';
|
||||||
|
case DfuUpdateState.aborted:
|
||||||
|
return 'Update canceled';
|
||||||
|
case DfuUpdateState.failed:
|
||||||
|
return 'Update failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return '$bytes B';
|
||||||
|
}
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _hexByte(int value) {
|
||||||
|
return '0x${(value & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()}';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
|
Future<void> _terminateConnectionAndGoHome(String toastMessage) async {
|
||||||
await _disconnectOnClose();
|
await _disconnectOnClose();
|
||||||
|
|
||||||
@ -465,6 +716,13 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
final isCurrentConnected = connectionData != null &&
|
final isCurrentConnected = connectionData != null &&
|
||||||
connectionData.$1 == ConnectionStatus.connected &&
|
connectionData.$1 == ConnectionStatus.connected &&
|
||||||
connectionData.$2 == widget.deviceAddress;
|
connectionData.$2 == widget.deviceAddress;
|
||||||
|
final canSelectFirmware =
|
||||||
|
isCurrentConnected && !_isSelectingFirmware && !_isFirmwareUpdateBusy;
|
||||||
|
final canStartFirmware = isCurrentConnected &&
|
||||||
|
!_isSelectingFirmware &&
|
||||||
|
!_isFirmwareUpdateBusy &&
|
||||||
|
_selectedFirmware != null;
|
||||||
|
final canCancelFirmware = _isFirmwareUpdateBusy;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: false,
|
canPop: false,
|
||||||
@ -508,27 +766,53 @@ class _DeviceDetailsPageState extends ConsumerState<DeviceDetailsPage> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _connectButtonToBike,
|
onPressed:
|
||||||
|
_isFirmwareUpdateBusy ? null : _connectButtonToBike,
|
||||||
icon: const Icon(Icons.link),
|
icon: const Icon(Icons.link),
|
||||||
label: const Text('Connect Button to Bike'),
|
label: const Text('Connect Button to Bike'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
GearRatioEditorCard(
|
_FirmwareUpdateCard(
|
||||||
ratios: _gearRatios,
|
selectedFirmware: _selectedFirmware,
|
||||||
defaultGearIndex: _defaultGearIndex,
|
progress: _dfuProgress,
|
||||||
isLoading: _isGearRatiosLoading,
|
isSelecting: _isSelectingFirmware,
|
||||||
errorText: _gearRatiosError,
|
isStarting: _isStartingFirmwareUpdate,
|
||||||
onRetry: _loadGearRatios,
|
canSelect: canSelectFirmware,
|
||||||
onSave: _saveGearRatios,
|
canStart: canStartFirmware,
|
||||||
presets: const [
|
canCancel: canCancelFirmware,
|
||||||
GearRatioPreset(
|
phaseText: _dfuPhaseText(_dfuProgress.state),
|
||||||
name: 'KeAnt Classic',
|
statusText: _firmwareUserMessage,
|
||||||
description:
|
formattedProgressBytes:
|
||||||
'17-step baseline from KeAnt cross app gearing.',
|
'${_formatBytes(_dfuProgress.sentBytes)} / ${_formatBytes(_dfuProgress.totalBytes)}',
|
||||||
ratios: _keAntRatios,
|
onSelectFirmware: _selectFirmwareFile,
|
||||||
|
onStartUpdate: _startFirmwareUpdate,
|
||||||
|
onCancelUpdate: _cancelFirmwareUpdate,
|
||||||
|
ackSequenceHex: _hexByte(_dfuProgress.lastAckedSequence),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Opacity(
|
||||||
|
opacity: _isFirmwareUpdateBusy ? 0.6 : 1,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
absorbing: _isFirmwareUpdateBusy,
|
||||||
|
child: GearRatioEditorCard(
|
||||||
|
ratios: _gearRatios,
|
||||||
|
defaultGearIndex: _defaultGearIndex,
|
||||||
|
isLoading: _isGearRatiosLoading,
|
||||||
|
errorText: _gearRatiosError,
|
||||||
|
onRetry:
|
||||||
|
_isFirmwareUpdateBusy ? null : _loadGearRatios,
|
||||||
|
onSave: _saveGearRatios,
|
||||||
|
presets: const [
|
||||||
|
GearRatioPreset(
|
||||||
|
name: 'KeAnt Classic',
|
||||||
|
description:
|
||||||
|
'17-step baseline from KeAnt cross app gearing.',
|
||||||
|
ratios: _keAntRatios,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -583,6 +867,159 @@ class _StatusHistoryEntry {
|
|||||||
final CentralStatus status;
|
final CentralStatus status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FirmwareUpdateCard extends StatelessWidget {
|
||||||
|
const _FirmwareUpdateCard({
|
||||||
|
required this.selectedFirmware,
|
||||||
|
required this.progress,
|
||||||
|
required this.isSelecting,
|
||||||
|
required this.isStarting,
|
||||||
|
required this.canSelect,
|
||||||
|
required this.canStart,
|
||||||
|
required this.canCancel,
|
||||||
|
required this.phaseText,
|
||||||
|
required this.statusText,
|
||||||
|
required this.formattedProgressBytes,
|
||||||
|
required this.ackSequenceHex,
|
||||||
|
required this.onSelectFirmware,
|
||||||
|
required this.onStartUpdate,
|
||||||
|
required this.onCancelUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DfuV1PreparedFirmware? selectedFirmware;
|
||||||
|
final DfuUpdateProgress progress;
|
||||||
|
final bool isSelecting;
|
||||||
|
final bool isStarting;
|
||||||
|
final bool canSelect;
|
||||||
|
final bool canStart;
|
||||||
|
final bool canCancel;
|
||||||
|
final String phaseText;
|
||||||
|
final String? statusText;
|
||||||
|
final String formattedProgressBytes;
|
||||||
|
final String ackSequenceHex;
|
||||||
|
final Future<void> Function() onSelectFirmware;
|
||||||
|
final Future<void> Function() onStartUpdate;
|
||||||
|
final Future<void> Function() onCancelUpdate;
|
||||||
|
|
||||||
|
bool get _showProgress {
|
||||||
|
return progress.totalBytes > 0 ||
|
||||||
|
progress.sentBytes > 0 ||
|
||||||
|
progress.state != DfuUpdateState.idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _showRebootExpectation {
|
||||||
|
return progress.state == DfuUpdateState.finishing ||
|
||||||
|
progress.state == DfuUpdateState.completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Firmware Update',
|
||||||
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: canSelect ? onSelectFirmware : null,
|
||||||
|
icon: isSelecting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.upload_file),
|
||||||
|
label: const Text('Select Firmware'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: canStart ? onStartUpdate : null,
|
||||||
|
icon: isStarting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.system_update_alt),
|
||||||
|
label: const Text('Start Update'),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: canCancel ? onCancelUpdate : null,
|
||||||
|
icon: const Icon(Icons.stop_circle_outlined),
|
||||||
|
label: const Text('Cancel Update'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
selectedFirmware == null
|
||||||
|
? 'Selected file: none'
|
||||||
|
: 'Selected file: ${selectedFirmware!.fileName}',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
if (selectedFirmware != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Size: ${selectedFirmware!.fileBytes.length} bytes | Session: ${selectedFirmware!.metadata.sessionId} | CRC32: 0x${selectedFirmware!.metadata.crc32.toRadixString(16).padLeft(8, '0').toUpperCase()}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text('Phase: $phaseText', style: theme.textTheme.bodyMedium),
|
||||||
|
if (_showProgress) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: progress.totalBytes > 0 ? progress.fractionComplete : 0,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'${progress.percentComplete}% • $formattedProgressBytes • Last ACK $ackSequenceHex',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_showRebootExpectation) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Expected behavior: the button reboots after FINISH, disconnects briefly, then reconnects.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (statusText != null && statusText!.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
statusText!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: progress.state == DfuUpdateState.failed
|
||||||
|
? colorScheme.error
|
||||||
|
: theme.textTheme.bodySmall?.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _StatusBanner extends StatelessWidget {
|
class _StatusBanner extends StatelessWidget {
|
||||||
const _StatusBanner({
|
const _StatusBanner({
|
||||||
required this.status,
|
required this.status,
|
||||||
|
|||||||
Reference in New Issue
Block a user