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/app_upgrade_registry.dart b/open_wearable/lib/models/app_upgrade_registry.dart index d5d04ab8..a72f88da 100644 --- a/open_wearable/lib/models/app_upgrade_registry.dart +++ b/open_wearable/lib/models/app_upgrade_registry.dart @@ -179,6 +179,44 @@ class AppUpgradeRegistry { ), ], ), + AppUpgradeHighlight( + version: '1.5.0', + eyebrow: 'OpenWearables 1.5.0', + title: 'Add labels while\nyou record', + summary: + 'Mark important moments in local recordings and find devices faster.', + heroDescription: + 'OpenWearables 1.5.0 brings reusable recording labels to the local recorder. ' + 'Create label sets, choose one before recording, and tap labels during a session so key events are saved with your sensor data.', + accentColor: Color(0xFF8F6A67), + useHeroGradient: false, + features: [ + AppUpgradeFeatureHighlight( + icon: Icons.label_rounded, + title: 'Recording labels', + description: + 'Create reusable label sets and mark events while recording. Label states are saved alongside sensor data with timestamps for easier review and analysis.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.filter_alt_rounded, + title: 'Device name filter', + description: + 'Filter the available device list by name, starting with OpenEarable, so nearby Bluetooth devices are easier to scan and connect.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.verified_rounded, + title: 'Smarter firmware matching', + description: + 'Firmware lists and post-update checks now better recognize installed versions, including beta and pull-request builds.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.auto_fix_high_rounded, + title: 'Workflow polish', + description: + 'Small fixes make label-set editing, delete confirmations, selected dropdowns, color indicators, and update verification feel more predictable.', + ), + ], + ), ]; /// Returns the configured highlight for [version], if any. 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/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/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