diff --git a/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt b/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt
index 90d484a2..71435e35 100644
--- a/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt
+++ b/open_wearable/android/app/src/main/kotlin/com/example/open_wearable/MainActivity.kt
@@ -1,5 +1,36 @@
package edu.kit.teco.openWearable
+import android.content.Intent
+import android.provider.Settings
import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
-class MainActivity: FlutterActivity()
+class MainActivity : FlutterActivity() {
+ companion object {
+ private const val SYSTEM_SETTINGS_CHANNEL = "edu.kit.teco.open_wearable/system_settings"
+ }
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+
+ MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ SYSTEM_SETTINGS_CHANNEL,
+ ).setMethodCallHandler { call, result ->
+ if (call.method == "openBluetoothSettings") {
+ try {
+ val intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ startActivity(intent)
+ result.success(true)
+ } catch (_: Exception) {
+ result.success(false)
+ }
+ } else {
+ result.notImplemented()
+ }
+ }
+ }
+}
diff --git a/open_wearable/ios/Runner/AppDelegate.swift b/open_wearable/ios/Runner/AppDelegate.swift
index 84aee014..1dffdfa3 100644
--- a/open_wearable/ios/Runner/AppDelegate.swift
+++ b/open_wearable/ios/Runner/AppDelegate.swift
@@ -3,14 +3,40 @@ import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
+ private var sensorShutdownBackgroundTask: UIBackgroundTaskIdentifier = .invalid
+ private var lifecycleChannel: FlutterMethodChannel?
+
+ private func beginSensorShutdownBackgroundTask() {
+ guard sensorShutdownBackgroundTask == .invalid else {
+ return
+ }
+
+ sensorShutdownBackgroundTask = UIApplication.shared.beginBackgroundTask(
+ withName: "SensorShutdown"
+ ) { [weak self] in
+ self?.endSensorShutdownBackgroundTask()
+ }
+ }
+
+ private func endSensorShutdownBackgroundTask() {
+ guard sensorShutdownBackgroundTask != .invalid else {
+ return
+ }
+
+ UIApplication.shared.endBackgroundTask(sensorShutdownBackgroundTask)
+ sensorShutdownBackgroundTask = .invalid
+ }
+
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
- let channel = FlutterMethodChannel(name: "edu.teco.open_folder", binaryMessenger: controller.binaryMessenger)
+ let openFolderChannel = FlutterMethodChannel(name: "edu.teco.open_folder", binaryMessenger: controller.binaryMessenger)
+ let systemSettingsChannel = FlutterMethodChannel(name: "edu.kit.teco.open_wearable/system_settings", binaryMessenger: controller.binaryMessenger)
+ lifecycleChannel = FlutterMethodChannel(name: "edu.kit.teco.open_wearable/lifecycle", binaryMessenger: controller.binaryMessenger)
- channel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
+ openFolderChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
if call.method == "openFolder", let args = call.arguments as? [String: Any], let path = args["path"] as? String {
guard let url = URL(string: path) else {
result(FlutterError(code: "INVALID_ARGUMENT", message: "Invalid folder path", details: nil))
@@ -27,6 +53,42 @@ import UIKit
}
}
+ systemSettingsChannel.setMethodCallHandler { (call: FlutterMethodCall, result: @escaping FlutterResult) in
+ if call.method == "openBluetoothSettings" {
+ guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
+ result(false)
+ return
+ }
+
+ if UIApplication.shared.canOpenURL(settingsUrl) {
+ UIApplication.shared.open(settingsUrl, options: [:]) { success in
+ result(success)
+ }
+ } else {
+ result(false)
+ }
+ } else {
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ lifecycleChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
+ guard let self = self else {
+ result(false)
+ return
+ }
+
+ if call.method == "beginBackgroundExecution" {
+ self.beginSensorShutdownBackgroundTask()
+ result(true)
+ } else if call.method == "endBackgroundExecution" {
+ self.endSensorShutdownBackgroundTask()
+ result(true)
+ } else {
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
diff --git a/open_wearable/lib/apps/fever_thermometer/assets/fever_thermometer_icon.svg b/open_wearable/lib/apps/fever_thermometer/assets/fever_thermometer_icon.svg
new file mode 100644
index 00000000..2c858eb1
--- /dev/null
+++ b/open_wearable/lib/apps/fever_thermometer/assets/fever_thermometer_icon.svg
@@ -0,0 +1,24 @@
+
diff --git a/open_wearable/lib/apps/fever_thermometer/fever_thermometer_page.dart b/open_wearable/lib/apps/fever_thermometer/fever_thermometer_page.dart
new file mode 100644
index 00000000..c5f34978
--- /dev/null
+++ b/open_wearable/lib/apps/fever_thermometer/fever_thermometer_page.dart
@@ -0,0 +1,1112 @@
+import 'dart:async';
+import 'dart:math';
+
+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/device_name_formatter.dart';
+import 'package:open_wearable/models/sensor_streams.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/sensors/sensor_page_spacing.dart';
+import 'package:provider/provider.dart';
+
+class FeverThermometerPage extends StatefulWidget {
+ final Wearable wearable;
+ final Sensor? opticalTemperatureSensor;
+ final SensorConfigurationProvider sensorConfigProvider;
+
+ const FeverThermometerPage({
+ super.key,
+ required this.wearable,
+ required this.opticalTemperatureSensor,
+ required this.sensorConfigProvider,
+ });
+
+ @override
+ State createState() => _FeverThermometerPageState();
+}
+
+class _FeverThermometerPageState extends State {
+ static const double _coreOffsetCelsius = 0.4;
+ static const int _temperatureTargetFrequencyHz = 10;
+ static const int _maxTrendPoints = 140;
+ static const double _smoothingFactor = 0.24;
+ static const Duration _minimumStabilizationDuration = Duration(minutes: 5);
+ static const double _inEarTemperatureThresholdCelsius = 32.0;
+
+ final Map
+ _savedConfigurations = {};
+
+ StreamSubscription? _temperatureSubscription;
+ int _temperatureAxisIndex = 0;
+ double? _smoothedCoreTempCelsius;
+ bool _inEarDetected = false;
+ List _coreTrend = const [];
+ DateTime? _stabilizationStartedAt;
+ int _sampleCount = 0;
+ String? _temperatureStreamError;
+
+ @override
+ void initState() {
+ super.initState();
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ unawaited(_startStreaming());
+ });
+ }
+
+ @override
+ void dispose() {
+ _temperatureSubscription?.cancel();
+ final temperatureSensor = widget.opticalTemperatureSensor;
+ if (temperatureSensor != null) {
+ unawaited(_restoreSensorConfiguration(temperatureSensor));
+ }
+ super.dispose();
+ }
+
+ Future _startStreaming() async {
+ final temperatureSensor = widget.opticalTemperatureSensor;
+ if (temperatureSensor == null) {
+ return;
+ }
+
+ try {
+ await _prepareSensorForStreaming(
+ temperatureSensor,
+ targetFrequencyHz: _temperatureTargetFrequencyHz,
+ );
+ _temperatureAxisIndex = _resolveTemperatureAxis(temperatureSensor);
+ } catch (_) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _temperatureStreamError =
+ 'Could not configure optical temperature streaming.';
+ });
+ return;
+ }
+
+ await _temperatureSubscription?.cancel();
+ _temperatureSubscription = SensorStreams.shared(temperatureSensor).listen(
+ _onSensorValue,
+ onError: (_) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _temperatureStreamError = 'Optical temperature stream failed.';
+ });
+ },
+ );
+ }
+
+ void _onSensorValue(SensorValue sensorValue) {
+ final values = _valuesFromSensorValue(sensorValue);
+ if (values.isEmpty) {
+ return;
+ }
+
+ final axisIndex =
+ _temperatureAxisIndex < values.length ? _temperatureAxisIndex : 0;
+ final opticalTemp = values[axisIndex];
+ if (!opticalTemp.isFinite) {
+ return;
+ }
+
+ final inEarNow = opticalTemp > _inEarTemperatureThresholdCelsius;
+ final now = DateTime.now();
+
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _temperatureStreamError = null;
+ final wasInEar = _inEarDetected;
+ _inEarDetected = inEarNow;
+ if (wasInEar && !inEarNow) {
+ _resetStabilizationState();
+ }
+
+ if (_inEarDetected) {
+ final estimatedCoreTemp = opticalTemp + _coreOffsetCelsius;
+ final smoothed = _smoothedCoreTempCelsius == null
+ ? estimatedCoreTemp
+ : _smoothedCoreTempCelsius! * (1 - _smoothingFactor) +
+ estimatedCoreTemp * _smoothingFactor;
+ final nextTrend = List.from(_coreTrend)..add(smoothed);
+ if (nextTrend.length > _maxTrendPoints) {
+ nextTrend.removeRange(0, nextTrend.length - _maxTrendPoints);
+ }
+ _smoothedCoreTempCelsius = smoothed;
+ _coreTrend = nextTrend;
+ _sampleCount += 1;
+ _stabilizationStartedAt ??= now;
+ }
+ });
+ }
+
+ int _resolveTemperatureAxis(Sensor sensor) {
+ for (int i = 0; i < sensor.axisNames.length; i++) {
+ final axisName = sensor.axisNames[i].toLowerCase();
+ if (axisName.contains('temp') || axisName.contains('temperature')) {
+ return i;
+ }
+ }
+ return 0;
+ }
+
+ List _valuesFromSensorValue(SensorValue value) {
+ if (value is SensorDoubleValue) {
+ return value.values;
+ }
+ if (value is SensorIntValue) {
+ return value.values.map((entry) => entry.toDouble()).toList();
+ }
+ return value.valueStrings
+ .map(double.tryParse)
+ .whereType()
+ .toList(growable: false);
+ }
+
+ Future _prepareSensorForStreaming(
+ Sensor sensor, {
+ required int targetFrequencyHz,
+ }) async {
+ for (final config in sensor.relatedConfigurations) {
+ _savedConfigurations.putIfAbsent(
+ config,
+ () => widget.sensorConfigProvider.getSelectedConfigurationValue(config),
+ );
+
+ if (config is ConfigurableSensorConfiguration &&
+ config.availableOptions
+ .any((option) => option is StreamSensorConfigOption)) {
+ widget.sensorConfigProvider.addSensorConfigurationOption(
+ config,
+ const StreamSensorConfigOption(),
+ markPending: false,
+ );
+ }
+
+ final availableValues = widget.sensorConfigProvider
+ .getSensorConfigurationValues(config, distinct: true);
+ if (availableValues.isEmpty) {
+ continue;
+ }
+
+ final selectedValue = _selectBestValue(
+ availableValues,
+ targetFrequencyHz: targetFrequencyHz,
+ );
+ widget.sensorConfigProvider.addSensorConfiguration(
+ config,
+ selectedValue,
+ markPending: false,
+ );
+ config.setConfiguration(selectedValue);
+ }
+ }
+
+ SensorConfigurationValue _selectBestValue(
+ List values, {
+ required int targetFrequencyHz,
+ }) {
+ if (values.length == 1) {
+ return values.first;
+ }
+
+ final frequencyValues =
+ values.whereType().toList();
+ if (frequencyValues.isEmpty) {
+ return values.first;
+ }
+
+ SensorFrequencyConfigurationValue? nextBigger;
+ SensorFrequencyConfigurationValue? maxValue;
+
+ for (final value in frequencyValues) {
+ if (maxValue == null || value.frequencyHz > maxValue.frequencyHz) {
+ maxValue = value;
+ }
+ if (value.frequencyHz >= targetFrequencyHz &&
+ (nextBigger == null || value.frequencyHz < nextBigger.frequencyHz)) {
+ nextBigger = value;
+ }
+ }
+
+ return nextBigger ?? maxValue ?? values.first;
+ }
+
+ Future _restoreSensorConfiguration(Sensor sensor) async {
+ for (final config in sensor.relatedConfigurations) {
+ try {
+ final savedValue = _savedConfigurations[config];
+ if (savedValue != null) {
+ widget.sensorConfigProvider.addSensorConfiguration(
+ config,
+ savedValue,
+ markPending: false,
+ );
+ config.setConfiguration(savedValue);
+ continue;
+ }
+
+ final offValue = config.offValue;
+ if (offValue != null) {
+ widget.sensorConfigProvider.addSensorConfiguration(
+ config,
+ offValue,
+ markPending: false,
+ );
+ config.setConfiguration(offValue);
+ }
+
+ if (config is ConfigurableSensorConfiguration &&
+ config.availableOptions
+ .any((option) => option is StreamSensorConfigOption)) {
+ widget.sensorConfigProvider.removeSensorConfigurationOption(
+ config,
+ const StreamSensorConfigOption(),
+ markPending: false,
+ );
+ final selected =
+ widget.sensorConfigProvider.getSelectedConfigurationValue(config);
+ if (selected is ConfigurableSensorConfigurationValue) {
+ config.setConfiguration(selected);
+ }
+ }
+ } catch (_) {
+ // Continue restoring the remaining configurations.
+ }
+ }
+ }
+
+ void _resetStabilizationState() {
+ _stabilizationStartedAt = null;
+ _smoothedCoreTempCelsius = null;
+ _coreTrend = const [];
+ _sampleCount = 0;
+ }
+
+ Duration _stabilizationElapsed(DateTime now) {
+ final startedAt = _stabilizationStartedAt;
+ if (startedAt == null) {
+ return Duration.zero;
+ }
+ final elapsed = now.difference(startedAt);
+ return elapsed.isNegative ? Duration.zero : elapsed;
+ }
+
+ Duration _stabilizationRemaining(DateTime now) {
+ final elapsed = _stabilizationElapsed(now);
+ if (elapsed >= _minimumStabilizationDuration) {
+ return Duration.zero;
+ }
+ return _minimumStabilizationDuration - elapsed;
+ }
+
+ bool _isStabilized(DateTime now) {
+ if (_stabilizationStartedAt == null) {
+ return false;
+ }
+ return _stabilizationElapsed(now) >= _minimumStabilizationDuration;
+ }
+
+ double _stabilizationProgress(DateTime now) {
+ final elapsed = _stabilizationElapsed(now).inMilliseconds;
+ final total = _minimumStabilizationDuration.inMilliseconds;
+ if (total <= 0) {
+ return 1.0;
+ }
+ return (elapsed / total).clamp(0.0, 1.0);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final now = DateTime.now();
+ final canStabilize = _inEarDetected;
+ final isStabilized = canStabilize && _isStabilized(now);
+ final stabilizationRemaining = _stabilizationRemaining(now);
+ final stabilizationProgress = _stabilizationProgress(now);
+ final hasStabilizationStarted = _stabilizationStartedAt != null;
+ final connected = context.watch().wearables.any(
+ (wearable) => wearable.deviceId == widget.wearable.deviceId,
+ );
+ final theme = Theme.of(context);
+ final visibleCoreTemperature =
+ isStabilized ? _smoothedCoreTempCelsius : null;
+ final visibleTrend = isStabilized ? _coreTrend : const [];
+ final feverState = switch ((isStabilized, _inEarDetected)) {
+ (true, _) => _feverStateFor(visibleCoreTemperature, theme),
+ (false, false) => _outOfEarStateFor(theme),
+ _ => _stabilizingStateFor(
+ theme,
+ hasSamples: hasStabilizationStarted,
+ remaining: stabilizationRemaining,
+ ),
+ };
+
+ return PlatformScaffold(
+ appBar: PlatformAppBar(
+ title: PlatformText('Fever Thermometer'),
+ ),
+ body: ListView(
+ padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
+ children: [
+ _HeroFeverCard(
+ wearableName: formatWearableDisplayName(widget.wearable.name),
+ connected: connected,
+ state: feverState,
+ coreTempCelsius: visibleCoreTemperature,
+ streamError: _temperatureStreamError,
+ inEarDetected: _inEarDetected,
+ sampleCount: _sampleCount,
+ isStabilized: isStabilized,
+ hasStabilizationStarted: hasStabilizationStarted,
+ stabilizationRemaining: stabilizationRemaining,
+ stabilizationProgress: stabilizationProgress,
+ minimumStabilizationDuration: _minimumStabilizationDuration,
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ _ThermometerCard(
+ state: feverState,
+ coreTempCelsius: visibleCoreTemperature,
+ coreTrend: visibleTrend,
+ ),
+ const SizedBox(height: 8),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ child: _DemoOnlyFootnote(
+ text:
+ 'This view is for demonstration purposes only. It is not a medical device and must not be used for diagnosis, treatment, or emergency decisions.',
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _HeroFeverCard extends StatelessWidget {
+ final String wearableName;
+ final bool connected;
+ final _FeverState state;
+ final double? coreTempCelsius;
+ final String? streamError;
+ final bool inEarDetected;
+ final int sampleCount;
+ final bool isStabilized;
+ final bool hasStabilizationStarted;
+ final Duration stabilizationRemaining;
+ final double stabilizationProgress;
+ final Duration minimumStabilizationDuration;
+
+ const _HeroFeverCard({
+ required this.wearableName,
+ required this.connected,
+ required this.state,
+ required this.coreTempCelsius,
+ required this.streamError,
+ required this.inEarDetected,
+ required this.sampleCount,
+ required this.isStabilized,
+ required this.hasStabilizationStarted,
+ required this.stabilizationRemaining,
+ required this.stabilizationProgress,
+ required this.minimumStabilizationDuration,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final coreText = coreTempCelsius != null
+ ? '${coreTempCelsius!.toStringAsFixed(1)} °C'
+ : '--.- °C';
+ final statusMessage = _statusMessage(
+ inEarDetected: inEarDetected,
+ isStabilized: isStabilized,
+ hasStabilizationStarted: hasStabilizationStarted,
+ stabilizationRemaining: stabilizationRemaining,
+ minimumStabilizationDuration: minimumStabilizationDuration,
+ stateDetail: state.detail,
+ );
+ final canShowProgress = !isStabilized && inEarDetected;
+ final combinedStatusPillLabel = _combinedStatusPillLabel(
+ stateLabel: state.label,
+ inEarDetected: inEarDetected,
+ );
+
+ return Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ state.gradientStart,
+ state.gradientEnd,
+ ],
+ ),
+ borderRadius: BorderRadius.circular(18),
+ boxShadow: [
+ BoxShadow(
+ color: state.statusColor.withValues(alpha: 0.22),
+ blurRadius: 14,
+ offset: const Offset(0, 8),
+ ),
+ ],
+ ),
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ height: 34,
+ width: 34,
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.2),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: const Icon(
+ Icons.thermostat_rounded,
+ color: Colors.white,
+ size: 21,
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ wearableName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.titleMedium?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ _SignalPill(
+ label: connected ? 'Connected' : 'Disconnected',
+ color: connected
+ ? const Color(0xFF2F8F5B)
+ : theme.colorScheme.error,
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Expanded(
+ child: Text(
+ coreText,
+ style: theme.textTheme.headlineMedium?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w800,
+ height: 1.02,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 2),
+ Text(
+ statusMessage,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: Colors.white.withValues(alpha: 0.94),
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ if (canShowProgress) ...[
+ const SizedBox(height: 6),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(999),
+ child: LinearProgressIndicator(
+ value: stabilizationProgress,
+ minHeight: 7,
+ backgroundColor: Colors.white.withValues(alpha: 0.2),
+ valueColor: const AlwaysStoppedAnimation(Colors.white),
+ ),
+ ),
+ ],
+ const SizedBox(height: 10),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ _SignalPill(
+ label: combinedStatusPillLabel,
+ color: Colors.white.withValues(alpha: 0.14),
+ textColor: Colors.white,
+ ),
+ _SignalPill(
+ label: _sampleLabel(sampleCount),
+ color: Colors.white.withValues(alpha: 0.14),
+ textColor: Colors.white,
+ ),
+ ],
+ ),
+ if (streamError != null) ...[
+ const SizedBox(height: 10),
+ Text(
+ streamError!,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.errorContainer,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+
+ static String _sampleLabel(int count) {
+ if (count <= 0) {
+ return 'No samples yet';
+ }
+ return '$count samples';
+ }
+
+ static String _statusMessage({
+ required bool inEarDetected,
+ required bool isStabilized,
+ required bool hasStabilizationStarted,
+ required Duration stabilizationRemaining,
+ required Duration minimumStabilizationDuration,
+ required String stateDetail,
+ }) {
+ if (isStabilized) {
+ return stateDetail;
+ }
+
+ if (!inEarDetected) {
+ return 'Reinsert device to restart the timer.';
+ }
+
+ if (hasStabilizationStarted) {
+ return 'Stabilizing for ${_formatClock(stabilizationRemaining)} more. Result unlocks after ${minimumStabilizationDuration.inMinutes} minutes.';
+ }
+
+ return 'Stabilization starts after first optical sample. Result is hidden until ${minimumStabilizationDuration.inMinutes} minutes are complete.';
+ }
+
+ static String _combinedStatusPillLabel({
+ required String stateLabel,
+ required bool inEarDetected,
+ }) {
+ return '$stateLabel • ${inEarDetected ? 'In ear' : 'Out of ear'}';
+ }
+
+ static String _formatClock(Duration duration) {
+ final totalSeconds = max(0, duration.inSeconds);
+ final minutes = totalSeconds ~/ 60;
+ final seconds = totalSeconds % 60;
+ return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
+ }
+}
+
+class _ThermometerCard extends StatelessWidget {
+ final _FeverState state;
+ final double? coreTempCelsius;
+ final List coreTrend;
+
+ const _ThermometerCard({
+ required this.state,
+ required this.coreTempCelsius,
+ required this.coreTrend,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final valueText = coreTempCelsius != null
+ ? '${coreTempCelsius!.toStringAsFixed(1)} °C'
+ : '--.- °C';
+
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Fever Thermometer',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 3),
+ Text(
+ 'Adjusted core estimate with recent trend.',
+ style: theme.textTheme.bodySmall,
+ ),
+ const SizedBox(height: 12),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _ThermometerGauge(
+ valueCelsius: coreTempCelsius,
+ fillColor: state.statusColor,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ valueText,
+ style: theme.textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.w800,
+ color: state.statusColor,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ state.label,
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 8),
+ if (coreTrend.length > 1)
+ Container(
+ height: 70,
+ padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
+ decoration: BoxDecoration(
+ color: state.statusColor.withValues(alpha: 0.08),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: state.statusColor.withValues(alpha: 0.2),
+ ),
+ ),
+ child: _TemperatureTrendSparkline(
+ values: coreTrend,
+ color: state.statusColor,
+ ),
+ )
+ else
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 14,
+ ),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest
+ .withValues(alpha: 0.42),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Text(
+ 'Collecting enough samples for trend view...',
+ style: theme.textTheme.bodySmall?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _DemoOnlyFootnote extends StatelessWidget {
+ final String text;
+
+ const _DemoOnlyFootnote({
+ required this.text,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(
+ Icons.warning_amber_rounded,
+ size: 13,
+ color: Theme.of(context).colorScheme.error,
+ ),
+ const SizedBox(width: 5),
+ Expanded(
+ child: Text(
+ text,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _ThermometerGauge extends StatelessWidget {
+ final double? valueCelsius;
+ final Color fillColor;
+
+ const _ThermometerGauge({
+ required this.valueCelsius,
+ required this.fillColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ const minCelsius = 35.0;
+ const maxCelsius = 41.0;
+ final theme = Theme.of(context);
+ final normalized = valueCelsius == null
+ ? 0.0
+ : ((valueCelsius! - minCelsius) / (maxCelsius - minCelsius))
+ .clamp(0.0, 1.0);
+ final ticks = List.generate(7, (index) => 35.0 + index.toDouble());
+
+ return SizedBox(
+ width: 122,
+ height: 226,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Positioned(
+ left: 30,
+ bottom: 20,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: [
+ Container(
+ width: 34,
+ height: 162,
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceContainerHighest
+ .withValues(alpha: 0.55),
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(
+ color: theme.colorScheme.outline.withValues(alpha: 0.2),
+ ),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(5, 5, 5, 5),
+ child: Align(
+ alignment: Alignment.bottomCenter,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 340),
+ curve: Curves.easeOutCubic,
+ width: double.infinity,
+ height: max(0, 152 * normalized),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(999),
+ gradient: LinearGradient(
+ begin: Alignment.bottomCenter,
+ end: Alignment.topCenter,
+ colors: [
+ fillColor.withValues(alpha: 0.92),
+ fillColor.withValues(alpha: 0.55),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ bottom: -18,
+ child: Container(
+ width: 52,
+ height: 52,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ gradient: LinearGradient(
+ begin: Alignment.bottomLeft,
+ end: Alignment.topRight,
+ colors: [
+ fillColor.withValues(alpha: 0.95),
+ fillColor.withValues(alpha: 0.72),
+ ],
+ ),
+ border: Border.all(
+ color: fillColor.withValues(alpha: 0.34),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ...ticks.map((tick) {
+ final fraction = (tick - minCelsius) / (maxCelsius - minCelsius);
+ final topPosition = 10 + (1 - fraction) * 162;
+
+ return Positioned(
+ left: 72,
+ top: topPosition,
+ child: Row(
+ children: [
+ Container(
+ width: 14,
+ height: 1.4,
+ color: theme.colorScheme.onSurfaceVariant
+ .withValues(alpha: 0.44),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ '${tick.toStringAsFixed(0)}°',
+ style: theme.textTheme.labelSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ );
+ }),
+ ],
+ ),
+ );
+ }
+}
+
+class _TemperatureTrendSparkline extends StatelessWidget {
+ final List values;
+ final Color color;
+
+ const _TemperatureTrendSparkline({
+ required this.values,
+ required this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return CustomPaint(
+ painter: _TrendPainter(
+ values: values,
+ lineColor: color,
+ ),
+ child: const SizedBox.expand(),
+ );
+ }
+}
+
+class _TrendPainter extends CustomPainter {
+ final List values;
+ final Color lineColor;
+
+ _TrendPainter({
+ required this.values,
+ required this.lineColor,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (values.length < 2 || size.width <= 0 || size.height <= 0) {
+ return;
+ }
+
+ var minValue = values.first;
+ var maxValue = values.first;
+ for (final value in values) {
+ minValue = min(minValue, value);
+ maxValue = max(maxValue, value);
+ }
+ if ((maxValue - minValue).abs() < 0.05) {
+ maxValue = minValue + 0.05;
+ }
+
+ final path = Path();
+ final areaPath = Path();
+ for (int i = 0; i < values.length; i++) {
+ final x = (i / (values.length - 1)) * size.width;
+ final normalized = (values[i] - minValue) / (maxValue - minValue);
+ final y = size.height - normalized * size.height;
+ if (i == 0) {
+ path.moveTo(x, y);
+ areaPath.moveTo(x, size.height);
+ areaPath.lineTo(x, y);
+ } else {
+ path.lineTo(x, y);
+ areaPath.lineTo(x, y);
+ }
+ }
+ areaPath.lineTo(size.width, size.height);
+ areaPath.close();
+
+ final fillPaint = Paint()
+ ..shader = LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ lineColor.withValues(alpha: 0.28),
+ lineColor.withValues(alpha: 0.02),
+ ],
+ ).createShader(Offset.zero & size);
+ canvas.drawPath(areaPath, fillPaint);
+
+ final linePaint = Paint()
+ ..color = lineColor
+ ..strokeWidth = 2.1
+ ..style = PaintingStyle.stroke
+ ..strokeCap = StrokeCap.round
+ ..strokeJoin = StrokeJoin.round;
+ canvas.drawPath(path, linePaint);
+ }
+
+ @override
+ bool shouldRepaint(covariant _TrendPainter oldDelegate) {
+ return oldDelegate.values != values || oldDelegate.lineColor != lineColor;
+ }
+}
+
+class _SignalPill extends StatelessWidget {
+ final String label;
+ final Color color;
+ final Color? textColor;
+
+ const _SignalPill({
+ required this.label,
+ required this.color,
+ this.textColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: textColor ?? Colors.white,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ );
+ }
+}
+
+class _FeverState {
+ final String label;
+ final String detail;
+ final Color statusColor;
+ final Color gradientStart;
+ final Color gradientEnd;
+
+ const _FeverState({
+ required this.label,
+ required this.detail,
+ required this.statusColor,
+ required this.gradientStart,
+ required this.gradientEnd,
+ });
+}
+
+String _formatClockDuration(Duration duration) {
+ final totalSeconds = max(0, duration.inSeconds);
+ final minutes = totalSeconds ~/ 60;
+ final seconds = totalSeconds % 60;
+ return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
+}
+
+_FeverState _stabilizingStateFor(
+ ThemeData theme, {
+ required bool hasSamples,
+ required Duration remaining,
+}) {
+ if (!hasSamples) {
+ return _FeverState(
+ label: 'Waiting for data',
+ detail:
+ 'Insert the device into your ear and keep still to start stabilization.',
+ statusColor: theme.colorScheme.primary,
+ gradientStart: const Color(0xFF7A6552),
+ gradientEnd: const Color(0xFF9C826D),
+ );
+ }
+
+ return _FeverState(
+ label: 'Stabilizing',
+ detail:
+ 'Measuring for stability. ${_formatClockDuration(remaining)} remaining before result.',
+ statusColor: theme.colorScheme.primary,
+ gradientStart: const Color(0xFF7A6552),
+ gradientEnd: const Color(0xFF9C826D),
+ );
+}
+
+_FeverState _outOfEarStateFor(ThemeData theme) {
+ return _FeverState(
+ label: 'Out of ear',
+ detail: 'Device appears out of ear. Reinsert it and keep it steady.',
+ statusColor: theme.colorScheme.error,
+ gradientStart: const Color(0xFF8A5D54),
+ gradientEnd: const Color(0xFFB0766A),
+ );
+}
+
+_FeverState _feverStateFor(double? value, ThemeData theme) {
+ if (value == null) {
+ return _FeverState(
+ label: 'Waiting for data',
+ detail: 'Insert device into ear to start.',
+ statusColor: theme.colorScheme.primary,
+ gradientStart: const Color(0xFF7A6552),
+ gradientEnd: const Color(0xFF9C826D),
+ );
+ }
+
+ if (value < 35.5) {
+ return const _FeverState(
+ label: 'Below baseline',
+ detail: 'Reading is lower than typical core-body range.',
+ statusColor: Color(0xFF3C86C2),
+ gradientStart: Color(0xFF507DB0),
+ gradientEnd: Color(0xFF6B9AC7),
+ );
+ }
+ if (value < 37.5) {
+ return const _FeverState(
+ label: 'Normal',
+ detail: 'Estimated core temperature is within normal range.',
+ statusColor: Color(0xFF2F8F5B),
+ gradientStart: Color(0xFF4B8D66),
+ gradientEnd: Color(0xFF6AAC7F),
+ );
+ }
+ if (value < 38.0) {
+ return const _FeverState(
+ label: 'Elevated',
+ detail: 'Temperature is mildly elevated; keep monitoring.',
+ statusColor: Color(0xFFC18C2C),
+ gradientStart: Color(0xFFAA7A2A),
+ gradientEnd: Color(0xFFCB9A41),
+ );
+ }
+ if (value < 39.5) {
+ return const _FeverState(
+ label: 'Fever',
+ detail: 'Estimated fever range detected.',
+ statusColor: Color(0xFFC75E2F),
+ gradientStart: Color(0xFFB5562D),
+ gradientEnd: Color(0xFFD27A45),
+ );
+ }
+ return const _FeverState(
+ label: 'High fever',
+ detail: 'High temperature estimate. Please verify with care.',
+ statusColor: Color(0xFFC53A3A),
+ gradientStart: Color(0xFFA73636),
+ gradientEnd: Color(0xFFCD5D50),
+ );
+}
diff --git a/open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart b/open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart
index e3727350..11996256 100644
--- a/open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart
+++ b/open_wearable/lib/apps/heart_tracker/model/band_pass_filter.dart
@@ -8,17 +8,35 @@ class BandPassFilter {
late final double a0, a1, a2, b1, b2;
double x1 = 0, x2 = 0;
double y1 = 0, y2 = 0;
+ bool _isInitialized = false;
BandPassFilter({
required this.sampleFreq,
required this.lowCut,
required this.highCut,
}) {
- final centerFreq = sqrt(lowCut * highCut);
- final bandwidth = highCut - lowCut;
+ final safeSampleFreq =
+ sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
+ final nyquist = safeSampleFreq / 2.0;
+
+ var safeLow = lowCut;
+ if (!safeLow.isFinite || safeLow <= 0) {
+ safeLow = 0.45;
+ }
+
+ var safeHigh = highCut;
+ if (!safeHigh.isFinite || safeHigh <= safeLow) {
+ safeHigh = safeLow + 0.6;
+ }
+ safeHigh = min(safeHigh, nyquist - 0.05);
+ safeLow = min(safeLow, safeHigh - 0.15);
+ safeLow = max(0.05, safeLow);
+
+ final centerFreq = sqrt(safeLow * safeHigh);
+ final bandwidth = max(0.15, safeHigh - safeLow);
final q = centerFreq / bandwidth;
- final omega = 2 * pi * centerFreq / sampleFreq;
+ final omega = 2 * pi * centerFreq / safeSampleFreq;
final alpha = sin(omega) / (2 * q);
final cosOmega = cos(omega);
@@ -32,6 +50,15 @@ class BandPassFilter {
}
double filter(double x) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ x1 = x;
+ x2 = x;
+ y1 = 0;
+ y2 = 0;
+ return 0;
+ }
+
final y = a0 * x + a1 * x1 + a2 * x2 - b1 * y1 - b2 * y2;
x2 = x1;
x1 = x;
diff --git a/open_wearable/lib/apps/heart_tracker/model/msptd_fast_v2_detector.dart b/open_wearable/lib/apps/heart_tracker/model/msptd_fast_v2_detector.dart
new file mode 100644
index 00000000..ae5e717b
--- /dev/null
+++ b/open_wearable/lib/apps/heart_tracker/model/msptd_fast_v2_detector.dart
@@ -0,0 +1,265 @@
+import 'dart:math';
+import 'dart:typed_data';
+
+/// Adapted from `msptdfastv2_beat_detector.m` in:
+/// https://github.com/peterhcharlton/ppg-beats (MIT-licensed file).
+///
+/// The original implementation is MATLAB; this is a Dart adaptation for
+/// real-time windowed use in the app.
+class MsptdFastV2Detector {
+ const MsptdFastV2Detector();
+
+ static const double _minPlausibleHeartRateBpm = 30.0;
+ static const double _targetDownsampleHz = 20.0;
+
+ List detectPeakIndices(
+ List samples, {
+ required double sampleFreqHz,
+ }) {
+ if (samples.length < 8 || !sampleFreqHz.isFinite || sampleFreqHz <= 0) {
+ return const [];
+ }
+
+ final prepared = _prepareSignal(
+ samples,
+ sampleFreqHz: sampleFreqHz,
+ );
+ final detrended = _detrend(prepared.samples);
+ final candidatePeaks = _detectPeakCandidates(
+ detrended,
+ sampleFreqHz: prepared.sampleFreqHz,
+ );
+ if (candidatePeaks.isEmpty) {
+ return const [];
+ }
+
+ final refinedPeaks = _refineInOriginalSignal(
+ candidates: candidatePeaks,
+ originalSignal: samples,
+ originalSampleFreqHz: sampleFreqHz,
+ workingSampleFreqHz: prepared.sampleFreqHz,
+ downsampleFactor: prepared.downsampleFactor,
+ );
+
+ final minDistanceSamples = max(1, (0.28 * sampleFreqHz).round());
+ return _enforceMinimumDistance(
+ refinedPeaks,
+ minDistanceSamples: minDistanceSamples,
+ );
+ }
+
+ _PreparedSignal _prepareSignal(
+ List samples, {
+ required double sampleFreqHz,
+ }) {
+ var downsampleFactor = 1;
+ if (sampleFreqHz > _targetDownsampleHz) {
+ downsampleFactor = max(1, (sampleFreqHz / _targetDownsampleHz).floor());
+ }
+
+ if (downsampleFactor <= 1) {
+ return _PreparedSignal(
+ samples: samples,
+ sampleFreqHz: sampleFreqHz,
+ downsampleFactor: 1,
+ );
+ }
+
+ final downsampled = [];
+ for (var i = 0; i < samples.length; i += downsampleFactor) {
+ downsampled.add(samples[i]);
+ }
+
+ return _PreparedSignal(
+ samples: downsampled,
+ sampleFreqHz: sampleFreqHz / downsampleFactor,
+ downsampleFactor: downsampleFactor,
+ );
+ }
+
+ List _detrend(List signal) {
+ final n = signal.length;
+ if (n < 2) {
+ return signal;
+ }
+
+ final sumX = (n - 1) * n / 2.0;
+ final sumX2 = (n - 1) * n * ((2 * n) - 1) / 6.0;
+ var sumY = 0.0;
+ var sumXY = 0.0;
+ for (var i = 0; i < n; i++) {
+ final y = signal[i];
+ sumY += y;
+ sumXY += i * y;
+ }
+
+ final denominator = (n * sumX2) - (sumX * sumX);
+ final slope = denominator.abs() < 1e-9
+ ? 0.0
+ : ((n * sumXY) - (sumX * sumY)) / denominator;
+ final intercept = (sumY - (slope * sumX)) / n;
+
+ return List.generate(
+ n,
+ (i) => signal[i] - (intercept + (slope * i)),
+ growable: false,
+ );
+ }
+
+ List _detectPeakCandidates(
+ List signal, {
+ required double sampleFreqHz,
+ }) {
+ final n = signal.length;
+ if (n < 5 || !sampleFreqHz.isFinite || sampleFreqHz <= 0) {
+ return const [];
+ }
+
+ final halfLength = (n / 2).ceil() - 1;
+ if (halfLength < 1) {
+ return const [];
+ }
+
+ final maxScale = _reduceScalesForPlausibleHeartRates(
+ halfLength: halfLength,
+ signalLength: n,
+ sampleFreqHz: sampleFreqHz,
+ );
+
+ final mMax = List.generate(
+ maxScale,
+ (_) => Uint8List(n),
+ growable: false,
+ );
+
+ for (var k = 1; k <= maxScale; k++) {
+ final row = mMax[k - 1];
+ for (var i = k; i < n - k; i++) {
+ if (signal[i] > signal[i - k] && signal[i] > signal[i + k]) {
+ row[i] = 1;
+ }
+ }
+ }
+
+ var lambdaRow = 0;
+ var bestRowSum = -1;
+ for (var rowIndex = 0; rowIndex < mMax.length; rowIndex++) {
+ var rowSum = 0;
+ final row = mMax[rowIndex];
+ for (var i = 0; i < row.length; i++) {
+ rowSum += row[i];
+ }
+ if (rowSum > bestRowSum) {
+ bestRowSum = rowSum;
+ lambdaRow = rowIndex;
+ }
+ }
+
+ final peaks = [];
+ for (var col = 0; col < n; col++) {
+ var isPeak = true;
+ for (var row = 0; row <= lambdaRow; row++) {
+ if (mMax[row][col] == 0) {
+ isPeak = false;
+ break;
+ }
+ }
+ if (isPeak) {
+ peaks.add(col);
+ }
+ }
+ return peaks;
+ }
+
+ int _reduceScalesForPlausibleHeartRates({
+ required int halfLength,
+ required int signalLength,
+ required double sampleFreqHz,
+ }) {
+ final durationSeconds = signalLength / sampleFreqHz;
+ if (!durationSeconds.isFinite || durationSeconds <= 0) {
+ return halfLength;
+ }
+ final minPlausibleHz = _minPlausibleHeartRateBpm / 60.0;
+
+ var reducedMaxScale = 1;
+ for (var k = 1; k <= halfLength; k++) {
+ final scaleFrequencyHz = (halfLength / k) / durationSeconds;
+ if (scaleFrequencyHz >= minPlausibleHz) {
+ reducedMaxScale = k;
+ }
+ }
+
+ return reducedMaxScale.clamp(1, halfLength);
+ }
+
+ List _refineInOriginalSignal({
+ required List candidates,
+ required List originalSignal,
+ required double originalSampleFreqHz,
+ required double workingSampleFreqHz,
+ required int downsampleFactor,
+ }) {
+ if (candidates.isEmpty || originalSignal.isEmpty) {
+ return const [];
+ }
+
+ final toleranceSeconds = workingSampleFreqHz < 10
+ ? 0.2
+ : (workingSampleFreqHz < 20 ? 0.1 : 0.05);
+ final toleranceSamples = max(1, (originalSampleFreqHz * toleranceSeconds).round());
+ final refined = [];
+
+ for (final candidate in candidates) {
+ final approxIndex = candidate * downsampleFactor;
+ if (approxIndex < 0 || approxIndex >= originalSignal.length) {
+ continue;
+ }
+
+ final start = max(0, approxIndex - toleranceSamples);
+ final end = min(originalSignal.length - 1, approxIndex + toleranceSamples);
+ var maxIndex = start;
+ var maxValue = originalSignal[start];
+ for (var i = start + 1; i <= end; i++) {
+ final value = originalSignal[i];
+ if (value > maxValue) {
+ maxValue = value;
+ maxIndex = i;
+ }
+ }
+ refined.add(maxIndex);
+ }
+
+ refined.sort();
+ return refined;
+ }
+
+ List _enforceMinimumDistance(
+ List peaks, {
+ required int minDistanceSamples,
+ }) {
+ if (peaks.isEmpty) {
+ return const [];
+ }
+
+ final deduped = [peaks.first];
+ for (var i = 1; i < peaks.length; i++) {
+ if (peaks[i] - deduped.last >= minDistanceSamples) {
+ deduped.add(peaks[i]);
+ }
+ }
+ return deduped;
+ }
+}
+
+class _PreparedSignal {
+ final List samples;
+ final double sampleFreqHz;
+ final int downsampleFactor;
+
+ const _PreparedSignal({
+ required this.samples,
+ required this.sampleFreqHz,
+ required this.downsampleFactor,
+ });
+}
diff --git a/open_wearable/lib/apps/heart_tracker/model/open_ring_classic_heart_processor.dart b/open_wearable/lib/apps/heart_tracker/model/open_ring_classic_heart_processor.dart
new file mode 100644
index 00000000..6a2ee52d
--- /dev/null
+++ b/open_wearable/lib/apps/heart_tracker/model/open_ring_classic_heart_processor.dart
@@ -0,0 +1,392 @@
+import 'dart:async';
+import 'dart:collection';
+import 'dart:math';
+
+import 'package:open_wearable/apps/heart_tracker/model/ppg_filter.dart';
+
+class OpenRingClassicHeartProcessor {
+ final Stream inputStream;
+ final double sampleFreq;
+
+ Stream<_OpenRingSampleOutput>? _outputStream;
+ Stream<(int, double)>? _displaySignalStream;
+ Stream? _heartRateStream;
+ Stream? _hrvStream;
+ Stream? _signalQualityStream;
+
+ OpenRingClassicHeartProcessor({
+ required this.inputStream,
+ required this.sampleFreq,
+ });
+
+ Stream<(int, double)> get displaySignalStream {
+ if (_displaySignalStream != null) {
+ return _displaySignalStream!;
+ }
+ _displaySignalStream = _sampleStream
+ .map((sample) => (sample.timestamp, sample.filteredSignal))
+ .asBroadcastStream();
+ return _displaySignalStream!;
+ }
+
+ Stream get heartRateStream {
+ if (_heartRateStream != null) {
+ return _heartRateStream!;
+ }
+ _heartRateStream =
+ _sampleStream.map((sample) => sample.heartRateBpm).distinct();
+ return _heartRateStream!;
+ }
+
+ Stream get hrvStream {
+ if (_hrvStream != null) {
+ return _hrvStream!;
+ }
+ _hrvStream = _sampleStream.map((sample) => sample.hrvRmssdMs).distinct();
+ return _hrvStream!;
+ }
+
+ Stream get signalQualityStream {
+ if (_signalQualityStream != null) {
+ return _signalQualityStream!;
+ }
+ _signalQualityStream =
+ _sampleStream.map((sample) => sample.signalQuality).distinct();
+ return _signalQualityStream!;
+ }
+
+ void dispose() {}
+
+ Stream<_OpenRingSampleOutput> get _sampleStream {
+ if (_outputStream != null) {
+ return _outputStream!;
+ }
+ _outputStream = _createOutputStream().asBroadcastStream();
+ return _outputStream!;
+ }
+
+ Stream<_OpenRingSampleOutput> _createOutputStream() async* {
+ final safeSampleFreq =
+ sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
+ final hrWindowSize = max(12, (safeSampleFreq * 5).round());
+ final hrUpdateIntervalSamples = max(1, safeSampleFreq.round());
+ final historyLength = 5;
+
+ final selectedSignalBuffer = ListQueue();
+ final qualitySignalBuffer = ListQueue();
+ final hrHistory = ListQueue();
+
+ var samplesSinceLastHrUpdate = 0;
+ double? currentHeartRate;
+ double? currentHrv;
+ var currentQuality = PpgSignalQuality.unavailable;
+
+ await for (final sample in inputStream) {
+ final selectedSignal = _selectHeartSignal(sample);
+ final qualitySignal = _selectQualitySignal(sample);
+
+ _pushLimited(selectedSignalBuffer, selectedSignal, hrWindowSize);
+ _pushLimited(qualitySignalBuffer, qualitySignal, hrWindowSize);
+
+ final selectedList = selectedSignalBuffer.toList(growable: false);
+ final filteredSignal = _applyPhysiologicalFilter(
+ selectedList,
+ sampleFreqHz: safeSampleFreq,
+ );
+ final filteredValue =
+ filteredSignal.isNotEmpty ? filteredSignal.last : selectedSignal;
+
+ currentQuality = _estimateSignalQuality(qualitySignalBuffer);
+ samplesSinceLastHrUpdate += 1;
+ if (samplesSinceLastHrUpdate >= hrUpdateIntervalSamples) {
+ samplesSinceLastHrUpdate = 0;
+
+ if (currentQuality == PpgSignalQuality.bad ||
+ currentQuality == PpgSignalQuality.unavailable) {
+ currentHeartRate = null;
+ currentHrv = null;
+ } else {
+ final estimate = _estimateHeartRateAndHrv(
+ filteredSignal,
+ sampleRateHz: safeSampleFreq,
+ );
+ final estimatedHeartRate = estimate.heartRateBpm;
+ if (estimatedHeartRate != null) {
+ _pushLimited(hrHistory, estimatedHeartRate, historyLength);
+ currentHeartRate = _weightedAverage(hrHistory);
+ } else {
+ currentHeartRate = null;
+ }
+ currentHrv = estimate.hrvRmssdMs;
+ }
+ }
+
+ yield _OpenRingSampleOutput(
+ timestamp: sample.timestamp,
+ filteredSignal: filteredValue,
+ heartRateBpm: currentHeartRate,
+ hrvRmssdMs: currentHrv,
+ signalQuality: currentQuality,
+ );
+ }
+ }
+
+ void _pushLimited(ListQueue queue, double value, int maxSize) {
+ queue.addLast(value);
+ while (queue.length > maxSize) {
+ queue.removeFirst();
+ }
+ }
+
+ double _weightedAverage(ListQueue values) {
+ if (values.isEmpty) {
+ return 0;
+ }
+ var weightedSum = 0.0;
+ var totalWeight = 0;
+ for (var i = 0; i < values.length; i++) {
+ final weight = i + 1;
+ weightedSum += values.elementAt(i) * weight;
+ totalWeight += weight;
+ }
+ return weightedSum / max(1, totalWeight);
+ }
+
+ double _selectHeartSignal(PpgOpticalSample sample) {
+ // Match OpenRing classical inference path: HR is derived from IR.
+ if (sample.ir.isFinite && sample.ir.abs() > 1e-6) {
+ return sample.ir;
+ }
+ // Defensive fallback only when IR is missing.
+ if (sample.red.isFinite) {
+ return sample.red;
+ }
+ if (sample.green.isFinite) {
+ return sample.green;
+ }
+ return 0;
+ }
+
+ double _selectQualitySignal(PpgOpticalSample sample) {
+ if (sample.green.isFinite) {
+ return sample.green;
+ }
+ return 0;
+ }
+
+ List _applyPhysiologicalFilter(
+ List signal, {
+ required double sampleFreqHz,
+ }) {
+ if (signal.isEmpty) {
+ return const [];
+ }
+
+ final lowPassWindow = max(3, (sampleFreqHz * 0.5).round());
+ final trendWindow = max(lowPassWindow + 2, (sampleFreqHz * 2.0).round());
+ final lowPassed = _centeredMovingAverage(signal, lowPassWindow);
+ final trend = _centeredMovingAverage(lowPassed, trendWindow);
+
+ return List.generate(
+ signal.length,
+ (index) => lowPassed[index] - trend[index],
+ growable: false,
+ );
+ }
+
+ List _centeredMovingAverage(
+ List signal,
+ int windowSize,
+ ) {
+ if (signal.isEmpty || windowSize <= 1) {
+ return List.from(signal);
+ }
+ final output = List.filled(signal.length, 0, growable: false);
+ final half = windowSize ~/ 2;
+ for (var i = 0; i < signal.length; i++) {
+ final start = max(0, i - half);
+ final end = min(signal.length, i + half + 1);
+ var sum = 0.0;
+ for (var j = start; j < end; j++) {
+ sum += signal[j];
+ }
+ output[i] = sum / max(1, end - start);
+ }
+ return output;
+ }
+
+ ({
+ double? heartRateBpm,
+ double? hrvRmssdMs,
+ }) _estimateHeartRateAndHrv(
+ List filteredSignal, {
+ required double sampleRateHz,
+ }) {
+ if (filteredSignal.length < 8 ||
+ sampleRateHz <= 0 ||
+ !sampleRateHz.isFinite) {
+ return (heartRateBpm: null, hrvRmssdMs: null);
+ }
+
+ final peaks = _detectPeaks(
+ filteredSignal,
+ thresholdRatio: 0.0,
+ sampleRateHz: sampleRateHz,
+ minIntervalSec: 0.4,
+ );
+ if (peaks.length < 2) {
+ return (heartRateBpm: null, hrvRmssdMs: null);
+ }
+
+ final intervalsSec = [];
+ for (var i = 1; i < peaks.length; i++) {
+ final intervalSec = (peaks[i] - peaks[i - 1]) / sampleRateHz;
+ if (intervalSec >= 0.3 && intervalSec <= 1.5) {
+ intervalsSec.add(intervalSec);
+ }
+ }
+ if (intervalsSec.isEmpty) {
+ return (heartRateBpm: null, hrvRmssdMs: null);
+ }
+
+ intervalsSec.sort();
+ final medianInterval = intervalsSec[intervalsSec.length ~/ 2];
+ final heartRate = 60.0 / medianInterval;
+ if (!heartRate.isFinite || heartRate < 40 || heartRate > 200) {
+ return (heartRateBpm: null, hrvRmssdMs: null);
+ }
+
+ double? rmssdMs;
+ if (intervalsSec.length >= 2) {
+ var sumSquared = 0.0;
+ var count = 0;
+ for (var i = 1; i < intervalsSec.length; i++) {
+ final delta = intervalsSec[i] - intervalsSec[i - 1];
+ sumSquared += delta * delta;
+ count += 1;
+ }
+ if (count > 0) {
+ final rmssdSec = sqrt(sumSquared / count);
+ final value = rmssdSec * 1000.0;
+ if (value.isFinite && value >= 5 && value <= 300) {
+ rmssdMs = value;
+ }
+ }
+ }
+
+ return (
+ heartRateBpm: heartRate,
+ hrvRmssdMs: rmssdMs,
+ );
+ }
+
+ List _detectPeaks(
+ List signal, {
+ required double thresholdRatio,
+ required double sampleRateHz,
+ required double minIntervalSec,
+ }) {
+ final peaks = [];
+ if (signal.length < 3) {
+ return peaks;
+ }
+
+ var minValue = double.infinity;
+ var maxValue = double.negativeInfinity;
+ var mean = 0.0;
+ for (final value in signal) {
+ if (value < minValue) minValue = value;
+ if (value > maxValue) maxValue = value;
+ mean += value;
+ }
+ mean /= signal.length;
+
+ final dynamicThreshold = mean + ((maxValue - mean) * thresholdRatio);
+ final minDistanceSamples = minIntervalSec > 0
+ ? max(1, (minIntervalSec * sampleRateHz).round())
+ : 0;
+
+ var lastPeakIndex = -minDistanceSamples;
+ var i = 0;
+ while (i < signal.length) {
+ if (signal[i] >= dynamicThreshold) {
+ var regionMaxIndex = i;
+ var regionMaxValue = signal[i];
+ var j = i + 1;
+ while (j < signal.length && signal[j] >= dynamicThreshold) {
+ if (signal[j] > regionMaxValue) {
+ regionMaxValue = signal[j];
+ regionMaxIndex = j;
+ }
+ j += 1;
+ }
+
+ if (minDistanceSamples <= 0 ||
+ peaks.isEmpty ||
+ (regionMaxIndex - lastPeakIndex) >= minDistanceSamples) {
+ peaks.add(regionMaxIndex);
+ lastPeakIndex = regionMaxIndex;
+ }
+ i = j;
+ continue;
+ }
+ i += 1;
+ }
+
+ return peaks;
+ }
+
+ PpgSignalQuality _estimateSignalQuality(ListQueue samples) {
+ if (samples.isEmpty) {
+ return PpgSignalQuality.unavailable;
+ }
+
+ var minValue = double.infinity;
+ var maxValue = double.negativeInfinity;
+ var sum = 0.0;
+ for (final value in samples) {
+ if (value < minValue) minValue = value;
+ if (value > maxValue) maxValue = value;
+ sum += value;
+ }
+ final mean = sum / samples.length;
+ final range = maxValue - minValue;
+
+ if (!mean.isFinite || !range.isFinite || range <= 1e-6) {
+ return PpgSignalQuality.unavailable;
+ }
+
+ // Mirrors OpenRing's threshold-style quality gating:
+ // quality from signal mean + dynamic range.
+ if (mean > 1000 && range > 500) {
+ if (range > 2000) {
+ return PpgSignalQuality.good;
+ }
+ if (range > 1500) {
+ return PpgSignalQuality.good;
+ }
+ if (range > 1000) {
+ return PpgSignalQuality.fair;
+ }
+ return PpgSignalQuality.bad;
+ }
+
+ return PpgSignalQuality.bad;
+ }
+}
+
+class _OpenRingSampleOutput {
+ final int timestamp;
+ final double filteredSignal;
+ final double? heartRateBpm;
+ final double? hrvRmssdMs;
+ final PpgSignalQuality signalQuality;
+
+ const _OpenRingSampleOutput({
+ required this.timestamp,
+ required this.filteredSignal,
+ required this.heartRateBpm,
+ required this.hrvRmssdMs,
+ required this.signalQuality,
+ });
+}
diff --git a/open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart b/open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart
index d41c06aa..b8567962 100644
--- a/open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart
+++ b/open_wearable/lib/apps/heart_tracker/model/ppg_filter.dart
@@ -1,142 +1,1092 @@
+// ignore_for_file: cancel_subscriptions
+
+import 'dart:async';
+import 'dart:collection';
import 'dart:math';
-import 'package:logger/logger.dart';
import 'package:open_wearable/apps/heart_tracker/model/band_pass_filter.dart';
+import 'package:open_wearable/apps/heart_tracker/model/high_pass_filter.dart';
+import 'package:open_wearable/apps/heart_tracker/model/msptd_fast_v2_detector.dart';
+
+enum PpgSignalQuality {
+ unavailable,
+ bad,
+ fair,
+ good,
+}
+
+class PpgOpticalSample {
+ final int timestamp;
+ final double red;
+ final double ir;
+ final double green;
+ final double ambient;
-Logger _logger = Logger();
+ const PpgOpticalSample({
+ required this.timestamp,
+ required this.red,
+ required this.ir,
+ required this.green,
+ required this.ambient,
+ });
+}
+
+class PpgVitals {
+ final double? heartRateBpm;
+ final double? hrvRmssdMs;
+ final PpgSignalQuality signalQuality;
+
+ const PpgVitals({
+ required this.heartRateBpm,
+ required this.hrvRmssdMs,
+ required this.signalQuality,
+ });
+
+ const PpgVitals.invalid({
+ this.signalQuality = PpgSignalQuality.unavailable,
+ }) : heartRateBpm = null,
+ hrvRmssdMs = null;
+}
+
+class PpgMotionSample {
+ final int timestamp;
+ final double x;
+ final double y;
+ final double z;
+
+ const PpgMotionSample({
+ required this.timestamp,
+ required this.x,
+ required this.y,
+ required this.z,
+ });
+
+ double get magnitude => sqrt((x * x) + (y * y) + (z * z));
+}
+
+class PpgTemperatureSample {
+ final int timestamp;
+ final double celsius;
+
+ const PpgTemperatureSample({
+ required this.timestamp,
+ required this.celsius,
+ });
+}
class PpgFilter {
- final Stream<(int, double)> inputStream;
+ final Stream inputStream;
+ final Stream? motionStream;
+ final Stream? opticalTemperatureStream;
final double sampleFreq;
+ final int timestampExponent;
- final double _minProminence = 0.1;
- final int _minPeakDistanceMs = 300; // e.g., 200 BPM max
+ final MsptdFastV2Detector _msptdDetector = const MsptdFastV2Detector();
double _hrEstimate = 75.0;
- double _p = 1.0;
- final double _q = 0.01; // process noise
- final double _r = 4.0;
-
- int timestampExponent; // measurement noise
+ double _hrCovariance = 1.0;
+ final double _hrProcessNoise = 0.02;
+ final double _hrMeasurementNoise = 5.0;
+
+ double _hrvEstimateMs = 35.0;
+ final double _hrvSmoothingAlpha = 0.18;
+
+ StreamSubscription? _motionSubscription;
+ StreamSubscription? _temperatureSubscription;
+ Stream<_MotionAwareSample>? _processedStream;
+ Stream<(int, double)>? _rawSignalStream;
+ Stream<(int, double)>? _displaySignalStream;
+ Stream? _vitalsStream;
- Stream<(int, double)>? _filteredStream;
+ double? _latestOpticalTemperatureCelsius;
+ int? _latestOpticalTemperatureTimestamp;
+
+ static const double _reasonableInEarTemperatureCelsius = 32.0;
+ static const double _maxTemperatureSampleAgeSec = 20.0;
+ static const double _minBeatIntervalSec = 0.25;
+ static const double _maxBeatIntervalSec = 2.0;
PpgFilter({
required this.inputStream,
required this.sampleFreq,
required this.timestampExponent,
+ this.motionStream,
+ this.opticalTemperatureStream,
});
- Stream<(int, double)> get filteredStream {
- final filter = BandPassFilter(
- sampleFreq: sampleFreq,
- lowCut: 0.5,
- highCut: 4,
+ Stream<(int, double)> get displaySignalStream {
+ if (_displaySignalStream != null) {
+ return _displaySignalStream!;
+ }
+ _displaySignalStream = _sampleStream
+ .map((sample) => (sample.timestamp, sample.displaySignal))
+ .asBroadcastStream();
+ return _displaySignalStream!;
+ }
+
+ Stream<(int, double)> get rawSignalStream {
+ if (_rawSignalStream != null) {
+ return _rawSignalStream!;
+ }
+ _rawSignalStream = _sampleStream
+ .map((sample) => (sample.timestamp, sample.rawGreen))
+ .asBroadcastStream();
+ return _rawSignalStream!;
+ }
+
+ Stream get heartRateStream =>
+ _metricsStream.map((vitals) => vitals.heartRateBpm);
+
+ Stream get hrvStream =>
+ _metricsStream.map((vitals) => vitals.hrvRmssdMs);
+
+ Stream get signalQualityStream =>
+ _metricsStream.map((vitals) => vitals.signalQuality).distinct();
+
+ void dispose() {
+ final motionSubscription = _motionSubscription;
+ _motionSubscription = null;
+ if (motionSubscription != null) {
+ unawaited(motionSubscription.cancel());
+ }
+
+ final temperatureSubscription = _temperatureSubscription;
+ _temperatureSubscription = null;
+ if (temperatureSubscription != null) {
+ unawaited(temperatureSubscription.cancel());
+ }
+ }
+
+ Stream<_MotionAwareSample> get _sampleStream {
+ if (_processedStream != null) {
+ return _processedStream!;
+ }
+ _processedStream = _createProcessedStream().asBroadcastStream();
+ return _processedStream!;
+ }
+
+ Stream get _metricsStream {
+ if (_vitalsStream != null) {
+ return _vitalsStream!;
+ }
+ _vitalsStream = _createVitalsStream().asBroadcastStream();
+ return _vitalsStream!;
+ }
+
+ Stream<_MotionAwareSample> _createProcessedStream() {
+ final safeSampleFreq =
+ sampleFreq.isFinite && sampleFreq > 0 ? sampleFreq : 50.0;
+ final ambientCanceler = _AmbientLightCanceler();
+ final motionSuppressor = _MotionNoiseSuppressor();
+ final imuCanceler = _MultiReferenceMotionCanceler();
+ final opticalChannelSelector = _AdaptiveOpticalChannelSelector();
+ final dcBlockFilter = HighPassFilter(
+ cutoffFreq: 0.12,
+ sampleFreq: safeSampleFreq,
);
-
- if (_filteredStream == null) {
- _logger.d("Creating filtered stream");
- _filteredStream = inputStream.map((event) {
- final (timestamp, rawValue) = event;
- final filteredValue = filter.filter(rawValue);
- return (timestamp, filteredValue);
- }).asBroadcastStream();
- } else {
- _logger.d("Using existing filtered stream");
+ final bandPassFilter = BandPassFilter(
+ sampleFreq: safeSampleFreq,
+ lowCut: 0.45,
+ highCut: 5.5,
+ );
+ final normalizer = _BoundedSignalNormalizer();
+ final displayDetrender = _DisplayBaselineDetrender(
+ sampleFreqHz: safeSampleFreq,
+ timeConstantSeconds: 3.2,
+ );
+
+ if (motionStream != null) {
+ _motionSubscription = motionStream!.listen((event) {
+ motionSuppressor.updateMotionMagnitude(event.magnitude);
+ imuCanceler.updateMotion(event);
+ });
+ }
+ if (opticalTemperatureStream != null) {
+ _temperatureSubscription = opticalTemperatureStream!.listen((sample) {
+ _latestOpticalTemperatureCelsius = sample.celsius;
+ _latestOpticalTemperatureTimestamp = sample.timestamp;
+ });
}
- return _filteredStream!;
+ return inputStream.map((sample) {
+ final selectedOpticalSignal = opticalChannelSelector.select(sample);
+ final ambientCanceled = ambientCanceler.filter(
+ green: selectedOpticalSignal,
+ ambient: sample.ambient,
+ );
+ final imuCleaned = imuCanceler.filter(
+ ambientCanceled,
+ motionLevel: motionSuppressor.motionLevel,
+ );
+ final motionSuppressed = motionSuppressor.filter(imuCleaned);
+ final dcBlocked = dcBlockFilter.filter(motionSuppressed);
+ final bandPassed = bandPassFilter.filter(dcBlocked);
+ final bounded = normalizer.filter(
+ bandPassed,
+ motionLevel: motionSuppressor.motionLevel,
+ );
+ final displaySignal = displayDetrender.filter(bounded);
+ return _MotionAwareSample(
+ timestamp: sample.timestamp,
+ rawGreen: selectedOpticalSignal,
+ rawAmbient: sample.ambient,
+ signal: bounded,
+ displaySignal: displaySignal,
+ motionLevel: motionSuppressor.motionLevel,
+ );
+ });
}
- double _kalmanUpdate(double measurement) {
- if (measurement.isNaN || measurement.isInfinite) return _hrEstimate;
+ double _kalmanUpdateHeartRate(double measurement) {
+ if (!measurement.isFinite) {
+ return _hrEstimate;
+ }
- _p += _q;
- final k = _p / (_p + _r);
- _hrEstimate += k * (measurement - _hrEstimate);
- _p *= (1 - k);
+ _hrCovariance += _hrProcessNoise;
+ final gain = _hrCovariance / (_hrCovariance + _hrMeasurementNoise);
+ _hrEstimate += gain * (measurement - _hrEstimate);
+ _hrCovariance *= (1 - gain);
return _hrEstimate;
}
- List<(int, double)> smoothBuffer(List<(int, double)> raw, {int radius = 2}) {
- final smoothed = <(int, double)>[];
- for (int i = 0; i < raw.length; i++) {
- int start = max(0, i - radius);
- int end = min(raw.length - 1, i + radius);
- final avg = raw.sublist(start, end + 1).map((e) => e.$2).reduce((a, b) => a + b) / (end - start + 1);
- smoothed.add((raw[i].$1, avg));
+ double _smoothHrv(double measurementMs) {
+ if (!measurementMs.isFinite || measurementMs <= 0) {
+ return _hrvEstimateMs;
}
- return smoothed;
+ _hrvEstimateMs = (_hrvEstimateMs * (1.0 - _hrvSmoothingAlpha)) +
+ (measurementMs * _hrvSmoothingAlpha);
+ return _hrvEstimateMs;
}
- List detectPeaks(List<(int, double)> buffer) {
- buffer = smoothBuffer(buffer, radius: 4);
- final peakTimestamps = [];
+ ({double? heartRateBpm, List peakTimestamps}) _estimateHeartRateByPeak(
+ List<_MotionAwareSample> samples, {
+ required double ticksPerSecond,
+ required double estimatedSampleFreqHz,
+ }) {
+ if (samples.length < 8) {
+ return (heartRateBpm: null, peakTimestamps: const []);
+ }
+
+ // Match MSPTDfast-v2 usage: run beat detection on the raw PPG waveform
+ // (lightly centered only) and let the detector handle detrending/scales.
+ final signal =
+ samples.map((sample) => sample.rawGreen).toList(growable: false);
+ final mean = signal.reduce((a, b) => a + b) / signal.length;
+ final centered =
+ signal.map((value) => value - mean).toList(growable: false);
+ final msptdIndices = _msptdDetector.detectPeakIndices(
+ centered,
+ sampleFreqHz: estimatedSampleFreqHz,
+ );
- for (int i = 1; i < buffer.length - 1; i++) {
- final (tPrev, vPrev) = buffer[i - 1];
- final (tCurr, vCurr) = buffer[i];
- final (tNext, vNext) = buffer[i + 1];
+ final peaks = msptdIndices
+ .where((index) => index >= 0 && index < samples.length)
+ .map((index) => samples[index].timestamp)
+ .toList(growable: false);
- // Skip too-close peaks
- final lastPeak = peakTimestamps.isNotEmpty ? peakTimestamps.last : 0;
- if (tCurr - lastPeak < _minPeakDistanceMs) continue;
+ return _estimateHeartRateFromPeakTimestamps(
+ peaks,
+ ticksPerSecond: ticksPerSecond,
+ );
+ }
- // Simple 3-point peak
- if (vCurr > vPrev && vCurr > vNext &&
- (vCurr - vPrev) > _minProminence &&
- (vCurr - vNext) > _minProminence) {
- peakTimestamps.add(tCurr);
+ ({double? heartRateBpm, List peakTimestamps})
+ _estimateHeartRateFromPeakTimestamps(
+ List peaks, {
+ required double ticksPerSecond,
+ }) {
+ if (peaks.length < 2) {
+ return (heartRateBpm: null, peakTimestamps: peaks);
+ }
+
+ final intervalsSeconds = [];
+ for (var i = 1; i < peaks.length; i++) {
+ final intervalSeconds =
+ (peaks[i] - peaks[i - 1]).toDouble() / max(1.0, ticksPerSecond);
+ if (intervalSeconds >= _minBeatIntervalSec &&
+ intervalSeconds <= _maxBeatIntervalSec) {
+ intervalsSeconds.add(intervalSeconds);
}
}
+ if (intervalsSeconds.isEmpty) {
+ return (heartRateBpm: null, peakTimestamps: peaks);
+ }
- return peakTimestamps;
+ intervalsSeconds.sort();
+ final medianIntervalSeconds =
+ intervalsSeconds[intervalsSeconds.length ~/ 2];
+ final heartRate = 60.0 / medianIntervalSeconds;
+ if (!heartRate.isFinite || heartRate < 30 || heartRate > 240) {
+ return (heartRateBpm: null, peakTimestamps: peaks);
+ }
+
+ return (heartRateBpm: heartRate, peakTimestamps: peaks);
+ }
+
+ double _estimateEffectiveSampleFreqHz(
+ List<_MotionAwareSample> samples, {
+ required double ticksPerSecond,
+ }) {
+ if (samples.length < 2) {
+ return sampleFreq;
+ }
+ final durationTicks =
+ (samples.last.timestamp - samples.first.timestamp).toDouble();
+ if (durationTicks <= 0) {
+ return sampleFreq;
+ }
+ final estimated = ((samples.length - 1) * ticksPerSecond) / durationTicks;
+ if (!estimated.isFinite || estimated < 5 || estimated > 200) {
+ return sampleFreq;
+ }
+ return estimated;
+ }
+
+ List _removeIbiOutliers(List ibiTicks) {
+ if (ibiTicks.length < 3) {
+ return ibiTicks;
+ }
+
+ final sorted = [...ibiTicks]..sort();
+ final median = sorted[sorted.length ~/ 2];
+ final low = median * 0.65;
+ final high = median * 1.35;
+ final filtered = ibiTicks
+ .where((ibi) => ibi >= low && ibi <= high)
+ .toList(growable: false);
+ return filtered.length >= 2 ? filtered : ibiTicks;
+ }
+
+ double? _computeRmssd(List ibiTicks) {
+ if (ibiTicks.length < 2) {
+ return null;
+ }
+
+ var sumSquared = 0.0;
+ var count = 0;
+ for (var i = 1; i < ibiTicks.length; i++) {
+ final delta = ibiTicks[i] - ibiTicks[i - 1];
+ sumSquared += delta * delta;
+ count += 1;
+ }
+
+ if (count == 0) {
+ return null;
+ }
+ return sqrt(sumSquared / count);
+ }
+
+ double _standardDeviation(List values) {
+ if (values.length < 2) {
+ return 0;
+ }
+ final mean = values.reduce((a, b) => a + b) / values.length;
+ var variance = 0.0;
+ for (final value in values) {
+ final diff = value - mean;
+ variance += diff * diff;
+ }
+ variance /= values.length;
+ return sqrt(variance);
+ }
+
+ PpgSignalQuality _classifyQuality(double score) {
+ if (!score.isFinite || score <= 0) {
+ return PpgSignalQuality.unavailable;
+ }
+ if (score < 0.30) {
+ return PpgSignalQuality.bad;
+ }
+ if (score < 0.62) {
+ return PpgSignalQuality.fair;
+ }
+ return PpgSignalQuality.good;
+ }
+
+ ({double score, double averageMotion}) _estimateRecentWaveformQualityScore(
+ List<_MotionAwareSample> samples, {
+ required int latestTimestamp,
+ required double ticksPerSecond,
+ }) {
+ final qualityWindowTicks = 3.0 * ticksPerSecond;
+ final recent = samples
+ .where(
+ (sample) => sample.timestamp >= latestTimestamp - qualityWindowTicks,
+ )
+ .toList(growable: false);
+ final minimumSamples = max(8, (sampleFreq * 1.2).round());
+ if (recent.length < minimumSamples) {
+ return (score: 0.0, averageMotion: 0.0);
+ }
+
+ // Score waveform quality from the filtered signal that is displayed.
+ final filteredValues =
+ recent.map((sample) => sample.signal).toList(growable: false);
+ final meanFilteredAbs =
+ filteredValues.map((value) => value.abs()).reduce((a, b) => a + b) /
+ filteredValues.length;
+ if (!meanFilteredAbs.isFinite || meanFilteredAbs <= 1e-6) {
+ return (score: 0.0, averageMotion: 0.0);
+ }
+
+ final minFiltered = filteredValues.reduce(min);
+ final maxFiltered = filteredValues.reduce(max);
+ final rangeFiltered = maxFiltered - minFiltered;
+ final stdFiltered = _standardDeviation(filteredValues);
+
+ final averageMotion =
+ recent.map((sample) => sample.motionLevel).reduce((a, b) => a + b) /
+ recent.length;
+ // Filtered signal is normalized/bounded, so fixed thresholds are stable.
+ final rangeScore = ((rangeFiltered - 0.08) / 0.95).clamp(0.0, 1.0);
+ final stdScore = ((stdFiltered - 0.025) / 0.30).clamp(0.0, 1.0);
+ final motionScore = (1.0 - (averageMotion / 2.2)).clamp(0.0, 1.0);
+
+ var score = ((0.45 * rangeScore) + (0.35 * stdScore) + (0.20 * motionScore))
+ .clamp(0.0, 1.0);
+
+ // Make accelerometer motion a strong quality prior:
+ // heavy movement should almost always mark PPG quality as bad.
+ if (averageMotion >= 1.45) {
+ score = min(score, 0.10);
+ } else if (averageMotion >= 1.12) {
+ score = min(score, 0.24);
+ } else if (averageMotion >= 0.88) {
+ score = min(score, 0.45);
+ }
+
+ return (
+ score: score,
+ averageMotion: averageMotion,
+ );
}
- Stream get heartRateStream async* {
- int timestampFactor = pow(10, -timestampExponent).toInt();
- int windowDurationMs = 8 * timestampFactor; // 8 seconds
- final List<(int, double)> buffer = [];
+ ({bool hasFreshTemperatureSample, bool inEarByTemperature})
+ _estimateInEarByOpticalTemperature({
+ required int latestTimestamp,
+ required double ticksPerSecond,
+ }) {
+ if (opticalTemperatureStream == null) {
+ return (hasFreshTemperatureSample: false, inEarByTemperature: true);
+ }
- await for (final (timestamp, value) in filteredStream) {
- buffer.add((timestamp, value));
+ final latestTemperature = _latestOpticalTemperatureCelsius;
+ final latestTemperatureTimestamp = _latestOpticalTemperatureTimestamp;
+ if (latestTemperature == null || latestTemperatureTimestamp == null) {
+ return (hasFreshTemperatureSample: false, inEarByTemperature: false);
+ }
- buffer.removeWhere((event) => event.$1 < timestamp - windowDurationMs);
+ final maxAgeTicks = _maxTemperatureSampleAgeSec * ticksPerSecond;
+ if (latestTimestamp - latestTemperatureTimestamp > maxAgeTicks) {
+ return (hasFreshTemperatureSample: false, inEarByTemperature: false);
+ }
- if ((buffer.last.$1 - buffer.first.$1) < windowDurationMs / 2) {
- _logger.d("waiting to fill buffer, time difference: ${buffer.last.$1 - buffer.first.$1}");
+ return (
+ hasFreshTemperatureSample: true,
+ inEarByTemperature:
+ latestTemperature >= _reasonableInEarTemperatureCelsius,
+ );
+ }
+
+ Stream _createVitalsStream() async* {
+ final ticksPerSecond = pow(10, -timestampExponent).toDouble();
+ final ticksToMilliseconds = pow(10, timestampExponent + 3).toDouble();
+ final windowDurationTicks = 10.0 * ticksPerSecond;
+ final minimumWindowTicks = 4.0 * ticksPerSecond;
+ final evaluationPeriodTicks = max(1.0, ticksPerSecond);
+ final buffer = <_MotionAwareSample>[];
+ var lastEvaluationTick = double.negativeInfinity;
+
+ await for (final sample in _sampleStream) {
+ buffer.add(sample);
+ buffer.removeWhere(
+ (item) => item.timestamp < sample.timestamp - windowDurationTicks,
+ );
+
+ if ((sample.timestamp - lastEvaluationTick) < evaluationPeriodTicks) {
continue;
}
+ lastEvaluationTick = sample.timestamp.toDouble();
- List peakTimestamps = detectPeaks(buffer);
+ if (buffer.length < 20 ||
+ (buffer.last.timestamp - buffer.first.timestamp) <
+ minimumWindowTicks) {
+ yield const PpgVitals.invalid(
+ signalQuality: PpgSignalQuality.unavailable,
+ );
+ continue;
+ }
+ final recentQuality = _estimateRecentWaveformQualityScore(
+ buffer,
+ latestTimestamp: sample.timestamp,
+ ticksPerSecond: ticksPerSecond,
+ );
+ var qualityScore = recentQuality.score;
+ final recentMotion = recentQuality.averageMotion;
- // Need at least 2 peaks to compute HR
- if (peakTimestamps.length < 2) {
- _logger.w("not enough peaks ${peakTimestamps.length}, in buffer of size ${buffer.length}");
+ if (recentMotion >= 1.45) {
+ yield const PpgVitals.invalid(
+ signalQuality: PpgSignalQuality.bad,
+ );
continue;
}
- final ibiList = [];
- for (int i = 1; i < peakTimestamps.length; i++) {
- final ibi = (peakTimestamps[i] - peakTimestamps[i - 1]).toDouble();
- ibiList.add(ibi);
+ final inEarTemperature = _estimateInEarByOpticalTemperature(
+ latestTimestamp: sample.timestamp,
+ ticksPerSecond: ticksPerSecond,
+ );
+ if (opticalTemperatureStream != null) {
+ if (!inEarTemperature.hasFreshTemperatureSample) {
+ yield const PpgVitals.invalid(
+ signalQuality: PpgSignalQuality.unavailable,
+ );
+ continue;
+ }
+ if (!inEarTemperature.inEarByTemperature) {
+ yield const PpgVitals.invalid(
+ signalQuality: PpgSignalQuality.bad,
+ );
+ continue;
+ }
+ }
+
+ final effectiveSampleFreqHz = _estimateEffectiveSampleFreqHz(
+ buffer,
+ ticksPerSecond: ticksPerSecond,
+ );
+ final peakEstimate = _estimateHeartRateByPeak(
+ buffer,
+ ticksPerSecond: ticksPerSecond,
+ estimatedSampleFreqHz: effectiveSampleFreqHz,
+ );
+ final peaks = peakEstimate.peakTimestamps;
+ final peakHeartRate = peakEstimate.heartRateBpm;
+
+ final peakScore = (peaks.length / 8.0).clamp(0.0, 1.0);
+ qualityScore =
+ ((0.78 * qualityScore) + (0.22 * peakScore)).clamp(0.0, 1.0);
+
+ final ibiTicks = [];
+ for (var i = 1; i < peaks.length; i++) {
+ final interval = (peaks[i] - peaks[i - 1]).toDouble();
+ final intervalSeconds = interval / ticksPerSecond;
+ if (interval > 0 &&
+ intervalSeconds >= _minBeatIntervalSec &&
+ intervalSeconds <= _maxBeatIntervalSec) {
+ ibiTicks.add(interval);
+ }
+ }
+ if (ibiTicks.length >= 2) {
+ final robustIbiTicks = _removeIbiOutliers(ibiTicks);
+ final meanIbiTicks =
+ robustIbiTicks.reduce((a, b) => a + b) / robustIbiTicks.length;
+ if (meanIbiTicks.isFinite && meanIbiTicks > 0) {
+ final ibiVariation =
+ _standardDeviation(robustIbiTicks) / meanIbiTicks;
+ if (ibiVariation.isFinite) {
+ final rhythmScore = (1.0 - (ibiVariation / 0.55)).clamp(0.0, 1.0);
+ qualityScore =
+ ((0.78 * qualityScore) + (0.22 * rhythmScore)).clamp(0.0, 1.0);
+ }
+ }
}
- final avgIbi = ibiList.reduce((a, b) => a + b) / ibiList.length;
- if (avgIbi <= 0 || avgIbi.isNaN || avgIbi.isInfinite) {
- _logger.w("unexpected avgIbi: $avgIbi");
+ final classifiedQuality = _classifyQuality(qualityScore);
+ if (classifiedQuality == PpgSignalQuality.bad ||
+ classifiedQuality == PpgSignalQuality.unavailable) {
+ yield PpgVitals.invalid(
+ signalQuality: classifiedQuality,
+ );
continue;
}
- final hr = 60 * timestampFactor / avgIbi;
- final smoothedHr = _kalmanUpdate(hr);
+ if (peakHeartRate == null) {
+ yield PpgVitals.invalid(
+ signalQuality: classifiedQuality,
+ );
+ continue;
+ }
+ final smoothedHeartRate = _kalmanUpdateHeartRate(peakHeartRate);
- if (smoothedHr > 30 && smoothedHr < 220) {
- yield smoothedHr;
+ double? smoothedHrvMs;
+ if (ibiTicks.length >= 2) {
+ final robustIbiTicks = _removeIbiOutliers(ibiTicks);
+ final rmssdTicks = _computeRmssd(robustIbiTicks);
+ if (rmssdTicks != null && rmssdTicks.isFinite && rmssdTicks > 0) {
+ final hrvMs = rmssdTicks * ticksToMilliseconds;
+ if (hrvMs.isFinite && hrvMs >= 5 && hrvMs <= 300) {
+ smoothedHrvMs = _smoothHrv(hrvMs);
+ }
+ }
}
- // Optional: clear buffer for independent windows
- buffer.clear();
+ yield PpgVitals(
+ heartRateBpm: smoothedHeartRate,
+ hrvRmssdMs: smoothedHrvMs,
+ signalQuality: classifiedQuality,
+ );
}
}
}
+
+class _AdaptiveOpticalChannelSelector {
+ bool _isInitialized = false;
+ double _meanGreen = 0;
+ double _meanRed = 0;
+ double _meanIr = 0;
+ double _energyGreen = 0;
+ double _energyRed = 0;
+ double _energyIr = 0;
+
+ double select(PpgOpticalSample sample) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _meanGreen = sample.green;
+ _meanRed = sample.red;
+ _meanIr = sample.ir;
+ } else {
+ _meanGreen = _ema(_meanGreen, sample.green, 0.02);
+ _meanRed = _ema(_meanRed, sample.red, 0.02);
+ _meanIr = _ema(_meanIr, sample.ir, 0.02);
+ }
+
+ _energyGreen = _ema(_energyGreen, (sample.green - _meanGreen).abs(), 0.08);
+ _energyRed = _ema(_energyRed, (sample.red - _meanRed).abs(), 0.08);
+ _energyIr = _ema(_energyIr, (sample.ir - _meanIr).abs(), 0.08);
+
+ final strongestAltEnergy = max(_energyRed, _energyIr);
+ final greenLikelyMissing = sample.green.abs() < 1e-6 &&
+ (sample.red.abs() > 1e-3 || sample.ir.abs() > 1e-3);
+ final greenWeakComparedToAlternatives =
+ strongestAltEnergy > 1e-6 && _energyGreen < (strongestAltEnergy * 0.35);
+ if (greenLikelyMissing || greenWeakComparedToAlternatives) {
+ final preferRed = _energyRed >= _energyIr;
+ final fallback = preferRed ? sample.red : sample.ir;
+ if (fallback.isFinite) {
+ return fallback;
+ }
+ }
+
+ if (sample.green.isFinite) {
+ return sample.green;
+ }
+ if (sample.red.isFinite && sample.ir.isFinite) {
+ return sample.red.abs() >= sample.ir.abs() ? sample.red : sample.ir;
+ }
+ if (sample.red.isFinite) {
+ return sample.red;
+ }
+ if (sample.ir.isFinite) {
+ return sample.ir;
+ }
+ return 0;
+ }
+
+ double _ema(double state, double value, double alpha) {
+ return (state * (1.0 - alpha)) + (value * alpha);
+ }
+}
+
+class _MotionAwareSample {
+ final int timestamp;
+ final double rawGreen;
+ final double rawAmbient;
+ final double signal;
+ final double displaySignal;
+ final double motionLevel;
+
+ const _MotionAwareSample({
+ required this.timestamp,
+ required this.rawGreen,
+ required this.rawAmbient,
+ required this.signal,
+ required this.displaySignal,
+ required this.motionLevel,
+ });
+}
+
+class _DisplayBaselineDetrender {
+ final double _alpha;
+ bool _isInitialized = false;
+ double _baseline = 0;
+ double _lastOutput = 0;
+
+ _DisplayBaselineDetrender({
+ required double sampleFreqHz,
+ double timeConstantSeconds = 3.0,
+ }) : _alpha = _computeAlpha(
+ sampleFreqHz: sampleFreqHz,
+ timeConstantSeconds: timeConstantSeconds,
+ );
+
+ double filter(double value) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _baseline = value;
+ _lastOutput = 0;
+ return 0;
+ }
+
+ _baseline = _baseline + (_alpha * (value - _baseline));
+ final detrended = value - _baseline;
+ _lastOutput = (_lastOutput * 0.82) + (detrended * 0.18);
+ return _lastOutput;
+ }
+
+ static double _computeAlpha({
+ required double sampleFreqHz,
+ required double timeConstantSeconds,
+ }) {
+ final safeSampleFreq =
+ sampleFreqHz.isFinite && sampleFreqHz > 0 ? sampleFreqHz : 50.0;
+ final safeTau = timeConstantSeconds.isFinite && timeConstantSeconds > 0
+ ? timeConstantSeconds
+ : 3.0;
+ final alpha = 1 - exp(-1 / (safeTau * safeSampleFreq));
+ return alpha.clamp(0.001, 0.2);
+ }
+}
+
+class _AmbientLightCanceler {
+ bool _isInitialized = false;
+ double _meanGreen = 0;
+ double _meanAmbient = 0;
+ double _ambientVariance = 1.0;
+ double _greenAmbientCovariance = 0.0;
+ double _ambientGain = 0.65;
+
+ double filter({
+ required double green,
+ required double ambient,
+ }) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _meanGreen = green;
+ _meanAmbient = ambient;
+ return 0;
+ }
+
+ const meanAlpha = 0.02;
+ const covarianceAlpha = 0.04;
+
+ _meanGreen = (_meanGreen * (1.0 - meanAlpha)) + (green * meanAlpha);
+ _meanAmbient = (_meanAmbient * (1.0 - meanAlpha)) + (ambient * meanAlpha);
+
+ final centeredGreen = green - _meanGreen;
+ final centeredAmbient = ambient - _meanAmbient;
+
+ _ambientVariance = (_ambientVariance * (1.0 - covarianceAlpha)) +
+ ((centeredAmbient * centeredAmbient) * covarianceAlpha);
+ _greenAmbientCovariance =
+ (_greenAmbientCovariance * (1.0 - covarianceAlpha)) +
+ ((centeredGreen * centeredAmbient) * covarianceAlpha);
+
+ if (_ambientVariance > 1e-6) {
+ _ambientGain = (_greenAmbientCovariance / _ambientVariance).clamp(
+ 0.0,
+ 2.0,
+ );
+ }
+
+ final cleaned = centeredGreen - (_ambientGain * centeredAmbient);
+ return -cleaned;
+ }
+}
+
+class _MultiReferenceMotionCanceler {
+ final _PadasipStyleMultiInputNlmsCanceler _canceler =
+ _PadasipStyleMultiInputNlmsCanceler(
+ tapsPerAxis: 8,
+ );
+
+ bool _isInitialized = false;
+ double _gravityX = 0;
+ double _gravityY = 0;
+ double _gravityZ = 0;
+ double _dynamicX = 0;
+ double _dynamicY = 0;
+ double _dynamicZ = 0;
+ double _referenceScaleX = 0.2;
+ double _referenceScaleY = 0.2;
+ double _referenceScaleZ = 0.2;
+
+ void updateMotion(PpgMotionSample sample) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _gravityX = sample.x;
+ _gravityY = sample.y;
+ _gravityZ = sample.z;
+ _dynamicX = 0;
+ _dynamicY = 0;
+ _dynamicZ = 0;
+ _referenceScaleX = 0.2;
+ _referenceScaleY = 0.2;
+ _referenceScaleZ = 0.2;
+ return;
+ }
+
+ const gravityAlpha = 0.04;
+ const dynamicAlpha = 0.18;
+ const scaleAlpha = 0.06;
+
+ _gravityX = (_gravityX * (1.0 - gravityAlpha)) + (sample.x * gravityAlpha);
+ _gravityY = (_gravityY * (1.0 - gravityAlpha)) + (sample.y * gravityAlpha);
+ _gravityZ = (_gravityZ * (1.0 - gravityAlpha)) + (sample.z * gravityAlpha);
+
+ final hpX = sample.x - _gravityX;
+ final hpY = sample.y - _gravityY;
+ final hpZ = sample.z - _gravityZ;
+ _referenceScaleX =
+ (_referenceScaleX * (1.0 - scaleAlpha)) + (hpX.abs() * scaleAlpha);
+ _referenceScaleY =
+ (_referenceScaleY * (1.0 - scaleAlpha)) + (hpY.abs() * scaleAlpha);
+ _referenceScaleZ =
+ (_referenceScaleZ * (1.0 - scaleAlpha)) + (hpZ.abs() * scaleAlpha);
+
+ final normalizedHpX = hpX / max(0.08, _referenceScaleX);
+ final normalizedHpY = hpY / max(0.08, _referenceScaleY);
+ final normalizedHpZ = hpZ / max(0.08, _referenceScaleZ);
+ _dynamicX =
+ (_dynamicX * (1.0 - dynamicAlpha)) + (normalizedHpX * dynamicAlpha);
+ _dynamicY =
+ (_dynamicY * (1.0 - dynamicAlpha)) + (normalizedHpY * dynamicAlpha);
+ _dynamicZ =
+ (_dynamicZ * (1.0 - dynamicAlpha)) + (normalizedHpZ * dynamicAlpha);
+ }
+
+ double filter(
+ double value, {
+ required double motionLevel,
+ }) {
+ if (!_isInitialized) {
+ return value;
+ }
+ return _canceler.filter(
+ signal: value,
+ referenceX: _dynamicX,
+ referenceY: _dynamicY,
+ referenceZ: _dynamicZ,
+ motionLevel: motionLevel,
+ );
+ }
+}
+
+/// Multi-input normalized LMS adaptive canceller adapted from the update rule
+/// used in the open-source `padasip` NLMS implementation (MIT):
+/// https://github.com/matousc89/padasip
+class _PadasipStyleMultiInputNlmsCanceler {
+ static const double _baseMu = 0.08;
+ static const double _maxMu = 1.0;
+ static const double _epsilon = 1e-6;
+ static const double _leakage = 0.00025;
+
+ final int tapsPerAxis;
+ late final List _weights;
+ late final List _historyX;
+ late final List _historyY;
+ late final List _historyZ;
+ late final List _featureVector;
+
+ bool _isInitialized = false;
+ double _smoothedError = 0;
+
+ _PadasipStyleMultiInputNlmsCanceler({
+ required this.tapsPerAxis,
+ }) {
+ final length = max(3, tapsPerAxis * 3);
+ _weights = List.filled(length, 0, growable: false);
+ _historyX = List.filled(tapsPerAxis, 0, growable: false);
+ _historyY = List.filled(tapsPerAxis, 0, growable: false);
+ _historyZ = List.filled(tapsPerAxis, 0, growable: false);
+ _featureVector = List.filled(length, 0, growable: false);
+ }
+
+ double filter({
+ required double signal,
+ required double referenceX,
+ required double referenceY,
+ required double referenceZ,
+ required double motionLevel,
+ }) {
+ if (!signal.isFinite ||
+ !referenceX.isFinite ||
+ !referenceY.isFinite ||
+ !referenceZ.isFinite) {
+ return signal;
+ }
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _smoothedError = signal;
+ }
+
+ _push(_historyX, referenceX);
+ _push(_historyY, referenceY);
+ _push(_historyZ, referenceZ);
+ _composeFeatureVector();
+
+ final predictedNoise = _dot(_weights, _featureVector);
+ final error = signal - predictedNoise;
+ final norm = _epsilon + _dot(_featureVector, _featureVector);
+
+ final motionScale = (motionLevel / 1.6).clamp(0.0, 1.0);
+ final mu = _baseMu + ((_maxMu - _baseMu) * motionScale);
+ final step = (mu * error) / norm;
+
+ for (var i = 0; i < _weights.length; i++) {
+ final updatedWeight =
+ ((1.0 - _leakage) * _weights[i]) + (step * _featureVector[i]);
+ _weights[i] = updatedWeight.clamp(-4.0, 4.0);
+ }
+
+ final smoothAlpha = motionLevel > 1.0 ? 0.22 : 0.11;
+ _smoothedError =
+ (_smoothedError * (1.0 - smoothAlpha)) + (error * smoothAlpha);
+ return _smoothedError;
+ }
+
+ void _push(List history, double sample) {
+ for (var i = history.length - 1; i > 0; i--) {
+ history[i] = history[i - 1];
+ }
+ history[0] = sample;
+ }
+
+ void _composeFeatureVector() {
+ var index = 0;
+ for (var i = 0; i < tapsPerAxis; i++) {
+ _featureVector[index++] = _historyX[i];
+ }
+ for (var i = 0; i < tapsPerAxis; i++) {
+ _featureVector[index++] = _historyY[i];
+ }
+ for (var i = 0; i < tapsPerAxis; i++) {
+ _featureVector[index++] = _historyZ[i];
+ }
+ }
+
+ double _dot(List a, List b) {
+ var sum = 0.0;
+ for (var i = 0; i < a.length; i++) {
+ sum += a[i] * b[i];
+ }
+ return sum;
+ }
+}
+
+class _MotionNoiseSuppressor {
+ static const int _windowSize = 25;
+ static const double _baseOutlierSigma = 3.0;
+ static const double _baseStepScale = 5.5;
+ static const double _baseAlpha = 0.26;
+
+ final ListQueue _history = ListQueue();
+
+ bool _isInitialized = false;
+ double _lastOutput = 0;
+ double _gravityMagnitude = 9.81;
+ double _motionLevel = 0;
+
+ double get motionLevel => _motionLevel;
+
+ void updateMotionMagnitude(double magnitude) {
+ if (!_isInitialized) {
+ _gravityMagnitude = magnitude;
+ _motionLevel = 0;
+ return;
+ }
+
+ _gravityMagnitude = (_gravityMagnitude * 0.96) + (magnitude * 0.04);
+ final dynamicMagnitude = (magnitude - _gravityMagnitude).abs();
+ final dynamicScale = max(0.08, _gravityMagnitude.abs() * 0.02);
+ final normalizedDynamicMagnitude = dynamicMagnitude / dynamicScale;
+ _motionLevel =
+ ((_motionLevel * 0.85) + (normalizedDynamicMagnitude * 0.15)).clamp(
+ 0.0,
+ 8.0,
+ );
+ }
+
+ double filter(double rawValue) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _lastOutput = rawValue;
+ _history
+ ..clear()
+ ..addAll(List.filled(_windowSize, rawValue));
+ return rawValue;
+ }
+
+ if (_history.length >= _windowSize) {
+ _history.removeFirst();
+ }
+ _history.add(rawValue);
+
+ final median = _medianOf(_history);
+ final mad = _medianOf(_history.map((value) => (value - median).abs()));
+ final sigma = max(1e-3, mad * 1.4826);
+
+ final motionFactor = 1.0 + min(_motionLevel / 0.85, 2.8);
+ final outlierSigma = _baseOutlierSigma / motionFactor;
+ final minBound = median - (sigma * outlierSigma);
+ final maxBound = median + (sigma * outlierSigma);
+ final clipped = rawValue.clamp(minBound, maxBound).toDouble();
+
+ final stepLimit = max(1e-3, (_baseStepScale * sigma) / motionFactor);
+ final stepped = _lastOutput +
+ (clipped - _lastOutput).clamp(-stepLimit, stepLimit).toDouble();
+
+ final alpha = (_baseAlpha / motionFactor).clamp(0.07, _baseAlpha);
+ final smoothed = _lastOutput + (alpha * (stepped - _lastOutput));
+ _lastOutput = smoothed;
+ return smoothed;
+ }
+
+ double _medianOf(Iterable values) {
+ final sorted = values.toList(growable: false)..sort();
+ if (sorted.isEmpty) {
+ return 0;
+ }
+ final middle = sorted.length ~/ 2;
+ if (sorted.length.isOdd) {
+ return sorted[middle];
+ }
+ return (sorted[middle - 1] + sorted[middle]) / 2;
+ }
+}
+
+class _BoundedSignalNormalizer {
+ bool _isInitialized = false;
+ double _center = 0;
+ double _envelope = 0.25;
+ double _lastOutput = 0;
+
+ double filter(
+ double value, {
+ required double motionLevel,
+ }) {
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _center = value;
+ _envelope = max(0.18, value.abs());
+ _lastOutput = 0;
+ return 0;
+ }
+
+ const centerAlpha = 0.01;
+ const envelopeAlpha = 0.02;
+
+ _center = (_center * (1.0 - centerAlpha)) + (value * centerAlpha);
+ final centered = value - _center;
+ _envelope =
+ (_envelope * (1.0 - envelopeAlpha)) + (centered.abs() * envelopeAlpha);
+
+ final normalized = centered / max(0.12, _envelope);
+ final maxAbs = motionLevel > 1.2 ? 1.15 : 1.45;
+ final clipped = normalized.clamp(-maxAbs, maxAbs).toDouble();
+
+ final alpha = motionLevel > 1.2 ? 0.12 : 0.22;
+ final smoothed = _lastOutput + (alpha * (clipped - _lastOutput));
+ _lastOutput = smoothed;
+ return smoothed;
+ }
+}
diff --git a/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart b/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart
index 0febd165..c7dccc34 100644
--- a/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart
+++ b/open_wearable/lib/apps/heart_tracker/widgets/heart_tracker_page.dart
@@ -1,122 +1,620 @@
+import 'dart:async';
+
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/apps/heart_tracker/model/open_ring_classic_heart_processor.dart';
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/device_name_formatter.dart';
+import 'package:open_wearable/models/wearable_display_group.dart';
import 'package:open_wearable/view_models/sensor_configuration_provider.dart';
+import 'package:open_wearable/widgets/devices/devices_page.dart';
import 'package:provider/provider.dart';
class HeartTrackerPage extends StatefulWidget {
+ final Wearable wearable;
final Sensor ppgSensor;
+ final Sensor? accelerometerSensor;
+ final Sensor? opticalTemperatureSensor;
- const HeartTrackerPage({super.key, required this.ppgSensor});
+ const HeartTrackerPage({
+ super.key,
+ required this.wearable,
+ required this.ppgSensor,
+ this.accelerometerSensor,
+ this.opticalTemperatureSensor,
+ });
@override
State createState() => _HeartTrackerPageState();
}
class _HeartTrackerPageState extends State {
- late final PpgFilter ppgFilter;
+ PpgFilter? _ppgFilter;
+ OpenRingClassicHeartProcessor? _openRingProcessor;
+ Stream<(int, double)>? _displayPpgSignalStream;
+ Stream? _heartRateStream;
+ Stream? _hrvStream;
+ Stream? _signalQualityStream;
+ bool _usesOpenRingPipeline = false;
+ SensorConfigurationProvider? _sensorConfigProvider;
@override
void initState() {
super.initState();
- final sensor = widget.ppgSensor;
-
WidgetsBinding.instance.addPostFrameCallback((_) {
- SensorConfigurationProvider configProvider = Provider.of(context, listen: false);
- SensorConfiguration configuration = sensor.relatedConfigurations.first;
+ final configProvider =
+ Provider.of(context, listen: false);
+ _sensorConfigProvider = configProvider;
+ final ppgSensor = widget.ppgSensor;
+ final accelerometerSensor = widget.accelerometerSensor;
+ final opticalTemperatureSensor = widget.opticalTemperatureSensor;
- if (configuration is ConfigurableSensorConfiguration &&
- configuration.availableOptions.contains(StreamSensorConfigOption())) {
- configProvider.addSensorConfigurationOption(configuration, StreamSensorConfigOption());
+ final sampleFreq = _configureSensorForStreaming(
+ ppgSensor,
+ configProvider,
+ fallbackFrequency: 50.0,
+ targetFrequencyHz: 50,
+ );
+ if (accelerometerSensor != null) {
+ _configureSensorForStreaming(
+ accelerometerSensor,
+ configProvider,
+ fallbackFrequency: 50.0,
+ targetFrequencyHz: 50,
+ );
+ }
+ if (opticalTemperatureSensor != null) {
+ _configureSensorForStreaming(
+ opticalTemperatureSensor,
+ configProvider,
+ fallbackFrequency: 5.0,
+ targetFrequencyHz: 5,
+ );
}
- List values = configProvider.getSensorConfigurationValues(configuration, distinct: true);
- configProvider.addSensorConfiguration(configuration, values.first);
- SensorConfigurationValue selectedValue = configProvider.getSelectedConfigurationValue(configuration)!;
- configuration.setConfiguration(selectedValue);
+ final ppgStream = ppgSensor.sensorStream
+ .map((data) {
+ final values = _sensorValuesAsDoubles(data);
+ if (values == null) {
+ return null;
+ }
+ return _extractPpgOpticalSample(ppgSensor, data, values);
+ })
+ .where((sample) => sample != null)
+ .cast()
+ .asBroadcastStream();
- double sampleFreq;
- if (selectedValue is SensorFrequencyConfigurationValue) {
- sampleFreq = selectedValue.frequencyHz;
- } else {
- sampleFreq = 25;
+ Stream? accelerometerMotionStream;
+ if (accelerometerSensor != null) {
+ accelerometerMotionStream = accelerometerSensor.sensorStream
+ .map((data) {
+ final values = _sensorValuesAsDoubles(data);
+ if (values == null) {
+ return null;
+ }
+ return _extractImuMotionSample(
+ accelerometerSensor,
+ data,
+ values,
+ );
+ })
+ .where((sample) => sample != null)
+ .cast()
+ .asBroadcastStream();
}
- setState(() {
- ppgFilter = PpgFilter(
- inputStream: sensor.sensorStream.asyncMap((data) {
- SensorDoubleValue sensorData = data as SensorDoubleValue;
- return (
- sensorData.timestamp,
- -(sensorData.values[2] + sensorData.values[3])
- );
- }).asBroadcastStream(),
+ Stream? opticalTemperatureStream;
+ if (opticalTemperatureSensor != null) {
+ opticalTemperatureStream = opticalTemperatureSensor.sensorStream
+ .map((data) {
+ final values = _sensorValuesAsDoubles(data);
+ if (values == null) {
+ return null;
+ }
+ return _extractOpticalTemperatureSample(
+ opticalTemperatureSensor,
+ data,
+ values,
+ );
+ })
+ .where((sample) => sample != null)
+ .cast()
+ .asBroadcastStream();
+ }
+
+ if (!mounted) {
+ return;
+ }
+ if (_isOpenRingWearable(widget.wearable.name)) {
+ final openRingProcessor = OpenRingClassicHeartProcessor(
+ inputStream: ppgStream,
sampleFreq: sampleFreq,
- timestampExponent: sensor.timestampExponent,
);
+ setState(() {
+ _displayPpgSignalStream = openRingProcessor.displaySignalStream;
+ _heartRateStream = openRingProcessor.heartRateStream;
+ _hrvStream = openRingProcessor.hrvStream;
+ _signalQualityStream = openRingProcessor.signalQualityStream;
+ _openRingProcessor = openRingProcessor;
+ _usesOpenRingPipeline = true;
+ });
+ return;
+ }
+
+ final ppgFilter = PpgFilter(
+ inputStream: ppgStream,
+ motionStream: accelerometerMotionStream,
+ opticalTemperatureStream: opticalTemperatureStream,
+ sampleFreq: sampleFreq,
+ timestampExponent: ppgSensor.timestampExponent,
+ );
+ setState(() {
+ _displayPpgSignalStream = ppgFilter.displaySignalStream;
+ _heartRateStream = ppgFilter.heartRateStream;
+ _hrvStream = ppgFilter.hrvStream;
+ _signalQualityStream = ppgFilter.signalQualityStream;
+ _ppgFilter = ppgFilter;
+ _usesOpenRingPipeline = false;
});
});
}
+ @override
+ void dispose() {
+ final configProvider = _sensorConfigProvider;
+ if (configProvider != null) {
+ _disableSensorStreaming(widget.ppgSensor, configProvider);
+ final accelerometerSensor = widget.accelerometerSensor;
+ if (accelerometerSensor != null) {
+ _disableSensorStreaming(accelerometerSensor, configProvider);
+ }
+ final opticalTemperatureSensor = widget.opticalTemperatureSensor;
+ if (opticalTemperatureSensor != null) {
+ _disableSensorStreaming(opticalTemperatureSensor, configProvider);
+ }
+ }
+ _ppgFilter?.dispose();
+ _openRingProcessor?.dispose();
+ super.dispose();
+ }
+
+ bool _isOpenRingWearable(String wearableName) {
+ final normalizedRaw = wearableName.trim().toLowerCase();
+ if (normalizedRaw.startsWith('openring') ||
+ normalizedRaw.startsWith('bcl')) {
+ return true;
+ }
+
+ final normalizedDisplay =
+ formatWearableDisplayName(wearableName).trim().toLowerCase();
+ return normalizedDisplay.startsWith('openring');
+ }
+
+ void _disableSensorStreaming(
+ Sensor sensor,
+ SensorConfigurationProvider configProvider,
+ ) {
+ for (final config in sensor.relatedConfigurations) {
+ try {
+ final offValue = config.offValue;
+ if (offValue != null) {
+ configProvider.addSensorConfiguration(
+ config,
+ offValue,
+ markPending: false,
+ );
+ config.setConfiguration(offValue);
+ continue;
+ }
+
+ if (config is ConfigurableSensorConfiguration &&
+ config.availableOptions
+ .any((option) => option is StreamSensorConfigOption)) {
+ configProvider.removeSensorConfigurationOption(
+ config,
+ const StreamSensorConfigOption(),
+ markPending: false,
+ );
+ final selected = configProvider.getSelectedConfigurationValue(config);
+ if (selected is ConfigurableSensorConfigurationValue) {
+ config.setConfiguration(selected);
+ }
+ }
+ } catch (_) {
+ // Best-effort teardown: continue even if one write fails.
+ }
+ }
+ }
+
+ double _configureSensorForStreaming(
+ Sensor sensor,
+ SensorConfigurationProvider configProvider, {
+ required double fallbackFrequency,
+ required int targetFrequencyHz,
+ }) {
+ final configuration = sensor.relatedConfigurations.firstOrNull;
+ if (configuration == null) {
+ return fallbackFrequency;
+ }
+
+ if (configuration is ConfigurableSensorConfiguration &&
+ configuration.availableOptions.contains(StreamSensorConfigOption())) {
+ configProvider.addSensorConfigurationOption(
+ configuration,
+ StreamSensorConfigOption(),
+ markPending: false,
+ );
+ }
+
+ final values = configProvider.getSensorConfigurationValues(
+ configuration,
+ distinct: true,
+ );
+ SensorConfigurationValue? appliedValue;
+ if (values.isNotEmpty) {
+ appliedValue = _selectBestConfigurationValue(
+ values,
+ targetFrequencyHz: targetFrequencyHz,
+ );
+ configProvider.addSensorConfiguration(
+ configuration,
+ appliedValue,
+ markPending: false,
+ );
+ }
+
+ final selectedValue =
+ configProvider.getSelectedConfigurationValue(configuration) ??
+ appliedValue;
+ if (selectedValue != null) {
+ configuration.setConfiguration(selectedValue);
+ }
+
+ if (selectedValue is SensorFrequencyConfigurationValue) {
+ return selectedValue.frequencyHz;
+ }
+
+ return fallbackFrequency;
+ }
+
+ SensorConfigurationValue _selectBestConfigurationValue(
+ List values, {
+ required int targetFrequencyHz,
+ }) {
+ final frequencyValues =
+ values.whereType().toList();
+ if (frequencyValues.isEmpty) {
+ return values.first;
+ }
+
+ SensorFrequencyConfigurationValue? nextBigger;
+ SensorFrequencyConfigurationValue? maxValue;
+ for (final value in frequencyValues) {
+ if (maxValue == null || value.frequencyHz > maxValue.frequencyHz) {
+ maxValue = value;
+ }
+ if (value.frequencyHz >= targetFrequencyHz &&
+ (nextBigger == null || value.frequencyHz < nextBigger.frequencyHz)) {
+ nextBigger = value;
+ }
+ }
+
+ return nextBigger ?? maxValue ?? values.first;
+ }
+
+ List? _sensorValuesAsDoubles(SensorValue data) {
+ if (data is SensorDoubleValue) {
+ return data.values;
+ }
+ if (data is SensorIntValue) {
+ return data.values
+ .map((value) => value.toDouble())
+ .toList(growable: false);
+ }
+ return null;
+ }
+
+ PpgOpticalSample? _extractPpgOpticalSample(
+ Sensor sensor,
+ SensorValue data,
+ List values,
+ ) {
+ if (values.isEmpty) {
+ return null;
+ }
+
+ int? findAxisIndex(List keywords) {
+ for (var i = 0; i < sensor.axisNames.length; i++) {
+ final axis = sensor.axisNames[i].toLowerCase();
+ if (keywords.any(axis.contains)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ double valueAt(int? index, double fallback) {
+ if (index != null && index >= 0 && index < values.length) {
+ return values[index];
+ }
+ return fallback;
+ }
+
+ final fallbackRed = values[0];
+ final fallbackIr = values.length > 1 ? values[1] : fallbackRed;
+ final fallbackGreen = values.length > 2 ? values[2] : fallbackRed;
+ final fallbackAmbient = values.length > 3 ? values[3] : 0.0;
+
+ // Usually channels are [red, ir, green, ambient], but we prefer axis-name
+ // matching when available to avoid firmware-order mismatches.
+ final red = valueAt(findAxisIndex(['red']), fallbackRed);
+ final ir = valueAt(findAxisIndex(['ir', 'infrared']), fallbackIr);
+ final green = valueAt(findAxisIndex(['green']), fallbackGreen);
+ final ambient = valueAt(findAxisIndex(['ambient']), fallbackAmbient);
+
+ return PpgOpticalSample(
+ timestamp: data.timestamp,
+ red: red,
+ ir: ir,
+ green: green,
+ ambient: ambient,
+ );
+ }
+
+ PpgMotionSample _extractImuMotionSample(
+ Sensor sensor,
+ SensorValue data,
+ List values,
+ ) {
+ int? findAxisIndex(List keywords) {
+ for (var i = 0; i < sensor.axisNames.length; i++) {
+ final axis = sensor.axisNames[i].toLowerCase();
+ if (keywords.any(axis.contains)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ double valueAt(int? index, double fallback) {
+ if (index != null && index >= 0 && index < values.length) {
+ return values[index];
+ }
+ return fallback;
+ }
+
+ final fallbackX = values.isNotEmpty ? values[0] : 0.0;
+ final fallbackY = values.length > 1 ? values[1] : 0.0;
+ final fallbackZ = values.length > 2 ? values[2] : 0.0;
+
+ final x = valueAt(findAxisIndex(['x']), fallbackX);
+ final y = valueAt(findAxisIndex(['y']), fallbackY);
+ final z = valueAt(findAxisIndex(['z']), fallbackZ);
+
+ return PpgMotionSample(
+ timestamp: data.timestamp,
+ x: x,
+ y: y,
+ z: z,
+ );
+ }
+
+ PpgTemperatureSample? _extractOpticalTemperatureSample(
+ Sensor sensor,
+ SensorValue data,
+ List values,
+ ) {
+ if (values.isEmpty) {
+ return null;
+ }
+
+ int? findAxisIndex(List keywords) {
+ for (var i = 0; i < sensor.axisNames.length; i++) {
+ final axis = sensor.axisNames[i].toLowerCase();
+ if (keywords.any(axis.contains)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ final axisIndex = findAxisIndex(['temp', 'temperature']) ?? 0;
+ if (axisIndex < 0 || axisIndex >= values.length) {
+ return null;
+ }
+ final celsius = values[axisIndex];
+ if (!celsius.isFinite) {
+ return null;
+ }
+ return PpgTemperatureSample(
+ timestamp: data.timestamp,
+ celsius: celsius,
+ );
+ }
+
@override
Widget build(BuildContext context) {
+ final displayPpgSignalStream = _displayPpgSignalStream;
+ final heartRateStream = _heartRateStream;
+ final hrvStream = _hrvStream;
+ final signalQualityStream = _signalQualityStream;
return PlatformScaffold(
appBar: PlatformAppBar(
- title: PlatformText("Heart Tracker"),
- ),
- body: Padding(
- padding: EdgeInsets.symmetric(horizontal: 10),
- child: ppgFilterWidget(),
+ title: PlatformText('Heart Tracker'),
),
+ body: displayPpgSignalStream == null ||
+ heartRateStream == null ||
+ hrvStream == null ||
+ signalQualityStream == null
+ ? const Center(child: PlatformCircularProgressIndicator())
+ : _buildContent(
+ context,
+ displayPpgSignalStream,
+ heartRateStream,
+ hrvStream,
+ signalQualityStream,
+ usesOpenRingPipeline: _usesOpenRingPipeline,
+ ),
);
}
- Widget ppgFilterWidget() {
- if (!mounted) {
- return Center(child: PlatformCircularProgressIndicator());
- }
-
+ Widget _buildContent(
+ BuildContext context,
+ Stream<(int, double)> displayPpgSignalStream,
+ Stream heartRateStream,
+ Stream hrvStream,
+ Stream signalQualityStream, {
+ required bool usesOpenRingPipeline,
+ }) {
return ListView(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 20),
children: [
- StreamBuilder(
- stream: ppgFilter.heartRateStream,
+ DeviceRow(
+ group: WearableDisplayGroup.single(wearable: widget.wearable),
+ ),
+ const SizedBox(height: 12),
+ StreamBuilder(
+ stream: signalQualityStream,
+ initialData: PpgSignalQuality.unavailable,
builder: (context, snapshot) {
- double bpm = snapshot.data ?? double.nan;
- return Padding(
- padding: EdgeInsets.all(10),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- // BeatingHeart(bpm: bpm.isFinite ? bpm : 60),
- PlatformText(
- "${bpm.isNaN ? "--" : bpm.toStringAsFixed(0)} BPM",
- style: Theme.of(context).textTheme.titleLarge,
- ),
- ],
- ),
- );
+ final quality = snapshot.data ?? PpgSignalQuality.unavailable;
+ return _SignalQualityCard(quality: quality);
},
),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: StreamBuilder(
+ stream: heartRateStream,
+ builder: (context, snapshot) {
+ final bpm = snapshot.data;
+ return _MetricCard(
+ title: 'Heart Rate',
+ icon: Icons.favorite_rounded,
+ value: bpm != null && bpm.isFinite
+ ? bpm.toStringAsFixed(0)
+ : '--',
+ unit: 'BPM',
+ );
+ },
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: StreamBuilder(
+ stream: hrvStream,
+ builder: (context, snapshot) {
+ final hrv = snapshot.data;
+ return _MetricCard(
+ title: 'HRV',
+ icon: Icons.monitor_heart_rounded,
+ value: hrv != null && hrv.isFinite
+ ? hrv.toStringAsFixed(0)
+ : '--',
+ unit: 'ms',
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ _SignalPanelCard(
+ title: 'Filtered PPG',
+ subtitle: usesOpenRingPipeline
+ ? 'OpenRing physiological preprocessing and classic peak-based heart-rate estimation.'
+ : 'Noise-suppressed PPG signal via NLMS Motion-ANC.',
+ icon: Icons.show_chart_rounded,
+ chartStream: displayPpgSignalStream,
+ timestampExponent: widget.ppgSensor.timestampExponent,
+ fixedMeasureMin: usesOpenRingPipeline ? null : -1.6,
+ fixedMeasureMax: usesOpenRingPipeline ? null : 1.6,
+ ),
+ const SizedBox(height: 12),
Card(
- child: Column(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ Icons.menu_book_rounded,
+ size: 18,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ 'Algorithm Citation',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 6),
+ if (usesOpenRingPipeline) ...[
+ Text(
+ 'OpenRing processing path: physiological signal filter and classic adaptive peak detection adapted from Tsinghua OpenRing source '
+ '(SignalFilters.java, ClassicAlgorithmProcessor.java).',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Device citation: τ-Ring: A Smart Ring Platform for Multimodal Physiological and Behavioral Sensing '
+ '(Tang et al., UbiComp Companion, 2025). arXiv:2508.00778',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ ] else ...[
+ Text(
+ 'Beat detection: MSPTDfast v2 '
+ '(Charlton et al., 2025). '
+ 'DOI: 10.1088/1361-6579/adb89e',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Motion artifact suppression: multi-reference NLMS Motion-ANC '
+ 'adapted from padasip (MIT, Cejnek & Vrba, 2022). '
+ 'DOI: 10.1016/j.jocs.2022.101887',
+ style: Theme.of(context).textTheme.bodySmall,
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Implementation optimized for real-time, smartphone-class '
+ 'compute using streaming filters and bounded state.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4),
+ child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Padding(
- padding: EdgeInsets.all(10),
- child: PlatformText(
- "Blood Flow",
- style: Theme.of(context).textTheme.titleMedium,
- ),
+ Icon(
+ Icons.warning_amber_rounded,
+ size: 13,
+ color: Theme.of(context).colorScheme.error,
),
- SizedBox(
- height: 200,
- child: RollingChart(
- dataSteam: ppgFilter.filteredStream,
- timestampExponent: widget.ppgSensor.timestampExponent,
- timeWindow: 5,
+ const SizedBox(width: 5),
+ Expanded(
+ child: Text(
+ 'This view is for demonstration purposes only. It is not a medical device and must not be used for diagnosis, treatment, or emergency decisions.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
),
),
],
@@ -126,3 +624,241 @@ class _HeartTrackerPageState extends State {
);
}
}
+
+class _MetricCard extends StatelessWidget {
+ final String title;
+ final IconData icon;
+ final String value;
+ final String unit;
+
+ const _MetricCard({
+ required this.title,
+ required this.icon,
+ required this.value,
+ required this.unit,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ icon,
+ size: 18,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ title,
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Text(
+ value,
+ style: Theme.of(context).textTheme.headlineSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(width: 4),
+ Padding(
+ padding: const EdgeInsets.only(bottom: 3),
+ child: Text(
+ unit,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _SignalPanelCard extends StatelessWidget {
+ final String title;
+ final String subtitle;
+ final IconData icon;
+ final Stream<(int, double)> chartStream;
+ final int timestampExponent;
+ final double? fixedMeasureMin;
+ final double? fixedMeasureMax;
+
+ const _SignalPanelCard({
+ required this.title,
+ required this.subtitle,
+ required this.icon,
+ required this.chartStream,
+ required this.timestampExponent,
+ this.fixedMeasureMin,
+ this.fixedMeasureMax,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(
+ icon,
+ size: 18,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ title,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 4),
+ Text(
+ subtitle,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 10),
+ SizedBox(
+ height: 88,
+ child: RollingChart(
+ dataSteam: chartStream,
+ timestampExponent: timestampExponent,
+ timeWindow: 5,
+ showXAxis: false,
+ showYAxis: false,
+ fixedMeasureMin: fixedMeasureMin,
+ fixedMeasureMax: fixedMeasureMax,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _SignalQualityCard extends StatelessWidget {
+ final PpgSignalQuality quality;
+
+ const _SignalQualityCard({
+ required this.quality,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final (label, hint, icon, color) = _presentQuality(colorScheme);
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ Icon(
+ icon,
+ size: 18,
+ color: color,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ 'Heartbeat Signal',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ Container(
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 4,
+ ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 6),
+ Text(
+ hint,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ (String, String, IconData, Color) _presentQuality(ColorScheme colors) {
+ switch (quality) {
+ case PpgSignalQuality.unavailable:
+ return (
+ 'Unavailable',
+ 'No stable heartbeat waveform yet. Ensure stable wearable placement.',
+ Icons.portable_wifi_off_rounded,
+ colors.onSurfaceVariant,
+ );
+ case PpgSignalQuality.bad:
+ return (
+ 'Bad',
+ 'Signal is noisy. Reduce motion and improve wearable contact.',
+ Icons.signal_cellular_connected_no_internet_4_bar_rounded,
+ colors.error,
+ );
+ case PpgSignalQuality.fair:
+ return (
+ 'Fair',
+ 'Heartbeat is partially visible. Hold still for a clearer reading.',
+ Icons.network_check_rounded,
+ Colors.orange.shade700,
+ );
+ case PpgSignalQuality.good:
+ return (
+ 'Good',
+ 'Signal quality is good for HR and HRV estimation.',
+ Icons.check_circle_rounded,
+ Colors.green.shade700,
+ );
+ }
+ }
+}
diff --git a/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart b/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart
index 6455f325..7eb575bf 100644
--- a/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart
+++ b/open_wearable/lib/apps/heart_tracker/widgets/rowling_chart.dart
@@ -1,18 +1,27 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
-import 'package:community_charts_flutter/community_charts_flutter.dart' as charts;
+import 'package:community_charts_flutter/community_charts_flutter.dart'
+ as charts;
class RollingChart extends StatefulWidget {
final Stream<(int, double)> dataSteam;
final int timestampExponent; // e.g., 6 for microseconds to milliseconds
- final int timeWindow; // in milliseconds
+ final int timeWindow; // in seconds
+ final bool showXAxis;
+ final bool showYAxis;
+ final double? fixedMeasureMin;
+ final double? fixedMeasureMax;
const RollingChart({
super.key,
required this.dataSteam,
required this.timestampExponent,
required this.timeWindow,
+ this.showXAxis = true,
+ this.showYAxis = true,
+ this.fixedMeasureMin,
+ this.fixedMeasureMax,
});
@override
@@ -20,8 +29,9 @@ class RollingChart extends StatefulWidget {
}
class _RollingChartState extends State {
- List> _seriesList = [];
- final List<_ChartPoint> _data = [];
+ List> _seriesList = [];
+ final List<_RawChartPoint> _rawData = [];
+ List<_ChartPoint> _normalizedData = [];
StreamSubscription? _subscription;
@override
@@ -42,56 +52,122 @@ class _RollingChartState extends State {
void _listenToStream() {
_subscription = widget.dataSteam.listen((event) {
final (timestamp, value) = event;
-
+ if (!value.isFinite) {
+ return;
+ }
+
setState(() {
- _data.add(_ChartPoint(timestamp, value));
-
+ _rawData.add(_RawChartPoint(timestamp, value));
+
// Remove old data outside time window
- int cutoffTime = timestamp - (widget.timeWindow * pow(10, -widget.timestampExponent) as int);
- _data.removeWhere((data) => data.time < cutoffTime);
-
+ final ticksPerSecond = pow(10, -widget.timestampExponent).toDouble();
+ final cutoffTime =
+ timestamp - (widget.timeWindow * ticksPerSecond).round();
+ _rawData.removeWhere((data) => data.timestamp < cutoffTime);
+
_updateSeries();
});
});
}
void _updateSeries() {
+ if (_rawData.isEmpty) {
+ _normalizedData = [];
+ _seriesList = [];
+ return;
+ }
+
+ final finiteRawData = _rawData
+ .where((point) => point.value.isFinite)
+ .toList(growable: false);
+ if (finiteRawData.length < 2) {
+ _normalizedData = [];
+ _seriesList = [];
+ return;
+ }
+
+ final firstTimestamp = finiteRawData.first.timestamp;
+ final secondsPerTick = pow(10, widget.timestampExponent).toDouble();
+
+ _normalizedData = finiteRawData
+ .map(
+ (point) => _ChartPoint(
+ (point.timestamp - firstTimestamp) * secondsPerTick,
+ point.value,
+ ),
+ )
+ .toList(growable: false);
+
_seriesList = [
- charts.Series<_ChartPoint, int>(
- id: 'Live Data',
- colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault,
- domainFn: (_ChartPoint point, _) => point.time,
- measureFn: (_ChartPoint point, _) => point.value,
- data: List.of(_data),
+ charts.Series<_ChartPoint, num>(
+ id: 'Live Data',
+ colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault,
+ domainFn: (_ChartPoint point, _) => point.timeSeconds,
+ measureFn: (_ChartPoint point, _) => point.value,
+ data: _normalizedData,
),
];
}
@override
Widget build(BuildContext context) {
- final filteredPoints = _data;
+ if (_seriesList.isEmpty || _normalizedData.length < 2) {
+ return Center(
+ child: Text(
+ 'Waiting for signal...',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+
+ final filteredPoints = _normalizedData;
- final xValues = filteredPoints.map((e) => e.time).toList();
+ final xValues = filteredPoints.map((e) => e.timeSeconds).toList();
final yValues = filteredPoints.map((e) => e.value).toList();
- final int? xMin = xValues.isNotEmpty ? xValues.reduce((a, b) => a < b ? a : b) : null;
- final int? xMax = xValues.isNotEmpty ? xValues.reduce((a, b) => a > b ? a : b) : null;
+ final double xMin = 0;
+ final double xMax = max(
+ widget.timeWindow.toDouble(),
+ xValues.isNotEmpty ? xValues.reduce((a, b) => a > b ? a : b) : 0,
+ );
- final double? yMin = yValues.isNotEmpty ? yValues.reduce((a, b) => a < b ? a : b) : null;
- final double? yMax = yValues.isNotEmpty ? yValues.reduce((a, b) => a > b ? a : b) : null;
+ final double? dynamicYMin =
+ yValues.isNotEmpty ? yValues.reduce((a, b) => a < b ? a : b) : null;
+ final double? dynamicYMax =
+ yValues.isNotEmpty ? yValues.reduce((a, b) => a > b ? a : b) : null;
+ var yMin = widget.fixedMeasureMin ?? dynamicYMin;
+ var yMax = widget.fixedMeasureMax ?? dynamicYMax;
+ if (yMin != null && yMax != null && yMin >= yMax) {
+ final center = yMin;
+ final pad = max(center.abs() * 0.05, 1.0);
+ yMin = center - pad;
+ yMax = center + pad;
+ }
return charts.LineChart(
_seriesList,
animate: false,
+ defaultInteractions: false,
+ behaviors: const [],
domainAxis: charts.NumericAxisSpec(
- viewport: xMin != null && xMax != null
- ? charts.NumericExtents(xMin, xMax)
- : null,
+ viewport: charts.NumericExtents(xMin, xMax),
+ renderSpec: widget.showXAxis ? null : const charts.NoneRenderSpec(),
+ tickFormatterSpec: charts.BasicNumericTickFormatterSpec((num? value) {
+ if (value == null) return '';
+ final rounded = value.roundToDouble();
+ if ((value - rounded).abs() < 0.05) {
+ return '${rounded.toInt()}s';
+ }
+ return '${value.toStringAsFixed(1)}s';
+ }),
),
primaryMeasureAxis: charts.NumericAxisSpec(
viewport: yMin != null && yMax != null
- ? charts.NumericExtents(yMin, yMax)
- : null,
+ ? charts.NumericExtents(yMin, yMax)
+ : null,
+ renderSpec: widget.showYAxis ? null : const charts.NoneRenderSpec(),
),
);
}
@@ -103,9 +179,16 @@ class _RollingChartState extends State {
}
}
+class _RawChartPoint {
+ final int timestamp;
+ final double value;
+
+ _RawChartPoint(this.timestamp, this.value);
+}
+
class _ChartPoint {
- final int time;
+ final double timeSeconds;
final double value;
- _ChartPoint(this.time, this.value);
+ _ChartPoint(this.timeSeconds, this.value);
}
diff --git a/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart b/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart
index 0f08ded3..a1a02844 100644
--- a/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart
+++ b/open_wearable/lib/apps/posture_tracker/model/earable_attitude_tracker.dart
@@ -24,7 +24,11 @@ class EarableAttitudeTracker extends AttitudeTracker {
final bool _isLeft;
- EarableAttitudeTracker(this._sensorManager, this._sensorConfigurationProvider, this._isLeft);
+ EarableAttitudeTracker(
+ this._sensorManager,
+ this._sensorConfigurationProvider,
+ this._isLeft,
+ );
@override
void start() {
@@ -33,18 +37,33 @@ class EarableAttitudeTracker extends AttitudeTracker {
return;
}
- final Sensor accelSensor = _sensorManager.sensors.firstWhere((s) => s.sensorName.toLowerCase() == "accelerometer".toLowerCase());
+ final Sensor accelSensor = _sensorManager.sensors.firstWhere(
+ (s) => s.sensorName.toLowerCase() == "accelerometer".toLowerCase(),
+ );
final Set configurations = {};
configurations.addAll(accelSensor.relatedConfigurations);
for (final SensorConfiguration configuration in configurations) {
- if (configuration is ConfigurableSensorConfiguration && configuration.availableOptions.contains(StreamSensorConfigOption())) {
- _sensorConfigurationProvider.addSensorConfigurationOption(configuration, StreamSensorConfigOption());
+ if (configuration is ConfigurableSensorConfiguration &&
+ configuration.availableOptions.contains(StreamSensorConfigOption())) {
+ _sensorConfigurationProvider.addSensorConfigurationOption(
+ configuration,
+ StreamSensorConfigOption(),
+ markPending: false,
+ );
}
- List values = _sensorConfigurationProvider.getSensorConfigurationValues(configuration, distinct: true);
- _sensorConfigurationProvider.addSensorConfiguration(configuration, values.first);
- configuration.setConfiguration(_sensorConfigurationProvider.getSelectedConfigurationValue(configuration)!);
+ List values = _sensorConfigurationProvider
+ .getSensorConfigurationValues(configuration, distinct: true);
+ _sensorConfigurationProvider.addSensorConfiguration(
+ configuration,
+ values.first,
+ markPending: false,
+ );
+ configuration.setConfiguration(
+ _sensorConfigurationProvider
+ .getSelectedConfigurationValue(configuration)!,
+ );
}
calibrate(
diff --git a/open_wearable/lib/apps/posture_tracker/view/arc_painter.dart b/open_wearable/lib/apps/posture_tracker/view/arc_painter.dart
index 65ae2628..71c3bbde 100644
--- a/open_wearable/lib/apps/posture_tracker/view/arc_painter.dart
+++ b/open_wearable/lib/apps/posture_tracker/view/arc_painter.dart
@@ -1,90 +1,97 @@
-// ignore_for_file: unnecessary_this
-
import 'dart:math';
import 'package:flutter/material.dart';
class ArcPainter extends CustomPainter {
- /// the angle of rotation
final double angle;
final double angleThreshold;
-
- ArcPainter({required this.angle, this.angleThreshold = 0});
+ final Color circleColor;
+ final Color angleColor;
+ final Color thresholdColor;
+ final Color overshootColor;
+ final double strokeWidth;
+
+ ArcPainter({
+ required this.angle,
+ this.angleThreshold = 0,
+ this.circleColor = const Color(0xFFC3C3C3),
+ this.angleColor = Colors.blue,
+ this.thresholdColor = const Color(0x664285F4),
+ this.overshootColor = Colors.red,
+ this.strokeWidth = 5,
+ });
@override
void paint(Canvas canvas, Size size) {
- Paint circlePaint = Paint()
- ..color = const Color.fromARGB(255, 195, 195, 195)
+ final center = Offset(size.width / 2, size.height / 2);
+ final radius = min(size.width, size.height) / 2;
+ final safeAngle = angle.isFinite ? angle : 0.0;
+ final safeThreshold = angleThreshold.abs();
+ const startAngle = -pi / 2;
+
+ final circlePaint = Paint()
+ ..color = circleColor
..style = PaintingStyle.stroke
- ..strokeWidth = 5.0;
+ ..strokeWidth = strokeWidth;
+ canvas.drawCircle(center, radius, circlePaint);
- Path circlePath = Path();
- circlePath.addOval(Rect.fromCircle(center: Offset(size.width / 2, size.height / 2), radius: min(size.width, size.height) / 2));
- canvas.drawPath(circlePath, circlePaint);
-
- // Create a paint object with purple color and stroke style
- Paint anglePaint = Paint()
- ..color = Colors.purpleAccent
+ final anglePaint = Paint()
+ ..color = angleColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
- ..strokeWidth = 5.0;
-
- // Create a path object to draw the arc
- Path anglePath = Path();
-
- // Calculate the center and radius of the circle
- Offset center = Offset(size.width / 2, size.height / 2);
- double radius = min(size.width, size.height) / 2;
-
- // Calculate the start and end angles of the arc
- double startAngle = -pi / 2; // start from the top of the circle
- double endAngle = angle;
-
- // Add an arc to the path
- anglePath.addArc(
- Rect.fromCircle(center: center, radius: radius), // create a rectangle from the center and radius
- startAngle, // start angle
- endAngle, // sweep angle
- );
-
- Path angleOvershootPath = Path();
- angleOvershootPath.addArc(
- Rect.fromCircle(center: center, radius: radius), // create a rectangle from the center and radius
- startAngle + angle.sign * angleThreshold, // start angle
- angle.sign * (angle.abs() - angleThreshold), // sweep angle
- );
+ ..strokeWidth = strokeWidth;
- Paint angleOvershootPaint = Paint()
- ..color = Colors.red
+ final thresholdPaint = Paint()
+ ..color = thresholdColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
- ..strokeWidth = 5.0;
+ ..strokeWidth = strokeWidth;
- Path thresholdPath = Path();
- thresholdPath.addArc(
- Rect.fromCircle(center: center, radius: radius), // create a rectangle from the center and radius
- startAngle - angleThreshold, // start angle
- 2 * angleThreshold, // sweep angle
- );
-
- Paint thresholdPaint = Paint()
- ..color = Colors.purpleAccent[100]!
+ final angleOvershootPaint = Paint()
+ ..color = overshootColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
- ..strokeWidth = 5.0;
+ ..strokeWidth = strokeWidth;
+
+ final arcBounds = Rect.fromCircle(center: center, radius: radius);
+ if (safeThreshold > 0) {
+ canvas.drawArc(
+ arcBounds,
+ startAngle - safeThreshold,
+ 2 * safeThreshold,
+ false,
+ thresholdPaint,
+ );
+ }
+ canvas.drawArc(
+ arcBounds,
+ startAngle,
+ safeAngle,
+ false,
+ anglePaint,
+ );
- // Draw the path on the canvas
- canvas.drawPath(thresholdPath, thresholdPaint);
- canvas.drawPath(anglePath, anglePaint);
- if (angle.abs() > angleThreshold.abs()) {
- canvas.drawPath(angleOvershootPath, angleOvershootPaint);
+ if (safeThreshold > 0 && safeAngle.abs() > safeThreshold) {
+ canvas.drawArc(
+ arcBounds,
+ startAngle + safeAngle.sign * safeThreshold,
+ safeAngle.sign * (safeAngle.abs() - safeThreshold),
+ false,
+ angleOvershootPaint,
+ );
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
- // check if oldDelegate is an ArcPainter and if the angle is the same
- return oldDelegate is ArcPainter && oldDelegate.angle != this.angle;
+ return oldDelegate is ArcPainter &&
+ (oldDelegate.angle != angle ||
+ oldDelegate.angleThreshold != angleThreshold ||
+ oldDelegate.circleColor != circleColor ||
+ oldDelegate.angleColor != angleColor ||
+ oldDelegate.thresholdColor != thresholdColor ||
+ oldDelegate.overshootColor != overshootColor ||
+ oldDelegate.strokeWidth != strokeWidth);
}
}
diff --git a/open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart b/open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart
index 495538fa..5cd7ff38 100644
--- a/open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart
+++ b/open_wearable/lib/apps/posture_tracker/view/posture_roll_view.dart
@@ -1,14 +1,11 @@
-// ignore_for_file: unnecessary_this
-
import 'dart:math';
import 'package:flutter/material.dart';
-import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:open_wearable/apps/posture_tracker/view/arc_painter.dart';
/// A widget that displays the roll of the head and neck.
class PostureRollView extends StatelessWidget {
- static final double _maxRoll = pi / 2;
+ static const double _maxRoll = pi / 2;
/// The roll of the head and neck in radians.
final double roll;
@@ -17,6 +14,9 @@ class PostureRollView extends StatelessWidget {
final String headAssetPath;
final String neckAssetPath;
final AlignmentGeometry headAlignment;
+ final double visualSize;
+ final Color? goodColor;
+ final Color? badColor;
const PostureRollView({
super.key,
@@ -25,42 +25,61 @@ class PostureRollView extends StatelessWidget {
required this.headAssetPath,
required this.neckAssetPath,
this.headAlignment = Alignment.center,
+ this.visualSize = 118,
+ this.goodColor,
+ this.badColor,
});
@override
Widget build(BuildContext context) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final boundedRoll =
+ roll.isFinite ? roll.clamp(-_maxRoll, _maxRoll).toDouble() : 0.0;
+ final hasOvershoot = roll.abs() > angleThreshold.abs();
+ final healthyColor = goodColor ?? const Color(0xFF2F8F5B);
+ final unhealthyColor = badColor ?? colorScheme.error;
+ final displayColor = hasOvershoot ? unhealthyColor : healthyColor;
+
return Column(
children: [
- PlatformText(
- "${(this.roll * 180 / 3.14).abs().toStringAsFixed(0)}°",
- style: TextStyle(
- // use proper color matching the background
- color: Theme.of(context).colorScheme.onSurface,
- fontSize: 30,
- fontWeight: FontWeight.bold,
- ),
+ Text(
+ '${(roll * 180 / pi).abs().toStringAsFixed(0)}°',
+ style: Theme.of(context).textTheme.titleLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: displayColor,
+ ),
),
+ const SizedBox(height: 2),
CustomPaint(
- painter:
- ArcPainter(angle: this.roll, angleThreshold: this.angleThreshold),
- child: Padding(
- padding: EdgeInsets.all(10),
+ painter: ArcPainter(
+ angle: roll,
+ angleThreshold: angleThreshold,
+ circleColor: colorScheme.outlineVariant.withValues(alpha: 0.65),
+ angleColor: displayColor,
+ thresholdColor: displayColor.withValues(alpha: 0.35),
+ overshootColor: unhealthyColor,
+ ),
+ child: SizedBox.square(
+ dimension: visualSize,
child: ClipOval(
child: Container(
- color: roll.abs() > _maxRoll
- ? Colors.red.withValues(alpha: 0.5)
- : Colors.transparent,
+ color: hasOvershoot
+ ? unhealthyColor.withValues(alpha: 0.18)
+ : healthyColor.withValues(alpha: 0.12),
child: Stack(
+ fit: StackFit.expand,
children: [
- Image.asset(this.neckAssetPath),
+ Image.asset(
+ neckAssetPath,
+ fit: BoxFit.contain,
+ ),
Transform.rotate(
- angle: this.roll.isFinite
- ? roll.abs() < _maxRoll
- ? this.roll
- : roll.sign * _maxRoll
- : 0,
- alignment: this.headAlignment,
- child: Image.asset(this.headAssetPath),
+ angle: boundedRoll,
+ alignment: headAlignment,
+ child: Image.asset(
+ headAssetPath,
+ fit: BoxFit.contain,
+ ),
),
],
),
diff --git a/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart b/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart
index bb178e16..4dd17c0d 100644
--- a/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart
+++ b/open_wearable/lib/apps/posture_tracker/view/posture_tracker_view.dart
@@ -1,4 +1,4 @@
-// ignore_for_file: unnecessary_this
+import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
@@ -7,6 +7,7 @@ import 'package:open_wearable/apps/posture_tracker/model/bad_posture_reminder.da
import 'package:open_wearable/apps/posture_tracker/view/posture_roll_view.dart';
import 'package:open_wearable/apps/posture_tracker/view/settings_view.dart';
import 'package:open_wearable/apps/posture_tracker/view_model/posture_tracker_view_model.dart';
+import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
import 'package:provider/provider.dart';
class PostureTrackerView extends StatefulWidget {
@@ -19,108 +20,460 @@ class PostureTrackerView extends StatefulWidget {
}
class _PostureTrackerViewState extends State {
+ static const Color _goodPostureColor = Color(0xFF2F8F5B);
+
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
- create: (context) => PostureTrackerViewModel(widget._tracker,
- BadPostureReminder(attitudeTracker: widget._tracker),),
- builder: (context, child) => Consumer(
- builder: (context, postureTrackerViewModel, child) => Scaffold(
- appBar: AppBar(
- title: PlatformText("Posture Tracker"),
- actions: [
- IconButton(
- onPressed: () => Navigator.of(context).push(
- MaterialPageRoute(
- builder: (context) =>
- SettingsView(postureTrackerViewModel),),),
- icon: Icon(Icons.settings),),
+ create: (context) => PostureTrackerViewModel(
+ widget._tracker,
+ BadPostureReminder(attitudeTracker: widget._tracker),
+ ),
+ builder: (context, child) => Consumer(
+ builder: (context, postureTrackerViewModel, child) => PlatformScaffold(
+ appBar: PlatformAppBar(
+ title: const Text('Posture Tracker'),
+ trailingActions: [
+ PlatformIconButton(
+ icon: const Icon(Icons.settings_outlined),
+ onPressed: () {
+ Navigator.of(context).push(
+ platformPageRoute(
+ context: context,
+ builder: (context) =>
+ SettingsView(postureTrackerViewModel),
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ body: _buildContentView(context, postureTrackerViewModel),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildContentView(
+ BuildContext context,
+ PostureTrackerViewModel postureTrackerViewModel,
+ ) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final isGoodPosture = _isGoodPosture(postureTrackerViewModel);
+ final status = _trackingStatus(
+ isAvailable: postureTrackerViewModel.isAvailable,
+ isTracking: postureTrackerViewModel.isTracking,
+ isGoodPosture: isGoodPosture,
+ );
+ final rollWithinThreshold = _isWithinThreshold(
+ value: postureTrackerViewModel.attitude.roll,
+ thresholdDegrees: postureTrackerViewModel
+ .badPostureSettings.rollAngleThreshold
+ .toDouble(),
+ );
+ final pitchWithinThreshold = _isWithinThreshold(
+ value: postureTrackerViewModel.attitude.pitch,
+ thresholdDegrees: postureTrackerViewModel
+ .badPostureSettings.pitchAngleThreshold
+ .toDouble(),
+ );
+ final infoText = postureTrackerViewModel.isTracking
+ ? 'Live feedback for head tilt and neck alignment.'
+ : 'Start tracking for live posture feedback. Calibration is optional.';
+
+ return Padding(
+ padding: SensorPageSpacing.pagePadding,
+ child: Column(
+ children: [
+ Card(
+ color: postureTrackerViewModel.isTracking && isGoodPosture
+ ? _goodPostureColor.withValues(alpha: 0.12)
+ : null,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ 'Live Posture Feedback',
+ style:
+ Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ _TrackingStatusChip(
+ label: status.label,
+ color: status.color,
+ ),
],
),
- body: Center(
- child: this._buildContentView(postureTrackerViewModel),
+ const SizedBox(height: 4),
+ Text(
+ infoText,
+ style: Theme.of(context).textTheme.bodySmall,
),
- backgroundColor: Theme.of(context).colorScheme.surface,
- ),),);
- }
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ _AngleMetricPill(
+ label: 'Roll',
+ valueRadians: postureTrackerViewModel.attitude.roll,
+ accentColor: postureTrackerViewModel.isTracking
+ ? (rollWithinThreshold
+ ? _goodPostureColor
+ : colorScheme.error)
+ : colorScheme.primary,
+ ),
+ _AngleMetricPill(
+ label: 'Pitch',
+ valueRadians: postureTrackerViewModel.attitude.pitch,
+ accentColor: postureTrackerViewModel.isTracking
+ ? (pitchWithinThreshold
+ ? _goodPostureColor
+ : colorScheme.error)
+ : colorScheme.primary,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ Expanded(
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Head Posture',
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ if (!postureTrackerViewModel.isAvailable) ...[
+ const SizedBox(height: 4),
+ Text(
+ 'No compatible OpenEarable connected.',
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: colorScheme.error,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ const SizedBox(height: 6),
+ Expanded(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ const headGap = 8.0;
+ const headChromeHeight = 52.0;
+ final headSpecs =
+ _createHeadSpecs(postureTrackerViewModel);
+ final viewWidth = min(constraints.maxWidth, 300.0);
+ final perHeadHeight =
+ (constraints.maxHeight - headGap) / 2;
+ final previewByHeight =
+ perHeadHeight - headChromeHeight;
+ final previewByWidth = viewWidth - 20;
+ final previewSize =
+ min(previewByHeight, previewByWidth)
+ .clamp(72.0, 200.0);
- Widget _buildContentView(PostureTrackerViewModel postureTrackerViewModel) {
- var headViews = this._createHeadViews(postureTrackerViewModel);
- return Column(mainAxisAlignment: MainAxisAlignment.center, children: [
- ...headViews.map((e) => FractionallySizedBox(
- widthFactor: .7,
- child: e,
- ),),
- this._buildTrackingButton(postureTrackerViewModel),
- ],);
+ return Align(
+ alignment: Alignment.center,
+ child: SizedBox(
+ width: viewWidth,
+ child: Column(
+ children: [
+ Expanded(
+ child: Center(
+ child: _buildHeadView(
+ context,
+ headSpecs[0],
+ previewSize: previewSize,
+ ),
+ ),
+ ),
+ const SizedBox(height: headGap),
+ Expanded(
+ child: Center(
+ child: _buildHeadView(
+ context,
+ headSpecs[1],
+ previewSize: previewSize,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ SafeArea(
+ top: false,
+ child: Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
+ child: Column(
+ children: [
+ SizedBox(
+ width: double.infinity,
+ child: PlatformElevatedButton(
+ onPressed: postureTrackerViewModel.isAvailable
+ ? () {
+ if (postureTrackerViewModel.isTracking) {
+ postureTrackerViewModel.stopTracking();
+ return;
+ }
+ postureTrackerViewModel.startTracking();
+ }
+ : null,
+ color: postureTrackerViewModel.isTracking
+ ? colorScheme.error
+ : null,
+ child: Text(
+ postureTrackerViewModel.isTracking
+ ? 'Stop Tracking'
+ : 'Start Tracking',
+ ),
+ ),
+ ),
+ const SizedBox(height: 6),
+ SizedBox(
+ width: double.infinity,
+ child: PlatformTextButton(
+ onPressed: postureTrackerViewModel.isTracking
+ ? postureTrackerViewModel.calibrate
+ : null,
+ child: const Text('Calibrate (Optional)'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
}
- Widget _buildHeadView(String headAssetPath, String neckAssetPath,
- AlignmentGeometry headAlignment, double roll, double angleThreshold,) {
- return Padding(
- padding: const EdgeInsets.all(5),
- child: PostureRollView(
- roll: roll,
- angleThreshold: angleThreshold * 3.14 / 180,
- headAssetPath: headAssetPath,
- neckAssetPath: neckAssetPath,
- headAlignment: headAlignment,
- ),
+ Widget _buildHeadView(
+ BuildContext context,
+ _HeadPreviewSpec spec, {
+ required double previewSize,
+ }) {
+ final colorScheme = Theme.of(context).colorScheme;
+ final withinThreshold = _isWithinThreshold(
+ value: spec.roll,
+ thresholdDegrees: spec.angleThreshold,
+ );
+ final accentColor = withinThreshold ? _goodPostureColor : colorScheme.error;
+
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: accentColor,
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ spec.label,
+ style: Theme.of(context).textTheme.labelLarge?.copyWith(
+ fontWeight: FontWeight.w700,
+ color: accentColor,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 2),
+ PostureRollView(
+ roll: spec.roll,
+ angleThreshold: _degreesToRadians(spec.angleThreshold),
+ headAssetPath: spec.headAssetPath,
+ neckAssetPath: spec.neckAssetPath,
+ headAlignment: spec.headAlignment,
+ visualSize: previewSize,
+ goodColor: _goodPostureColor,
+ badColor: colorScheme.error,
+ ),
+ ],
);
}
- List _createHeadViews(PostureTrackerViewModel postureTrackerViewModel) {
+ List<_HeadPreviewSpec> _createHeadSpecs(
+ PostureTrackerViewModel postureTrackerViewModel,
+ ) {
return [
- this._buildHeadView(
- "lib/apps/posture_tracker/assets/Head_Front.png",
- "lib/apps/posture_tracker/assets/Neck_Front.png",
- Alignment.center.add(Alignment(0, 0.3)),
- postureTrackerViewModel.attitude.roll,
- postureTrackerViewModel.badPostureSettings.rollAngleThreshold
- .toDouble(),),
- this._buildHeadView(
- "lib/apps/posture_tracker/assets/Head_Side.png",
- "lib/apps/posture_tracker/assets/Neck_Side.png",
- Alignment.center.add(Alignment(0, 0.3)),
- -postureTrackerViewModel.attitude.pitch,
- postureTrackerViewModel.badPostureSettings.pitchAngleThreshold
- .toDouble(),),
+ _HeadPreviewSpec(
+ label: 'Side-to-Side Tilt',
+ headAssetPath: 'lib/apps/posture_tracker/assets/Head_Front.png',
+ neckAssetPath: 'lib/apps/posture_tracker/assets/Neck_Front.png',
+ headAlignment: Alignment.center.add(const Alignment(0, 0.3)),
+ roll: postureTrackerViewModel.attitude.roll,
+ angleThreshold: postureTrackerViewModel
+ .badPostureSettings.rollAngleThreshold
+ .toDouble(),
+ ),
+ _HeadPreviewSpec(
+ label: 'Forward/Backward Tilt',
+ headAssetPath: 'lib/apps/posture_tracker/assets/Head_Side.png',
+ neckAssetPath: 'lib/apps/posture_tracker/assets/Neck_Side.png',
+ headAlignment: Alignment.center.add(const Alignment(0, 0.3)),
+ roll: -postureTrackerViewModel.attitude.pitch,
+ angleThreshold: postureTrackerViewModel
+ .badPostureSettings.pitchAngleThreshold
+ .toDouble(),
+ ),
];
}
- Widget _buildTrackingButton(PostureTrackerViewModel postureTrackerViewModel) {
- return Column(children: [
- ElevatedButton(
- onPressed: postureTrackerViewModel.isAvailable
- ? () {
- postureTrackerViewModel.isTracking
- ? postureTrackerViewModel.stopTracking()
- : postureTrackerViewModel.startTracking();
- }
- : null,
- style: ElevatedButton.styleFrom(
- backgroundColor: !postureTrackerViewModel.isTracking
- ? Color(0xff77F2A1)
- : Color(0xfff27777),
- foregroundColor: Colors.black,
- ),
- child: postureTrackerViewModel.isTracking
- ? PlatformText("Stop Tracking")
- : PlatformText("Start Tracking"),
+ bool _isGoodPosture(PostureTrackerViewModel postureTrackerViewModel) {
+ final rollWithinThreshold = _isWithinThreshold(
+ value: postureTrackerViewModel.attitude.roll,
+ thresholdDegrees: postureTrackerViewModel
+ .badPostureSettings.rollAngleThreshold
+ .toDouble(),
+ );
+ final pitchWithinThreshold = _isWithinThreshold(
+ value: postureTrackerViewModel.attitude.pitch,
+ thresholdDegrees: postureTrackerViewModel
+ .badPostureSettings.pitchAngleThreshold
+ .toDouble(),
+ );
+ return rollWithinThreshold && pitchWithinThreshold;
+ }
+
+ bool _isWithinThreshold({
+ required double value,
+ required double thresholdDegrees,
+ }) {
+ return value.abs() <= _degreesToRadians(thresholdDegrees).abs();
+ }
+
+ double _degreesToRadians(double value) {
+ return value * pi / 180;
+ }
+
+ ({String label, Color color}) _trackingStatus({
+ required bool isAvailable,
+ required bool isTracking,
+ required bool isGoodPosture,
+ }) {
+ if (!isAvailable) {
+ return (label: 'Unavailable', color: Theme.of(context).colorScheme.error);
+ }
+ if (!isTracking) {
+ return (label: 'Ready', color: Theme.of(context).colorScheme.primary);
+ }
+ if (isGoodPosture) {
+ return (label: 'Good Posture', color: _goodPostureColor);
+ }
+ return (
+ label: 'Adjust Posture',
+ color: Theme.of(context).colorScheme.error
+ );
+ }
+}
+
+class _HeadPreviewSpec {
+ final String label;
+ final String headAssetPath;
+ final String neckAssetPath;
+ final AlignmentGeometry headAlignment;
+ final double roll;
+ final double angleThreshold;
+
+ const _HeadPreviewSpec({
+ required this.label,
+ required this.headAssetPath,
+ required this.neckAssetPath,
+ required this.headAlignment,
+ required this.roll,
+ required this.angleThreshold,
+ });
+}
+
+class _TrackingStatusChip extends StatelessWidget {
+ final String label;
+ final Color color;
+
+ const _TrackingStatusChip({
+ required this.label,
+ required this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(color: color.withValues(alpha: 0.28)),
),
- Visibility(
- visible: !postureTrackerViewModel.isAvailable,
- maintainState: true,
- maintainAnimation: true,
- maintainSize: true,
- child: PlatformText(
- "No Earable Connected",
- style: TextStyle(
- color: Colors.red,
- fontSize: 12,
- ),
- ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ );
+ }
+}
+
+class _AngleMetricPill extends StatelessWidget {
+ final String label;
+ final double valueRadians;
+ final Color accentColor;
+
+ const _AngleMetricPill({
+ required this.label,
+ required this.valueRadians,
+ required this.accentColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final value = (valueRadians * 180 / pi).toStringAsFixed(0);
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: accentColor.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(999),
),
- ],);
+ child: Text(
+ '$label $value°',
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: accentColor.withValues(alpha: 0.95),
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ );
}
}
diff --git a/open_wearable/lib/apps/posture_tracker/view/settings_view.dart b/open_wearable/lib/apps/posture_tracker/view/settings_view.dart
index 0a029d4d..467eb1d7 100644
--- a/open_wearable/lib/apps/posture_tracker/view/settings_view.dart
+++ b/open_wearable/lib/apps/posture_tracker/view/settings_view.dart
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
-import 'package:open_wearable/apps/posture_tracker/model/bad_posture_reminder.dart';
import 'package:open_wearable/apps/posture_tracker/view_model/posture_tracker_view_model.dart';
+import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
import 'package:provider/provider.dart';
class SettingsView extends StatefulWidget {
@@ -26,206 +26,304 @@ class _SettingsViewState extends State {
super.initState();
_viewModel = widget._viewModel;
_rollAngleThresholdController = TextEditingController(
- text: _viewModel.badPostureSettings.rollAngleThreshold.toString(),);
+ text: _viewModel.badPostureSettings.rollAngleThreshold.toString(),
+ );
_pitchAngleThresholdController = TextEditingController(
- text: _viewModel.badPostureSettings.pitchAngleThreshold.toString(),);
+ text: _viewModel.badPostureSettings.pitchAngleThreshold.toString(),
+ );
_badPostureTimeThresholdController = TextEditingController(
- text: _viewModel.badPostureSettings.timeThreshold.toString(),);
+ text: _viewModel.badPostureSettings.timeThreshold.toString(),
+ );
_goodPostureTimeThresholdController = TextEditingController(
- text: _viewModel.badPostureSettings.resetTimeThreshold.toString(),);
+ text: _viewModel.badPostureSettings.resetTimeThreshold.toString(),
+ );
}
@override
Widget build(BuildContext context) {
return PlatformScaffold(
- appBar: PlatformAppBar(title: PlatformText("Posture Tracker Settings")),
+ appBar: PlatformAppBar(title: const Text('Posture Tracker Settings')),
body: ChangeNotifierProvider.value(
- value: _viewModel,
- builder: (context, child) => Consumer(
- builder: (context, postureTrackerViewModel, child) =>
- _buildSettingsView(),
- ),),
- backgroundColor: Theme.of(context).colorScheme.surface,
+ value: _viewModel,
+ builder: (context, child) => Consumer(
+ builder: (context, postureTrackerViewModel, child) =>
+ _buildSettingsView(context, postureTrackerViewModel),
+ ),
+ ),
);
}
- Widget _buildSettingsView() {
- return Padding(
- padding: EdgeInsets.all(10),
- child: Column(
+ Widget _buildSettingsView(
+ BuildContext context,
+ PostureTrackerViewModel postureTrackerViewModel,
+ ) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final statusColor = !postureTrackerViewModel.isAvailable
+ ? colorScheme.error
+ : postureTrackerViewModel.isTracking
+ ? const Color(0xFF2F8F5B)
+ : colorScheme.primary;
+ final statusLabel = postureTrackerViewModel.isTracking
+ ? 'Tracking'
+ : postureTrackerViewModel.isAvailable
+ ? 'Ready'
+ : 'Unavailable';
+
+ return ListView(
+ padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
children: [
Card(
- child: PlatformListTile(
- title: PlatformText("Status"),
- trailing: PlatformText(_viewModel.isTracking
- ? "Tracking"
- : _viewModel.isAvailable
- ? "Available"
- : "Unavailable",),
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Tracker status',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Adjust reminder thresholds and calibrate your posture baseline.',
+ style: theme.textTheme.bodyMedium,
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ _SettingsStatusChip(label: statusLabel, color: statusColor),
+ ],
+ ),
),
),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
Card(
- child: Column(children: [
- // add a switch to control the `isActive` property of the `BadPostureSettings`
- PlatformListTile(
- title: PlatformText("Bad Posture Reminder"),
- trailing: PlatformSwitch(
- value: _viewModel.badPostureSettings.isActive,
- onChanged: (value) {
- BadPostureSettings settings = _viewModel.badPostureSettings;
- settings.isActive = value;
- _viewModel.setBadPostureSettings(settings);
- },
- ),
- ),
- Visibility(
- visible: _viewModel.badPostureSettings.isActive,
- child: Column(children: [
- PlatformListTile(
- title: PlatformText("Roll Angle Threshold (in degrees)"),
- trailing: SizedBox(
- height: 37.0,
- width: 52,
- //TODO: use cupertino text field on ios
- child: TextField(
- controller: _rollAngleThresholdController,
- textAlign: TextAlign.end,
- style: TextStyle(color: Colors.black),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(10),
- floatingLabelBehavior:
- FloatingLabelBehavior.never,
- border: OutlineInputBorder(),
- labelText: 'Roll',
- filled: true,
- labelStyle: TextStyle(color: Colors.black),
- fillColor: Colors.white,),
- keyboardType: TextInputType.number,
- onChanged: (_) {
- _updatePostureSettings();
- },
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ 'Bad Posture Reminder',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
),
),
- ),
- PlatformListTile(
- title: PlatformText("Pitch Angle Threshold (in degrees)"),
- trailing: SizedBox(
- height: 37.0,
- width: 52,
- //TODO: use cupertino text field on ios
- child: TextField(
- controller: _pitchAngleThresholdController,
- textAlign: TextAlign.end,
- style: TextStyle(color: Colors.black),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(10),
- floatingLabelBehavior:
- FloatingLabelBehavior.never,
- border: OutlineInputBorder(),
- labelText: 'Pitch',
- filled: true,
- labelStyle: TextStyle(color: Colors.black),
- fillColor: Colors.white,),
- keyboardType: TextInputType.number,
- onChanged: (_) {
- _updatePostureSettings();
- },
- ),
+ PlatformSwitch(
+ value:
+ postureTrackerViewModel.badPostureSettings.isActive,
+ onChanged: (value) {
+ final settings = _viewModel.badPostureSettings;
+ settings.isActive = value;
+ _viewModel.setBadPostureSettings(settings);
+ },
),
+ ],
+ ),
+ if (postureTrackerViewModel.badPostureSettings.isActive) ...[
+ const SizedBox(height: 10),
+ Divider(
+ height: 1,
+ thickness: 0.6,
+ color: colorScheme.outlineVariant.withValues(alpha: 0.55),
),
- PlatformListTile(
- title: PlatformText("Bad Posture Time Threshold (in seconds)"),
- trailing: SizedBox(
- height: 37.0,
- width: 52,
- //TODO: use cupertino text field on ios
- child: TextField(
- controller: _badPostureTimeThresholdController,
- textAlign: TextAlign.end,
- style: TextStyle(color: Colors.black),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(10),
- floatingLabelBehavior:
- FloatingLabelBehavior.never,
- border: OutlineInputBorder(),
- labelText: 'Seconds',
- filled: true,
- labelStyle: TextStyle(color: Colors.black),
- fillColor: Colors.white,),
- keyboardType: TextInputType.number,
- onChanged: (_) {
- _updatePostureSettings();
- },
- ),
- ),
+ const SizedBox(height: 10),
+ _buildNumericSettingRow(
+ context: context,
+ label: 'Roll angle threshold',
+ controller: _rollAngleThresholdController,
+ suffix: '°',
),
- PlatformListTile(
- title: PlatformText("Good Posture Time Threshold (in seconds)"),
- trailing: SizedBox(
- height: 37.0,
- width: 52,
- //TODO: use cupertino text field on ios
- child: TextField(
- controller: _goodPostureTimeThresholdController,
- textAlign: TextAlign.end,
- style: TextStyle(color: Colors.black),
- decoration: InputDecoration(
- contentPadding: EdgeInsets.all(10),
- floatingLabelBehavior:
- FloatingLabelBehavior.never,
- border: OutlineInputBorder(),
- labelText: 'Seconds',
- filled: true,
- labelStyle: TextStyle(color: Colors.black),
- fillColor: Colors.white,),
- keyboardType: TextInputType.number,
- onChanged: (_) {
- _updatePostureSettings();
- },
- ),
+ const SizedBox(height: 8),
+ _buildNumericSettingRow(
+ context: context,
+ label: 'Pitch angle threshold',
+ controller: _pitchAngleThresholdController,
+ suffix: '°',
+ ),
+ const SizedBox(height: 8),
+ _buildNumericSettingRow(
+ context: context,
+ label: 'Bad posture time threshold',
+ controller: _badPostureTimeThresholdController,
+ suffix: 's',
+ ),
+ const SizedBox(height: 8),
+ _buildNumericSettingRow(
+ context: context,
+ label: 'Good posture reset threshold',
+ controller: _goodPostureTimeThresholdController,
+ suffix: 's',
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Calibration',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ postureTrackerViewModel.isTracking
+ ? 'Use your current head position as the neutral posture reference.'
+ : 'Start tracking to calibrate your neutral posture reference.',
+ style: theme.textTheme.bodyMedium,
+ ),
+ const SizedBox(height: 10),
+ SizedBox(
+ width: double.infinity,
+ child: PlatformElevatedButton(
+ onPressed: postureTrackerViewModel.isTracking
+ ? _calibrateAndClose
+ : postureTrackerViewModel.isAvailable
+ ? postureTrackerViewModel.startTracking
+ : null,
+ child: Text(
+ postureTrackerViewModel.isTracking
+ ? 'Calibrate as Main Posture'
+ : 'Start Tracking',
),
),
- ],),),
- ],),),
- Padding(
- padding: EdgeInsets.only(top: 8.0),
- child: Row(children: [
- Expanded(
- child: PlatformElevatedButton(
- color: _viewModel.isTracking
- ? Colors.green[300]
- : Colors.blue[300],
- onPressed: _viewModel.isTracking
- ? () {
- _viewModel.calibrate();
- Navigator.of(context).pop();
- }
- : () => _viewModel.startTracking(),
- child: PlatformText(_viewModel.isTracking
- ? "Calibrate as Main Posture"
- : "Start Calibration",),
- ),
+ ),
+ ],
),
- ],),
+ ),
),
],
- ),
);
}
+ Widget _buildNumericSettingRow({
+ required BuildContext context,
+ required String label,
+ required TextEditingController controller,
+ required String suffix,
+ }) {
+ return Row(
+ children: [
+ Expanded(
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ SizedBox(
+ width: 92,
+ child: TextField(
+ controller: controller,
+ textAlign: TextAlign.end,
+ keyboardType: TextInputType.number,
+ onChanged: (_) {
+ _updatePostureSettings();
+ },
+ decoration: InputDecoration(
+ isDense: true,
+ suffixText: suffix,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ void _calibrateAndClose() {
+ _viewModel.calibrate();
+ if (!mounted) {
+ return;
+ }
+ Navigator.of(context).pop();
+ }
+
void _updatePostureSettings() {
- BadPostureSettings settings = _viewModel.badPostureSettings;
- settings.rollAngleThreshold = int.parse(_rollAngleThresholdController.text);
- settings.pitchAngleThreshold =
- int.parse(_pitchAngleThresholdController.text);
- settings.timeThreshold = int.parse(_badPostureTimeThresholdController.text);
- settings.resetTimeThreshold =
- int.parse(_goodPostureTimeThresholdController.text);
+ final rollAngleThreshold =
+ int.tryParse(_rollAngleThresholdController.text.trim());
+ final pitchAngleThreshold =
+ int.tryParse(_pitchAngleThresholdController.text.trim());
+ final badPostureTimeThreshold =
+ int.tryParse(_badPostureTimeThresholdController.text.trim());
+ final goodPostureTimeThreshold =
+ int.tryParse(_goodPostureTimeThresholdController.text.trim());
+
+ if (rollAngleThreshold == null ||
+ pitchAngleThreshold == null ||
+ badPostureTimeThreshold == null ||
+ goodPostureTimeThreshold == null) {
+ return;
+ }
+
+ if (rollAngleThreshold < 0 ||
+ pitchAngleThreshold < 0 ||
+ badPostureTimeThreshold < 0 ||
+ goodPostureTimeThreshold < 0) {
+ return;
+ }
+
+ final settings = _viewModel.badPostureSettings;
+ settings.rollAngleThreshold = rollAngleThreshold;
+ settings.pitchAngleThreshold = pitchAngleThreshold;
+ settings.timeThreshold = badPostureTimeThreshold;
+ settings.resetTimeThreshold = goodPostureTimeThreshold;
_viewModel.setBadPostureSettings(settings);
}
@override
void dispose() {
+ _rollAngleThresholdController.dispose();
+ _pitchAngleThresholdController.dispose();
+ _badPostureTimeThresholdController.dispose();
+ _goodPostureTimeThresholdController.dispose();
super.dispose();
}
}
+
+class _SettingsStatusChip extends StatelessWidget {
+ final String label;
+ final Color color;
+
+ const _SettingsStatusChip({
+ required this.label,
+ required this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(color: color.withValues(alpha: 0.28)),
+ ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ );
+ }
+}
diff --git a/open_wearable/lib/apps/self_test/assets/self_test_icon.svg b/open_wearable/lib/apps/self_test/assets/self_test_icon.svg
new file mode 100644
index 00000000..6155a9d9
--- /dev/null
+++ b/open_wearable/lib/apps/self_test/assets/self_test_icon.svg
@@ -0,0 +1,14 @@
+
diff --git a/open_wearable/lib/apps/self_test/self_test_page.dart b/open_wearable/lib/apps/self_test/self_test_page.dart
new file mode 100644
index 00000000..48b58cff
--- /dev/null
+++ b/open_wearable/lib/apps/self_test/self_test_page.dart
@@ -0,0 +1,2297 @@
+import 'dart:async';
+import 'dart:math';
+
+import 'package:fl_chart/fl_chart.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/device_name_formatter.dart';
+import 'package:open_wearable/models/sensor_streams.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/stereo_position_badge.dart';
+import 'package:provider/provider.dart';
+
+class SelfTestPage extends StatefulWidget {
+ final Wearable wearable;
+ final SensorConfigurationProvider sensorConfigProvider;
+
+ const SelfTestPage({
+ super.key,
+ required this.wearable,
+ required this.sensorConfigProvider,
+ });
+
+ @override
+ State createState() => _SelfTestPageState();
+}
+
+class _SelfTestPageState extends State {
+ static const double _minimumTestDataCollectionSeconds = 3.0;
+ static const double _ledBrightnessFactor = 0.2;
+
+ late final List<_TestSpec> _tests;
+ late final Map _sensorByTestId;
+
+ final Map _resultsByTestId = {};
+ final Map
+ _savedConfigurations = {};
+
+ WearablesProvider? _wearablesProvider;
+ StreamSubscription? _sensorSubscription;
+ Timer? _timeoutTimer;
+ Timer? _autoAdvanceTimer;
+ bool _hasDisabledSensorsAfterCompletion = false;
+ int _postCheckTransitionToken = 0;
+
+ int _currentTestIndex = 0;
+ _TestAnalyzer? _currentAnalyzer;
+ bool _isRunning = false;
+ int? _firstTimestamp;
+ int _sampleIndex = 0;
+ bool _isInitializingSensor = false;
+ String _liveHint = '';
+ List<_ChartPoint> _liveCurve = const [];
+
+ @override
+ void initState() {
+ super.initState();
+
+ _tests = [
+ _TestSpec(
+ id: 'accelerometer',
+ title: 'Accelerometer',
+ description:
+ 'Shake the device a few times. This verifies non-static acceleration in m/s².',
+ timeout: const Duration(seconds: 16),
+ targetFrequencyHz: 50,
+ ),
+ _TestSpec(
+ id: 'gyroscope',
+ title: 'Gyroscope',
+ description:
+ 'Rotate or shake the device repeatedly. This verifies angular-rate response.',
+ timeout: const Duration(seconds: 16),
+ targetFrequencyHz: 50,
+ ),
+ _TestSpec(
+ id: 'magnetometer',
+ title: 'Magnetometer',
+ description:
+ 'Move or rotate the device near metal or a small magnet. This verifies magnetic-field response.',
+ timeout: const Duration(seconds: 16),
+ targetFrequencyHz: 25,
+ ),
+ _TestSpec(
+ id: 'barometer',
+ title: 'Barometer',
+ description:
+ 'Blow steadily into the device for about one second to create a pressure change.',
+ timeout: const Duration(seconds: 18),
+ targetFrequencyHz: 25,
+ ),
+ _TestSpec(
+ id: 'temperature',
+ title: 'Temperature Sensor',
+ description:
+ 'Touch the sensor with a finger. Expected valid range is 30 to 40 °C.',
+ timeout: const Duration(seconds: 16),
+ targetFrequencyHz: 10,
+ ),
+ _TestSpec(
+ id: 'ppg',
+ title: 'PPG',
+ description:
+ 'Place the PPG area on a finger and hold still for around 10 seconds. The app looks for a pulse pattern.',
+ timeout: const Duration(seconds: 30),
+ targetFrequencyHz: 50,
+ ),
+ ];
+
+ _sensorByTestId = _resolveSensorsForTests(widget.wearable);
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ final provider = context.read();
+ if (!identical(provider, _wearablesProvider)) {
+ _wearablesProvider?.removeListener(_onWearablesChanged);
+ _wearablesProvider = provider;
+ _wearablesProvider?.addListener(_onWearablesChanged);
+ }
+ }
+
+ @override
+ void dispose() {
+ _wearablesProvider?.removeListener(_onWearablesChanged);
+ _stopCurrentRun();
+ _autoAdvanceTimer?.cancel();
+ _restoreSavedConfigurations();
+ unawaited(_resetLedColor());
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final connected = context.watch().wearables.any(
+ (wearable) => wearable.deviceId == widget.wearable.deviceId,
+ );
+ final total = _tests.length;
+ final completed = _resultsByTestId.length;
+ final passed =
+ _resultsByTestId.values.where((result) => result.passed).length;
+ final currentSpec = _tests[_currentTestIndex];
+ final currentResult = _resultsByTestId[currentSpec.id];
+ final currentSensor = _sensorByTestId[currentSpec.id];
+
+ return PlatformScaffold(
+ appBar: PlatformAppBar(
+ title: PlatformText('Device Self Test'),
+ ),
+ body: ListView(
+ padding: const EdgeInsets.fromLTRB(14, 12, 14, 20),
+ children: [
+ _OverviewCard(
+ wearable: widget.wearable,
+ completed: completed,
+ total: total,
+ passed: passed,
+ connected: connected,
+ tests: _tests,
+ resultsByTestId: _resultsByTestId,
+ currentSpec: currentSpec,
+ running: _isRunning,
+ initializing: _isInitializingSensor,
+ currentHasSensor: currentSensor != null,
+ currentResult: currentResult,
+ onStartPressed: connected ? _startCurrentTest : null,
+ onNextPressed: _canGoNext() ? _goToNextTest : null,
+ onRetryPressed: connected ? _retryCurrentTest : null,
+ onRunAllAgainPressed: completed == total ? _resetAllTests : null,
+ ),
+ const SizedBox(height: 12),
+ _CurrentTestCard(
+ spec: currentSpec,
+ sensor: currentSensor,
+ running: _isRunning,
+ initializing: _isInitializingSensor,
+ liveHint: _liveHint,
+ liveCurve: _liveCurve,
+ result: currentResult,
+ ),
+ const SizedBox(height: 12),
+ _ResultsCard(
+ tests: _tests,
+ resultsByTestId: _resultsByTestId,
+ running: _isRunning || _isInitializingSensor,
+ onRetryTest: connected ? _retryTestById : null,
+ ),
+ ],
+ ),
+ );
+ }
+
+ Map _resolveSensorsForTests(Wearable wearable) {
+ final result = {
+ for (final test in _tests) test.id: null,
+ };
+
+ if (!wearable.hasCapability()) {
+ return result;
+ }
+
+ final sensors = wearable.requireCapability().sensors;
+
+ Sensor? findByKeywords(List keywords) {
+ final lowered =
+ keywords.map((k) => k.toLowerCase()).toList(growable: false);
+ for (final sensor in sensors) {
+ final text = '${sensor.sensorName} ${sensor.chartTitle}'.toLowerCase();
+ if (lowered.any(text.contains)) {
+ return sensor;
+ }
+ }
+ return null;
+ }
+
+ String normalizeToken(String input) {
+ final normalized = input
+ .trim()
+ .toUpperCase()
+ .replaceAll(RegExp(r'[^A-Z0-9]+'), '_')
+ .replaceAll(RegExp(r'_+'), '_');
+ return normalized.replaceAll(RegExp(r'^_|_$'), '');
+ }
+
+ Sensor? findByPreferredName(List preferredNames) {
+ final preferred = preferredNames.map(normalizeToken).toSet();
+ for (final sensor in sensors) {
+ final sensorName = normalizeToken(sensor.sensorName);
+ final chartName = normalizeToken(sensor.chartTitle);
+ if (preferred.contains(sensorName) || preferred.contains(chartName)) {
+ return sensor;
+ }
+ }
+ return null;
+ }
+
+ result['accelerometer'] = findByKeywords(['accelerometer', 'acc']);
+ result['gyroscope'] = findByKeywords(['gyroscope', 'gyro']);
+ result['magnetometer'] = findByKeywords([
+ 'magnetometer',
+ 'magnetic',
+ 'mag',
+ ]);
+ result['barometer'] = findByKeywords(['barometer', 'pressure', 'baro']);
+ result['temperature'] =
+ findByPreferredName(['OPTICAL_TEMPERATURE_SENSOR']) ??
+ findByKeywords(['temperature', 'temp']);
+ result['ppg'] = findByKeywords(['ppg', 'photopleth', 'pulse']);
+
+ return result;
+ }
+
+ void _onWearablesChanged() {
+ if (!mounted || !_isRunning) {
+ return;
+ }
+ final provider = _wearablesProvider;
+ if (provider == null) {
+ return;
+ }
+ final connected = provider.wearables.any(
+ (wearable) => wearable.deviceId == widget.wearable.deviceId,
+ );
+ if (!connected) {
+ _completeCurrentTest(
+ passed: false,
+ message: 'Connection lost while running this test.',
+ );
+ }
+ }
+
+ Future _startCurrentTest() async {
+ if (_isRunning || _isInitializingSensor) {
+ return;
+ }
+ _postCheckTransitionToken++;
+ _autoAdvanceTimer?.cancel();
+ unawaited(_setLedColor(r: 255, g: 255, b: 255));
+
+ final spec = _tests[_currentTestIndex];
+ final sensor = _sensorByTestId[spec.id];
+ if (sensor == null) {
+ _registerTestFailure(
+ testId: spec.id,
+ message: 'Required sensor is not available on this device.',
+ );
+ return;
+ }
+
+ final analyzer = _createAnalyzerFor(spec, sensor);
+ if (analyzer == null) {
+ _registerTestFailure(
+ testId: spec.id,
+ message: 'This test is not supported for the selected sensor setup.',
+ );
+ return;
+ }
+
+ try {
+ await _prepareSensorForStreaming(
+ sensor,
+ targetFrequencyHz: spec.targetFrequencyHz,
+ );
+ } catch (_) {
+ _registerTestFailure(
+ testId: spec.id,
+ message: 'Unable to configure sensor streaming for this test.',
+ );
+ return;
+ }
+
+ _stopCurrentRun();
+
+ setState(() {
+ _isRunning = true;
+ _currentAnalyzer = analyzer;
+ _firstTimestamp = null;
+ _sampleIndex = 0;
+ _liveHint = analyzer.liveStatus;
+ _liveCurve = const [];
+ _resultsByTestId.remove(spec.id);
+ });
+
+ _sensorSubscription = SensorStreams.shared(sensor).listen(
+ (value) => _onSensorValue(value, sensor: sensor, spec: spec),
+ onError: (_) {
+ _completeCurrentTest(
+ passed: false,
+ message: 'Sensor stream failed while running this test.',
+ );
+ },
+ );
+
+ _timeoutTimer = Timer(spec.timeout, () {
+ final analyzerAtTimeout = _currentAnalyzer;
+ if (!_isRunning || analyzerAtTimeout == null) {
+ return;
+ }
+ _completeCurrentTest(
+ passed: false,
+ message: analyzerAtTimeout.failureMessage(timedOut: true),
+ );
+ });
+ }
+
+ Future _prepareSensorForStreaming(
+ Sensor sensor, {
+ required int targetFrequencyHz,
+ }) async {
+ for (final config in sensor.relatedConfigurations) {
+ _savedConfigurations.putIfAbsent(
+ config,
+ () => widget.sensorConfigProvider.getSelectedConfigurationValue(config),
+ );
+
+ if (config is ConfigurableSensorConfiguration &&
+ config.availableOptions
+ .any((option) => option is StreamSensorConfigOption)) {
+ widget.sensorConfigProvider.addSensorConfigurationOption(
+ config,
+ const StreamSensorConfigOption(),
+ markPending: false,
+ );
+ }
+
+ final availableValues = widget.sensorConfigProvider
+ .getSensorConfigurationValues(config, distinct: true);
+ if (availableValues.isEmpty) {
+ continue;
+ }
+
+ final selected = _selectBestValue(
+ availableValues,
+ targetFrequencyHz: targetFrequencyHz,
+ );
+ widget.sensorConfigProvider.addSensorConfiguration(
+ config,
+ selected,
+ markPending: false,
+ );
+ config.setConfiguration(selected);
+ }
+ }
+
+ SensorConfigurationValue _selectBestValue(
+ List values, {
+ required int targetFrequencyHz,
+ }) {
+ if (values.length == 1) {
+ return values.first;
+ }
+
+ if (values.first is! SensorFrequencyConfigurationValue) {
+ return values.first;
+ }
+
+ SensorFrequencyConfigurationValue? nextBigger;
+ SensorFrequencyConfigurationValue? maxValue;
+
+ for (final value in values.whereType()) {
+ if (maxValue == null || value.frequencyHz > maxValue.frequencyHz) {
+ maxValue = value;
+ }
+
+ if (value.frequencyHz >= targetFrequencyHz &&
+ (nextBigger == null || value.frequencyHz < nextBigger.frequencyHz)) {
+ nextBigger = value;
+ }
+ }
+
+ return nextBigger ?? maxValue ?? values.first;
+ }
+
+ _TestAnalyzer? _createAnalyzerFor(_TestSpec spec, Sensor sensor) {
+ switch (spec.id) {
+ case 'accelerometer':
+ return _MotionAnalyzer(
+ unitLabel: sensor.axisUnits.firstOrNull ?? 'm/s²',
+ movementLabel: 'shake',
+ minimumEvents: 3,
+ deltaThreshold: 4.2,
+ minimumStdDev: 1.1,
+ minimumSamples: 24,
+ highThreshold: null,
+ lowThreshold: null,
+ );
+ case 'gyroscope':
+ return _MotionAnalyzer(
+ unitLabel: sensor.axisUnits.firstOrNull ?? '°/s',
+ movementLabel: 'rotation',
+ minimumEvents: 3,
+ deltaThreshold: 45,
+ minimumStdDev: 18,
+ minimumSamples: 24,
+ highThreshold: 120,
+ lowThreshold: 40,
+ );
+ case 'magnetometer':
+ return _MagnetometerAnalyzer(
+ unitLabel: sensor.axisUnits.firstOrNull ?? 'uT',
+ );
+ case 'barometer':
+ return _BarometerAnalyzer(
+ unitLabel: sensor.axisUnits.firstOrNull ?? 'Pa',
+ );
+ case 'temperature':
+ return _TemperatureAnalyzer(
+ unitLabel: sensor.axisUnits.firstOrNull ?? '°C',
+ );
+ case 'ppg':
+ return _PpgAnalyzer();
+ default:
+ return null;
+ }
+ }
+
+ void _onSensorValue(
+ SensorValue value, {
+ required Sensor sensor,
+ required _TestSpec spec,
+ }) {
+ if (!_isRunning) {
+ return;
+ }
+
+ final analyzer = _currentAnalyzer;
+ if (analyzer == null) {
+ return;
+ }
+
+ final sampleValues = _valuesFromSensorValue(value);
+ if (sampleValues.isEmpty) {
+ return;
+ }
+
+ _firstTimestamp ??= value.timestamp;
+ final timeSeconds = (value.timestamp - _firstTimestamp!) *
+ pow(10, sensor.timestampExponent).toDouble();
+
+ final sample = _SensorSample(
+ timeSeconds: timeSeconds,
+ values: sampleValues,
+ );
+
+ analyzer.addSample(sample);
+ final primaryValue = analyzer.primaryValue(sample);
+ final plotX = _sampleIndex.toDouble();
+ _sampleIndex += 1;
+ final minDurationReached =
+ sample.timeSeconds >= _minimumTestDataCollectionSeconds;
+ final remainingSeconds =
+ (_minimumTestDataCollectionSeconds - sample.timeSeconds)
+ .clamp(0.0, _minimumTestDataCollectionSeconds);
+ final nextLiveHint = analyzer.hasPassed && !minDurationReached
+ ? '${analyzer.liveStatus} Criteria reached. Collecting for ${remainingSeconds.toStringAsFixed(1)} s more.'
+ : analyzer.liveStatus;
+ final nextCurve = _appendCurvePoint(
+ _liveCurve,
+ _ChartPoint(plotX, primaryValue),
+ maxPoints: 180,
+ );
+
+ setState(() {
+ _liveCurve = nextCurve;
+ _liveHint = nextLiveHint;
+ });
+
+ if (analyzer.hasPassed && minDurationReached) {
+ _completeCurrentTest(
+ passed: true,
+ message: analyzer.successMessage,
+ );
+ return;
+ }
+
+ final thisTestResult = _resultsByTestId[spec.id];
+ if (thisTestResult != null) {
+ return;
+ }
+ }
+
+ List _valuesFromSensorValue(SensorValue value) {
+ if (value is SensorDoubleValue) {
+ return value.values;
+ }
+ if (value is SensorIntValue) {
+ return value.values.map((v) => v.toDouble()).toList(growable: false);
+ }
+ return value.valueStrings
+ .map(double.tryParse)
+ .whereType()
+ .toList(growable: false);
+ }
+
+ List<_ChartPoint> _appendCurvePoint(
+ List<_ChartPoint> curve,
+ _ChartPoint point, {
+ required int maxPoints,
+ }) {
+ final next = List<_ChartPoint>.from(curve)..add(point);
+ if (next.length <= maxPoints) {
+ return next;
+ }
+ return next.sublist(next.length - maxPoints);
+ }
+
+ void _completeCurrentTest({
+ required bool passed,
+ required String message,
+ }) {
+ final completedIndex = _currentTestIndex;
+ final completedSpec = _tests[completedIndex];
+ final curve = _downsampleCurve(_liveCurve, maxPoints: 90);
+ _stopCurrentRun();
+
+ if (!mounted) {
+ return;
+ }
+
+ late final bool allCompleted;
+ setState(() {
+ _resultsByTestId[completedSpec.id] = _TestResult(
+ passed: passed,
+ message: message,
+ curve: curve,
+ );
+ _liveHint = '';
+ _liveCurve = const [];
+ allCompleted = _resultsByTestId.length >= _tests.length;
+ });
+
+ if (!passed) {
+ unawaited(_setLedColor(r: 255, g: 0, b: 0));
+ }
+
+ unawaited(
+ _runPostCheckTransition(
+ completedSpec: completedSpec,
+ completedIndex: completedIndex,
+ passed: passed,
+ allCompleted: allCompleted,
+ ),
+ );
+ }
+
+ void _registerTestFailure({
+ required String testId,
+ required String message,
+ }) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _resultsByTestId[testId] = _TestResult(
+ passed: false,
+ message: message,
+ curve: const [],
+ );
+ _liveHint = '';
+ _liveCurve = const [];
+ });
+ unawaited(_setLedColor(r: 255, g: 0, b: 0));
+ }
+
+ void _stopCurrentRun() {
+ _timeoutTimer?.cancel();
+ _timeoutTimer = null;
+ _sensorSubscription?.cancel();
+ _sensorSubscription = null;
+ _currentAnalyzer = null;
+ _firstTimestamp = null;
+ _sampleIndex = 0;
+ _isRunning = false;
+ }
+
+ Future _runPostCheckTransition({
+ required _TestSpec completedSpec,
+ required int completedIndex,
+ required bool passed,
+ required bool allCompleted,
+ }) async {
+ final token = ++_postCheckTransitionToken;
+
+ await _disableSensorForSpec(completedSpec);
+ if (!mounted || token != _postCheckTransitionToken) {
+ return;
+ }
+
+ if (allCompleted) {
+ final allPassed =
+ _tests.every((test) => _resultsByTestId[test.id]?.passed == true);
+ await _setLedColor(
+ r: allPassed ? 0 : 255,
+ g: allPassed ? 255 : 0,
+ b: 0,
+ );
+ await _disableSensorsAfterCompletion();
+ return;
+ }
+
+ if (!passed) {
+ return;
+ }
+
+ final nextIndex = _nextTestIndexAfter(completedIndex);
+ if (nextIndex == null) {
+ return;
+ }
+ await _showCooldownAndMoveTo(nextIndex, token: token);
+ }
+
+ Future _disableSensorForSpec(_TestSpec spec) async {
+ final sensor = _sensorByTestId[spec.id];
+ if (sensor == null) {
+ return;
+ }
+
+ for (final config in sensor.relatedConfigurations) {
+ try {
+ final offValue = config.offValue;
+ if (offValue != null) {
+ widget.sensorConfigProvider.addSensorConfiguration(
+ config,
+ offValue,
+ markPending: false,
+ );
+ config.setConfiguration(offValue);
+ continue;
+ }
+
+ if (config is ConfigurableSensorConfiguration &&
+ config.availableOptions
+ .any((option) => option is StreamSensorConfigOption)) {
+ widget.sensorConfigProvider.removeSensorConfigurationOption(
+ config,
+ const StreamSensorConfigOption(),
+ markPending: false,
+ );
+ final selected =
+ widget.sensorConfigProvider.getSelectedConfigurationValue(config);
+ if (selected is ConfigurableSensorConfigurationValue) {
+ config.setConfiguration(selected);
+ }
+ }
+ } catch (_) {
+ // Continue with remaining configs even if one write fails.
+ }
+ }
+ }
+
+ Future _showCooldownAndMoveTo(
+ int nextIndex, {
+ required int token,
+ }) async {
+ if (!mounted || token != _postCheckTransitionToken) {
+ return;
+ }
+
+ setState(() {
+ _currentTestIndex = nextIndex;
+ _isInitializingSensor = true;
+ _liveHint = '';
+ _liveCurve = const [];
+ });
+
+ await Future.delayed(const Duration(seconds: 1));
+ if (!mounted || token != _postCheckTransitionToken) {
+ return;
+ }
+
+ setState(() {
+ _isInitializingSensor = false;
+ });
+ _autoStartCurrentTestIfPossible();
+ }
+
+ int? _nextTestIndexAfter(int fromIndex) {
+ for (int i = fromIndex + 1; i < _tests.length; i++) {
+ if (!_resultsByTestId.containsKey(_tests[i].id)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ List<_ChartPoint> _downsampleCurve(
+ List<_ChartPoint> source, {
+ required int maxPoints,
+ }) {
+ if (source.length <= maxPoints) {
+ return source;
+ }
+
+ final step = source.length / maxPoints;
+ final sampled = <_ChartPoint>[];
+ for (int i = 0; i < maxPoints; i++) {
+ final index = min((i * step).round(), source.length - 1);
+ sampled.add(source[index]);
+ }
+ return sampled;
+ }
+
+ bool _canGoNext() {
+ if (_isRunning || _isInitializingSensor) {
+ return false;
+ }
+ final currentId = _tests[_currentTestIndex].id;
+ if (!_resultsByTestId.containsKey(currentId)) {
+ return false;
+ }
+ return _currentTestIndex < _tests.length - 1;
+ }
+
+ void _goToNextTest() {
+ if (!_canGoNext()) {
+ return;
+ }
+ _postCheckTransitionToken++;
+ final token = _postCheckTransitionToken;
+ _autoAdvanceTimer?.cancel();
+ final fromSpec = _tests[_currentTestIndex];
+ final nextIndex = _currentTestIndex + 1;
+ unawaited(() async {
+ await _disableSensorForSpec(fromSpec);
+ await _showCooldownAndMoveTo(nextIndex, token: token);
+ }());
+ }
+
+ void _retryCurrentTest() {
+ if (_isRunning) {
+ return;
+ }
+ _postCheckTransitionToken++;
+ _autoAdvanceTimer?.cancel();
+ _hasDisabledSensorsAfterCompletion = false;
+ final currentId = _tests[_currentTestIndex].id;
+ setState(() {
+ _isInitializingSensor = false;
+ _resultsByTestId.remove(currentId);
+ _liveHint = '';
+ _liveCurve = const [];
+ });
+ _autoStartCurrentTestIfPossible();
+ }
+
+ void _retryTestById(String testId) {
+ if (_isRunning) {
+ return;
+ }
+ _postCheckTransitionToken++;
+ _autoAdvanceTimer?.cancel();
+ _hasDisabledSensorsAfterCompletion = false;
+ final index = _tests.indexWhere((test) => test.id == testId);
+ if (index < 0) {
+ return;
+ }
+ setState(() {
+ _isInitializingSensor = false;
+ _resultsByTestId.remove(testId);
+ _currentTestIndex = index;
+ _liveHint = '';
+ _liveCurve = const [];
+ });
+ _autoStartCurrentTestIfPossible();
+ }
+
+ void _resetAllTests() {
+ if (_isRunning) {
+ return;
+ }
+ _postCheckTransitionToken++;
+ _autoAdvanceTimer?.cancel();
+ _hasDisabledSensorsAfterCompletion = false;
+ setState(() {
+ _isInitializingSensor = false;
+ _resultsByTestId.clear();
+ _currentTestIndex = 0;
+ _liveHint = '';
+ _liveCurve = const [];
+ });
+ }
+
+ void _restoreSavedConfigurations() {
+ for (final entry in _savedConfigurations.entries) {
+ final original = entry.value;
+ if (original == null) {
+ continue;
+ }
+ try {
+ widget.sensorConfigProvider.addSensorConfiguration(
+ entry.key,
+ original,
+ markPending: false,
+ );
+ entry.key.setConfiguration(original);
+ } catch (_) {
+ // Ignore restoration failures during widget teardown.
+ }
+ }
+ }
+
+ Future _disableSensorsAfterCompletion() async {
+ if (_hasDisabledSensorsAfterCompletion) {
+ return;
+ }
+ _postCheckTransitionToken++;
+ _hasDisabledSensorsAfterCompletion = true;
+ _autoAdvanceTimer?.cancel();
+ if (mounted && _isInitializingSensor) {
+ setState(() {
+ _isInitializingSensor = false;
+ });
+ }
+
+ try {
+ await widget.sensorConfigProvider.turnOffAllSensors();
+ _savedConfigurations.clear();
+ return;
+ } catch (_) {
+ // Fallback: explicitly set off values for touched configurations.
+ }
+
+ for (final config in _savedConfigurations.keys) {
+ final offValue = config.offValue;
+ if (offValue == null) {
+ continue;
+ }
+ try {
+ widget.sensorConfigProvider.addSensorConfiguration(
+ config,
+ offValue,
+ markPending: false,
+ );
+ config.setConfiguration(offValue);
+ } catch (_) {
+ // Keep going with remaining configs.
+ }
+ }
+ _savedConfigurations.clear();
+ }
+
+ Future _setLedColor({
+ required int r,
+ required int g,
+ required int b,
+ }) async {
+ if (!widget.wearable.hasCapability()) {
+ return;
+ }
+
+ try {
+ if (widget.wearable.hasCapability()) {
+ await widget.wearable.requireCapability().showStatus(false);
+ }
+ final dimmedR = (r * _ledBrightnessFactor).round().clamp(0, 255);
+ final dimmedG = (g * _ledBrightnessFactor).round().clamp(0, 255);
+ final dimmedB = (b * _ledBrightnessFactor).round().clamp(0, 255);
+ await widget.wearable.requireCapability().writeLedColor(
+ r: dimmedR,
+ g: dimmedG,
+ b: dimmedB,
+ );
+ } catch (_) {
+ // LED feedback should not interrupt test execution.
+ }
+ }
+
+ Future _resetLedColor() async {
+ try {
+ if (widget.wearable.hasCapability()) {
+ await widget.wearable.requireCapability().showStatus(true);
+ return;
+ }
+ if (widget.wearable.hasCapability()) {
+ await widget.wearable.requireCapability().writeLedColor(
+ r: 0,
+ g: 0,
+ b: 0,
+ );
+ }
+ } catch (_) {
+ // LED reset is best-effort and should not interrupt test execution.
+ }
+ }
+
+ void _autoStartCurrentTestIfPossible() {
+ if (!mounted || _isRunning || _isInitializingSensor) {
+ return;
+ }
+
+ final provider = _wearablesProvider;
+ if (provider == null) {
+ return;
+ }
+
+ final connected = provider.wearables.any(
+ (wearable) => wearable.deviceId == widget.wearable.deviceId,
+ );
+ if (!connected) {
+ return;
+ }
+
+ final currentTestId = _tests[_currentTestIndex].id;
+ if (_resultsByTestId.containsKey(currentTestId)) {
+ return;
+ }
+
+ final sensor = _sensorByTestId[currentTestId];
+ if (sensor == null) {
+ return;
+ }
+
+ unawaited(_startCurrentTest());
+ }
+}
+
+class _TestSpec {
+ final String id;
+ final String title;
+ final String description;
+ final Duration timeout;
+ final int targetFrequencyHz;
+
+ const _TestSpec({
+ required this.id,
+ required this.title,
+ required this.description,
+ required this.timeout,
+ required this.targetFrequencyHz,
+ });
+}
+
+class _TestResult {
+ final bool passed;
+ final String message;
+ final List<_ChartPoint> curve;
+
+ const _TestResult({
+ required this.passed,
+ required this.message,
+ required this.curve,
+ });
+}
+
+class _SensorSample {
+ final double timeSeconds;
+ final List values;
+
+ const _SensorSample({
+ required this.timeSeconds,
+ required this.values,
+ });
+}
+
+class _ChartPoint {
+ final double x;
+ final double y;
+
+ const _ChartPoint(this.x, this.y);
+}
+
+abstract class _TestAnalyzer {
+ bool get hasPassed;
+ String get liveStatus;
+ String get successMessage;
+
+ void addSample(_SensorSample sample);
+ double primaryValue(_SensorSample sample);
+ String failureMessage({required bool timedOut});
+}
+
+class _MotionAnalyzer implements _TestAnalyzer {
+ final String unitLabel;
+ final String movementLabel;
+ final int minimumEvents;
+ final double deltaThreshold;
+ final double minimumStdDev;
+ final int minimumSamples;
+ final double? highThreshold;
+ final double? lowThreshold;
+
+ final List _magnitudes = [];
+
+ int _events = 0;
+ double? _previousMagnitude;
+ double _lastEventTime = -1000;
+ bool _peakStateHigh = false;
+ bool _passed = false;
+ double _stdDev = 0;
+
+ _MotionAnalyzer({
+ required this.unitLabel,
+ required this.movementLabel,
+ required this.minimumEvents,
+ required this.deltaThreshold,
+ required this.minimumStdDev,
+ required this.minimumSamples,
+ required this.highThreshold,
+ required this.lowThreshold,
+ });
+
+ @override
+ bool get hasPassed => _passed;
+
+ @override
+ String get liveStatus =>
+ 'Detected $_events of $minimumEvents required $movementLabel events. SD ${_stdDev.toStringAsFixed(1)} $unitLabel.';
+
+ @override
+ String get successMessage =>
+ 'Motion detected successfully ($_events events, SD ${_stdDev.toStringAsFixed(1)} $unitLabel).';
+
+ @override
+ void addSample(_SensorSample sample) {
+ final magnitude = _vectorMagnitude(sample.values);
+ _magnitudes.add(magnitude);
+
+ if (highThreshold != null && lowThreshold != null) {
+ if (!_peakStateHigh &&
+ magnitude >= highThreshold! &&
+ sample.timeSeconds - _lastEventTime >= 0.25) {
+ _events += 1;
+ _peakStateHigh = true;
+ _lastEventTime = sample.timeSeconds;
+ }
+ if (_peakStateHigh && magnitude <= lowThreshold!) {
+ _peakStateHigh = false;
+ }
+ } else {
+ if (_previousMagnitude != null) {
+ final delta = (magnitude - _previousMagnitude!).abs();
+ if (delta >= deltaThreshold &&
+ sample.timeSeconds - _lastEventTime >= 0.25) {
+ _events += 1;
+ _lastEventTime = sample.timeSeconds;
+ }
+ }
+ _previousMagnitude = magnitude;
+ }
+
+ _stdDev = _stdDevOf(_magnitudes);
+ _passed = _events >= minimumEvents &&
+ _magnitudes.length >= minimumSamples &&
+ _stdDev >= minimumStdDev;
+ }
+
+ @override
+ double primaryValue(_SensorSample sample) {
+ return _vectorMagnitude(sample.values);
+ }
+
+ @override
+ String failureMessage({required bool timedOut}) {
+ if (!timedOut) {
+ return 'Motion quality check failed.';
+ }
+ return 'Not enough movement detected. Please repeat with stronger motion and at least $minimumEvents clear events.';
+ }
+}
+
+class _BarometerAnalyzer implements _TestAnalyzer {
+ final String unitLabel;
+ final List _values = [];
+ final List _times = [];
+
+ static const double _minimumAbsolutePressurePa = 100000.0; // 100 kPa
+
+ double _baselinePa = 0;
+ double _baselineStdPa = 0;
+ double _maxRisePa = 0;
+ double _requiredRisePa = 0;
+ double _maxAbsolutePressurePa = 0;
+ double _sustainedStart = -1;
+ bool _sustainedRise = false;
+ bool _passed = false;
+
+ _BarometerAnalyzer({
+ required this.unitLabel,
+ });
+
+ double _toPascal(double value) {
+ final unit = unitLabel.toLowerCase();
+ if (unit.contains('kpa')) {
+ return value * 1000.0;
+ }
+ if (unit.contains('hpa') || unit.contains('mbar')) {
+ return value * 100.0;
+ }
+ if (unit.contains('pa')) {
+ return value;
+ }
+ return value;
+ }
+
+ @override
+ bool get hasPassed => _passed;
+
+ @override
+ String get liveStatus {
+ final absKPa = _maxAbsolutePressurePa / 1000.0;
+ final riseKPa = _maxRisePa / 1000.0;
+ return 'Abs ${_maxAbsolutePressurePa.toStringAsFixed(0)} Pa (${absKPa.toStringAsFixed(2)} kPa), min 100000 Pa. Rise ${_maxRisePa.toStringAsFixed(0)} Pa (${riseKPa.toStringAsFixed(2)} kPa).';
+ }
+
+ @override
+ String get successMessage {
+ return 'Pressure response detected (abs ${_maxAbsolutePressurePa.toStringAsFixed(0)} Pa, rise +${_maxRisePa.toStringAsFixed(0)} Pa).';
+ }
+
+ @override
+ void addSample(_SensorSample sample) {
+ final valuePa = _toPascal(sample.values.first);
+ if (valuePa > _maxAbsolutePressurePa) {
+ _maxAbsolutePressurePa = valuePa;
+ }
+
+ final timeSec = sample.timeSeconds;
+ _values.add(valuePa);
+ _times.add(timeSec);
+
+ final baselineWindow = [];
+ for (int i = 0; i < _values.length; i++) {
+ if (_times[i] <= 2.0 || baselineWindow.length < 20) {
+ baselineWindow.add(_values[i]);
+ }
+ }
+ if (baselineWindow.isNotEmpty) {
+ _baselinePa = _meanOf(baselineWindow);
+ _baselineStdPa = _stdDevOf(baselineWindow);
+ }
+
+ const floorRisePa = 600.0; // 0.6 kPa
+ const strongRisePa = 1200.0; // 1.2 kPa
+ _requiredRisePa = max(floorRisePa, _baselineStdPa * 8);
+
+ final risePa = valuePa - _baselinePa;
+ if (risePa > _maxRisePa) {
+ _maxRisePa = risePa;
+ }
+
+ if (risePa >= _requiredRisePa) {
+ if (_sustainedStart < 0) {
+ _sustainedStart = timeSec;
+ }
+ if (timeSec - _sustainedStart >= 0.45) {
+ _sustainedRise = true;
+ }
+ } else if (risePa < _requiredRisePa * 0.6) {
+ _sustainedStart = -1;
+ }
+
+ _passed = _maxAbsolutePressurePa >= _minimumAbsolutePressurePa &&
+ (_maxRisePa >= strongRisePa || _sustainedRise);
+ }
+
+ @override
+ double primaryValue(_SensorSample sample) {
+ return sample.values.first;
+ }
+
+ @override
+ String failureMessage({required bool timedOut}) {
+ if (_maxAbsolutePressurePa < _minimumAbsolutePressurePa) {
+ return 'Absolute pressure too low (${_maxAbsolutePressurePa.toStringAsFixed(0)} Pa). Need at least 100000 Pa.';
+ }
+ if (!timedOut) {
+ return 'Pressure response check failed.';
+ }
+ return 'No clear pressure rise detected. Blow steadily into the device for about one second and retry.';
+ }
+}
+
+class _MagnetometerAnalyzer implements _TestAnalyzer {
+ final String unitLabel;
+ final List> _axisValues = [];
+
+ static const int _minimumSamples = 40;
+ static const int _requiredEvents = 3;
+ static const double _minimumSpanUt = 18.0;
+ static const double _minimumStdUt = 4.0;
+ static const double _minimumDeltaEventUt = 6.0;
+
+ int _sampleCount = 0;
+ int _events = 0;
+ double _strongestSpanUt = 0;
+ double _strongestStdUt = 0;
+ double _lastAxisUt = 0;
+ double _lastEventTimeSec = -1000;
+ bool _hasLastAxis = false;
+ bool _passed = false;
+
+ _MagnetometerAnalyzer({
+ required this.unitLabel,
+ });
+
+ @override
+ bool get hasPassed => _passed;
+
+ @override
+ String get liveStatus {
+ return 'Move near metal/magnet. Events $_events/$_requiredEvents, span ${_strongestSpanUt.toStringAsFixed(1)} uT, SD ${_strongestStdUt.toStringAsFixed(1)} uT.';
+ }
+
+ @override
+ String get successMessage {
+ return 'Magnetometer response detected ($_events events, span ${_strongestSpanUt.toStringAsFixed(1)} uT).';
+ }
+
+ double _toMicroTesla(double value) {
+ final unit = unitLabel.toLowerCase();
+ if (unit.contains('mt')) {
+ return value * 1000.0;
+ }
+ if (unit.contains('nt')) {
+ return value / 1000.0;
+ }
+ return value;
+ }
+
+ @override
+ void addSample(_SensorSample sample) {
+ if (sample.values.isEmpty) {
+ return;
+ }
+
+ final valuesUt = sample.values.map(_toMicroTesla).toList(growable: false);
+ while (_axisValues.length < valuesUt.length) {
+ _axisValues.add([]);
+ }
+ for (int i = 0; i < valuesUt.length; i++) {
+ _axisValues[i].add(valuesUt[i]);
+ }
+
+ _sampleCount += 1;
+
+ final dominantIndex = _indexOfLargestAbsolute(valuesUt);
+ final dominantAxisUt = valuesUt[dominantIndex];
+ if (_hasLastAxis) {
+ final delta = (dominantAxisUt - _lastAxisUt).abs();
+ if (delta >= _minimumDeltaEventUt &&
+ sample.timeSeconds - _lastEventTimeSec >= 0.35) {
+ _events += 1;
+ _lastEventTimeSec = sample.timeSeconds;
+ }
+ }
+ _lastAxisUt = dominantAxisUt;
+ _hasLastAxis = true;
+
+ double bestSpan = 0;
+ double bestStd = 0;
+ for (final axis in _axisValues) {
+ if (axis.length < 2) {
+ continue;
+ }
+ final minValue = axis.reduce(min);
+ final maxValue = axis.reduce(max);
+ final span = maxValue - minValue;
+ if (span > bestSpan) {
+ bestSpan = span;
+ }
+ final std = _stdDevOf(axis);
+ if (std > bestStd) {
+ bestStd = std;
+ }
+ }
+
+ _strongestSpanUt = bestSpan;
+ _strongestStdUt = bestStd;
+ _passed = _sampleCount >= _minimumSamples &&
+ _events >= _requiredEvents &&
+ _strongestSpanUt >= _minimumSpanUt &&
+ _strongestStdUt >= _minimumStdUt;
+ }
+
+ int _indexOfLargestAbsolute(List values) {
+ int bestIndex = 0;
+ double bestValue = -1;
+ for (int i = 0; i < values.length; i++) {
+ final absValue = values[i].abs();
+ if (absValue > bestValue) {
+ bestValue = absValue;
+ bestIndex = i;
+ }
+ }
+ return bestIndex;
+ }
+
+ @override
+ double primaryValue(_SensorSample sample) {
+ if (sample.values.isEmpty) {
+ return 0;
+ }
+ return _toMicroTesla(sample.values.first);
+ }
+
+ @override
+ String failureMessage({required bool timedOut}) {
+ if (!timedOut) {
+ return 'Magnetometer quality check failed.';
+ }
+ return 'Magnetometer response too weak. Move near a small magnet or metal object and rotate the device.';
+ }
+}
+
+class _TemperatureAnalyzer implements _TestAnalyzer {
+ final String unitLabel;
+ final List _values = [];
+ static const double _minimumFingerSurfaceTemp = 30.0;
+ static const double _maximumExpectedTemp = 40.0;
+
+ bool _passed = false;
+ double _mean = 0;
+ double _minimum = 0;
+ double _maximum = 0;
+
+ _TemperatureAnalyzer({
+ required this.unitLabel,
+ });
+
+ @override
+ bool get hasPassed => _passed;
+
+ @override
+ String get liveStatus {
+ final current = _values.isNotEmpty ? _values.last : double.nan;
+ if (current.isNaN) {
+ return 'Waiting for temperature samples...';
+ }
+ return 'Current ${current.toStringAsFixed(1)} $unitLabel. Range ${_minimum.toStringAsFixed(1)} to ${_maximum.toStringAsFixed(1)} $unitLabel. Finger-contact range is ${_minimumFingerSurfaceTemp.toStringAsFixed(0)} to ${_maximumExpectedTemp.toStringAsFixed(0)} $unitLabel.';
+ }
+
+ @override
+ String get successMessage =>
+ 'Temperature sensor active (${_mean.toStringAsFixed(1)} $unitLabel, span ${(_maximum - _minimum).toStringAsFixed(2)} $unitLabel).';
+
+ @override
+ void addSample(_SensorSample sample) {
+ final value = sample.values.first;
+ _values.add(value);
+
+ _mean = _meanOf(_values);
+ _minimum = _values.reduce(min);
+ _maximum = _values.reduce(max);
+ final span = _maximum - _minimum;
+
+ final inRange =
+ _mean >= _minimumFingerSurfaceTemp && _mean <= _maximumExpectedTemp;
+ final notStatic = span >= 0.12;
+ _passed = _values.length >= 12 && inRange && notStatic;
+ }
+
+ @override
+ double primaryValue(_SensorSample sample) {
+ return sample.values.first;
+ }
+
+ @override
+ String failureMessage({required bool timedOut}) {
+ if (_values.isEmpty) {
+ return 'No temperature values received.';
+ }
+
+ if (_mean < _minimumFingerSurfaceTemp || _mean > _maximumExpectedTemp) {
+ return 'Temperature average is ${_mean.toStringAsFixed(1)} $unitLabel. Expected finger-contact range is ${_minimumFingerSurfaceTemp.toStringAsFixed(0)} to ${_maximumExpectedTemp.toStringAsFixed(0)} $unitLabel.';
+ }
+
+ final span = _maximum - _minimum;
+ if (span < 0.12) {
+ return 'Temperature data appears static. Move the device between air and skin briefly, then retry.';
+ }
+
+ return timedOut
+ ? 'Temperature check timed out.'
+ : 'Temperature quality check failed.';
+ }
+}
+
+class _PpgAnalyzer implements _TestAnalyzer {
+ static const double _minimumBpm = 35.0;
+ static const double _maximumBpm = 180.0;
+ static const double _minimumWindowSeconds = 8.0;
+ static const int _minimumSampleCount = 80;
+
+ final List _times = [];
+ final List> _axisValues = [];
+
+ bool _passed = false;
+ double? _detectedBpm;
+ double? _peakBpm;
+ double? _autocorrBpm;
+ double _autocorrScore = 0;
+ int _detectedPeaks = 0;
+
+ @override
+ bool get hasPassed => _passed;
+
+ @override
+ String get liveStatus {
+ if (_detectedBpm != null) {
+ return 'Pulse candidate ${_detectedBpm!.toStringAsFixed(0)} BPM (peaks $_detectedPeaks, corr ${_autocorrScore.toStringAsFixed(2)}).';
+ }
+ if (_autocorrBpm != null) {
+ return 'Pulse candidate ${_autocorrBpm!.toStringAsFixed(0)} BPM. Keep finger contact stable.';
+ }
+ return 'Looking for a pulse pattern. Keep finger contact stable.';
+ }
+
+ @override
+ String get successMessage =>
+ 'Pulse detected (${_detectedBpm?.toStringAsFixed(0) ?? '--'} BPM).';
+
+ @override
+ void addSample(_SensorSample sample) {
+ _times.add(sample.timeSeconds);
+ while (_axisValues.length < sample.values.length) {
+ _axisValues.add([]);
+ }
+ for (int i = 0; i < sample.values.length; i++) {
+ _axisValues[i].add(sample.values[i]);
+ }
+
+ if (_times.length < _minimumSampleCount) {
+ return;
+ }
+ final windowSeconds = _times.last - _times.first;
+ if (windowSeconds < _minimumWindowSeconds) {
+ return;
+ }
+
+ final axisIndex = _axisWithHighestStdDev();
+ final source = _axisValues[axisIndex];
+
+ final mean = _meanOf(source);
+ final centered =
+ source.map((value) => value - mean).toList(growable: false);
+ final smoothed = _movingAverage(centered, radius: 2);
+ final std = _stdDevOf(smoothed);
+ if (std <= 0) {
+ return;
+ }
+
+ final threshold = std * 0.30;
+ final peaks = [];
+ final minIntervalSec = 60.0 / _maximumBpm;
+
+ for (int i = 1; i < smoothed.length - 1; i++) {
+ final prev = smoothed[i - 1];
+ final curr = smoothed[i];
+ final next = smoothed[i + 1];
+ if (curr <= prev || curr <= next || curr < threshold) {
+ continue;
+ }
+ final t = _times[i];
+ if (peaks.isNotEmpty) {
+ final dt = t - peaks.last;
+ if (dt < minIntervalSec) {
+ continue;
+ }
+ }
+ peaks.add(t);
+ }
+
+ _detectedPeaks = peaks.length;
+ _peakBpm = _estimateBpmFromPeaks(peaks);
+ final normalized =
+ smoothed.map((value) => value / std).toList(growable: false);
+ _autocorrBpm = _estimateBpmFromAutocorrelation(
+ normalized,
+ windowSeconds: windowSeconds,
+ );
+
+ final peakValid = _peakBpm != null;
+ final corrValid = _autocorrBpm != null && _autocorrScore >= 0.20;
+
+ double? candidateBpm;
+ if (peakValid && corrValid && (_peakBpm! - _autocorrBpm!).abs() <= 12.0) {
+ candidateBpm = (_peakBpm! + _autocorrBpm!) / 2.0;
+ } else if (corrValid && _autocorrScore >= 0.28) {
+ candidateBpm = _autocorrBpm;
+ } else if (peakValid && _detectedPeaks >= 4) {
+ candidateBpm = _peakBpm;
+ }
+
+ if (candidateBpm == null) {
+ return;
+ }
+
+ final bpm = candidateBpm;
+ if (bpm < _minimumBpm || bpm > _maximumBpm) {
+ return;
+ }
+ _detectedBpm = bpm;
+ _passed = true;
+ }
+
+ double? _estimateBpmFromPeaks(List peaks) {
+ if (peaks.length < 4) {
+ return null;
+ }
+ final minIntervalSec = 60.0 / _maximumBpm;
+ final maxIntervalSec = 60.0 / _minimumBpm;
+ final intervals = [];
+ for (int i = 1; i < peaks.length; i++) {
+ final dt = peaks[i] - peaks[i - 1];
+ if (dt >= minIntervalSec && dt <= maxIntervalSec) {
+ intervals.add(dt);
+ }
+ }
+ if (intervals.length < 3) {
+ return null;
+ }
+ final avgInterval = _meanOf(intervals);
+ return avgInterval <= 0 ? null : 60.0 / avgInterval;
+ }
+
+ double? _estimateBpmFromAutocorrelation(
+ List normalized, {
+ required double windowSeconds,
+ }) {
+ _autocorrScore = 0;
+ if (normalized.length < 40 || windowSeconds <= 0) {
+ return null;
+ }
+
+ final avgDt = windowSeconds / max(1, normalized.length - 1);
+ if (avgDt <= 0) {
+ return null;
+ }
+
+ final minLag = max(2, (60.0 / _maximumBpm / avgDt).round());
+ final maxLag =
+ min(normalized.length - 3, (60.0 / _minimumBpm / avgDt).round());
+ if (maxLag <= minLag) {
+ return null;
+ }
+
+ var bestLag = -1;
+ var bestScore = -1.0;
+
+ for (int lag = minLag; lag <= maxLag; lag++) {
+ var cross = 0.0;
+ var energyA = 0.0;
+ var energyB = 0.0;
+ for (int i = lag; i < normalized.length; i++) {
+ final a = normalized[i];
+ final b = normalized[i - lag];
+ cross += a * b;
+ energyA += a * a;
+ energyB += b * b;
+ }
+
+ final denom = sqrt(energyA * energyB);
+ if (denom <= 1e-9) {
+ continue;
+ }
+
+ final score = cross / denom;
+ if (score > bestScore) {
+ bestScore = score;
+ bestLag = lag;
+ }
+ }
+
+ if (bestLag < 0) {
+ return null;
+ }
+
+ _autocorrScore = bestScore;
+ final periodSeconds = bestLag * avgDt;
+ if (periodSeconds <= 0) {
+ return null;
+ }
+ return 60.0 / periodSeconds;
+ }
+
+ int _axisWithHighestStdDev() {
+ int bestIndex = 0;
+ double bestStdDev = -1;
+ for (int i = 0; i < _axisValues.length; i++) {
+ final axis = _axisValues[i];
+ if (axis.isEmpty) {
+ continue;
+ }
+ final std = _stdDevOf(axis);
+ if (std > bestStdDev) {
+ bestStdDev = std;
+ bestIndex = i;
+ }
+ }
+ return bestIndex;
+ }
+
+ @override
+ double primaryValue(_SensorSample sample) {
+ if (sample.values.isEmpty) {
+ return 0;
+ }
+ return sample.values.first;
+ }
+
+ @override
+ String failureMessage({required bool timedOut}) {
+ if (!timedOut) {
+ return 'PPG quality check failed.';
+ }
+ return 'No reliable pulse pattern detected. Place PPG on a finger and keep still for around 10 seconds, then retry.';
+ }
+}
+
+class _OverviewCard extends StatelessWidget {
+ final Wearable wearable;
+ final int completed;
+ final int total;
+ final int passed;
+ final bool connected;
+ final List<_TestSpec> tests;
+ final Map resultsByTestId;
+ final _TestSpec currentSpec;
+ final bool running;
+ final bool initializing;
+ final bool currentHasSensor;
+ final _TestResult? currentResult;
+ final VoidCallback? onStartPressed;
+ final VoidCallback? onNextPressed;
+ final VoidCallback? onRetryPressed;
+ final VoidCallback? onRunAllAgainPressed;
+
+ const _OverviewCard({
+ required this.wearable,
+ required this.completed,
+ required this.total,
+ required this.passed,
+ required this.connected,
+ required this.tests,
+ required this.resultsByTestId,
+ required this.currentSpec,
+ required this.running,
+ required this.initializing,
+ required this.currentHasSensor,
+ required this.currentResult,
+ required this.onStartPressed,
+ required this.onNextPressed,
+ required this.onRetryPressed,
+ required this.onRunAllAgainPressed,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final progress = total == 0 ? 0.0 : completed / total;
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Row(
+ children: [
+ Flexible(
+ child: Text(
+ formatWearableDisplayName(wearable.name),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ if (wearable.hasCapability()) ...[
+ const SizedBox(width: 8),
+ StereoPositionBadge(
+ device: wearable.requireCapability(),
+ ),
+ ],
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ _StatusChip(
+ label: connected ? 'Connected' : 'Disconnected',
+ passed: connected,
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ LinearProgressIndicator(
+ value: progress,
+ minHeight: 8,
+ borderRadius: BorderRadius.circular(999),
+ backgroundColor: colorScheme.primary.withValues(alpha: 0.16),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ '$completed of $total tests completed. $passed passed.',
+ style: theme.textTheme.bodyMedium,
+ ),
+ const SizedBox(height: 10),
+ Divider(
+ height: 1,
+ thickness: 0.6,
+ color: theme.colorScheme.outlineVariant.withValues(alpha: 0.55),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ 'Quick Summary',
+ style: theme.textTheme.titleSmall?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: tests.map((test) {
+ final result = resultsByTestId[test.id];
+ final isCurrent = currentSpec.id == test.id;
+ final isActive = isCurrent && (running || initializing);
+
+ final bool isPass = result?.passed == true;
+ final bool isFail = result != null && !result.passed;
+ final IconData icon = isPass
+ ? Icons.check_circle_rounded
+ : isFail
+ ? Icons.cancel_rounded
+ : isActive
+ ? Icons.hourglass_top_rounded
+ : Icons.radio_button_unchecked_rounded;
+ final Color color = isPass
+ ? const Color(0xFF2F8F5B)
+ : isFail
+ ? theme.colorScheme.error
+ : isActive
+ ? theme.colorScheme.primary
+ : theme.colorScheme.onSurfaceVariant;
+
+ return Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10,
+ vertical: 6,
+ ),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(
+ color: color.withValues(alpha: 0.24),
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 14, color: color),
+ const SizedBox(width: 6),
+ Text(
+ _summaryLabelFor(test),
+ style: theme.textTheme.labelMedium?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ );
+ }).toList(growable: false),
+ ),
+ const SizedBox(height: 10),
+ Divider(
+ height: 1,
+ thickness: 0.6,
+ color: theme.colorScheme.outlineVariant.withValues(alpha: 0.55),
+ ),
+ const SizedBox(height: 10),
+ if (onRunAllAgainPressed != null) ...[
+ SizedBox(
+ width: double.infinity,
+ child: PlatformElevatedButton(
+ onPressed: initializing ? null : onRunAllAgainPressed,
+ child: PlatformText('Run All Tests Again'),
+ ),
+ ),
+ ] else if (currentResult != null) ...[
+ Row(
+ children: [
+ Expanded(
+ child: PlatformElevatedButton(
+ onPressed:
+ !running && !initializing ? onRetryPressed : null,
+ color: const Color(0xFF8E8E93),
+ child: const Icon(Icons.refresh_rounded),
+ ),
+ ),
+ if (onNextPressed != null) ...[
+ const SizedBox(width: 8),
+ Expanded(
+ child: PlatformElevatedButton(
+ onPressed: initializing ? null : onNextPressed,
+ child: PlatformText('Next Test'),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ] else ...[
+ SizedBox(
+ width: double.infinity,
+ child: PlatformElevatedButton(
+ onPressed: !running && !initializing && currentHasSensor
+ ? onStartPressed
+ : null,
+ child: PlatformText(
+ running ? 'Running...' : 'Start Test',
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+
+ String _summaryLabelFor(_TestSpec test) {
+ return switch (test.id) {
+ 'accelerometer' => 'Accel',
+ 'gyroscope' => 'Gyro',
+ 'magnetometer' => 'Mag',
+ 'barometer' => 'Baro',
+ 'temperature' => 'Temp',
+ 'ppg' => 'PPG',
+ _ => test.title,
+ };
+ }
+}
+
+class _CurrentTestCard extends StatelessWidget {
+ final _TestSpec spec;
+ final Sensor? sensor;
+ final bool running;
+ final bool initializing;
+ final String liveHint;
+ final List<_ChartPoint> liveCurve;
+ final _TestResult? result;
+
+ const _CurrentTestCard({
+ required this.spec,
+ required this.sensor,
+ required this.running,
+ required this.initializing,
+ required this.liveHint,
+ required this.liveCurve,
+ required this.result,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final hasResult = result != null;
+ final hasSensor = sensor != null;
+
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ spec.title,
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ if (hasResult)
+ _StatusChip(
+ label: result!.passed ? 'Passed' : 'Failed',
+ passed: result!.passed,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
+ spec.description,
+ style: theme.textTheme.bodyMedium,
+ ),
+ const SizedBox(height: 8),
+ if (!hasSensor)
+ Text(
+ 'Sensor not found for this test on the selected device.',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.error,
+ fontWeight: FontWeight.w600,
+ ),
+ )
+ else if (initializing)
+ Row(
+ children: [
+ SizedBox(
+ width: 16,
+ height: 16,
+ child: PlatformCircularProgressIndicator(),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'Initializing ${spec.title}... cooling down firmware for 1 second.',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ )
+ else if (running)
+ Text(
+ liveHint,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ ),
+ )
+ else if (hasResult)
+ Text(
+ result!.message,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w600,
+ color: result!.passed
+ ? const Color(0xFF2F8F5B)
+ : theme.colorScheme.error,
+ ),
+ ),
+ if (running ||
+ liveCurve.isNotEmpty ||
+ (hasResult && result!.curve.isNotEmpty)) ...[
+ const SizedBox(height: 8),
+ _SignalChart(
+ points: running ? liveCurve : (result?.curve ?? const []),
+ height: 92,
+ color: hasResult && !(result?.passed ?? true)
+ ? Theme.of(context).colorScheme.error
+ : Theme.of(context).colorScheme.primary,
+ ),
+ ],
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _ResultsCard extends StatelessWidget {
+ final List<_TestSpec> tests;
+ final Map resultsByTestId;
+ final bool running;
+ final void Function(String testId)? onRetryTest;
+
+ const _ResultsCard({
+ required this.tests,
+ required this.resultsByTestId,
+ required this.running,
+ required this.onRetryTest,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return Card(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Detailed Test Report',
+ style: theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 8),
+ ...List.generate(tests.length, (index) {
+ final test = tests[index];
+ final result = resultsByTestId[test.id];
+ final hasResult = result != null;
+
+ return Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(0, 8, 0, 8),
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ test.title,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ !hasResult ? 'Pending' : result.message,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: hasResult && !result.passed
+ ? theme.colorScheme.error
+ : theme.colorScheme.onSurfaceVariant,
+ fontWeight: hasResult
+ ? FontWeight.w600
+ : FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ if (hasResult && result.curve.isNotEmpty)
+ SizedBox(
+ width: 110,
+ child: _SignalChart(
+ points: result.curve,
+ height: 44,
+ color: result.passed
+ ? const Color(0xFF2F8F5B)
+ : theme.colorScheme.error,
+ ),
+ )
+ else
+ const SizedBox(width: 110),
+ const SizedBox(width: 8),
+ Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ _StatusChip(
+ label: !hasResult
+ ? 'Pending'
+ : result.passed
+ ? 'Pass'
+ : 'Fail',
+ passed: hasResult && result.passed,
+ ),
+ if (hasResult) ...[
+ const SizedBox(height: 4),
+ PlatformTextButton(
+ onPressed: running
+ ? null
+ : () => onRetryTest?.call(test.id),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 2,
+ ),
+ child: const Icon(Icons.refresh_rounded),
+ ),
+ ],
+ ],
+ ),
+ ],
+ ),
+ ),
+ if (index < tests.length - 1)
+ Divider(
+ height: 1,
+ thickness: 0.6,
+ color: theme.colorScheme.outlineVariant.withValues(
+ alpha: 0.55,
+ ),
+ ),
+ ],
+ );
+ }),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _StatusChip extends StatelessWidget {
+ final String label;
+ final bool passed;
+
+ const _StatusChip({
+ required this.label,
+ required this.passed,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final color =
+ passed ? const Color(0xFF2F8F5B) : Theme.of(context).colorScheme.error;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.14),
+ borderRadius: BorderRadius.circular(999),
+ border: Border.all(color: color.withValues(alpha: 0.28)),
+ ),
+ child: Text(
+ label,
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: color,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ );
+ }
+}
+
+class _SignalChart extends StatelessWidget {
+ final List<_ChartPoint> points;
+ final double height;
+ final Color color;
+
+ const _SignalChart({
+ required this.points,
+ required this.height,
+ required this.color,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ if (points.length < 2) {
+ return SizedBox(
+ height: height,
+ child: Center(
+ child: Text(
+ 'No signal yet',
+ style: Theme.of(context).textTheme.labelSmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ),
+ );
+ }
+
+ final yValues = points.map((point) => point.y).toList(growable: false);
+ var minY = yValues.reduce(min);
+ var maxY = yValues.reduce(max);
+ if ((maxY - minY).abs() < 1e-9) {
+ minY -= 1;
+ maxY += 1;
+ }
+
+ final minX = points.first.x;
+ final maxX = points.last.x <= minX ? minX + 1 : points.last.x;
+
+ return SizedBox(
+ height: height,
+ child: LineChart(
+ LineChartData(
+ minX: minX,
+ maxX: maxX,
+ minY: minY,
+ maxY: maxY,
+ titlesData: const FlTitlesData(
+ leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+ rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+ topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+ bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+ ),
+ gridData: FlGridData(
+ show: true,
+ drawHorizontalLine: true,
+ drawVerticalLine: false,
+ getDrawingHorizontalLine: (value) => FlLine(
+ color:
+ Theme.of(context).colorScheme.outline.withValues(alpha: 0.12),
+ strokeWidth: 1,
+ ),
+ ),
+ borderData: FlBorderData(show: false),
+ lineTouchData: const LineTouchData(enabled: false),
+ lineBarsData: [
+ LineChartBarData(
+ spots: points
+ .map((point) => FlSpot(point.x, point.y))
+ .toList(growable: false),
+ isCurved: true,
+ color: color,
+ barWidth: 1.8,
+ dotData: const FlDotData(show: false),
+ belowBarData: BarAreaData(
+ show: true,
+ color: color.withValues(alpha: 0.08),
+ ),
+ ),
+ ],
+ ),
+ duration: const Duration(milliseconds: 0),
+ ),
+ );
+ }
+}
+
+double _vectorMagnitude(List values) {
+ var sum = 0.0;
+ for (final value in values) {
+ sum += value * value;
+ }
+ return sqrt(sum);
+}
+
+double _meanOf(List values) {
+ if (values.isEmpty) {
+ return 0;
+ }
+ var sum = 0.0;
+ for (final value in values) {
+ sum += value;
+ }
+ return sum / values.length;
+}
+
+double _stdDevOf(List values) {
+ if (values.length < 2) {
+ return 0;
+ }
+ final mean = _meanOf(values);
+ var varianceSum = 0.0;
+ for (final value in values) {
+ final diff = value - mean;
+ varianceSum += diff * diff;
+ }
+ return sqrt(varianceSum / values.length);
+}
+
+List _movingAverage(
+ List input, {
+ required int radius,
+}) {
+ if (input.isEmpty) {
+ return const [];
+ }
+ final output = [];
+ for (int i = 0; i < input.length; i++) {
+ final start = max(0, i - radius);
+ final end = min(input.length - 1, i + radius);
+ var sum = 0.0;
+ var count = 0;
+ for (int j = start; j <= end; j++) {
+ sum += input[j];
+ count += 1;
+ }
+ output.add(sum / count);
+ }
+ return output;
+}
diff --git a/open_wearable/lib/apps/widgets/app_compatibility.dart b/open_wearable/lib/apps/widgets/app_compatibility.dart
new file mode 100644
index 00000000..8a0e85ff
--- /dev/null
+++ b/open_wearable/lib/apps/widgets/app_compatibility.dart
@@ -0,0 +1,40 @@
+import 'package:open_wearable/models/device_name_formatter.dart';
+
+bool wearableNameStartsWithPrefix(String wearableName, String prefix) {
+ final normalizedPrefix = prefix.trim().toLowerCase();
+ final normalizedWearableName = wearableName.trim().toLowerCase();
+ if (normalizedWearableName.isEmpty || normalizedPrefix.isEmpty) {
+ return false;
+ }
+
+ if (normalizedWearableName.startsWith(normalizedPrefix)) {
+ return true;
+ }
+
+ final formattedWearableName =
+ formatWearableDisplayName(wearableName).trim().toLowerCase();
+ if (formattedWearableName.isEmpty) {
+ return false;
+ }
+
+ return formattedWearableName.startsWith(normalizedPrefix);
+}
+
+bool wearableIsCompatibleWithApp({
+ required String wearableName,
+ required List supportedDevicePrefixes,
+}) {
+ if (supportedDevicePrefixes.isEmpty) return true;
+ return supportedDevicePrefixes.any(
+ (prefix) => wearableNameStartsWithPrefix(wearableName, prefix),
+ );
+}
+
+bool hasConnectedWearableForPrefix({
+ required String devicePrefix,
+ required Iterable connectedWearableNames,
+}) {
+ return connectedWearableNames.any(
+ (name) => wearableNameStartsWithPrefix(name, devicePrefix),
+ );
+}
diff --git a/open_wearable/lib/apps/widgets/app_tile.dart b/open_wearable/lib/apps/widgets/app_tile.dart
index 45fd370a..3c878bfe 100644
--- a/open_wearable/lib/apps/widgets/app_tile.dart
+++ b/open_wearable/lib/apps/widgets/app_tile.dart
@@ -1,34 +1,265 @@
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:open_wearable/apps/widgets/app_compatibility.dart';
import 'package:open_wearable/apps/widgets/apps_page.dart';
+import 'package:open_wearable/models/app_launch_session.dart';
class AppTile extends StatelessWidget {
final AppInfo app;
+ final bool isEnabled;
+ final List connectedWearableNames;
- const AppTile({super.key, required this.app});
+ const AppTile({
+ super.key,
+ required this.app,
+ required this.isEnabled,
+ required this.connectedWearableNames,
+ });
@override
Widget build(BuildContext context) {
- return PlatformListTile(
- title: PlatformText(app.title),
- subtitle: PlatformText(app.description),
- leading: SizedBox(
- height: 50.0,
- width: 50.0,
- child: ClipRRect(
- borderRadius: BorderRadius.circular(8.0),
- child: Image.asset(
- app.logoPath,
- fit: BoxFit.cover,
+ final theme = Theme.of(context);
+ final orderedSupportedDevices = _orderedSupportedDevices(
+ app.supportedDevices,
+ connectedWearableNames,
+ );
+ final titleStyle = theme.textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ color:
+ isEnabled ? theme.textTheme.titleMedium?.color : theme.disabledColor,
+ );
+
+ return Card(
+ margin: const EdgeInsets.only(bottom: 8),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: isEnabled
+ ? () {
+ AppLaunchSession.markAppFlowOpened();
+ Navigator.push(
+ context,
+ platformPageRoute(
+ context: context,
+ builder: (context) => app.widget,
+ ),
+ ).whenComplete(AppLaunchSession.markAppFlowClosed);
+ }
+ : null,
+ child: Opacity(
+ opacity: isEnabled ? 1 : 0.62,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Row(
+ children: [
+ Container(
+ height: 62,
+ width: 62,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: isEnabled
+ ? app.accentColor.withValues(alpha: 0.28)
+ : theme.disabledColor.withValues(alpha: 0.35),
+ ),
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(13),
+ child: app.logoPath.toLowerCase().endsWith('.svg')
+ ? Padding(
+ padding: EdgeInsets.all(app.svgIconInset ?? 10),
+ child: Transform.scale(
+ scale: app.svgIconScale ?? 1,
+ child: SvgPicture.asset(
+ app.logoPath,
+ fit: BoxFit.contain,
+ ),
+ ),
+ )
+ : Image.asset(
+ app.logoPath,
+ fit: BoxFit.cover,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ app.title,
+ style: titleStyle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ _LaunchAffordance(
+ accentColor: app.accentColor,
+ isEnabled: isEnabled,
+ ),
+ ],
+ ),
+ const SizedBox(height: 3),
+ Text(
+ app.description,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: isEnabled
+ ? theme.textTheme.bodyMedium?.color
+ : theme.disabledColor,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Supported devices',
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: isEnabled
+ ? theme.textTheme.bodySmall?.color
+ ?.withValues(alpha: 0.72)
+ : theme.disabledColor,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ children: orderedSupportedDevices
+ .map(
+ (device) => _SupportedDeviceChip(
+ text: device,
+ isConnected: hasConnectedWearableForPrefix(
+ devicePrefix: device,
+ connectedWearableNames:
+ connectedWearableNames,
+ ),
+ isEnabled: isEnabled,
+ ),
+ )
+ .toList(),
+ ),
+ ],
+ ),
+ ),
+ ],
),
),
),
- onTap: () {
- Navigator.push(
- context,
- platformPageRoute(context: context, builder: (context) => app.widget),
- );
- },
+ ),
+ );
+ }
+
+ List _orderedSupportedDevices(
+ List supportedDevices,
+ List connectedWearables,
+ ) {
+ final connected = [];
+ final notConnected = [];
+
+ for (final device in supportedDevices) {
+ final isConnected = hasConnectedWearableForPrefix(
+ devicePrefix: device,
+ connectedWearableNames: connectedWearables,
);
+ if (isConnected) {
+ connected.add(device);
+ } else {
+ notConnected.add(device);
+ }
+ }
+
+ return [...connected, ...notConnected];
+ }
+}
+
+class _LaunchAffordance extends StatelessWidget {
+ final Color accentColor;
+ final bool isEnabled;
+
+ const _LaunchAffordance({
+ required this.accentColor,
+ required this.isEnabled,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final disabledColor = Theme.of(context).disabledColor;
+ return Container(
+ margin: const EdgeInsets.only(left: 8),
+ height: 30,
+ width: 30,
+ decoration: BoxDecoration(
+ color: isEnabled
+ ? accentColor.withValues(alpha: 0.12)
+ : disabledColor.withValues(alpha: 0.18),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Icon(
+ Icons.arrow_forward_rounded,
+ size: 18,
+ color: isEnabled
+ ? accentColor.withValues(alpha: 0.9)
+ : disabledColor.withValues(alpha: 0.9),
+ ),
+ );
+ }
+}
+
+class _SupportedDeviceChip extends StatelessWidget {
+ final String text;
+ final bool isConnected;
+ final bool isEnabled;
+
+ const _SupportedDeviceChip({
+ required this.text,
+ required this.isConnected,
+ required this.isEnabled,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ const connectedPillColor = Color(0xFF2E7D32);
+ final disabledColor = theme.disabledColor;
+ final isConnectedAndEnabled = isEnabled && isConnected;
+ final connectedBackgroundColor = connectedPillColor.withValues(alpha: 0.15);
+ final isDark = theme.brightness == Brightness.dark;
+ final mutedBackgroundColor =
+ isDark ? const Color(0xFF3A3F45) : const Color(0xFFE3E7EC);
+ final mutedTextColor =
+ isDark ? const Color(0xFFD0D5DC) : const Color(0xFF5E6670);
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: isConnectedAndEnabled
+ ? connectedBackgroundColor
+ : isEnabled
+ ? mutedBackgroundColor
+ : disabledColor.withValues(alpha: 0.12),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ text,
+ style: theme.textTheme.labelSmall?.copyWith(
+ color: isConnectedAndEnabled
+ ? connectedPillColor
+ : isEnabled
+ ? mutedTextColor
+ : disabledColor.withValues(alpha: 0.95),
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ );
}
}
diff --git a/open_wearable/lib/apps/widgets/apps_page.dart b/open_wearable/lib/apps/widgets/apps_page.dart
index 9400bf94..db92573b 100644
--- a/open_wearable/lib/apps/widgets/apps_page.dart
+++ b/open_wearable/lib/apps/widgets/apps_page.dart
@@ -1,57 +1,167 @@
-import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
+import 'package:open_wearable/apps/fever_thermometer/fever_thermometer_page.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/posture_tracker/model/earable_attitude_tracker.dart';
import 'package:open_wearable/apps/posture_tracker/view/posture_tracker_view.dart';
+import 'package:open_wearable/apps/self_test/self_test_page.dart';
+import 'package:open_wearable/apps/widgets/app_compatibility.dart';
import 'package:open_wearable/apps/widgets/select_earable_view.dart';
import 'package:open_wearable/apps/widgets/app_tile.dart';
-
+import 'package:open_wearable/view_models/wearables_provider.dart';
+import 'package:open_wearable/widgets/recording_activity_indicator.dart';
+import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart';
+import 'package:provider/provider.dart';
class AppInfo {
final String logoPath;
final String title;
final String description;
+ final List supportedDevices;
+ final Color accentColor;
final Widget widget;
+ final double? svgIconInset;
+ final double? svgIconScale;
+ final Color? iconBackgroundColor;
AppInfo({
required this.logoPath,
required this.title,
required this.description,
+ required this.supportedDevices,
+ required this.accentColor,
required this.widget,
+ this.svgIconInset,
+ this.svgIconScale,
+ this.iconBackgroundColor,
});
}
-List _apps = [
+const Color _appAccentColor = Color(0xFF9A6F6B);
+const List _postureSupportedDevices = [
+ "OpenEarable",
+];
+const List _heartSupportedDevices = [
+ "OpenEarable",
+ "OpenRing",
+];
+const List _feverSupportedDevices = [
+ "OpenEarable",
+];
+const List _selfTestSupportedDevices = [
+ "OpenEarable",
+];
+
+Sensor? _findOpticalTemperatureSensor(List sensors) {
+ String normalizeToken(String input) {
+ return input
+ .trim()
+ .toUpperCase()
+ .replaceAll(RegExp(r'[^A-Z0-9]+'), '_')
+ .replaceAll(RegExp(r'_+'), '_')
+ .replaceAll(RegExp(r'^_|_$'), '');
+ }
+
+ const preferredNames = {
+ 'OPTICAL_TEMPERATURE_SENSOR',
+ 'TEMPERATURE_OPTICAL_SENSOR',
+ };
+ for (final sensor in sensors) {
+ final sensorName = normalizeToken(sensor.sensorName);
+ final chartName = normalizeToken(sensor.chartTitle);
+ if (preferredNames.contains(sensorName) ||
+ preferredNames.contains(chartName)) {
+ return sensor;
+ }
+ }
+
+ for (final sensor in sensors) {
+ final text = '${sensor.sensorName} ${sensor.chartTitle}'.toLowerCase();
+ final hasOptical = text.contains('optical');
+ final hasTemperature =
+ text.contains('temperature') || text.contains('temp');
+ if (hasOptical && hasTemperature) {
+ return sensor;
+ }
+ }
+
+ return null;
+}
+
+final List _apps = [
AppInfo(
logoPath: "lib/apps/posture_tracker/assets/logo.png",
title: "Posture Tracker",
description: "Get feedback on bad posture",
- widget: SelectEarableView(startApp: (wearable, sensorConfigProvider) {
- return PostureTrackerView(
- EarableAttitudeTracker(
- wearable.requireCapability(),
- sensorConfigProvider,
- wearable.name.endsWith("L"),
- ),
- );
- },),
+ supportedDevices: _postureSupportedDevices,
+ accentColor: _appAccentColor,
+ widget: SelectEarableView(
+ supportedDevicePrefixes: _postureSupportedDevices,
+ startApp: (wearable, sensorConfigProvider) {
+ return PostureTrackerView(
+ EarableAttitudeTracker(
+ wearable.requireCapability(),
+ sensorConfigProvider,
+ wearable.name.endsWith("L"),
+ ),
+ );
+ },
+ ),
),
AppInfo(
logoPath: "lib/apps/heart_tracker/assets/logo.png",
title: "Heart Tracker",
- description: "Track your heart rate and other vitals",
+ description: "Heart rate and HRV visualization",
+ supportedDevices: _heartSupportedDevices,
+ accentColor: _appAccentColor,
widget: SelectEarableView(
+ supportedDevicePrefixes: _heartSupportedDevices,
startApp: (wearable, _) {
if (wearable.hasCapability()) {
- //TODO: show alert if no ppg sensor is found
- Sensor ppgSensor = wearable.requireCapability().sensors.firstWhere(
- (s) => s.sensorName.toLowerCase() == "photoplethysmography".toLowerCase(),
- );
+ final sensors = wearable.requireCapability().sensors;
+ Sensor? ppgSensor;
+ for (final sensor in sensors) {
+ final text =
+ '${sensor.sensorName} ${sensor.chartTitle}'.toLowerCase();
+ if (text.contains('photoplethysmography') ||
+ text.contains('ppg') ||
+ text.contains('pulse')) {
+ ppgSensor = sensor;
+ break;
+ }
+ }
+
+ if (ppgSensor == null) {
+ return PlatformScaffold(
+ appBar: PlatformAppBar(
+ title: PlatformText('Heart Tracker'),
+ ),
+ body: Center(
+ child: PlatformText('No PPG sensor found on this wearable'),
+ ),
+ );
+ }
- return HeartTrackerPage(ppgSensor: ppgSensor);
+ Sensor? accelerometerSensor;
+ for (final sensor in sensors) {
+ final text =
+ '${sensor.sensorName} ${sensor.chartTitle}'.toLowerCase();
+ if (text.contains('accelerometer') || text.contains('acc')) {
+ accelerometerSensor = sensor;
+ break;
+ }
+ }
+ final opticalTemperatureSensor =
+ _findOpticalTemperatureSensor(sensors);
+
+ return HeartTrackerPage(
+ wearable: wearable,
+ ppgSensor: ppgSensor,
+ accelerometerSensor: accelerometerSensor,
+ opticalTemperatureSensor: opticalTemperatureSensor,
+ );
}
return PlatformScaffold(
appBar: PlatformAppBar(
@@ -64,18 +174,103 @@ List _apps = [
},
),
),
+ AppInfo(
+ logoPath: "lib/apps/fever_thermometer/assets/fever_thermometer_icon.svg",
+ title: "Fever Thermometer",
+ description: "Temperature estimate from the optical sensor",
+ supportedDevices: _feverSupportedDevices,
+ accentColor: const Color(0xFFB75E53),
+ svgIconInset: 0,
+ svgIconScale: 1.14,
+ widget: SelectEarableView(
+ supportedDevicePrefixes: _feverSupportedDevices,
+ startApp: (wearable, sensorConfigProvider) {
+ Sensor? opticalTemperatureSensor;
+ if (wearable.hasCapability()) {
+ final sensors = wearable.requireCapability().sensors;
+ opticalTemperatureSensor = _findOpticalTemperatureSensor(sensors);
+ }
+
+ return FeverThermometerPage(
+ wearable: wearable,
+ opticalTemperatureSensor: opticalTemperatureSensor,
+ sensorConfigProvider: sensorConfigProvider,
+ );
+ },
+ ),
+ ),
+ AppInfo(
+ logoPath: "lib/apps/self_test/assets/self_test_icon.svg",
+ title: "Device Self Test",
+ description: "Run guided OpenEarable hardware checks with a test report",
+ supportedDevices: _selfTestSupportedDevices,
+ accentColor: _appAccentColor,
+ svgIconInset: 0,
+ svgIconScale: 1.14,
+ iconBackgroundColor: const Color(0xFFF0E6E4),
+ widget: SelectEarableView(
+ supportedDevicePrefixes: _selfTestSupportedDevices,
+ startApp: (wearable, sensorConfigProvider) {
+ return SelfTestPage(
+ wearable: wearable,
+ sensorConfigProvider: sensorConfigProvider,
+ );
+ },
+ ),
+ ),
];
+int getAvailableAppsCount() => _apps.length;
+
+int getCompatibleAppsCountForWearables(Iterable wearables) {
+ final names = wearables.map((wearable) => wearable.name).toList();
+ if (names.isEmpty) return 0;
+
+ return _apps.where((app) {
+ return names.any(
+ (name) => wearableIsCompatibleWithApp(
+ wearableName: name,
+ supportedDevicePrefixes: app.supportedDevices,
+ ),
+ );
+ }).length;
+}
+
class AppsPage extends StatelessWidget {
const AppsPage({super.key});
@override
Widget build(BuildContext context) {
+ final connectedWearables = context.watch().wearables;
+ final connectedCount = connectedWearables.length;
+ final connectedWearableNames = connectedWearables
+ .map((wearable) => wearable.name)
+ .toList(growable: false);
+
+ final enabledApps = <_AppListEntry>[];
+ final disabledApps = <_AppListEntry>[];
+ for (final app in _apps) {
+ final isEnabled = connectedWearableNames.any(
+ (wearableName) => wearableIsCompatibleWithApp(
+ wearableName: wearableName,
+ supportedDevicePrefixes: app.supportedDevices,
+ ),
+ );
+ final entry = _AppListEntry(app: app, isEnabled: isEnabled);
+ if (isEnabled) {
+ enabledApps.add(entry);
+ } else {
+ disabledApps.add(entry);
+ }
+ }
+ final orderedApps = [...enabledApps, ...disabledApps];
+
return PlatformScaffold(
appBar: PlatformAppBar(
title: PlatformText("Apps"),
trailingActions: [
- PlatformIconButton(
+ const AppBarRecordingIndicator(),
+ PlatformIconButton(
icon: Icon(context.platformIcons.bluetooth),
onPressed: () {
context.push('/connect-devices');
@@ -83,14 +278,165 @@ class AppsPage extends StatelessWidget {
),
],
),
- body: Padding(
- padding: EdgeInsets.all(10),
- child: ListView.builder(
- itemCount: _apps.length,
- itemBuilder: (context, index) {
- return AppTile(app: _apps[index]);
- },
+ body: ListView(
+ padding: SensorPageSpacing.pagePaddingWithBottomInset(context),
+ children: [
+ _AppsHeroCard(
+ totalApps: _apps.length,
+ connectedDevices: connectedCount,
+ ),
+ const SizedBox(height: SensorPageSpacing.sectionGap),
+ Padding(
+ padding: const EdgeInsets.only(left: 2, bottom: 8),
+ child: Text(
+ 'Available apps',
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ ...orderedApps.map(
+ (entry) => AppTile(
+ app: entry.app,
+ isEnabled: entry.isEnabled,
+ connectedWearableNames: connectedWearableNames,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _AppListEntry {
+ final AppInfo app;
+ final bool isEnabled;
+
+ const _AppListEntry({
+ required this.app,
+ required this.isEnabled,
+ });
+}
+
+class _AppsHeroCard extends StatelessWidget {
+ final int totalApps;
+ final int connectedDevices;
+
+ const _AppsHeroCard({
+ required this.totalApps,
+ required this.connectedDevices,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return Container(
+ padding: const EdgeInsets.all(18),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(18),
+ gradient: const LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ Color(0xFF835B58),
+ Color(0xFFB48A86),
+ ],
),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.12),
+ blurRadius: 14,
+ offset: const Offset(0, 8),
+ ),
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Container(
+ height: 34,
+ width: 34,
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.16),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: const Icon(
+ Icons.auto_awesome,
+ color: Colors.white,
+ size: 20,
+ ),
+ ),
+ const SizedBox(width: 10),
+ Text(
+ 'App Studio',
+ style: theme.textTheme.titleMedium?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Text(
+ 'Launch wearable experiences from one place.',
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: Colors.white.withValues(alpha: 0.9),
+ ),
+ ),
+ const SizedBox(height: 14),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ _HeroStatPill(
+ label: '$totalApps apps',
+ icon: Icons.widgets_outlined,
+ ),
+ _HeroStatPill(
+ label: '$connectedDevices wearables connected',
+ icon: Icons.link_rounded,
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _HeroStatPill extends StatelessWidget {
+ final String label;
+ final IconData icon;
+
+ const _HeroStatPill({
+ required this.label,
+ required this.icon,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(999),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 15, color: Colors.white),
+ const SizedBox(width: 6),
+ Text(
+ label,
+ style: Theme.of(context).textTheme.labelMedium?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
),
);
}
diff --git a/open_wearable/lib/apps/widgets/select_earable_view.dart b/open_wearable/lib/apps/widgets/select_earable_view.dart
index 49653a88..a830a511 100644
--- a/open_wearable/lib/apps/widgets/select_earable_view.dart
+++ b/open_wearable/lib/apps/widgets/select_earable_view.dart
@@ -1,17 +1,24 @@
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/apps/widgets/app_compatibility.dart';
+import 'package:open_wearable/models/device_name_formatter.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/device_status_pills.dart';
+import 'package:open_wearable/widgets/devices/wearable_icon.dart';
import 'package:provider/provider.dart';
-class SelectEarableView extends StatefulWidget {
- /// Callback to start the app
- /// -- [wearable] the selected wearable
- /// returns a [Widget] of the home page of the app
+class SelectEarableView extends StatefulWidget {
final Widget Function(Wearable, SensorConfigurationProvider) startApp;
+ final List supportedDevicePrefixes;
- const SelectEarableView({super.key, required this.startApp});
+ const SelectEarableView({
+ super.key,
+ required this.startApp,
+ this.supportedDevicePrefixes = const [],
+ });
@override
State createState() => _SelectEarableViewState();
@@ -19,61 +26,377 @@ class SelectEarableView extends StatefulWidget {
class _SelectEarableViewState extends State {
Wearable? _selectedWearable;
+ Future>? _groupsFuture;
+ String _groupFingerprint = '';
@override
Widget build(BuildContext context) {
return PlatformScaffold(
appBar: PlatformAppBar(
- title: PlatformText("Select Earable"),
+ title: PlatformText('Select Wearable'),
),
- body: Consumer(
- builder: (context, WearablesProvider wearablesProvider, child) =>
- Column(
+ body: Consumer(
+ builder: (context, wearablesProvider, _) {
+ final compatibleWearables = wearablesProvider.wearables
+ .where(
+ (wearable) => wearableIsCompatibleWithApp(
+ wearableName: wearable.name,
+ supportedDevicePrefixes: widget.supportedDevicePrefixes,
+ ),
+ )
+ .toList(growable: false);
+
+ _refreshGroupFutureIfNeeded(compatibleWearables);
+ final selectedDeviceId = _selectedWearable?.deviceId;
+ final hasSelectedCompatibleWearable = selectedDeviceId != null &&
+ compatibleWearables.any(
+ (wearable) => wearable.deviceId == selectedDeviceId,
+ );
+
+ return Column(
children: [
- ListView.builder(
- shrinkWrap: true,
- physics: NeverScrollableScrollPhysics(),
- itemCount: wearablesProvider.wearables.length,
- itemBuilder: (context, index) {
- Wearable wearable = wearablesProvider.wearables[index];
- return PlatformListTile(
- title: PlatformText(wearable.name),
- subtitle: PlatformText(wearable.deviceId), //TODO: use device ID
- trailing: _selectedWearable == wearable
- ? Icon(Icons.check)
+ Expanded(
+ child: _buildBody(
+ context,
+ compatibleWearables: compatibleWearables,
+ wearablesProvider: wearablesProvider,
+ ),
+ ),
+ SafeArea(
+ top: false,
+ minimum: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+ child: SizedBox(
+ width: double.infinity,
+ child: PlatformElevatedButton(
+ onPressed: hasSelectedCompatibleWearable
+ ? () => _startSelectedApp(
+ context,
+ wearablesProvider,
+ compatibleWearables,
+ )
: null,
- onTap: () => setState(() {
- _selectedWearable = wearable;
- }),
- );
- },
+ child: PlatformText('Start App'),
+ ),
+ ),
),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ void _refreshGroupFutureIfNeeded(List wearables) {
+ final fingerprint = wearables
+ .map((wearable) => '${wearable.deviceId}:${wearable.name}')
+ .join('|');
+ if (_groupsFuture != null && _groupFingerprint == fingerprint) {
+ return;
+ }
+
+ _groupFingerprint = fingerprint;
+ _groupsFuture = buildWearableDisplayGroups(
+ wearables,
+ shouldCombinePair: (_, __) => false,
+ );
+ }
+
+ Widget _buildBody(
+ BuildContext context, {
+ required List compatibleWearables,
+ required WearablesProvider wearablesProvider,
+ }) {
+ if (compatibleWearables.isEmpty) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 18),
+ child: Text(
+ 'No compatible wearables connected for this app.',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ ),
+ );
+ }
+
+ return FutureBuilder>(
+ future: _groupsFuture,
+ builder: (context, snapshot) {
+ final groups = _sortGroupsForSelection(
+ snapshot.data ??
+ compatibleWearables
+ .map(
+ (wearable) =>
+ WearableDisplayGroup.single(wearable: wearable),
+ )
+ .toList(growable: false),
+ );
+
+ if (groups.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ final selectedId = _selectedWearable?.deviceId;
+
+ return ListView.builder(
+ padding: const EdgeInsets.all(10),
+ itemCount: groups.length,
+ itemBuilder: (context, index) {
+ final group = groups[index];
+ final wearable = group.primary;
+ final isSelected = selectedId == wearable.deviceId;
+
+ return _SelectableWearableCard(
+ wearable: wearable,
+ position: group.primaryPosition,
+ selected: isSelected,
+ onTap: () {
+ setState(() {
+ _selectedWearable = wearable;
+ });
+ },
+ );
+ },
+ );
+ },
+ );
+ }
+
+ List _sortGroupsForSelection(
+ List groups,
+ ) {
+ final indexed = groups.asMap().entries.toList();
+
+ String normalizedName(String name) {
+ var value = name.trim();
+ value = value.replaceFirst(
+ RegExp(r'\s*\((left|right|l|r)\)$', caseSensitive: false),
+ '',
+ );
+ value = value.replaceFirst(
+ RegExp(r'[\s_-]+(left|right|l|r)$', caseSensitive: false),
+ '',
+ );
+ value = value.trim();
+ return value.isEmpty ? name.trim() : value;
+ }
+
+ int positionRank(DevicePosition? position) {
+ return switch (position) {
+ DevicePosition.left => 0,
+ DevicePosition.right => 1,
+ _ => 2,
+ };
+ }
+
+ indexed.sort((a, b) {
+ final aBase = normalizedName(a.value.primary.name).toLowerCase();
+ final bBase = normalizedName(b.value.primary.name).toLowerCase();
+ final byBase = aBase.compareTo(bBase);
+ if (byBase != 0) {
+ return byBase;
+ }
+
+ final byPosition = positionRank(a.value.primaryPosition)
+ .compareTo(positionRank(b.value.primaryPosition));
+ if (byPosition != 0) {
+ return byPosition;
+ }
+
+ final byName = a.value.primary.name
+ .toLowerCase()
+ .compareTo(b.value.primary.name.toLowerCase());
+ if (byName != 0) {
+ return byName;
+ }
+
+ return a.key.compareTo(b.key);
+ });
+
+ return indexed.map((entry) => entry.value).toList(growable: false);
+ }
- PlatformElevatedButton(
- child: PlatformText("Start App"),
- onPressed: () {
- if (_selectedWearable != null) {
- Navigator.push(
- context,
- platformPageRoute(
- context: context,
- builder: (context) {
- return ChangeNotifierProvider.value(
- value: wearablesProvider.getSensorConfigurationProvider(_selectedWearable!),
- child: widget.startApp(
- _selectedWearable!,
- wearablesProvider.getSensorConfigurationProvider(_selectedWearable!),
+ void _startSelectedApp(
+ BuildContext context,
+ WearablesProvider wearablesProvider,
+ List compatibleWearables,
+ ) {
+ final selectedId = _selectedWearable?.deviceId;
+ if (selectedId == null) {
+ return;
+ }
+
+ final selectedWearable = compatibleWearables
+ .where((wearable) => wearable.deviceId == selectedId)
+ .firstOrNull;
+
+ if (selectedWearable == null) {
+ return;
+ }
+
+ final sensorConfigProvider =
+ wearablesProvider.getSensorConfigurationProvider(selectedWearable);
+
+ Navigator.push(
+ context,
+ platformPageRoute(
+ context: context,
+ builder: (context) => ChangeNotifierProvider.value(
+ value: sensorConfigProvider,
+ child: widget.startApp(
+ selectedWearable,
+ sensorConfigProvider,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SelectableWearableCard extends StatelessWidget {
+ final Wearable wearable;
+ final DevicePosition? position;
+ final bool selected;
+ final VoidCallback onTap;
+
+ const _SelectableWearableCard({
+ required this.wearable,
+ required this.position,
+ required this.selected,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final colorScheme = theme.colorScheme;
+ final iconVariant = _iconVariantForPosition(position);
+ final hasWearableIcon = _hasWearableIcon(iconVariant);
+ final cardColor = selected
+ ? colorScheme.primaryContainer.withValues(alpha: 0.34)
+ : colorScheme.surface;
+ final pills = _buildDeviceStatusPills();
+
+ return Card(
+ color: cardColor,
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (hasWearableIcon) ...[
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: SizedBox(
+ width: 56,
+ height: 56,
+ child: WearableIcon(
+ wearable: wearable,
+ initialVariant: iconVariant,
+ hideWhileResolvingStereoPosition: true,
+ hideWhenResolvedVariantIsSingle: true,
+ fallback: const SizedBox.shrink(),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ ],
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Text(
+ formatWearableDisplayName(wearable.name),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.bodyLarge?.copyWith(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 170),
+ child: Text(
+ wearable.deviceId,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.right,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ if (selected) ...[
+ const SizedBox(width: 6),
+ Padding(
+ padding: const EdgeInsets.only(top: 1),
+ child: Icon(
+ Icons.check_circle_rounded,
+ color: colorScheme.primary,
+ size: 18,
),
- );
- },
- ),
- );
- }
- },
+ ),
+ ],
+ ],
+ ),
+ if (pills.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ _buildStatusPillLine(pills),
+ ],
+ ],
+ ),
),
],
),
+ ),
),
);
}
+
+ WearableIconVariant _iconVariantForPosition(DevicePosition? position) {
+ return switch (position) {
+ DevicePosition.left => WearableIconVariant.left,
+ DevicePosition.right => WearableIconVariant.right,
+ _ => WearableIconVariant.single,
+ };
+ }
+
+ bool _hasWearableIcon(WearableIconVariant initialVariant) {
+ final variantPath = wearable.getWearableIconPath(variant: initialVariant);
+ if (variantPath != null && variantPath.isNotEmpty) {
+ return true;
+ }
+ final fallbackPath = wearable.getWearableIconPath();
+ return fallbackPath != null && fallbackPath.isNotEmpty;
+ }
+
+ List _buildDeviceStatusPills() {
+ String? sideLabel;
+ if (position == DevicePosition.left) {
+ sideLabel = 'L';
+ } else if (position == DevicePosition.right) {
+ sideLabel = 'R';
+ }
+
+ return buildDeviceStatusPills(
+ wearable: wearable,
+ sideLabel: sideLabel,
+ showStereoPosition: sideLabel == null,
+ batteryLiveUpdates: true,
+ batteryShowBackground: true,
+ );
+ }
+
+ Widget _buildStatusPillLine(List pills) {
+ return DevicePillLine(pills: pills);
+ }
}
diff --git a/open_wearable/lib/assets/devices/pair.png b/open_wearable/lib/assets/devices/pair.png
new file mode 100644
index 00000000..cd34dc91
Binary files /dev/null and b/open_wearable/lib/assets/devices/pair.png differ
diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart
index 70bead86..9740a25b 100644
--- a/open_wearable/lib/main.dart
+++ b/open_wearable/lib/main.dart
@@ -4,12 +4,22 @@ import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
+import 'package:open_wearable/models/app_background_execution_bridge.dart';
+import 'package:open_wearable/models/app_launch_session.dart';
+import 'package:open_wearable/models/app_shutdown_settings.dart';
+import 'package:open_wearable/models/auto_connect_preferences.dart';
import 'package:open_wearable/models/log_file_manager.dart';
-import 'package:open_wearable/models/wearable_connector.dart';
+import 'package:open_wearable/models/connector_settings.dart';
+import 'package:open_wearable/models/fota_post_update_verification.dart';
+import 'package:open_wearable/models/wearable_connector.dart'
+ hide WearableEvent;
import 'package:open_wearable/router.dart';
+import 'package:open_wearable/theme/app_theme.dart';
import 'package:open_wearable/view_models/sensor_recorder_provider.dart';
import 'package:open_wearable/widgets/app_banner.dart';
import 'package:open_wearable/widgets/global_app_banner_overlay.dart';
+import 'package:open_wearable/widgets/app_toast.dart';
+import 'package:open_wearable/widgets/fota/fota_verification_banner.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -23,6 +33,9 @@ void main() async {
LogFileManager logFileManager = await LogFileManager.create();
initOpenWearableLogger(logFileManager.libLogger);
initLogger(logFileManager.logger);
+ await ConnectorSettings.initialize();
+ await AutoConnectPreferences.initialize();
+ await AppShutdownSettings.initialize();
runApp(
MultiProvider(
@@ -59,6 +72,19 @@ class _MyAppState extends State