Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions open_wearable/lib/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 13 additions & 6 deletions open_wearable/lib/view_models/wearables_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -411,12 +411,19 @@ class WearablesProvider with ChangeNotifier {
/// Non-blocking for the caller.
Future<void> _checkForNewerFirmwareAsync(DeviceFirmwareVersion dev) async {
try {
logger.d('Checking for newer firmware for ${(dev as Wearable).name}');
final wearable = dev as Wearable;
if (!wearable.hasCapability<FotaManager>()) {
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;
}

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -38,6 +40,7 @@ class _DeviceDetailPageState extends State<DeviceDetailPage> {
'edu.kit.teco.open_wearable/system_settings',
);

StreamSubscription<List<Type>>? _capabilitySubscription;
Future<Object?>? _deviceIdentifierFuture;
Future<Object?>? _firmwareVersionFuture;
Future<FirmwareSupportStatus>? _firmwareSupportFuture;
Expand All @@ -46,17 +49,35 @@ class _DeviceDetailPageState extends State<DeviceDetailPage> {
@override
void initState() {
super.initState();
_attachCapabilityListener();
_prepareAsyncData();
}

@override
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<DeviceIdentifier>()
? widget.device
Expand All @@ -79,6 +100,13 @@ class _DeviceDetailPageState extends State<DeviceDetailPage> {
return defaultTargetPlatform == TargetPlatform.android;
}

/// Returns whether the device currently exposes the new FOTA capability.
bool get _supportsFota => widget.device.hasCapability<FotaManager>();

/// Returns whether the device can report firmware image slot metadata.
bool get _supportsFotaSlotInfo =>
widget.device.hasCapability<FotaSlotInfoCapability>();

Future<void> _openBluetoothSettings() async {
bool opened = false;
try {
Expand Down Expand Up @@ -170,13 +198,33 @@ class _DeviceDetailPageState extends State<DeviceDetailPage> {
}

void _openFirmwareUpdate() {
if (!_supportsFota) {
return;
}

Provider.of<FirmwareUpdateRequestProvider>(
context,
listen: false,
).setSelectedPeripheral(widget.device);
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 = <Widget>[
Expand All @@ -203,6 +251,7 @@ class _DeviceDetailPageState extends State<DeviceDetailPage> {
),
),
_buildInfoCard(context),
if (_supportsFotaSlotInfo) _buildFotaDetailsCard(),
if (widget.device.hasCapability<StatusLed>() &&
widget.device.hasCapability<RgbLed>())
AppSectionCard(
Expand Down Expand Up @@ -383,9 +432,11 @@ class _DeviceDetailPageState extends State<DeviceDetailPage> {
DetailInfoRow(
label: 'Firmware Version',
value: _buildFirmwareVersionValue(),
trailing: FirmwareTableUpdateHint(
onTap: _openFirmwareUpdate,
),
trailing: _supportsFota
? FirmwareTableUpdateHint(
onTap: _openFirmwareUpdate,
)
: null,
showDivider: hasHardware,
),
if (hasHardware)
Expand All @@ -401,6 +452,19 @@ class _DeviceDetailPageState extends State<DeviceDetailPage> {
);
}

/// 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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading