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
101 changes: 71 additions & 30 deletions open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:open_wearable/apps/heart_tracker/model/ppg_filter.dart';
import 'package:open_wearable/apps/heart_tracker/widgets/rowling_chart.dart';
import 'package:open_wearable/models/wearable_display_group.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
import 'package:open_wearable/view_models/wearables_provider.dart';
import 'package:open_wearable/widgets/devices/devices_page.dart';
import 'package:provider/provider.dart';

Expand All @@ -29,6 +30,9 @@ class HeartTrackerPage extends StatefulWidget {
}

class _HeartTrackerPageState extends State<HeartTrackerPage> {
late final WearablesProvider _wearablesProvider;
Wearable? _wearable;
int _ppgTimestampExponent = -3;
PpgFilter? _ppgFilter;
Stream<(int, double)>? _displayPpgSignalStream;
Stream<double?>? _heartRateStream;
Expand All @@ -38,22 +42,61 @@ class _HeartTrackerPageState extends State<HeartTrackerPage> {
@override
void initState() {
super.initState();
_wearablesProvider = context.read<WearablesProvider>();
_wearablesProvider.addListener(_updateConnection);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_initializePipeline();
_updateConnection();
});
}

void _initializePipeline() {
final configProvider =
Provider.of<SensorConfigurationProvider>(context, listen: false);
_sensorConfigProvider = configProvider;
final ppgSensor = widget.ppgSensor;
final accelerometerSensor = widget.accelerometerSensor;
final opticalTemperatureSensor = widget.opticalTemperatureSensor;
void _updateConnection() {
final wearable = _wearablesProvider.wearables
.where((device) => device.deviceId == widget.wearable.deviceId)
.firstOrNull;
if (identical(wearable, _wearable)) {
return;
}
setState(() {
_ppgFilter?.dispose();
_ppgFilter = null;
_displayPpgSignalStream = null;
_heartRateStream = null;
_signalQualityStream = null;
_sensorConfigProvider = null;
_wearable = wearable;
if (wearable == null) {
return;
}
final sensors = wearable.requireCapability<SensorManager>().sensors;
Sensor? currentSensor(Sensor? previous) => sensors
.where((sensor) => sensor.sensorName == previous?.sensorName)
.firstOrNull;
final ppgSensor = currentSensor(widget.ppgSensor);
if (ppgSensor == null) {
return;
}
final configProvider =
_wearablesProvider.getSensorConfigurationProvider(wearable);
_sensorConfigProvider = configProvider;
_ppgTimestampExponent = ppgSensor.timestampExponent;
_initializePipeline(
configProvider,
ppgSensor,
currentSensor(widget.accelerometerSensor),
currentSensor(widget.opticalTemperatureSensor),
);
});
}

void _initializePipeline(
SensorConfigurationProvider configProvider,
Sensor ppgSensor,
Sensor? accelerometerSensor,
Sensor? opticalTemperatureSensor,
) {
final sampleFreq = _configureSensorForStreaming(
ppgSensor,
configProvider,
Expand Down Expand Up @@ -135,20 +178,15 @@ class _HeartTrackerPageState extends State<HeartTrackerPage> {
timestampExponent: ppgSensor.timestampExponent,
);
ppgFilter.initialize();
if (!mounted) {
ppgFilter.dispose();
return;
}
setState(() {
_displayPpgSignalStream = ppgFilter.displaySignalStream;
_heartRateStream = ppgFilter.heartRateStream;
_signalQualityStream = ppgFilter.signalQualityStream;
_ppgFilter = ppgFilter;
});
_displayPpgSignalStream = ppgFilter.displaySignalStream;
_heartRateStream = ppgFilter.heartRateStream;
_signalQualityStream = ppgFilter.signalQualityStream;
_ppgFilter = ppgFilter;
}

@override
void dispose() {
_wearablesProvider.removeListener(_updateConnection);
final configProvider = _sensorConfigProvider;
if (configProvider != null) {
unawaited(configProvider.turnOffAllSensors());
Expand Down Expand Up @@ -372,16 +410,18 @@ class _HeartTrackerPageState extends State<HeartTrackerPage> {
appBar: PlatformAppBar(
title: PlatformText('Heart Tracker'),
),
body: displayPpgSignalStream == null ||
heartRateStream == null ||
signalQualityStream == null
? const Center(child: PlatformCircularProgressIndicator())
: _buildContent(
context,
displayPpgSignalStream,
heartRateStream,
signalQualityStream,
),
body: _wearable == null
? const Center(child: Text('The selected wearable is disconnected.'))
: displayPpgSignalStream == null ||
heartRateStream == null ||
signalQualityStream == null
? const Center(child: PlatformCircularProgressIndicator())
: _buildContent(
context,
displayPpgSignalStream,
heartRateStream,
signalQualityStream,
),
);
}

Expand All @@ -392,10 +432,11 @@ class _HeartTrackerPageState extends State<HeartTrackerPage> {
Stream<PpgSignalQuality> signalQualityStream,
) {
return ListView(
key: ObjectKey(_wearable),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 20),
children: [
DeviceRow(
group: WearableDisplayGroup.single(wearable: widget.wearable),
group: WearableDisplayGroup.single(wearable: _wearable!),
),
const SizedBox(height: 12),
StreamBuilder<PpgSignalQuality>(
Expand Down Expand Up @@ -427,7 +468,7 @@ class _HeartTrackerPageState extends State<HeartTrackerPage> {
'(0.5-3.2 Hz).',
icon: Icons.show_chart_rounded,
chartStream: displayPpgSignalStream,
timestampExponent: widget.ppgSensor.timestampExponent,
timestampExponent: _ppgTimestampExponent,
fixedMeasureMin: null,
fixedMeasureMax: null,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import 'dart:async';

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/apps/heart_tracker/widgets/heart_tracker_page.dart';
import 'package:open_wearable/apps/heart_tracker/widgets/rowling_chart.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
import 'package:open_wearable/view_models/wearables_provider.dart';
import 'package:provider/provider.dart';

class _Configuration extends SensorConfiguration {
_Configuration()
: super(
name: 'PPG',
values: [SensorConfigurationValue(key: '50 Hz')],
offValue: SensorConfigurationValue(key: 'off'),
);
final requests = <String>[];
@override
void setConfiguration(SensorConfigurationValue value) =>
requests.add(value.key);
}

class _Ppg extends Sensor<SensorDoubleValue> {
_Ppg(_Configuration config)
: super(
sensorName: 'PPG',
chartTitle: 'PPG',
shortChartTitle: 'PPG',
relatedConfigurations: [config],
);
final controller = StreamController<SensorDoubleValue>.broadcast();
@override
List<String> get axisNames => ['red', 'ir', 'green', 'ambient'];
@override
List<String> get axisUnits => ['raw', 'raw', 'raw', 'raw'];
@override
Stream<SensorDoubleValue> get sensorStream => controller.stream;
void emit(int time) => controller.add(
SensorDoubleValue(values: [100, 100, 100, 0], timestamp: time),
);
}

class _Ear extends Wearable
implements SensorManager, SensorConfigurationManager {
_Ear(this.deviceId)
: super(
name: 'OpenEarable',
disconnectNotifier: WearableDisconnectNotifier(),
);
@override
final String deviceId;
final config = _Configuration();
late final ppg = _Ppg(config);
@override
List<Sensor> get sensors => [ppg];
@override
List<SensorConfiguration> get sensorConfigurations => [config];
@override
Stream<Map<SensorConfiguration, SensorConfigurationValue>>
get sensorConfigurationStream => const Stream.empty();
@override
Future<void> disconnect() async {}
}

class _Wearables extends WearablesProvider {
@override
final List<Wearable> wearables = [];
final configs = <Wearable, SensorConfigurationProvider>{};
void connect(_Ear ear) {
wearables.add(ear);
configs[ear] = SensorConfigurationProvider(sensorConfigurationManager: ear);
notifyListeners();
}

void disconnect(_Ear ear) {
wearables.remove(ear);
configs.remove(ear)!.dispose();
notifyListeners();
}

@override
SensorConfigurationProvider getSensorConfigurationProvider(
Wearable wearable,
) =>
configs[wearable]!;
}

void main() {
testWidgets(
'heart tracker rebinds only the selected ear and stops its current sensors',
(tester) async {
final oldEar = _Ear('selected');
final otherEar = _Ear('other');
final newEar = _Ear('selected');
final wearables = _Wearables()
..connect(oldEar)
..connect(otherEar);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<WearablesProvider>.value(value: wearables),
ChangeNotifierProvider<SensorConfigurationProvider>.value(
value: wearables.getSensorConfigurationProvider(oldEar),
),
],
child: MaterialApp(
home: HeartTrackerPage(wearable: oldEar, ppgSensor: oldEar.ppg),
),
),
);
await tester.pump();
expect(oldEar.config.requests, ['50 Hz']);
expect(find.byType(RollingChart), findsOneWidget);

wearables.disconnect(otherEar);
wearables.connect(otherEar);
await tester.pump();
expect(oldEar.config.requests, ['50 Hz']);

wearables.disconnect(oldEar);
await tester.pump();
expect(find.text('The selected wearable is disconnected.'), findsOneWidget);
expect(find.byType(RollingChart), findsNothing);
expect(otherEar.config.requests, isEmpty);

wearables.connect(newEar);
await tester.pump();
expect(find.text('The selected wearable is disconnected.'), findsNothing);
expect(newEar.config.requests, ['50 Hz']);
final chart = tester.widget<RollingChart>(find.byType(RollingChart));
final received = <int>[];
final subscription =
chart.dataSteam.listen((sample) => received.add(sample.$1));
oldEar.ppg.emit(1);
newEar.ppg.emit(2);
await tester.pump();
expect(received, [2]);
unawaited(subscription.cancel());

await tester.pumpWidget(const SizedBox.shrink());
expect(oldEar.config.requests, ['50 Hz']);
expect(newEar.config.requests, ['50 Hz', 'off']);
expect(otherEar.config.requests, isEmpty);
wearables.disconnect(newEar);
wearables.disconnect(otherEar);
wearables.dispose();
for (final ear in [oldEar, newEar, otherEar]) {
unawaited(ear.ppg.controller.close());
}
expect(tester.takeException(), isNull);
});
}
Loading