Skip to content
Merged
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
39 changes: 36 additions & 3 deletions open_wearable/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -54,12 +57,42 @@ void main() async {
ChangeNotifierProvider(
create: (context) => FirmwareUpdateRequestProvider(),
),
ChangeNotifierProxyProvider<WearablesProvider, SensorRecorderProvider>(
ChangeNotifierProvider(
create: (context) => LabelSetProvider(),
),
ChangeNotifierProxyProvider<LabelSetProvider, LabelProvider>(
create: (context) => LabelProvider(null),
update: (context, labelSetProvider, labelProvider) {
labelProvider?.setLabelSet(labelSetProvider.selectedLabelSet);
return labelProvider!;
},
),
ChangeNotifierProxyProvider2<WearablesProvider, LabelSetProvider,
SensorRecorderProvider>(
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<LabelProvider>();
wearables.add(
LabelWearable(
labelSet: labelSet,
labelStream: labelProvider.activeLabelStream,
),
);
}
logger.t(
'Updating SensorRecorderProvider with label set: $labelSet',
);
provider.synchronizeConnectedWearables(
wearablesProvider.wearables,
wearables,
);
return provider;
},
Expand Down
38 changes: 38 additions & 0 deletions open_wearable/lib/models/app_upgrade_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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>[
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.
Expand Down
147 changes: 147 additions & 0 deletions open_wearable/lib/models/beta_firmware_title_resolver.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import 'dart:convert';
import 'dart:io';

import 'package:open_earable_flutter/open_earable_flutter.dart';

typedef PullRequestTitleFetcher = Future<Map<int, String>> Function(
Set<int> pullRequestNumbers,
);

class BetaFirmwareTitleResolver {
final PullRequestTitleFetcher _fetchTitles;

BetaFirmwareTitleResolver({
PullRequestTitleFetcher? fetchTitles,
}) : _fetchTitles = fetchTitles ?? fetchGitHubPullRequestTitles;

Future<List<FirmwareEntry>> resolve(List<FirmwareEntry> entries) async {
final pullRequestNumbers = <int>{
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<Map<int, String>> _fetchTitlesSafely(
Set<int> pullRequestNumbers,
) async {
try {
return await _fetchTitles(pullRequestNumbers);
} catch (_) {
return const {};
}
}

FirmwareEntry _entryWithResolvedTitle(
FirmwareEntry entry,
Map<int, String> 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<Map<int, String>> fetchGitHubPullRequestTitles(
Set<int> 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<MapEntry<int, String>?> _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<void>();
return null;
}

final body = await utf8.decodeStream(response);
final json = jsonDecode(body) as Map<String, dynamic>;
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) ?? '');
}
51 changes: 51 additions & 0 deletions open_wearable/lib/models/labels/file_label_set_storage.dart
Original file line number Diff line number Diff line change
@@ -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<File> _getFile() async {
final Directory dir = await getApplicationDocumentsDirectory();
return File(p.join(dir.path, fileName));
}

@override
Future<List<LabelSet>> 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<dynamic>;
return jsonList
.map((e) => LabelSet.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
// In case of any error, return empty list instead of crashing.
return [];
}
}

@override
Future<void> saveLabelSets(List<LabelSet> 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();
39 changes: 39 additions & 0 deletions open_wearable/lib/models/labels/label.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic> json) {
return Label(
name: json['name'] as String,
color: Color(int.parse(json['color'] as String)),
);
}

/// Label -> JSON
Map<String, dynamic> toJson() {
return {
'name': name,
'color': '0x${color.toARGB32().toRadixString(16)}',
};
}
}
Loading