From 5dc9c7c315db199c38d572932c22cfdcad97d9f0 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 15 Jul 2026 06:52:20 +0200 Subject: [PATCH 01/10] fix: match beta PR firmware versions --- .../lib/models/firmware_version_matcher.dart | 49 +++++++++++++++++++ .../models/fota_post_update_verification.dart | 20 ++------ .../fota/firmware_select/firmware_list.dart | 11 ++--- open_wearable/pubspec.yaml | 2 +- .../models/firmware_version_matcher_test.dart | 41 ++++++++++++++++ 5 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 open_wearable/lib/models/firmware_version_matcher.dart create mode 100644 open_wearable/test/models/firmware_version_matcher_test.dart diff --git a/open_wearable/lib/models/firmware_version_matcher.dart b/open_wearable/lib/models/firmware_version_matcher.dart new file mode 100644 index 00000000..fdf6c6ad --- /dev/null +++ b/open_wearable/lib/models/firmware_version_matcher.dart @@ -0,0 +1,49 @@ +String? normalizeFirmwareVersion(String? value) { + final cleaned = value?.replaceAll('\x00', '').trim(); + if (cleaned == null || cleaned.isEmpty) { + return null; + } + return cleaned; +} + +bool firmwareVersionsMatch(String? expected, String? actual) { + final normalizedExpected = normalizeFirmwareVersion(expected); + final normalizedActual = normalizeFirmwareVersion(actual); + if (normalizedExpected == null || normalizedActual == null) { + return false; + } + + final expectedComparison = _comparisonValue(normalizedExpected); + final actualComparison = _comparisonValue(normalizedActual); + if (expectedComparison == actualComparison) { + return true; + } + + final expectedPrNumber = _extractPullRequestNumber(expectedComparison); + final actualPrNumber = _extractPullRequestNumber(actualComparison); + if (expectedPrNumber != null && actualPrNumber != null) { + return expectedPrNumber == actualPrNumber; + } + + return actualComparison.contains(expectedComparison) || + expectedComparison.contains(actualComparison); +} + +String _comparisonValue(String value) { + return value.toLowerCase().replaceAll(RegExp(r'\s+'), ' '); +} + +String? _extractPullRequestNumber(String value) { + for (final pattern in _pullRequestPatterns) { + final match = pattern.firstMatch(value); + if (match != null) { + return match.group(1); + } + } + return null; +} + +final _pullRequestPatterns = [ + RegExp(r'(?:^|[^a-z0-9])pr\s*[-#]?\s*(\d+)(?=$|[^a-z0-9])'), + RegExp(r'(?:^|[^a-z0-9])pull\s*request\s*#?\s*(\d+)(?=$|[^a-z0-9])'), +]; diff --git a/open_wearable/lib/models/fota_post_update_verification.dart b/open_wearable/lib/models/fota_post_update_verification.dart index 6e04526b..54dac842 100644 --- a/open_wearable/lib/models/fota_post_update_verification.dart +++ b/open_wearable/lib/models/fota_post_update_verification.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/models/firmware_version_matcher.dart'; /// Metadata returned when a post-update verification check is armed. class ArmedFotaPostUpdateVerification { @@ -323,7 +324,7 @@ class FotaPostUpdateVerificationCoordinator { final version = await firmwareCap .readDeviceFirmwareVersion() .timeout(const Duration(seconds: 4)); - final normalized = _normalizeVersion(version); + final normalized = normalizeFirmwareVersion(version); if (normalized != null) { return normalized; } @@ -450,23 +451,20 @@ class FotaPostUpdateVerificationCoordinator { String? _extractExpectedFirmwareVersion(SelectedFirmware? firmware) { if (firmware is RemoteFirmware) { - return _normalizeVersion(firmware.version); + return normalizeFirmwareVersion(firmware.version); } if (firmware is LocalFirmware) { final match = RegExp(r'(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)').firstMatch(firmware.name); - return _normalizeVersion(match?.group(1)); + return normalizeFirmwareVersion(match?.group(1)); } return null; } bool _firmwareVersionsMatch(String expected, String actual) { - if (actual == expected) { - return true; - } - return actual.contains(expected) || expected.contains(actual); + return firmwareVersionsMatch(expected, actual); } String _buildMessage({ @@ -533,14 +531,6 @@ class FotaPostUpdateVerificationCoordinator { return trimmed.toLowerCase(); } - String? _normalizeVersion(String? value) { - final cleaned = value?.replaceAll('\x00', '').trim(); - if (cleaned == null || cleaned.isEmpty) { - return null; - } - return cleaned; - } - String? _normalizeSideLabel(String? sideLabel) { if (sideLabel == null || sideLabel.isEmpty) { return null; diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index 628a1711..04cd1fde 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart'; 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/firmware_version_matcher.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; @@ -691,7 +692,7 @@ class _FirmwareListState extends State { if (version == null) { return false; } - return firmware.version == version || firmware.version.contains(version); + return firmwareVersionsMatch(firmware.version, version); } FirmwareEntry? _findCurrentEntry(List orderedEntries) { @@ -702,7 +703,7 @@ class _FirmwareListState extends State { for (final entry in orderedEntries) { final fwVersion = entry.firmware.version; - if (fwVersion == version || fwVersion.contains(version)) { + if (firmwareVersionsMatch(fwVersion, version)) { return entry; } } @@ -710,11 +711,7 @@ class _FirmwareListState extends State { } String? get _normalizedDeviceVersion { - final version = firmwareVersion?.replaceAll('\x00', '').trim(); - if (version == null || version.isEmpty) { - return null; - } - return version; + return normalizeFirmwareVersion(firmwareVersion); } void _installFirmware(RemoteFirmware firmware) { diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index c460b7dd..b58f55a8 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.4.1+1 +version: 1.5.0 environment: sdk: ^3.6.0 diff --git a/open_wearable/test/models/firmware_version_matcher_test.dart b/open_wearable/test/models/firmware_version_matcher_test.dart new file mode 100644 index 00000000..4550b26b --- /dev/null +++ b/open_wearable/test/models/firmware_version_matcher_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/firmware_version_matcher.dart'; + +void main() { + group('normalizeFirmwareVersion', () { + test('removes transport nulls and surrounding whitespace', () { + expect(normalizeFirmwareVersion(' \x002.2.7\x00 '), '2.2.7'); + }); + + test('returns null for missing firmware versions', () { + expect(normalizeFirmwareVersion(null), isNull); + expect(normalizeFirmwareVersion(' '), isNull); + }); + }); + + group('firmwareVersionsMatch', () { + test('matches beta PR labels against device firmware ids', () { + expect(firmwareVersionsMatch('PR #123', '2.2.7-pr123'), isTrue); + expect(firmwareVersionsMatch('2.2.7-pr123', 'PR #123'), isTrue); + }); + + test('matches alternate beta PR spellings', () { + expect(firmwareVersionsMatch('PR #123', '2.2.7-PR-123+1'), isTrue); + expect( + firmwareVersionsMatch('pull request #123', '2.2.7-pr123'), + isTrue, + ); + }); + + test('rejects different beta PR numbers', () { + expect(firmwareVersionsMatch('PR #123', '2.2.7-pr124'), isFalse); + expect(firmwareVersionsMatch('PR #123', '2.2.7'), isFalse); + }); + + test('keeps existing stable version containment behavior', () { + expect(firmwareVersionsMatch('2.2.7', '2.2.7+1'), isTrue); + expect(firmwareVersionsMatch('2.2.7+1', '2.2.7'), isTrue); + expect(firmwareVersionsMatch('2.2.7', '2.2.8'), isFalse); + }); + }); +} From 33e6b45684d709c457556588e0ad7436e42b07f7 Mon Sep 17 00:00:00 2001 From: Philipp Lepold Date: Mon, 13 Jul 2026 15:05:11 +0200 Subject: [PATCH 02/10] Filter out "OpenEarable" devices in BLE scanning --- .../widgets/devices/connect_devices_page.dart | 115 +++++++++++++++++- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 7608a9f0..d42234be 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -34,6 +34,8 @@ class ConnectDevicesPage extends StatefulWidget { class _ConnectDevicesPageState extends State { bool _hasBlePermissions = false; bool _hasMicPermission = true; + bool _showOnlyOpenEarableDevices = false; + String _deviceNameFilterText = 'OpenEarable'; final Map _connectingDevices = {}; late ConnectDevicesScanSnapshot _scanSnapshot; @@ -134,12 +136,17 @@ class _ConnectDevicesPageState extends State { .where((device) => !connectedDeviceIds.contains(device.id)) .toList(); final thisDeviceEntry = _thisDeviceEntry; - final availableDevices = [ + final allAvailableDevices = [ if (thisDeviceEntry != null && !connectedDeviceIds.contains(thisDeviceEntry.id)) thisDeviceEntry, ...scannedDevices.where((device) => device.id != thisDeviceEntry?.id), ]; + final availableDevices = _showOnlyOpenEarableDevices + ? allAvailableDevices + .where((device) => device.name.contains(_deviceNameFilterText)) + .toList() + : allAvailableDevices; return PlatformScaffold( appBar: PlatformAppBar( @@ -249,6 +256,7 @@ class _ConnectDevicesPageState extends State { context, title: 'Available', count: availableDevices.length, + trailing: _buildOpenEarableFilterToggle(context), ), if (availableDevices.isEmpty) _buildEmptyCard( @@ -256,9 +264,7 @@ class _ConnectDevicesPageState extends State { title: _scanSnapshot.isScanning ? 'Scanning for devices...' : 'No devices found yet', - subtitle: _scanSnapshot.isScanning - ? 'Make sure your wearable is turned on and nearby.' - : 'Press scan again or pull to refresh.', + subtitle: _emptyAvailableDevicesMessage(), ) else ...availableDevices.map((device) { @@ -368,6 +374,7 @@ class _ConnectDevicesPageState extends State { BuildContext context, { required String title, required int count, + Widget? trailing, }) { return Padding( padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), @@ -381,6 +388,18 @@ class _ConnectDevicesPageState extends State { ), const SizedBox(width: 8), _StatusPill(label: '$count'), + if (trailing != null) ...[ + const SizedBox(width: 8), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + child: trailing, + ), + ), + ), + ], ], ), ); @@ -401,6 +420,85 @@ class _ConnectDevicesPageState extends State { ); } + Widget _buildOpenEarableFilterToggle(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Only show devices with ', + style: Theme.of(context).textTheme.labelSmall, + ), + InkWell( + borderRadius: BorderRadius.circular(6), + onTap: _editDeviceNameFilterText, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text( + _deviceNameFilterText, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + decoration: TextDecoration.underline, + ), + ), + ), + ), + Text( + ' in their name', + style: Theme.of(context).textTheme.labelSmall, + ), + Transform.scale( + scale: 0.75, + child: Switch.adaptive( + value: _showOnlyOpenEarableDevices, + onChanged: (value) { + setState(() { + _showOnlyOpenEarableDevices = value; + }); + }, + ), + ), + ], + ); + } + + Future _editDeviceNameFilterText() async { + var editedText = _deviceNameFilterText; + final nextText = await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Device name filter'), + content: TextFormField( + initialValue: editedText, + autofocus: true, + decoration: const InputDecoration(labelText: 'Name contains'), + textInputAction: TextInputAction.done, + onChanged: (value) => editedText = value, + onFieldSubmitted: (value) => Navigator.of(dialogContext).pop(value), + ), + actions: [ + PlatformDialogAction( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + PlatformDialogAction( + onPressed: () => Navigator.of(dialogContext).pop(editedText), + child: const Text('Apply'), + ), + ], + ), + ); + + final trimmedText = nextText?.trim(); + if (trimmedText == null || trimmedText.isEmpty || !mounted) { + return; + } + + setState(() { + _deviceNameFilterText = trimmedText; + }); + } + Widget _buildTrailingWidget( DiscoveredDevice device, { required VoidCallback onConnect, @@ -446,6 +544,15 @@ class _ConnectDevicesPageState extends State { return '${elapsed.inHours}h ago'; } + String _emptyAvailableDevicesMessage() { + if (_showOnlyOpenEarableDevices) { + return 'No device names match "$_deviceNameFilterText".'; + } + return _scanSnapshot.isScanning + ? 'Make sure your wearable is turned on and nearby.' + : 'Press scan again or pull to refresh.'; + } + Future _addThisDeviceToDiscovered() async { if (_thisDeviceEntry != null) return; final profile = await DeviceProfile.fetch(); From bd2821105a04a5c67daa449bd3bef6baa03d6b8a Mon Sep 17 00:00:00 2001 From: Philipp Lepold Date: Mon, 13 Jul 2026 17:05:28 +0200 Subject: [PATCH 03/10] Tweaked naming for device filter --- open_wearable/lib/widgets/devices/connect_devices_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index d42234be..0bd4022e 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -425,7 +425,7 @@ class _ConnectDevicesPageState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - 'Only show devices with ', + 'Filter for "', style: Theme.of(context).textTheme.labelSmall, ), InkWell( @@ -444,7 +444,7 @@ class _ConnectDevicesPageState extends State { ), ), Text( - ' in their name', + '" in the device name', style: Theme.of(context).textTheme.labelSmall, ), Transform.scale( From 503cecbcb5a4bf25921870f905ba3d0ff5bd1573 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 15 Jul 2026 07:17:45 +0200 Subject: [PATCH 04/10] chore: refine device name filter UI --- .../widgets/devices/connect_devices_page.dart | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 0bd4022e..8cd02c25 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -263,7 +263,7 @@ class _ConnectDevicesPageState extends State { context, title: _scanSnapshot.isScanning ? 'Scanning for devices...' - : 'No devices found yet', + : 'No devices found', subtitle: _emptyAvailableDevicesMessage(), ) else @@ -421,32 +421,45 @@ class _ConnectDevicesPageState extends State { } Widget _buildOpenEarableFilterToggle(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Row( mainAxisSize: MainAxisSize.min, children: [ Text( - 'Filter for "', - style: Theme.of(context).textTheme.labelSmall, + 'Device name filter', + style: theme.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), + const SizedBox(width: 6), InkWell( - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(999), onTap: _editDeviceNameFilterText, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Container( + constraints: const BoxConstraints(minWidth: 78, maxWidth: 128), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.9), + ), + ), child: Text( _deviceNameFilterText, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w700, - decoration: TextDecoration.underline, - ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), ), ), ), - Text( - '" in the device name', - style: Theme.of(context).textTheme.labelSmall, - ), + const SizedBox(width: 2), Transform.scale( scale: 0.75, child: Switch.adaptive( @@ -546,7 +559,7 @@ class _ConnectDevicesPageState extends State { String _emptyAvailableDevicesMessage() { if (_showOnlyOpenEarableDevices) { - return 'No device names match "$_deviceNameFilterText".'; + return 'No devices match "$_deviceNameFilterText".'; } return _scanSnapshot.isScanning ? 'Make sure your wearable is turned on and nearby.' From 6d3d8829438d071bc98d307b02b2904a57dc4f31 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 15 Jul 2026 09:29:52 +0200 Subject: [PATCH 05/10] fix: preserve beta firmware PR titles --- .../models/beta_firmware_title_resolver.dart | 147 ++++++++++++++++++ .../fota/firmware_select/firmware_list.dart | 6 +- .../beta_firmware_title_resolver_test.dart | 121 ++++++++++++++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 open_wearable/lib/models/beta_firmware_title_resolver.dart create mode 100644 open_wearable/test/models/beta_firmware_title_resolver_test.dart diff --git a/open_wearable/lib/models/beta_firmware_title_resolver.dart b/open_wearable/lib/models/beta_firmware_title_resolver.dart new file mode 100644 index 00000000..73efcc21 --- /dev/null +++ b/open_wearable/lib/models/beta_firmware_title_resolver.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +typedef PullRequestTitleFetcher = Future> Function( + Set pullRequestNumbers, +); + +class BetaFirmwareTitleResolver { + final PullRequestTitleFetcher _fetchTitles; + + BetaFirmwareTitleResolver({ + PullRequestTitleFetcher? fetchTitles, + }) : _fetchTitles = fetchTitles ?? fetchGitHubPullRequestTitles; + + Future> resolve(List entries) async { + final pullRequestNumbers = { + for (final entry in entries) + if (entry.isBeta) ...[ + if (_pullRequestNumberFor(entry.firmware) case final number?) number, + ], + }; + + if (pullRequestNumbers.isEmpty) { + return entries; + } + + final titles = await _fetchTitlesSafely(pullRequestNumbers); + if (titles.isEmpty) { + return entries; + } + + return [ + for (final entry in entries) _entryWithResolvedTitle(entry, titles), + ]; + } + + Future> _fetchTitlesSafely( + Set pullRequestNumbers, + ) async { + try { + return await _fetchTitles(pullRequestNumbers); + } catch (_) { + return const {}; + } + } + + FirmwareEntry _entryWithResolvedTitle( + FirmwareEntry entry, + Map titles, + ) { + if (!entry.isBeta) { + return entry; + } + + final pullRequestNumber = _pullRequestNumberFor(entry.firmware); + final title = titles[pullRequestNumber]?.trim(); + if (title == null || title.isEmpty || title == entry.firmware.name) { + return entry; + } + + final firmware = entry.firmware; + return FirmwareEntry( + firmware: RemoteFirmware( + name: title, + version: firmware.version, + url: firmware.url, + type: firmware.type, + ), + source: entry.source, + ); + } +} + +Future> fetchGitHubPullRequestTitles( + Set pullRequestNumbers, +) async { + if (pullRequestNumbers.isEmpty) { + return const {}; + } + + final client = HttpClient(); + try { + final entries = await Future.wait( + pullRequestNumbers.map( + (number) => _fetchGitHubPullRequestTitle(client, number), + ), + ); + return { + for (final entry in entries) + if (entry != null) entry.key: entry.value, + }; + } finally { + client.close(force: true); + } +} + +Future?> _fetchGitHubPullRequestTitle( + HttpClient client, + int pullRequestNumber, +) async { + try { + final request = await client.getUrl( + Uri.https( + 'api.github.com', + '/repos/OpenEarable/open-earable-2/pulls/$pullRequestNumber', + ), + ); + request.headers.set( + HttpHeaders.acceptHeader, + 'application/vnd.github+json', + ); + request.headers.set(HttpHeaders.userAgentHeader, 'OpenWearable'); + + final response = await request.close(); + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + return null; + } + + final body = await utf8.decodeStream(response); + final json = jsonDecode(body) as Map; + final title = (json['title'] as String?)?.trim(); + if (title == null || title.isEmpty) { + return null; + } + + return MapEntry(pullRequestNumber, title); + } catch (_) { + return null; + } +} + +int? _pullRequestNumberFor(RemoteFirmware firmware) { + return _pullRequestNumberFrom(firmware.version) ?? + _pullRequestNumberFrom(firmware.url) ?? + _pullRequestNumberFrom(firmware.name); +} + +int? _pullRequestNumberFrom(String value) { + final match = RegExp( + r'(?:^|[^a-z0-9])pr\s*[-#]?\s*(\d+)(?=$|[^a-z0-9])', + caseSensitive: false, + ).firstMatch(value); + return int.tryParse(match?.group(1) ?? ''); +} diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index 04cd1fde..f457dadc 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart'; 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/beta_firmware_title_resolver.dart'; import 'package:open_wearable/models/firmware_version_matcher.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; @@ -18,6 +19,7 @@ class FirmwareList extends StatefulWidget { class _FirmwareListState extends State { late Future> _firmwareFuture; final _repository = UnifiedFirmwareRepository(); + final _betaTitleResolver = BetaFirmwareTitleResolver(); String? firmwareVersion; bool _expanded = false; @@ -46,7 +48,9 @@ class _FirmwareListState extends State { } try { - beta = await _repository.getBetaFirmwares(); + beta = await _betaTitleResolver.resolve( + await _repository.getBetaFirmwares(), + ); } catch (error) { betaError = error; // Beta feed is optional. Ignore failures. diff --git a/open_wearable/test/models/beta_firmware_title_resolver_test.dart b/open_wearable/test/models/beta_firmware_title_resolver_test.dart new file mode 100644 index 00000000..67dc6da2 --- /dev/null +++ b/open_wearable/test/models/beta_firmware_title_resolver_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/beta_firmware_title_resolver.dart'; + +void main() { + group('BetaFirmwareTitleResolver', () { + test('uses GitHub pull request title for beta firmware display name', + () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (numbers) async { + expect(numbers, {250}); + return {250: '2.2.7'}; + }, + ); + + final entries = await resolver.resolve([ + _betaEntry( + name: '2 2 7', + version: 'PR #250', + url: + 'https://github.com/OpenEarable/open-earable-2/releases/download/pr-builds/pr-250-2_2_7-openearable_v2_fota.zip', + ), + ]); + + expect(entries.single.firmware.name, '2.2.7'); + expect(entries.single.firmware.version, 'PR #250'); + expect(entries.single.firmware.url, contains('pr-250-2_2_7')); + expect(entries.single.firmware.type, FirmwareType.multiImage); + expect(entries.single.isBeta, isTrue); + }); + + test('preserves punctuation from pull request titles', () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (_) async => {228: 'Feature/audio response'}, + ); + + final entries = await resolver.resolve([ + _betaEntry( + name: 'Feature audio response', + version: 'PR #228', + url: + 'https://github.com/OpenEarable/open-earable-2/releases/download/pr-builds/pr-228-Feature_audio_response-openearable_v2_fota.zip', + ), + ]); + + expect(entries.single.firmware.name, 'Feature/audio response'); + }); + + test('preserves brackets and dots from pull request titles', () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (_) async => {250: '[2.2.7] FOTA retry upload'}, + ); + + final entries = await resolver.resolve([ + _betaEntry( + name: '2 2 7 FOTA retry upload', + version: 'PR #250', + url: + 'https://github.com/OpenEarable/open-earable-2/releases/download/pr-builds/pr-250-2_2_7_FOTA_retry_upload-openearable_v2_fota.zip', + ), + ]); + + expect(entries.single.firmware.name, '[2.2.7] FOTA retry upload'); + }); + + test('does not change stable firmware entries', () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (numbers) async { + expect(numbers, isEmpty); + return const {}; + }, + ); + final entry = FirmwareEntry( + firmware: RemoteFirmware( + name: 'OpenEarable 2.2.7', + version: '2.2.7', + url: 'https://example.com/openearable_v2_fota.zip', + type: FirmwareType.multiImage, + ), + source: FirmwareSource.stable, + ); + + final entries = await resolver.resolve([entry]); + + expect(entries.single, same(entry)); + }); + + test('keeps asset-derived title when pull request title is unavailable', + () async { + final resolver = BetaFirmwareTitleResolver( + fetchTitles: (_) async => const {}, + ); + final entry = _betaEntry( + name: '2 2 7', + version: 'PR #250', + url: + 'https://github.com/OpenEarable/open-earable-2/releases/download/pr-builds/pr-250-2_2_7-openearable_v2_fota.zip', + ); + + final entries = await resolver.resolve([entry]); + + expect(entries.single, same(entry)); + }); + }); +} + +FirmwareEntry _betaEntry({ + required String name, + required String version, + required String url, +}) { + return FirmwareEntry( + firmware: RemoteFirmware( + name: name, + version: version, + url: url, + type: FirmwareType.multiImage, + ), + source: FirmwareSource.beta, + ); +} From a652a0ce7d2e7a4986e6d9b75004e5dcad6c7fa1 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 15 Jul 2026 09:31:08 +0200 Subject: [PATCH 06/10] chore: simplify beta firmware warning copy --- .../lib/widgets/fota/firmware_select/firmware_list.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index f457dadc..fcb41681 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -458,7 +458,7 @@ class _FirmwareListState extends State { const SizedBox(width: 8), Expanded( child: Text( - 'Beta firmware is experimental and is not recommended to be used. Use at your own risk.', + 'Beta firmware is experimental. Use at your own risk.', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurface, fontWeight: FontWeight.w600, From 229d9b758b4ea6f6bae30a6b4fb1d63d2d288207 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Wed, 15 Jul 2026 10:49:53 +0200 Subject: [PATCH 07/10] fix: apply edge recorder prefix to stereo pairs --- .../edge_recorder_prefix_row.dart | 14 +++++- .../sensor_configuration_device_row.dart | 5 ++ .../edge_recorder_prefix_row_test.dart | 47 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 open_wearable/test/widgets/sensors/configuration/edge_recorder_prefix_row_test.dart diff --git a/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart b/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart index 7537ad99..e3f15c65 100644 --- a/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart @@ -6,9 +6,14 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; /// Row that shows the current file prefix of an [EdgeRecorderManager] /// and lets the user change it. class EdgeRecorderPrefixRow extends StatefulWidget { - const EdgeRecorderPrefixRow({super.key, required this.manager}); + const EdgeRecorderPrefixRow({ + super.key, + required this.manager, + this.pairedManager, + }); final EdgeRecorderManager manager; + final EdgeRecorderManager? pairedManager; @override State createState() => _RecorderPrefixRowState(); @@ -83,7 +88,12 @@ class _RecorderPrefixRowState extends State { ); if (result == true) { - await widget.manager.setFilePrefix(_editPrefixController.text.trim()); + final prefix = _editPrefixController.text.trim(); + await Future.wait([ + widget.manager.setFilePrefix(prefix), + if (widget.pairedManager != null) + widget.pairedManager!.setFilePrefix(prefix), + ]); if (!mounted) { return; } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 4845f87a..71ff57d4 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -384,10 +384,15 @@ class _SensorConfigurationDeviceRowState ]; if (device.hasCapability()) { + final pairedEdgeRecorderManager = widget.pairedDevice != null && + widget.pairedDevice!.hasCapability() + ? widget.pairedDevice!.requireCapability() + : null; content.addAll([ const InsetSectionDivider(), EdgeRecorderPrefixRow( manager: device.requireCapability(), + pairedManager: pairedEdgeRecorderManager, ), ]); } diff --git a/open_wearable/test/widgets/sensors/configuration/edge_recorder_prefix_row_test.dart b/open_wearable/test/widgets/sensors/configuration/edge_recorder_prefix_row_test.dart new file mode 100644 index 00000000..1e29048a --- /dev/null +++ b/open_wearable/test/widgets/sensors/configuration/edge_recorder_prefix_row_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; + +void main() { + testWidgets('sets filename prefix on both paired edge recorder managers', + (tester) async { + final primaryManager = _FakeEdgeRecorderManager(''); + final pairedManager = _FakeEdgeRecorderManager(''); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EdgeRecorderPrefixRow( + manager: primaryManager, + pairedManager: pairedManager, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('On-Device Filename Prefix')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'session_01'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(primaryManager.prefix, 'session_01'); + expect(pairedManager.prefix, 'session_01'); + }); +} + +class _FakeEdgeRecorderManager implements EdgeRecorderManager { + _FakeEdgeRecorderManager(this.prefix); + + String prefix; + + @override + Future get filePrefix async => prefix; + + @override + Future setFilePrefix(String prefix) async { + this.prefix = prefix; + } +} From 3e039ad3bdfc55ff91cd681a3b8d4a4745412698 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:16:02 +0200 Subject: [PATCH 08/10] feat(labels): add persistent recording labels --- open_wearable/lib/main.dart | 39 ++- .../models/labels/file_label_set_storage.dart | 51 +++ open_wearable/lib/models/labels/label.dart | 39 +++ .../lib/models/labels/label_sensor.dart | 111 ++++++ .../lib/models/labels/label_set.dart | 42 +++ .../lib/models/labels/label_set_manager.dart | 66 ++++ .../lib/models/labels/label_set_storage.dart | 9 + .../models/labels/web_label_set_storage.dart | 38 +++ .../lib/view_models/label_provider.dart | 63 ++++ .../lib/view_models/label_set_provider.dart | 57 ++++ .../labels/active_label_bar.dart | 66 ++++ .../labels/label_set_selector.dart | 74 ++++ .../labels/label_sets_page.dart | 139 ++++++++ .../labels/labelset_dropdown.dart | 76 +++++ .../labels/labelset_editor_page.dart | 318 ++++++++++++++++++ open_wearable/pubspec.lock | 2 +- open_wearable/pubspec.yaml | 1 + 17 files changed, 1187 insertions(+), 4 deletions(-) create mode 100644 open_wearable/lib/models/labels/file_label_set_storage.dart create mode 100644 open_wearable/lib/models/labels/label.dart create mode 100644 open_wearable/lib/models/labels/label_sensor.dart create mode 100644 open_wearable/lib/models/labels/label_set.dart create mode 100644 open_wearable/lib/models/labels/label_set_manager.dart create mode 100644 open_wearable/lib/models/labels/label_set_storage.dart create mode 100644 open_wearable/lib/models/labels/web_label_set_storage.dart create mode 100644 open_wearable/lib/view_models/label_provider.dart create mode 100644 open_wearable/lib/view_models/label_set_provider.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/labels/active_label_bar.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/labels/label_set_selector.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/labels/label_sets_page.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/labels/labelset_dropdown.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/labels/labelset_editor_page.dart diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index 7abab9c5..0b5979ec 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -14,6 +14,7 @@ import 'package:open_wearable/models/app_upgrade_coordinator.dart'; import 'package:open_wearable/models/app_upgrade_highlight.dart'; import 'package:open_wearable/models/auto_connect_preferences.dart'; import 'package:open_wearable/models/connector_settings.dart'; +import 'package:open_wearable/models/labels/label_sensor.dart'; import 'package:open_wearable/models/log_file_manager.dart'; import 'package:open_wearable/models/fota_post_update_verification.dart'; import 'package:open_wearable/models/permissions_helper.dart'; @@ -35,6 +36,8 @@ import 'package:open_wearable/widgets/onboarding/permissions_onboarding_page.dar import 'models/bluetooth_auto_connector.dart'; import 'models/logger.dart'; import 'view_models/app_banner_controller.dart'; +import 'view_models/label_provider.dart'; +import 'view_models/label_set_provider.dart'; import 'view_models/wearables_provider.dart'; void main() async { @@ -54,12 +57,42 @@ void main() async { ChangeNotifierProvider( create: (context) => FirmwareUpdateRequestProvider(), ), - ChangeNotifierProxyProvider( + ChangeNotifierProvider( + create: (context) => LabelSetProvider(), + ), + ChangeNotifierProxyProvider( + create: (context) => LabelProvider(null), + update: (context, labelSetProvider, labelProvider) { + labelProvider?.setLabelSet(labelSetProvider.selectedLabelSet); + return labelProvider!; + }, + ), + ChangeNotifierProxyProvider2( create: (context) => SensorRecorderProvider(), - update: (context, wearablesProvider, recorderProvider) { + update: ( + context, + wearablesProvider, + labelSetProvider, + recorderProvider, + ) { final provider = recorderProvider ?? SensorRecorderProvider(); + final labelSet = labelSetProvider.selectedLabelSet; + final wearables = [...wearablesProvider.wearables]; + if (labelSet != null) { + final labelProvider = context.read(); + wearables.add( + LabelWearable( + labelSet: labelSet, + labelStream: labelProvider.activeLabelStream, + ), + ); + } + logger.t( + 'Updating SensorRecorderProvider with label set: $labelSet', + ); provider.synchronizeConnectedWearables( - wearablesProvider.wearables, + wearables, ); return provider; }, diff --git a/open_wearable/lib/models/labels/file_label_set_storage.dart b/open_wearable/lib/models/labels/file_label_set_storage.dart new file mode 100644 index 00000000..34613b67 --- /dev/null +++ b/open_wearable/lib/models/labels/file_label_set_storage.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'label_set.dart'; +import 'label_set_storage.dart'; + +class FileLabelSetStorage implements LabelSetStorage { + FileLabelSetStorage({this.fileName = 'label_sets.json'}); + + final String fileName; + + Future _getFile() async { + final Directory dir = await getApplicationDocumentsDirectory(); + return File(p.join(dir.path, fileName)); + } + + @override + Future> loadLabelSets() async { + try { + final file = await _getFile(); + if (!await file.exists()) { + return []; + } + final content = await file.readAsString(); + if (content.trim().isEmpty) { + return []; + } + final jsonList = jsonDecode(content) as List; + return jsonList + .map((e) => LabelSet.fromJson(e as Map)) + .toList(); + } catch (e) { + // In case of any error, return empty list instead of crashing. + return []; + } + } + + @override + Future saveLabelSets(List sets) async { + final file = await _getFile(); + final jsonList = sets.map((s) => s.toJson()).toList(); + final content = jsonEncode(jsonList); + await file.writeAsString(content); + } +} + +/// Factory used by conditional import. +LabelSetStorage createLabelSetStorage() => FileLabelSetStorage(); diff --git a/open_wearable/lib/models/labels/label.dart b/open_wearable/lib/models/labels/label.dart new file mode 100644 index 00000000..90a06996 --- /dev/null +++ b/open_wearable/lib/models/labels/label.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +/// A single label with a name and a color. +class Label { + final String name; + final Color color; + + const Label({ + required this.name, + required this.color, + }); + + /// Create a modified copy (useful for editing). + Label copyWith({ + String? name, + Color? color, + }) { + return Label( + name: name ?? this.name, + color: color ?? this.color, + ); + } + + /// JSON -> Label + factory Label.fromJson(Map json) { + return Label( + name: json['name'] as String, + color: Color(int.parse(json['color'] as String)), + ); + } + + /// Label -> JSON + Map toJson() { + return { + 'name': name, + 'color': '0x${color.toARGB32().toRadixString(16)}', + }; + } +} diff --git a/open_wearable/lib/models/labels/label_sensor.dart b/open_wearable/lib/models/labels/label_sensor.dart new file mode 100644 index 00000000..b8f908e0 --- /dev/null +++ b/open_wearable/lib/models/labels/label_sensor.dart @@ -0,0 +1,111 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/labels/label_set.dart'; + +import 'label.dart'; + +/// A wearable that provides labeling functionality. +/// +/// This is used as an Adapter for the RecorderProvider to record labels +/// alongside sensor data. +class LabelWearable extends Wearable implements SensorManager { + LabelWearable({ + required this.labelSet, + required Stream<(int, List