diff --git a/open_wearable/lib/router.dart b/open_wearable/lib/router.dart index 6e69c56c..1ef9a8e6 100644 --- a/open_wearable/lib/router.dart +++ b/open_wearable/lib/router.dart @@ -4,6 +4,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/widgets/devices/connect_devices_page.dart'; import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; import 'package:open_wearable/widgets/fota/firmware_update.dart'; +import 'package:open_wearable/widgets/fota/fota_slots_page.dart'; import 'package:open_wearable/widgets/fota/fota_warning_page.dart'; import 'package:open_wearable/widgets/home_page.dart'; import 'package:open_wearable/widgets/logging/log_files_screen.dart'; @@ -180,6 +181,18 @@ final GoRouter router = GoRouter( }, ), ), + GoRoute( + path: '/fota/slots', + name: 'fota/slots', + builder: (context, state) { + final device = state.extra; + if (device is! Wearable) { + return const HomePage(); + } + + return FotaSlotsPage(device: device); + }, + ), GoRoute( path: '/view', name: 'view', diff --git a/open_wearable/lib/view_models/wearables_provider.dart b/open_wearable/lib/view_models/wearables_provider.dart index 4b9d9b51..f81bba4e 100644 --- a/open_wearable/lib/view_models/wearables_provider.dart +++ b/open_wearable/lib/view_models/wearables_provider.dart @@ -411,12 +411,19 @@ class WearablesProvider with ChangeNotifier { /// Non-blocking for the caller. Future _checkForNewerFirmwareAsync(DeviceFirmwareVersion dev) async { try { - logger.d('Checking for newer firmware for ${(dev as Wearable).name}'); + final wearable = dev as Wearable; + if (!wearable.hasCapability()) { + logger.d( + 'Skipping firmware update availability check for ${wearable.name}: no FOTA capability registered', + ); + return; + } + + logger.d('Checking for newer firmware for ${wearable.name}'); final currentVersion = await dev.readDeviceFirmwareVersion(); if (currentVersion == null || currentVersion.isEmpty) { - logger - .d('Could not read firmware version for ${(dev as Wearable).name}'); + logger.d('Could not read firmware version for ${wearable.name}'); return; } @@ -430,18 +437,18 @@ class WearablesProvider with ChangeNotifier { currentVersion, )) { logger.i( - 'Newer firmware available for ${(dev as Wearable).name}: $currentVersion -> $latestVersion', + 'Newer firmware available for ${wearable.name}: $currentVersion -> $latestVersion', ); _emitWearableEvent( NewFirmwareAvailableEvent( - wearable: dev as Wearable, + wearable: wearable, currentVersion: currentVersion, latestVersion: latestVersion, ), ); } else { logger.d( - 'Firmware is up to date for ${(dev as Wearable).name}: $currentVersion', + 'Firmware is up to date for ${wearable.name}: $currentVersion', ); } } catch (e, st) { diff --git a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart index 96ed26e3..1dc1fde0 100644 --- a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart +++ b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; @@ -38,6 +40,7 @@ class _DeviceDetailPageState extends State { 'edu.kit.teco.open_wearable/system_settings', ); + StreamSubscription>? _capabilitySubscription; Future? _deviceIdentifierFuture; Future? _firmwareVersionFuture; Future? _firmwareSupportFuture; @@ -46,6 +49,7 @@ class _DeviceDetailPageState extends State { @override void initState() { super.initState(); + _attachCapabilityListener(); _prepareAsyncData(); } @@ -53,10 +57,27 @@ class _DeviceDetailPageState extends State { void didUpdateWidget(covariant DeviceDetailPage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.device != widget.device) { + _capabilitySubscription?.cancel(); + _attachCapabilityListener(); _prepareAsyncData(); } } + /// Rebuilds the page when new capabilities are registered after the page has + /// already been opened. + void _attachCapabilityListener() { + _capabilitySubscription = + widget.device.capabilityRegistered.listen((addedCapabilities) { + if (!mounted || addedCapabilities.isEmpty) { + return; + } + + setState(_prepareAsyncData); + }); + } + + /// Refreshes per-device metadata futures based on currently available + /// capabilities. void _prepareAsyncData() { _deviceIdentifierFuture = widget.device.hasCapability() ? widget.device @@ -79,6 +100,13 @@ class _DeviceDetailPageState extends State { return defaultTargetPlatform == TargetPlatform.android; } + /// Returns whether the device currently exposes the new FOTA capability. + bool get _supportsFota => widget.device.hasCapability(); + + /// Returns whether the device can report firmware image slot metadata. + bool get _supportsFotaSlotInfo => + widget.device.hasCapability(); + Future _openBluetoothSettings() async { bool opened = false; try { @@ -170,6 +198,10 @@ class _DeviceDetailPageState extends State { } void _openFirmwareUpdate() { + if (!_supportsFota) { + return; + } + Provider.of( context, listen: false, @@ -177,6 +209,22 @@ class _DeviceDetailPageState extends State { context.push('/fota'); } + /// Opens the detailed image-slot page for the current device. + void _openFotaSlotsPage() { + if (!_supportsFotaSlotInfo) { + return; + } + + context.push('/fota/slots', extra: widget.device); + } + + @override + void dispose() { + _capabilitySubscription?.cancel(); + _capabilitySubscription = null; + super.dispose(); + } + @override Widget build(BuildContext context) { final sections = [ @@ -203,6 +251,7 @@ class _DeviceDetailPageState extends State { ), ), _buildInfoCard(context), + if (_supportsFotaSlotInfo) _buildFotaDetailsCard(), if (widget.device.hasCapability() && widget.device.hasCapability()) AppSectionCard( @@ -383,9 +432,11 @@ class _DeviceDetailPageState extends State { DetailInfoRow( label: 'Firmware Version', value: _buildFirmwareVersionValue(), - trailing: FirmwareTableUpdateHint( - onTap: _openFirmwareUpdate, - ), + trailing: _supportsFota + ? FirmwareTableUpdateHint( + onTap: _openFirmwareUpdate, + ) + : null, showDivider: hasHardware, ), if (hasHardware) @@ -401,6 +452,19 @@ class _DeviceDetailPageState extends State { ); } + /// Builds the entry card for the dedicated FOTA image-slot page. + Widget _buildFotaDetailsCard() { + return AppSectionCard( + title: 'Firmware Update', + subtitle: 'Inspect the device-reported image slots and boot flags.', + child: NavigationSurface( + title: 'Image Slots', + subtitle: 'Show detailed information about the firmware image slots.', + onTap: _openFotaSlotsPage, + ), + ); + } + Widget _buildFirmwareVersionValue() { return Row( children: [ diff --git a/open_wearable/lib/widgets/devices/device_detail/device_detail_shared_widgets.dart b/open_wearable/lib/widgets/devices/device_detail/device_detail_shared_widgets.dart index 83ba9316..c354fb9b 100644 --- a/open_wearable/lib/widgets/devices/device_detail/device_detail_shared_widgets.dart +++ b/open_wearable/lib/widgets/devices/device_detail/device_detail_shared_widgets.dart @@ -57,6 +57,76 @@ class ActionSurface extends StatelessWidget { } } +/// A compact card-like row that navigates to deeper detail screens. +class NavigationSurface extends StatelessWidget { + final String title; + final String subtitle; + final VoidCallback onTap; + final IconData leadingIcon; + + const NavigationSurface({ + super.key, + required this.title, + required this.subtitle, + required this.onTap, + this.leadingIcon = Icons.chevron_right_rounded, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Ink( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Icon( + leadingIcon, + size: 20, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} + class DetailInfoRow extends StatelessWidget { final String label; final Widget value; diff --git a/open_wearable/lib/widgets/fota/fota_slots_page.dart b/open_wearable/lib/widgets/fota/fota_slots_page.dart new file mode 100644 index 00000000..cf21fbc7 --- /dev/null +++ b/open_wearable/lib/widgets/fota/fota_slots_page.dart @@ -0,0 +1,626 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/common/app_section_card.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Detailed screen for firmware image slot metadata exposed by a wearable. +class FotaSlotsPage extends StatefulWidget { + final Wearable device; + + const FotaSlotsPage({ + super.key, + required this.device, + }); + + @override + State createState() => _FotaSlotsPageState(); +} + +class _FotaSlotsPageState extends State { + static final Uri _mcumgrWebUri = + Uri.parse('https://boogie.github.io/mcumgr-web/'); + + late Future> _slotFuture; + + @override + void initState() { + super.initState(); + _slotFuture = _loadSlots(); + } + + /// Reads the current slot snapshot from the device capability. + Future> _loadSlots() async { + if (!widget.device.hasCapability()) { + return const []; + } + + final capability = + widget.device.requireCapability(); + final slots = await capability.readFirmwareSlots(); + final sortedSlots = [...slots]..sort((a, b) { + final imageCompare = a.image.compareTo(b.image); + if (imageCompare != 0) { + return imageCompare; + } + return a.slot.compareTo(b.slot); + }); + return sortedSlots; + } + + /// Triggers a fresh device read and rebuilds the page state. + Future _refreshSlots() async { + final future = _loadSlots(); + setState(() { + _slotFuture = future; + }); + await future; + } + + /// Opens the external mcumgr web UI that can help erase image slots. + Future _openMcumgrWeb() async { + final opened = await launchUrl( + _mcumgrWebUri, + mode: LaunchMode.externalApplication, + ); + if (opened || !mounted) { + return; + } + + AppToast.show( + context, + message: 'Could not open mcumgr web.', + type: AppToastType.error, + icon: Icons.link_off_rounded, + ); + } + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Image Slots'), + ), + body: FutureBuilder>( + future: _slotFuture, + builder: (context, snapshot) { + final slots = snapshot.data ?? const []; + + return RefreshIndicator( + onRefresh: _refreshSlots, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + _SlotsOverviewCard( + device: widget.device, + slots: slots, + isLoading: + snapshot.connectionState == ConnectionState.waiting, + ), + const SizedBox(height: SensorPageSpacing.sectionGap), + if (snapshot.connectionState == ConnectionState.waiting) + const _SlotsLoadingCard() + else if (snapshot.hasError) + _SlotsErrorCard(onRetry: _refreshSlots) + else if (slots.isEmpty) + const _SlotsEmptyCard() + else + ..._buildImageSections(context, slots), + const SizedBox(height: SensorPageSpacing.sectionGap), + _SlotRecoveryCard(onOpenTool: _openMcumgrWeb), + ], + ), + ); + }, + ), + ); + } + + /// Builds grouped sections per firmware image index. + List _buildImageSections( + BuildContext context, + List slots, + ) { + final widgets = []; + final slotsByImage = >{}; + + for (final slot in slots) { + slotsByImage + .putIfAbsent(slot.image, () => []) + .add(slot); + } + + final imageIds = slotsByImage.keys.toList()..sort(); + for (var index = 0; index < imageIds.length; index++) { + final imageId = imageIds[index]; + final imageSlots = slotsByImage[imageId]!; + widgets.add( + AppSectionCard( + title: 'Image $imageId', + subtitle: + '${imageSlots.length} reported slot${imageSlots.length == 1 ? '' : 's'}.', + child: Column( + children: [ + for (var slotIndex = 0; + slotIndex < imageSlots.length; + slotIndex++) ...[ + _SlotTile(slot: imageSlots[slotIndex]), + if (slotIndex < imageSlots.length - 1) + const SizedBox(height: SensorPageSpacing.sectionGap), + ], + ], + ), + ), + ); + + if (index < imageIds.length - 1) { + widgets.add(const SizedBox(height: SensorPageSpacing.sectionGap)); + } + } + + return widgets; + } +} + +/// Recovery card that points users to an external slot-erasing tool. +class _SlotRecoveryCard extends StatelessWidget { + final Future Function() onOpenTool; + + const _SlotRecoveryCard({ + required this.onOpenTool, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const warningBackground = Color(0xFFFFECEC); + const warningForeground = Color(0xFF8A1C1C); + + return AppSectionCard( + title: 'Recovery', + subtitle: 'Use this if firmware update stops working correctly.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: warningBackground, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + Icons.warning_amber_rounded, + size: 18, + color: warningForeground, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Erasing the slots resets the FOTA state and may help recover devices when FOTA is stuck or not working correctly.', + style: theme.textTheme.bodyMedium?.copyWith( + color: warningForeground, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + 'A possible tool for this is mcumgr web. If the device is in a broken update state, erasing the slots can clear the image table and let you start the firmware update flow again.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'Note: It may be necessary to remove the wearable from the app and settings in order to discover it in the mcumgr web tool.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: onOpenTool, + icon: const Icon(Icons.open_in_new_rounded, size: 18), + label: const Text('Open mcumgr web'), + ), + ), + ], + ), + ); + } +} + +/// Summary card shown above the detailed slot list. +class _SlotsOverviewCard extends StatelessWidget { + final Wearable device; + final List slots; + final bool isLoading; + + const _SlotsOverviewCard({ + required this.device, + required this.slots, + required this.isLoading, + }); + + @override + Widget build(BuildContext context) { + final activeSlots = slots.where((slot) => slot.active).length; + final pendingSlots = slots.where((slot) => slot.pending).length; + final confirmedSlots = slots.where((slot) => slot.confirmed).length; + + return AppSectionCard( + title: 'Firmware Slot State', + subtitle: 'Live MCUboot image table details reported by the device.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + formatWearableDisplayName(device.name), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _SummaryChip( + label: '${slots.length} slots', + tone: _SummaryChipTone.neutral, + ), + _SummaryChip( + label: '$activeSlots active', + tone: _SummaryChipTone.good, + ), + _SummaryChip( + label: '$pendingSlots pending', + tone: pendingSlots > 0 + ? _SummaryChipTone.warning + : _SummaryChipTone.neutral, + ), + _SummaryChip( + label: '$confirmedSlots confirmed', + tone: _SummaryChipTone.info, + ), + if (isLoading) + const _SummaryChip( + label: 'Refreshing', + tone: _SummaryChipTone.neutral, + ), + ], + ), + ], + ), + ); + } +} + +/// Loading state card for slot reads. +class _SlotsLoadingCard extends StatelessWidget { + const _SlotsLoadingCard(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AppSectionCard( + title: 'Reading Slots', + subtitle: 'Fetching the current firmware image table from the wearable.', + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'This usually finishes within a few seconds.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ); + } +} + +/// Empty state card shown when the device reports no image slots. +class _SlotsEmptyCard extends StatelessWidget { + const _SlotsEmptyCard(); + + @override + Widget build(BuildContext context) { + return AppSectionCard( + title: 'No Slot Data', + subtitle: 'The device did not return any firmware image slot entries.', + child: Text( + 'Try pulling to refresh after reconnecting if you expected slot data to be available.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } +} + +/// Error state card shown when the slot read fails. +class _SlotsErrorCard extends StatelessWidget { + final Future Function() onRetry; + + const _SlotsErrorCard({ + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AppSectionCard( + title: 'Could Not Read Slots', + subtitle: + 'The wearable rejected the request or the connection was interrupted.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Make sure the device stays connected, then try again.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Retry'), + ), + ], + ), + ); + } +} + +/// Visual card for one reported firmware slot. +class _SlotTile extends StatelessWidget { + final FirmwareSlotInfo slot; + + const _SlotTile({ + required this.slot, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.30), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Slot ${slot.slot}', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + slot.version?.trim().isNotEmpty == true + ? slot.version!.trim() + : 'Version unknown', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (slot.active) + const _StatusChip(label: 'Active', tone: _StatusChipTone.good), + if (slot.confirmed) + const _StatusChip( + label: 'Confirmed', + tone: _StatusChipTone.info, + ), + if (slot.pending) + const _StatusChip( + label: 'Pending', + tone: _StatusChipTone.warning, + ), + if (slot.permanent) + const _StatusChip( + label: 'Permanent', + tone: _StatusChipTone.info, + ), + _StatusChip( + label: slot.bootable ? 'Bootable' : 'Not Bootable', + tone: slot.bootable + ? _StatusChipTone.good + : _StatusChipTone.muted, + ), + ], + ), + const SizedBox(height: 12), + _SlotMetadataRow(label: 'Image', value: '${slot.image}'), + const SizedBox(height: 8), + _SlotMetadataRow(label: 'Hash', value: _formatHash(slot.hashString)), + ], + ), + ); + } + + /// Shortens the reported hash for dense mobile presentation. + String _formatHash(String hash) { + if (hash.length <= 20) { + return hash; + } + + return '${hash.substring(0, 10)}...${hash.substring(hash.length - 8)}'; + } +} + +/// Compact label-value row for slot metadata. +class _SlotMetadataRow extends StatelessWidget { + final String label; + final String value; + + const _SlotMetadataRow({ + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 58, + child: Text( + label, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SelectableText( + value, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + ), + ), + ), + ], + ); + } +} + +enum _StatusChipTone { good, info, warning, muted } + +/// Small status pill used on each slot card. +class _StatusChip extends StatelessWidget { + final String label; + final _StatusChipTone tone; + + const _StatusChip({ + required this.label, + required this.tone, + }); + + @override + Widget build(BuildContext context) { + final colors = _colorsFor(context, tone); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colors.$1, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.$2, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +enum _SummaryChipTone { neutral, good, info, warning } + +/// Summary pill used in the page overview card. +class _SummaryChip extends StatelessWidget { + final String label; + final _SummaryChipTone tone; + + const _SummaryChip({ + required this.label, + required this.tone, + }); + + @override + Widget build(BuildContext context) { + final mappedTone = switch (tone) { + _SummaryChipTone.good => _StatusChipTone.good, + _SummaryChipTone.info => _StatusChipTone.info, + _SummaryChipTone.warning => _StatusChipTone.warning, + _SummaryChipTone.neutral => _StatusChipTone.muted, + }; + final colors = _colorsFor(context, mappedTone); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colors.$1, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.$2, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +/// Resolves chip foreground and background colors for the current theme. +(Color, Color) _colorsFor(BuildContext context, _StatusChipTone tone) { + final colorScheme = Theme.of(context).colorScheme; + + return switch (tone) { + _StatusChipTone.good => ( + colorScheme.tertiaryContainer, + colorScheme.onTertiaryContainer, + ), + _StatusChipTone.info => ( + colorScheme.secondaryContainer, + colorScheme.onSecondaryContainer, + ), + _StatusChipTone.warning => ( + const Color(0xFFFFE7C2), + const Color(0xFF7A4B00), + ), + _StatusChipTone.muted => ( + colorScheme.surfaceContainerHighest, + colorScheme.onSurfaceVariant, + ), + }; +} diff --git a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart index bbd37841..a94c2f44 100644 --- a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart +++ b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/fota_post_update_verification.dart'; @@ -30,10 +31,14 @@ class UpdateStepView extends StatefulWidget { class _UpdateStepViewState extends State { static const Color _successGreen = Color(0xFF2E7D32); + static const int _resetValidateLoopTransitionThreshold = 3; bool _lastReportedRunning = false; bool _startRequested = false; bool _verificationBannerShown = false; + bool _loopWarningHandled = false; + String? _lastResetValidateStage; + int _resetValidateLoopTransitions = 0; @override void initState() { @@ -85,6 +90,11 @@ class _UpdateStepViewState extends State { return BlocConsumer( listener: (context, state) async { _reportRunningState(_isUpdateInProgress(state)); + final updateProvider = context.read(); + await _maybeShowLoopWarning( + state: state, + updateProvider: updateProvider, + ); if (state is UpdateFirmwareStateHistory && state.isComplete && state.history.isNotEmpty && @@ -93,7 +103,6 @@ class _UpdateStepViewState extends State { return; } _verificationBannerShown = true; - final updateProvider = context.read(); final armedVerification = await FotaPostUpdateVerificationCoordinator .instance .armFromUpdateRequest( @@ -133,6 +142,195 @@ class _UpdateStepViewState extends State { return true; } + /// Shows a one-time warning when the update appears to restart image uploads + /// more often than the selected firmware package should require. + Future _maybeShowLoopWarning({ + required UpdateState state, + required FirmwareUpdateRequestProvider updateProvider, + }) async { + if (_loopWarningHandled || !_isUpdateInProgress(state)) { + return; + } + + final currentState = state is UpdateFirmwareStateHistory + ? state.currentState + : state is UpdateFirmware + ? state + : null; + final stage = currentState?.stage; + + if (_looksLikeResetValidateLoop(stage)) { + await _showLoopWarningDialog( + updateProvider: updateProvider, + details: + 'The update appears to be bouncing between reset and validate. This can mean the wearable is stuck in a FOTA loop and there may be a problem.', + ); + return; + } + + if (currentState is! UpdateProgressFirmware) { + return; + } + + final expectedImageCount = + _expectedImageCount(updateProvider.updateParameters); + final suspectedLoopThreshold = expectedImageCount; + if (currentState.imageNumber <= suspectedLoopThreshold) { + return; + } + + await _showLoopWarningDialog( + updateProvider: updateProvider, + details: + 'The update appears to be repeating image uploads more often than expected. This can mean the wearable is stuck in a FOTA loop and there may be a problem.', + ); + } + + /// Tracks repeated `Reset <-> Validate` oscillation and returns true when the + /// update appears stuck between those two states. + bool _looksLikeResetValidateLoop(String? stage) { + final normalizedStage = stage?.trim().toLowerCase(); + final isLoopStage = + normalizedStage == 'reset' || normalizedStage == 'validate'; + + if (!isLoopStage) { + _lastResetValidateStage = null; + _resetValidateLoopTransitions = 0; + return false; + } + + if (_lastResetValidateStage == null) { + _lastResetValidateStage = normalizedStage; + return false; + } + + if (_lastResetValidateStage == normalizedStage) { + return false; + } + + _lastResetValidateStage = normalizedStage; + _resetValidateLoopTransitions++; + + return _resetValidateLoopTransitions >= + _resetValidateLoopTransitionThreshold; + } + + /// Presents the generic loop warning and optionally links to slot info. + Future _showLoopWarningDialog({ + required FirmwareUpdateRequestProvider updateProvider, + required String details, + }) async { + _loopWarningHandled = true; + final wearable = updateProvider.selectedWearable; + final supportsSlotInfo = + wearable?.hasCapability() ?? false; + + final action = await showPlatformDialog<_LoopWarningAction>( + context: context, + builder: (_) => PlatformAlertDialog( + title: const Text('Firmware update may be stuck'), + content: Text( + '$details\n\n' + '${supportsSlotInfo ? 'You can inspect the reported image slots for recovery hints, or ignore this warning and continue waiting.' : 'You can ignore this warning and continue waiting.'}', + ), + actions: [ + PlatformDialogAction( + child: const Text('Ignore'), + onPressed: () => + Navigator.of(context).pop(_LoopWarningAction.ignore), + ), + if (supportsSlotInfo) + PlatformDialogAction( + cupertino: (_, __) => CupertinoDialogActionData( + isDefaultAction: true, + ), + child: const Text('Open Image Slots'), + onPressed: () => + Navigator.of(context).pop(_LoopWarningAction.openSlots), + ), + ], + ), + ); + + if (!mounted || + action != _LoopWarningAction.openSlots || + wearable == null || + !supportsSlotInfo) { + return; + } + + context.read().add(AbortUpdate()); + context.push('/fota/slots', extra: wearable); + } + + /// Estimates how many image uploads the current firmware package should need. + int _expectedImageCount(FirmwareUpdateRequest request) { + if (request is SingleImageFirmwareUpdateRequest) { + return 1; + } + if (request is MultiImageFirmwareUpdateRequest) { + final imageCount = request.firmwareImages?.length; + if (imageCount != null && imageCount > 0) { + return imageCount; + } + return 2; + } + return 1; + } + + /// Requests explicit confirmation before aborting an active update. + Future _confirmAbortUpdate(BuildContext context) async { + final updateBloc = this.context.read(); + final shouldAbort = await showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: const Text('Abort firmware update?'), + content: const Text( + 'Aborting a firmware update can leave the device in an incomplete state until the update is started again.\n\nDo you want to abort the update now?', + ), + actions: [ + PlatformDialogAction( + child: const Text('Keep Updating'), + onPressed: () => Navigator.of(context).pop(false), + ), + PlatformDialogAction( + cupertino: (_, __) => CupertinoDialogActionData( + isDestructiveAction: true, + ), + child: const Text('Abort Update'), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + + if (!mounted || shouldAbort != true) { + return; + } + + updateBloc.add(AbortUpdate()); + } + + /// Builds the destructive abort action shown while an update is active. + Widget _abortButton(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _confirmAbortUpdate(context), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide( + color: colorScheme.error.withValues(alpha: 0.55), + ), + ), + icon: const Icon(Icons.cancel_outlined, size: 18), + label: const Text('Abort Update'), + ), + ); + } + Widget _buildInitial( BuildContext context, FirmwareUpdateRequest request, @@ -151,6 +349,8 @@ class _UpdateStepViewState extends State { _firmwareInfoCard(context, firmware), const SizedBox(height: 12), _buildPendingState(context, 'Starting update...'), + const SizedBox(height: 12), + _abortButton(context), ], ); } @@ -213,6 +413,10 @@ class _UpdateStepViewState extends State { _currentStatePanel(context, state), const SizedBox(height: 10), ], + if (!state.isComplete) ...[ + _abortButton(context), + const SizedBox(height: 10), + ], if (showSuccessMessage) ...[ _successPanel(context), const SizedBox(height: 10), @@ -482,3 +686,8 @@ class _VerificationWarningPanelState extends State<_VerificationWarningPanel> { ); } } + +enum _LoopWarningAction { + ignore, + openSlots, +} diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index be71e2bd..2fbca35a 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -500,10 +500,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -564,10 +564,10 @@ packages: dependency: "direct main" description: name: open_earable_flutter - sha256: "80b8ed0e08eba3fc403bcb011e9fbdefd5128f4e03a5570fb7e62733aaa5de50" + sha256: "078c8a64ad05265b5b7afae991830549e08729fecacfd255dc4a8e038f8ad12b" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" open_file: dependency: "direct main" description: @@ -945,10 +945,10 @@ packages: dependency: transitive description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" tuple: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 63a85363..3b63fd83 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 open_file: ^3.3.2 - open_earable_flutter: ^2.3.4 + open_earable_flutter: ^2.3.5 flutter_platform_widgets: ^9.0.0 provider: ^6.1.2 logger: ^2.5.0