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
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)}',
};
}
}
111 changes: 111 additions & 0 deletions open_wearable/lib/models/labels/label_sensor.dart
Original file line number Diff line number Diff line change
@@ -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<Label>)> labelStream,
}) : _labelStream = labelStream,
super(
name: "Label",
disconnectNotifier: WearableDisconnectNotifier(),
);

final LabelSet labelSet;
final Stream<(int, List<Label>)> _labelStream;

@override
List<Sensor> get sensors => [
LabelSensor(
labelSet: labelSet,
labelStream: _labelStream,
),
];

@override
String get deviceId => "label";

@override
Future<void> disconnect() {
throw UnimplementedError();
}
}

class LabelSensor extends Sensor<SensorLabelValue> {
LabelSensor({
required this.labelSet,
required Stream<(int, List<Label>)> labelStream,
}) : _labelStream = labelStream,
super(
sensorName: "Label_${labelSet.name}",
chartTitle: "",
shortChartTitle: "",
);

final LabelSet labelSet;
final Stream<(int, List<Label>)> _labelStream;

@override
List<String> get axisNames =>
labelSet.labels.map((label) => label.name).toList()
..sort((a, b) => a.compareTo(b));

@override
List<String> get axisUnits => labelSet.labels.map((_) => "isActive").toList();

@override
Stream<SensorLabelValue> get sensorStream => _labelStream.map(
(data) => SensorLabelValue(
set: labelSet,
selectedLabels: data.$2,
timestamp: data.$1,
),
);
}

class SensorLabelValue extends SensorValue {
/// Maps each label to whether it is active.
/// Labels are guaranteed to be sorted alphabetically by name.
final Map<Label, bool> labelStates;

SensorLabelValue({
required LabelSet set,
required List<Label> selectedLabels,
required super.timestamp,
}) : labelStates = _buildLabelStates(set, selectedLabels),
super(
valueStrings: _buildValueStrings(set, selectedLabels),
);

/// Builds the sorted label -> active map.
static Map<Label, bool> _buildLabelStates(
LabelSet set,
List<Label> selectedLabels,
) {
final sortedLabels = [...set.labels]
..sort((a, b) => a.name.compareTo(b.name));

return {
for (final label in sortedLabels) label: selectedLabels.contains(label),
};
}

/// Builds the valueStrings in the same deterministic order.
static List<String> _buildValueStrings(
LabelSet set,
List<Label> selectedLabels,
) {
final sortedLabels = [...set.labels]
..sort((a, b) => a.name.compareTo(b.name));

return sortedLabels
.map((label) => selectedLabels.contains(label).toString())
.toList();
}
}
42 changes: 42 additions & 0 deletions open_wearable/lib/models/labels/label_set.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// A set of labels, identified by a name.
import 'label.dart';

class LabelSet {
final String name;
final List<Label> labels;

const LabelSet({
required this.name,
required this.labels,
});

/// Create a modified copy (e.g., renamed set, updated labels list).
LabelSet copyWith({
String? name,
List<Label>? labels,
}) {
return LabelSet(
name: name ?? this.name,
labels: labels ?? this.labels,
);
}

/// JSON -> LabelSet
factory LabelSet.fromJson(Map<String, dynamic> json) {
final labelsJson = json['labels'] as List<dynamic>? ?? [];
return LabelSet(
name: json['name'] as String,
labels: labelsJson
.map((e) => Label.fromJson(e as Map<String, dynamic>))
.toList(),
);
}

/// LabelSet -> JSON
Map<String, dynamic> toJson() {
return {
'name': name,
'labels': labels.map((l) => l.toJson()).toList(),
};
}
}
Loading