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 with WidgetsBindingObserver { late final BluetoothAutoConnector _autoConnector; late final Future _prefsFuture; late final StreamSubscription _wearableProvEventSub; + late final WearablesProvider _wearablesProvider; + late final SensorRecorderProvider _sensorRecorderProvider; + bool _closingSensorShutdownInProgress = false; + bool _shouldCloseOpenScreensOnResume = false; + Timer? _pendingCloseShutdownTimer; + DateTime? _backgroundEnteredAt; + bool _backgroundExecutionRequestedForShutdown = false; + bool _backgroundExecutionRequestedForRecording = false; + bool _isBackgroundExecutionActive = false; + + static const Duration _closeShutdownGracePeriod = Duration( + seconds: 10, + ); @override void initState() { @@ -67,10 +93,11 @@ class _MyAppState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); // Read provider without listening, allowed in initState with Provider - final wearablesProvider = context.read(); + _wearablesProvider = context.read(); + _sensorRecorderProvider = context.read(); _unsupportedFirmwareSub = - wearablesProvider.unsupportedFirmwareStream.listen((evt) { + _wearablesProvider.unsupportedFirmwareStream.listen((evt) { // No async/await here. No widget context usage either. final nav = rootNavigatorKey.currentState; if (nav == null || !mounted) return; @@ -98,7 +125,8 @@ class _MyAppState extends State with WidgetsBindingObserver { ); }); - _wearableProvEventSub = wearablesProvider.wearableEventStream.listen((event) { + _wearableProvEventSub = + _wearablesProvider.wearableEventStream.listen((event) { if (!mounted) return; // Handle firmware update available events with a dialog @@ -127,7 +155,8 @@ class _MyAppState extends State with WidgetsBindingObserver { child: const Text('Update Now'), onPressed: () { // Set the selected peripheral for firmware update - final updateProvider = Provider.of( + final updateProvider = + Provider.of( rootNavigatorKey.currentContext!, listen: false, ); @@ -147,67 +176,268 @@ class _MyAppState extends State with WidgetsBindingObserver { final appBannerController = context.read(); appBannerController.showBanner( (id) { - late final Color backgroundColor; - if (event is WearableErrorEvent) { - backgroundColor = Theme.of(context).colorScheme.error; - } else { - backgroundColor = Theme.of(context).colorScheme.primary; - } - - late final Color textColor; - if (event is WearableErrorEvent) { - textColor = Theme.of(context).colorScheme.onError; - } else { - textColor = Theme.of(context).colorScheme.onPrimary; - } + final colorScheme = Theme.of(context).colorScheme; + final bool isError = event is WearableErrorEvent; + final bool isTimeSync = event is WearableTimeSynchronizedEvent; + const timeSyncBackground = Color(0xFFEDE4FF); + const timeSyncForeground = Color(0xFF5A2EA6); + final backgroundColor = isError + ? colorScheme.errorContainer + : isTimeSync + ? timeSyncBackground + : colorScheme.primaryContainer; + final textColor = isError + ? colorScheme.onErrorContainer + : isTimeSync + ? timeSyncForeground + : colorScheme.onPrimaryContainer; + final icon = isError + ? Icons.error_outline_rounded + : isTimeSync + ? Icons.schedule_rounded + : Icons.info_outline_rounded; + final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ); return AppBanner( - content: Text( - event.description, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: textColor, - ), + content: _buildBannerContent( + event: event, + textColor: textColor, + textStyle: textStyle, + accentColor: textColor, ), backgroundColor: backgroundColor, + foregroundColor: textColor, + leadingIcon: icon, key: ValueKey(id), ); }, - duration: const Duration(seconds: 3), + duration: const Duration(seconds: 4), ); }); final WearableConnector connector = context.read(); - final SensorRecorderProvider sensorRecorderProvider = - context.read(); _autoConnector = BluetoothAutoConnector( navStateGetter: () => rootNavigatorKey.currentState, wearableManager: WearableManager(), - connector: connector, prefsFuture: _prefsFuture, - onWearableConnected: (wearable) { - wearablesProvider.addWearable(wearable); - sensorRecorderProvider.addWearable(wearable); - }, + onWearableConnected: _handleWearableConnected, + ); + AutoConnectPreferences.autoConnectEnabledListenable.addListener( + _syncAutoConnectorWithSetting, ); _wearableEventSub = connector.events.listen((event) { if (event is WearableConnectEvent) { - wearablesProvider.addWearable(event.wearable); - sensorRecorderProvider.addWearable(event.wearable); + _handleWearableConnected(event.wearable); } }); - _autoConnector.start(); + _syncAutoConnectorWithSetting(); + } + + void _syncAutoConnectorWithSetting() { + if (AutoConnectPreferences.autoConnectEnabled) { + _autoConnector.start(); + return; + } + _autoConnector.stop(); + } + + void _handleWearableConnected(Wearable wearable) { + _wearablesProvider.addWearable(wearable); + _sensorRecorderProvider.addWearable(wearable); + _maybeFinalizePostUpdateVerification(wearable); + } + + Future _maybeFinalizePostUpdateVerification(Wearable wearable) async { + final result = await FotaPostUpdateVerificationCoordinator.instance + .verifyOnWearableConnected(wearable); + if (!mounted || result == null) { + return; + } + + dismissFotaVerificationBannerById(context, result.verificationId); + final accentColor = result.success + ? const Color(0xFF1E6A3A) + : Theme.of(context).colorScheme.onErrorContainer; + AppToast.showContent( + context, + content: _buildPostUpdateVerificationToastContent( + result: result, + accentColor: accentColor, + ), + type: result.success ? AppToastType.success : AppToastType.error, + icon: + result.success ? Icons.verified_rounded : Icons.error_outline_rounded, + duration: result.success + ? const Duration(seconds: 6) + : const Duration(seconds: 8), + ); + } + + Future _maybeTurnOffAllSensorsOnAppClose() async { + if (_closingSensorShutdownInProgress) { + return; + } + + if (!AppShutdownSettings.shutOffAllSensorsOnAppClose || + _sensorRecorderProvider.isRecording) { + _setBackgroundExecutionForShutdown(false); + return; + } + + _closingSensorShutdownInProgress = true; + try { + await _wearablesProvider.turnOffSensorsForAllDevices(); + _shouldCloseOpenScreensOnResume = true; + } catch (e, st) { + logger.w('Failed to shut off sensors on app close: $e\n$st'); + } finally { + _setBackgroundExecutionForShutdown(false); + } + } + + void _closeOpenScreensAfterSensorShutdownIfNeeded() { + if (!_shouldCloseOpenScreensOnResume) { + return; + } + + _shouldCloseOpenScreensOnResume = false; + if (!AppLaunchSession.hasOpenAppFlow) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + final nav = rootNavigatorKey.currentState; + if (nav == null || !nav.canPop() || !AppLaunchSession.hasOpenAppFlow) { + return; + } + nav.popUntil((route) => route.isFirst); + AppLaunchSession.reset(); + }); + } + + void _setBackgroundExecutionForShutdown(bool enabled) { + if (_backgroundExecutionRequestedForShutdown == enabled) { + return; + } + _backgroundExecutionRequestedForShutdown = enabled; + _syncBackgroundExecutionWindow(); + } + + void _setBackgroundExecutionForRecording(bool enabled) { + if (_backgroundExecutionRequestedForRecording == enabled) { + return; + } + _backgroundExecutionRequestedForRecording = enabled; + _syncBackgroundExecutionWindow(); + } + + void _syncBackgroundExecutionWindow() { + final shouldHoldBackgroundExecution = + _backgroundExecutionRequestedForShutdown || + _backgroundExecutionRequestedForRecording; + if (shouldHoldBackgroundExecution == _isBackgroundExecutionActive) { + return; + } + + _isBackgroundExecutionActive = shouldHoldBackgroundExecution; + if (shouldHoldBackgroundExecution) { + unawaited(AppBackgroundExecutionBridge.beginSensorShutdownWindow()); + return; + } + unawaited(AppBackgroundExecutionBridge.endSensorShutdownWindow()); + } + + void _scheduleCloseShutdownIfNeeded() { + _pendingCloseShutdownTimer?.cancel(); + _pendingCloseShutdownTimer = null; + + if (_sensorRecorderProvider.isRecording || + !AppShutdownSettings.shutOffAllSensorsOnAppClose) { + _setBackgroundExecutionForShutdown(false); + return; + } + + _setBackgroundExecutionForShutdown(true); + _pendingCloseShutdownTimer = Timer(_closeShutdownGracePeriod, () { + _pendingCloseShutdownTimer = null; + unawaited(_maybeTurnOffAllSensorsOnAppClose()); + }); } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.resumed) { - _autoConnector.start(); + final backgroundEnteredAt = _backgroundEnteredAt; + _backgroundEnteredAt = null; + + _pendingCloseShutdownTimer?.cancel(); + _pendingCloseShutdownTimer = null; + _setBackgroundExecutionForShutdown(false); + _setBackgroundExecutionForRecording(false); + + final shouldCatchUpShutdown = + AppShutdownSettings.shutOffAllSensorsOnAppClose && + !_shouldCloseOpenScreensOnResume && + !_closingSensorShutdownInProgress && + !_sensorRecorderProvider.isRecording && + backgroundEnteredAt != null && + DateTime.now().difference(backgroundEnteredAt) >= + _closeShutdownGracePeriod; + if (shouldCatchUpShutdown) { + unawaited( + () async { + await _maybeTurnOffAllSensorsOnAppClose(); + if (!mounted) { + return; + } + _closeOpenScreensAfterSensorShutdownIfNeeded(); + _closingSensorShutdownInProgress = false; + _syncAutoConnectorWithSetting(); + }(), + ); + return; + } + + _closingSensorShutdownInProgress = false; + _closeOpenScreensAfterSensorShutdownIfNeeded(); + _syncAutoConnectorWithSetting(); + } else if (state == AppLifecycleState.inactive) { + _backgroundEnteredAt ??= DateTime.now(); + if (_sensorRecorderProvider.isRecording) { + _pendingCloseShutdownTimer?.cancel(); + _pendingCloseShutdownTimer = null; + _setBackgroundExecutionForShutdown(false); + _setBackgroundExecutionForRecording(true); + } else { + _setBackgroundExecutionForRecording(false); + _scheduleCloseShutdownIfNeeded(); + } } else if (state == AppLifecycleState.paused) { _autoConnector.stop(); + _backgroundEnteredAt ??= DateTime.now(); + if (_sensorRecorderProvider.isRecording) { + _pendingCloseShutdownTimer?.cancel(); + _pendingCloseShutdownTimer = null; + _setBackgroundExecutionForShutdown(false); + _setBackgroundExecutionForRecording(true); + } else { + _setBackgroundExecutionForRecording(false); + _scheduleCloseShutdownIfNeeded(); + } + } else if (state == AppLifecycleState.detached) { + _backgroundEnteredAt = null; + _pendingCloseShutdownTimer?.cancel(); + _pendingCloseShutdownTimer = null; + _setBackgroundExecutionForShutdown(false); + _setBackgroundExecutionForRecording(false); + _autoConnector.stop(); } } @@ -230,12 +460,179 @@ class _MyAppState extends State with WidgetsBindingObserver { } } + Widget _buildPostUpdateVerificationToastContent({ + required FotaPostUpdateVerificationResult result, + required Color accentColor, + }) { + final titleStyle = Theme.of(context).textTheme.titleSmall?.copyWith( + color: accentColor, + fontWeight: FontWeight.w800, + height: 1.05, + ); + final detailStyle = Theme.of(context).textTheme.bodySmall?.copyWith( + color: accentColor, + fontWeight: FontWeight.w600, + ); + final statusLabel = result.success ? 'Update verified' : 'Update failed'; + + final detailText = _verificationToastDetail(result); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + '$statusLabel: ${result.wearableName}', + style: titleStyle, + ), + if (result.sideLabel != null) + _ToastStereoSideBadge( + sideLabel: result.sideLabel!, + accentColor: accentColor, + ), + ], + ), + if (detailText != null) ...[ + const SizedBox(height: 6), + Text(detailText, style: detailStyle), + ], + ], + ); + } + + String? _verificationToastDetail(FotaPostUpdateVerificationResult result) { + if (result.success) { + final version = result.detectedFirmwareVersion; + if (version == null) { + return null; + } + return 'Firmware version: $version'; + } + + final detected = result.detectedFirmwareVersion; + final expected = result.expectedFirmwareVersion; + final expectedLabel = expected ?? 'unknown'; + final detectedLabel = detected ?? 'unknown'; + return 'Expected: $expectedLabel. Detected: $detectedLabel.'; + } + + Widget _buildBannerContent({ + required WearableEvent event, + required Color textColor, + required Color accentColor, + TextStyle? textStyle, + }) { + final isTimeSync = event is WearableTimeSynchronizedEvent; + final normalizedDescription = isTimeSync + ? _removeTrailingPeriod(event.description) + : _ensureSentenceEndsWithPeriod(event.description); + final resolvedTextStyle = textStyle ?? + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: textColor, + fontWeight: FontWeight.w600, + ) ?? + TextStyle( + color: textColor, + fontWeight: FontWeight.w600, + ); + + if (!isTimeSync) { + return Text(normalizedDescription, style: resolvedTextStyle); + } + + final parsed = _ParsedStereoSyncMessage.tryParse(normalizedDescription); + if (parsed == null) { + return Text(normalizedDescription, style: resolvedTextStyle); + } + + return Text.rich( + TextSpan( + style: resolvedTextStyle, + children: [ + if (parsed.prefix.isNotEmpty) TextSpan(text: '${parsed.prefix} '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _ToastStereoSideBadge( + sideLabel: parsed.sideLabel, + accentColor: accentColor, + ), + ), + if (parsed.suffix.isNotEmpty) TextSpan(text: ' ${parsed.suffix}'), + ], + ), + ); + } + + String _ensureSentenceEndsWithPeriod(String text) { + final trimmed = text.trimRight(); + if (trimmed.isEmpty) { + return trimmed; + } + + final lastChar = trimmed[trimmed.length - 1]; + if (lastChar == '.' || lastChar == '!' || lastChar == '?') { + return trimmed; + } + + if (trimmed.length > 1 && + (lastChar == '"' || + lastChar == '\'' || + lastChar == ')' || + lastChar == ']')) { + final previousChar = trimmed[trimmed.length - 2]; + if (previousChar == '.' || previousChar == '!' || previousChar == '?') { + return trimmed; + } + } + + return '$trimmed.'; + } + + String _removeTrailingPeriod(String text) { + final trimmed = text.trimRight(); + if (trimmed.isEmpty) { + return trimmed; + } + + final lastChar = trimmed[trimmed.length - 1]; + if (lastChar == '.') { + return trimmed.substring(0, trimmed.length - 1); + } + + if (trimmed.length > 1 && + (lastChar == '"' || + lastChar == '\'' || + lastChar == ')' || + lastChar == ']')) { + final previousChar = trimmed[trimmed.length - 2]; + if (previousChar == '.') { + return '${trimmed.substring(0, trimmed.length - 2)}$lastChar'; + } + } + + return trimmed; + } + @override void dispose() { _unsupportedFirmwareSub.cancel(); _wearableEventSub.cancel(); _wearableProvEventSub.cancel(); + AutoConnectPreferences.autoConnectEnabledListenable.removeListener( + _syncAutoConnectorWithSetting, + ); + ConnectorSettings.dispose(); WidgetsBinding.instance.removeObserver(this); + _pendingCloseShutdownTimer?.cancel(); + _pendingCloseShutdownTimer = null; + _backgroundEnteredAt = null; + _setBackgroundExecutionForShutdown(false); + _setBackgroundExecutionForRecording(false); _autoConnector.stop(); super.dispose(); } @@ -244,17 +641,15 @@ class _MyAppState extends State with WidgetsBindingObserver { Widget build(BuildContext context) { return PlatformProvider( settings: PlatformSettingsData( - iosUsesMaterialWidgets: true, + platformStyle: const PlatformStyleData( + ios: PlatformStyle.Material, + macos: PlatformStyle.Material, + ), ), builder: (context) => PlatformTheme( - materialLightTheme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - cardTheme: const CardThemeData( - color: Colors.white, - elevation: 0, - ), - ), + materialLightTheme: AppTheme.lightTheme(), + materialDarkTheme: AppTheme.darkTheme(), + themeMode: ThemeMode.light, builder: (context) => GlobalAppBannerOverlay( child: PlatformApp.router( routerConfig: router, @@ -270,3 +665,72 @@ class _MyAppState extends State with WidgetsBindingObserver { ); } } + +class _ParsedStereoSyncMessage { + final String prefix; + final String sideLabel; + final String suffix; + + const _ParsedStereoSyncMessage({ + required this.prefix, + required this.sideLabel, + required this.suffix, + }); + + static _ParsedStereoSyncMessage? tryParse(String message) { + final match = RegExp(r'\((Left|Right)\)').firstMatch(message); + if (match == null) return null; + + final sideWord = match.group(1); + final sideLabel = switch (sideWord) { + 'Left' => 'L', + 'Right' => 'R', + _ => null, + }; + if (sideLabel == null) return null; + + final prefix = message.substring(0, match.start).trimRight(); + final suffix = message.substring(match.end).trimLeft(); + + return _ParsedStereoSyncMessage( + prefix: prefix, + sideLabel: sideLabel, + suffix: suffix, + ); + } +} + +class _ToastStereoSideBadge extends StatelessWidget { + final String sideLabel; + final Color accentColor; + + const _ToastStereoSideBadge({ + required this.sideLabel, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final foreground = accentColor; + final background = foreground.withValues(alpha: 0.16); + final border = foreground.withValues(alpha: 0.34); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 1), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: border), + ), + child: Text( + sideLabel, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w800, + letterSpacing: 0.1, + ), + ), + ); + } +} diff --git a/open_wearable/lib/models/app_background_execution_bridge.dart b/open_wearable/lib/models/app_background_execution_bridge.dart new file mode 100644 index 00000000..3f9228e8 --- /dev/null +++ b/open_wearable/lib/models/app_background_execution_bridge.dart @@ -0,0 +1,23 @@ +import 'package:flutter/services.dart'; + +class AppBackgroundExecutionBridge { + static const MethodChannel _channel = MethodChannel( + 'edu.kit.teco.open_wearable/lifecycle', + ); + + static Future beginSensorShutdownWindow() async { + try { + await _channel.invokeMethod('beginBackgroundExecution'); + } catch (_) { + // Best-effort bridge. Missing plugin / unsupported platform is fine. + } + } + + static Future endSensorShutdownWindow() async { + try { + await _channel.invokeMethod('endBackgroundExecution'); + } catch (_) { + // Best-effort bridge. Missing plugin / unsupported platform is fine. + } + } +} diff --git a/open_wearable/lib/models/app_launch_session.dart b/open_wearable/lib/models/app_launch_session.dart new file mode 100644 index 00000000..ef3f514c --- /dev/null +++ b/open_wearable/lib/models/app_launch_session.dart @@ -0,0 +1,21 @@ +class AppLaunchSession { + static int _openAppFlowCount = 0; + + static bool get hasOpenAppFlow => _openAppFlowCount > 0; + + static void markAppFlowOpened() { + _openAppFlowCount += 1; + } + + static void markAppFlowClosed() { + if (_openAppFlowCount <= 0) { + _openAppFlowCount = 0; + return; + } + _openAppFlowCount -= 1; + } + + static void reset() { + _openAppFlowCount = 0; + } +} diff --git a/open_wearable/lib/models/app_shutdown_settings.dart b/open_wearable/lib/models/app_shutdown_settings.dart new file mode 100644 index 00000000..3e49b527 --- /dev/null +++ b/open_wearable/lib/models/app_shutdown_settings.dart @@ -0,0 +1,102 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AppShutdownSettings { + static const String _shutOffAllSensorsOnAppCloseKey = + 'app_shut_off_all_sensors_on_close'; + static const String _disableLiveDataGraphsKey = + 'app_disable_live_data_graphs'; + static const String _hideLiveDataGraphsWithoutDataKey = + 'app_hide_live_data_graphs_without_data'; + + static final ValueNotifier _shutOffAllSensorsOnAppCloseNotifier = + ValueNotifier(false); + static final ValueNotifier _disableLiveDataGraphsNotifier = + ValueNotifier(false); + static final ValueNotifier _hideLiveDataGraphsWithoutDataNotifier = + ValueNotifier(false); + + static ValueListenable get shutOffAllSensorsOnAppCloseListenable => + _shutOffAllSensorsOnAppCloseNotifier; + static ValueListenable get disableLiveDataGraphsListenable => + _disableLiveDataGraphsNotifier; + static ValueListenable get hideLiveDataGraphsWithoutDataListenable => + _hideLiveDataGraphsWithoutDataNotifier; + + static bool get shutOffAllSensorsOnAppClose => + _shutOffAllSensorsOnAppCloseNotifier.value; + static bool get disableLiveDataGraphs => _disableLiveDataGraphsNotifier.value; + static bool get hideLiveDataGraphsWithoutData => + _hideLiveDataGraphsWithoutDataNotifier.value; + + static Future initialize() async { + await Future.wait([ + loadShutOffAllSensorsOnAppClose(), + loadDisableLiveDataGraphs(), + loadHideLiveDataGraphsWithoutData(), + ]); + } + + static Future loadShutOffAllSensorsOnAppClose() async { + final prefs = await SharedPreferences.getInstance(); + final enabled = prefs.getBool(_shutOffAllSensorsOnAppCloseKey) ?? false; + _setShutOffAllSensorsOnAppClose(enabled); + return enabled; + } + + static Future saveShutOffAllSensorsOnAppClose(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_shutOffAllSensorsOnAppCloseKey, enabled); + _setShutOffAllSensorsOnAppClose(enabled); + return enabled; + } + + static Future loadDisableLiveDataGraphs() async { + final prefs = await SharedPreferences.getInstance(); + final enabled = prefs.getBool(_disableLiveDataGraphsKey) ?? false; + _setDisableLiveDataGraphs(enabled); + return enabled; + } + + static Future saveDisableLiveDataGraphs(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_disableLiveDataGraphsKey, enabled); + _setDisableLiveDataGraphs(enabled); + return enabled; + } + + static Future loadHideLiveDataGraphsWithoutData() async { + final prefs = await SharedPreferences.getInstance(); + final enabled = prefs.getBool(_hideLiveDataGraphsWithoutDataKey) ?? false; + _setHideLiveDataGraphsWithoutData(enabled); + return enabled; + } + + static Future saveHideLiveDataGraphsWithoutData(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_hideLiveDataGraphsWithoutDataKey, enabled); + _setHideLiveDataGraphsWithoutData(enabled); + return enabled; + } + + static void _setShutOffAllSensorsOnAppClose(bool enabled) { + if (_shutOffAllSensorsOnAppCloseNotifier.value == enabled) { + return; + } + _shutOffAllSensorsOnAppCloseNotifier.value = enabled; + } + + static void _setDisableLiveDataGraphs(bool enabled) { + if (_disableLiveDataGraphsNotifier.value == enabled) { + return; + } + _disableLiveDataGraphsNotifier.value = enabled; + } + + static void _setHideLiveDataGraphsWithoutData(bool enabled) { + if (_hideLiveDataGraphsWithoutDataNotifier.value == enabled) { + return; + } + _hideLiveDataGraphsWithoutDataNotifier.value = enabled; + } +} diff --git a/open_wearable/lib/models/auto_connect_preferences.dart b/open_wearable/lib/models/auto_connect_preferences.dart new file mode 100644 index 00000000..e9380935 --- /dev/null +++ b/open_wearable/lib/models/auto_connect_preferences.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AutoConnectPreferences { + static const String connectedDeviceNamesKey = 'connectedDeviceNames'; + static const String autoConnectEnabledKey = 'auto_connect_enabled'; + static final StreamController _changesController = + StreamController.broadcast(); + static final ValueNotifier _autoConnectEnabledNotifier = + ValueNotifier(true); + + static Stream get changes => _changesController.stream; + static ValueListenable get autoConnectEnabledListenable => + _autoConnectEnabledNotifier; + static bool get autoConnectEnabled => _autoConnectEnabledNotifier.value; + + static Future initialize() async { + await loadAutoConnectEnabled(); + } + + static Future loadAutoConnectEnabled() async { + final prefs = await SharedPreferences.getInstance(); + final enabled = prefs.getBool(autoConnectEnabledKey) ?? true; + _setAutoConnectEnabled(enabled); + return enabled; + } + + static Future saveAutoConnectEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + final success = await prefs.setBool(autoConnectEnabledKey, enabled); + if (success) { + _setAutoConnectEnabled(enabled); + _changesController.add(null); + } + return enabled; + } + + static List readRememberedDeviceNames(SharedPreferences prefs) { + final names = + prefs.getStringList(connectedDeviceNamesKey) ?? const []; + final normalizedNames = []; + + for (final name in names) { + final normalizedName = name.trim(); + if (normalizedName.isEmpty) { + continue; + } + normalizedNames.add(normalizedName); + } + + return normalizedNames; + } + + static int countRememberedDeviceName( + SharedPreferences prefs, + String deviceName, + ) { + final normalizedName = deviceName.trim(); + if (normalizedName.isEmpty) { + return 0; + } + final names = readRememberedDeviceNames(prefs); + return names.where((name) => name == normalizedName).length; + } + + static Future rememberDeviceName( + SharedPreferences prefs, + String deviceName, + ) async { + final normalizedName = deviceName.trim(); + if (normalizedName.isEmpty) { + return; + } + + final names = readRememberedDeviceNames(prefs); + + final success = await prefs.setStringList(connectedDeviceNamesKey, [ + ...names, + normalizedName, + ]); + if (success) { + _changesController.add(null); + } + } + + static Future forgetDeviceName( + SharedPreferences prefs, + String deviceName, + ) async { + final normalizedName = deviceName.trim(); + if (normalizedName.isEmpty) { + return; + } + + final names = readRememberedDeviceNames(prefs); + final index = names.indexOf(normalizedName); + if (index < 0) { + return; + } + final updatedNames = [...names]..removeAt(index); + + final success = await prefs.setStringList( + connectedDeviceNamesKey, + updatedNames, + ); + if (success) { + _changesController.add(null); + } + } + + static void _setAutoConnectEnabled(bool enabled) { + if (_autoConnectEnabledNotifier.value == enabled) { + return; + } + _autoConnectEnabledNotifier.value = enabled; + } +} diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart index bc68d6b8..4ae76315 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/bluetooth_auto_connector.dart @@ -3,120 +3,286 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; -import 'package:shared_preferences/shared_preferences.dart'; // New dependency for persistence +import 'package:shared_preferences/shared_preferences.dart'; +import 'auto_connect_preferences.dart'; import 'logger.dart'; -import 'wearable_connector.dart'; - -const String _connectedDeviceNamesKey = "connectedDeviceNames"; class BluetoothAutoConnector { + static const Duration _scanRetryInterval = Duration(seconds: 3); + final NavigatorState? Function() navStateGetter; final WearableManager wearableManager; - final WearableConnector connector; final Future prefsFuture; final void Function(Wearable wearable) onWearableConnected; StreamSubscription? _connectSubscription; StreamSubscription? _scanSubscription; + StreamSubscription? _preferencesSubscription; + Timer? _scanRetryTimer; bool _isConnecting = false; + bool _isAttemptingConnection = false; bool _askedPermissionsThisSession = false; + int _sessionToken = 0; // Names to look for during scanning List _targetNames = []; + Map _targetNameCounts = const {}; + final Set _connectedDeviceIds = {}; + final Map _connectedNameCounts = {}; + final Set _pendingDeviceIds = {}; BluetoothAutoConnector({ required this.navStateGetter, required this.wearableManager, - required this.connector, required this.prefsFuture, required this.onWearableConnected, }); void start() async { - stop(); + final token = ++_sessionToken; + _stopInternal(); + _connectedDeviceIds.clear(); + _connectedNameCounts.clear(); + _pendingDeviceIds.clear(); // Load the last connected names - final prefs = await prefsFuture; - _targetNames = prefs.getStringList(_connectedDeviceNamesKey) ?? []; + await _reloadTargetNames(token: token, reloadPrefs: false); + if (token != _sessionToken) { + return; + } // Start listening for successful connections (to save names and set disconnect logic) _connectSubscription = wearableManager.connectStream.listen(_onDeviceConnected); + _preferencesSubscription = AutoConnectPreferences.changes.listen((_) { + unawaited(_syncTargetsWithPreferences(token: token, restartScan: true)); + }); + _ensureScanRetryLoop(token: token); // Initiate the connection sequence - _attemptConnection(); + _attemptConnection(token: token); } void stop() { + _sessionToken++; + _stopInternal(); + } + + void _stopInternal() { _connectSubscription?.cancel(); _connectSubscription = null; - _scanSubscription?.cancel(); - _scanSubscription = null; - // Stop any ongoing scan initiated by this class - // Use the public WearableManager function to stop the scan - wearableManager.setAutoConnect([]); + _preferencesSubscription?.cancel(); + _preferencesSubscription = null; + _isAttemptingConnection = false; + _isConnecting = false; + _pendingDeviceIds.clear(); + _scanRetryTimer?.cancel(); + _scanRetryTimer = null; + _stopScanning(); + } - // Cancel the local listener to prevent further triggers - _scanSubscription?.cancel(); - _scanSubscription = null; + String _normalizeDeviceId(String id) => id.trim().toUpperCase(); + + Map _buildNameCounts(List names) { + final counts = {}; + for (final name in names) { + counts[name] = (counts[name] ?? 0) + 1; + } + return counts; } - /// Called when the WearableManager successfully connects to a device. - void _onDeviceConnected(Wearable wearable) async { + int _requiredConnectionsForName(String name) => _targetNameCounts[name] ?? 0; + + void _markConnected({ + required String deviceId, + required String deviceName, + }) { + final normalizedId = _normalizeDeviceId(deviceId); + final inserted = _connectedDeviceIds.add(normalizedId); + if (!inserted) { + return; + } + _connectedNameCounts[deviceName] = + (_connectedNameCounts[deviceName] ?? 0) + 1; + } + + void _markDisconnected({ + required String deviceId, + required String deviceName, + }) { + final normalizedId = _normalizeDeviceId(deviceId); + final removed = _connectedDeviceIds.remove(normalizedId); + if (!removed) { + return; + } + final current = _connectedNameCounts[deviceName]; + if (current == null) { + return; + } + if (current <= 1) { + _connectedNameCounts.remove(deviceName); + return; + } + _connectedNameCounts[deviceName] = current - 1; + } + + bool _hasUnconnectedTargets() { + if (_targetNameCounts.isEmpty) { + return false; + } + return _targetNameCounts.entries.any((entry) { + return (_connectedNameCounts[entry.key] ?? 0) < entry.value; + }); + } + + String _deviceErrorMessageSafe(Object error, DiscoveredDevice device) { + try { + return wearableManager.deviceErrorMessage(error, device.name); + } catch (_) { + final fallback = error.toString().trim(); + if (fallback.isEmpty) { + return 'Unknown connection error.'; + } + return fallback; + } + } + + bool _isAlreadyConnectedMessage(String message) => + message.toLowerCase().contains('already connected'); + + Future _reloadTargetNames({ + required int token, + bool reloadPrefs = true, + }) async { final prefs = await prefsFuture; + if (token != _sessionToken) { + return; + } + if (reloadPrefs) { + await prefs.reload(); + if (token != _sessionToken) { + return; + } + } + _targetNames = AutoConnectPreferences.readRememberedDeviceNames(prefs); + _targetNameCounts = _buildNameCounts(_targetNames); + } - List deviceNames = - prefs.getStringList(_connectedDeviceNamesKey) ?? []; - if (!deviceNames.contains(wearable.name)) { - deviceNames.add(wearable.name); - await prefs.setStringList(_connectedDeviceNamesKey, deviceNames); + Future _syncTargetsWithPreferences({ + required int token, + bool restartScan = false, + }) async { + await _reloadTargetNames(token: token); + if (token != _sessionToken) { + return; } - // Stop scanning immediately when a successful connection is made - if (_scanSubscription != null) { - // stop scan - wearableManager.setAutoConnect([]); + if (_targetNames.isEmpty || !_hasUnconnectedTargets()) { + _stopScanning(); + return; + } - _scanSubscription?.cancel(); - _scanSubscription = null; + if (restartScan) { + await _restartScanIfNeeded(); + } + } - _scanSubscription?.cancel(); - _scanSubscription = null; + void _ensureScanRetryLoop({required int token}) { + _scanRetryTimer?.cancel(); + _scanRetryTimer = Timer.periodic(_scanRetryInterval, (timer) { + if (token != _sessionToken) { + timer.cancel(); + return; + } + if (_isAttemptingConnection || _isConnecting) { + return; + } + unawaited(_syncTargetsWithPreferences(token: token, restartScan: true)); + }); + } + + /// Called when the WearableManager successfully connects to a device. + void _onDeviceConnected(Wearable wearable) async { + final token = _sessionToken; + _markConnected(deviceId: wearable.deviceId, deviceName: wearable.name); + + final prefs = await prefsFuture; + if (token != _sessionToken) { + return; + } + final rememberedCount = AutoConnectPreferences.countRememberedDeviceName( + prefs, + wearable.name, + ); + final connectedCount = _connectedNameCounts[wearable.name] ?? 0; + if (connectedCount > rememberedCount) { + await AutoConnectPreferences.rememberDeviceName(prefs, wearable.name); } + // Stop scanning immediately when a successful connection is made + _stopScanning(); + // Set up the disconnect listener to trigger a scan for the saved name. - wearable.addDisconnectListener(() { + wearable.addDisconnectListener(() async { + if (token != _sessionToken) { + return; + } logger.i( "Device ${wearable.name} disconnected. Initiating reconnection scan.", ); + _markDisconnected(deviceId: wearable.deviceId, deviceName: wearable.name); - prefs.reload(); - _targetNames = prefs.getStringList(_connectedDeviceNamesKey) ?? []; + await _syncTargetsWithPreferences(token: token); - _attemptConnection(); + if (_hasUnconnectedTargets()) { + _attemptConnection(); + } }); } - Future _attemptConnection() async { + Future _attemptConnection({int? token}) async { + final activeToken = token ?? _sessionToken; + if (activeToken != _sessionToken) { + return; + } + if (_isAttemptingConnection) { + return; + } + + _isAttemptingConnection = true; if (!Platform.isIOS) { final hasPerm = await wearableManager.hasPermissions(); + if (activeToken != _sessionToken) { + _isAttemptingConnection = false; + return; + } if (!hasPerm) { if (!_askedPermissionsThisSession) { _askedPermissionsThisSession = true; _showPermissionsDialog(); } logger.w('Skipping auto-connect: no permissions granted yet.'); + _isAttemptingConnection = false; return; } } - await connector.connectToSystemDevices(); + try { + await _syncTargetsWithPreferences(token: activeToken); + if (activeToken != _sessionToken) { + return; + } - if (_targetNames.isNotEmpty) { - _setupScanListener(); - await wearableManager.startScan(); + if (_targetNames.isNotEmpty && _hasUnconnectedTargets()) { + _setupScanListener(); + await wearableManager.startScan(); + } + } catch (error, stackTrace) { + logger.w('Auto-connect attempt failed: $error\n$stackTrace'); + } finally { + _isAttemptingConnection = false; } } @@ -126,31 +292,77 @@ class BluetoothAutoConnector { _scanSubscription = wearableManager.scanStream.listen((device) { if (_isConnecting) return; - if (_targetNames.contains(device.name)) { - _isConnecting = true; - // stop scan - wearableManager.setAutoConnect([]); - _scanSubscription?.cancel(); - _scanSubscription = null; + final normalizedId = _normalizeDeviceId(device.id); + if (_pendingDeviceIds.contains(normalizedId) || + _connectedDeviceIds.contains(normalizedId)) { + return; + } + final requiredConnections = _requiredConnectionsForName(device.name); + if (requiredConnections == 0) { + return; + } + if ((_connectedNameCounts[device.name] ?? 0) >= requiredConnections) { + return; + } - logger.i( - "Match found for ${device.name}. Connecting using rotating ID: ${device.id}", - ); + _isConnecting = true; + _pendingDeviceIds.add(normalizedId); + _stopScanning(); + + logger.i( + "Match found for ${device.name}. Connecting using rotating ID: ${device.id}", + ); - wearableManager - .connectToDevice(device) - .then(onWearableConnected) - .catchError((e) { - logger.e( - "Failed to connect to ${device.id}: ${wearableManager.deviceErrorMessage(e, device.name)}", + wearableManager.connectToDevice(device).then((wearable) { + _markConnected( + deviceId: wearable.deviceId, + deviceName: wearable.name, + ); + onWearableConnected(wearable); + }).catchError((error, stackTrace) { + final message = _deviceErrorMessageSafe(error, device); + if (_isAlreadyConnectedMessage(message)) { + _markConnected(deviceId: device.id, deviceName: device.name); + logger.i( + 'Skipping auto-connect for ${device.id}: $message', ); - }).whenComplete(() { - _isConnecting = false; - }); - } + return; + } + logger.w( + 'Failed to connect to ${device.id}: $message\n$stackTrace', + ); + }).whenComplete(() { + _pendingDeviceIds.remove(normalizedId); + _isConnecting = false; + unawaited(_restartScanIfNeeded()); + }); }); } + Future _restartScanIfNeeded() async { + if (_isConnecting || _isAttemptingConnection) { + return; + } + if (_scanSubscription != null) { + return; + } + if (!_hasUnconnectedTargets()) { + return; + } + try { + _setupScanListener(); + await wearableManager.startScan(); + } catch (error, stackTrace) { + logger.w('Failed to restart auto-connect scan: $error\n$stackTrace'); + _stopScanning(); + } + } + + void _stopScanning() { + _scanSubscription?.cancel(); + _scanSubscription = null; + } + void _showPermissionsDialog() { final nav = navStateGetter(); final navCtx = nav?.context; diff --git a/open_wearable/lib/models/connector_settings.dart b/open_wearable/lib/models/connector_settings.dart new file mode 100644 index 00000000..cf66a1dd --- /dev/null +++ b/open_wearable/lib/models/connector_settings.dart @@ -0,0 +1,261 @@ +// ignore_for_file: cancel_subscriptions + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class UdpBridgeConnectorSettings { + final bool enabled; + final String host; + final int port; + final String streamPrefix; + + const UdpBridgeConnectorSettings({ + required this.enabled, + required this.host, + required this.port, + required this.streamPrefix, + }); + + const UdpBridgeConnectorSettings.defaults() + : enabled = false, + host = '', + port = defaultUdpBridgePort, + streamPrefix = defaultUdpBridgeStreamPrefix; + + bool get isConfigured => host.trim().isNotEmpty; + + UdpBridgeConnectorSettings copyWith({ + bool? enabled, + String? host, + int? port, + String? streamPrefix, + }) { + return UdpBridgeConnectorSettings( + enabled: enabled ?? this.enabled, + host: host ?? this.host, + port: port ?? this.port, + streamPrefix: streamPrefix ?? this.streamPrefix, + ); + } +} + +class ConnectorSettings { + // Keep persisted keys stable to preserve existing user settings. + static const String _udpBridgeEnabledKey = 'connector_lsl_enabled'; + static const String _udpBridgeHostKey = 'connector_lsl_host'; + static const String _udpBridgePortKey = 'connector_lsl_port'; + static const String _udpBridgeStreamPrefixKey = 'connector_lsl_stream_prefix'; + + static final UdpBridgeForwarder _udpBridgeForwarder = + UdpBridgeForwarder.instance; + static final ValueNotifier + _udpBridgeSettingsNotifier = ValueNotifier( + const UdpBridgeConnectorSettings.defaults(), + ); + static final ValueNotifier + _udpBridgeConnectionStateNotifier = + ValueNotifier( + SensorForwarderConnectionState.active, + ); + static StreamSubscription? + _udpBridgeConnectionStateSubscription; + static const Duration _udpBridgeRecoveryStabilization = Duration(seconds: 3); + static DateTime? _lastUdpBridgeUnreachableAt; + static Timer? _udpBridgeRecoveryTimer; + + static ValueListenable + get udpBridgeSettingsListenable => _udpBridgeSettingsNotifier; + static ValueListenable + get udpBridgeConnectionStateListenable => + _udpBridgeConnectionStateNotifier; + + static bool get isUdpBridgeActive { + final settings = _udpBridgeSettingsNotifier.value; + return settings.enabled && settings.isConfigured; + } + + static UdpBridgeConnectorSettings get currentUdpBridgeSettings => + _udpBridgeSettingsNotifier.value; + + static Future initialize() async { + _ensureUdpBridgeConnectionStateSubscription(); + final settings = await loadUdpBridgeSettings(); + _setUdpBridgeSettings(settings); + _ensureUdpBridgeForwarderRegistered(); + applyUdpBridgeSettings(settings); + _setUdpBridgeConnectionState(_udpBridgeForwarder.connectionState); + } + + static void dispose() { + _resetUdpBridgeConnectionStateStabilization(); + final subscription = _udpBridgeConnectionStateSubscription; + _udpBridgeConnectionStateSubscription = null; + if (subscription != null) { + unawaited(subscription.cancel()); + } + } + + static Future loadUdpBridgeSettings() async { + final prefs = await SharedPreferences.getInstance(); + + final rawSettings = UdpBridgeConnectorSettings( + enabled: prefs.getBool(_udpBridgeEnabledKey) ?? false, + host: prefs.getString(_udpBridgeHostKey) ?? '', + port: prefs.getInt(_udpBridgePortKey) ?? defaultUdpBridgePort, + streamPrefix: prefs.getString(_udpBridgeStreamPrefixKey) ?? + defaultUdpBridgeStreamPrefix, + ); + + final normalized = _normalizeUdpBridgeSettings(rawSettings); + _setUdpBridgeSettings(normalized); + return normalized; + } + + static Future saveUdpBridgeSettings( + UdpBridgeConnectorSettings settings, + ) async { + final normalized = _normalizeUdpBridgeSettings(settings); + final prefs = await SharedPreferences.getInstance(); + + await prefs.setBool(_udpBridgeEnabledKey, normalized.enabled); + await prefs.setString(_udpBridgeHostKey, normalized.host); + await prefs.setInt(_udpBridgePortKey, normalized.port); + await prefs.setString(_udpBridgeStreamPrefixKey, normalized.streamPrefix); + + _setUdpBridgeSettings(normalized); + _ensureUdpBridgeForwarderRegistered(); + applyUdpBridgeSettings(normalized); + + return normalized; + } + + static void applyUdpBridgeSettings(UdpBridgeConnectorSettings settings) { + final normalized = _normalizeUdpBridgeSettings(settings); + _setUdpBridgeSettings(normalized); + _ensureUdpBridgeConnectionStateSubscription(); + _resetUdpBridgeConnectionStateStabilization(); + + if (!normalized.isConfigured || !normalized.enabled) { + _udpBridgeForwarder.reset(); + _setUdpBridgeConnectionState(_udpBridgeForwarder.connectionState); + return; + } + + _udpBridgeForwarder.configure( + host: normalized.host, + port: normalized.port, + enabled: normalized.enabled, + streamPrefix: normalized.streamPrefix, + ); + _setUdpBridgeConnectionState(_udpBridgeForwarder.connectionState); + } + + static void _ensureUdpBridgeForwarderRegistered() { + final manager = WearableManager(); + final alreadyRegistered = manager.sensorForwarders.any( + (forwarder) => identical(forwarder, _udpBridgeForwarder), + ); + if (!alreadyRegistered) { + manager.addSensorForwarder(_udpBridgeForwarder); + } + } + + static UdpBridgeConnectorSettings _normalizeUdpBridgeSettings( + UdpBridgeConnectorSettings settings, + ) { + final normalizedHost = settings.host.trim(); + final normalizedPort = (settings.port > 0 && settings.port <= 65535) + ? settings.port + : defaultUdpBridgePort; + final normalizedPrefix = settings.streamPrefix.trim().isEmpty + ? defaultUdpBridgeStreamPrefix + : settings.streamPrefix.trim(); + final normalizedEnabled = normalizedHost.isNotEmpty && settings.enabled; + + return settings.copyWith( + enabled: normalizedEnabled, + host: normalizedHost, + port: normalizedPort, + streamPrefix: normalizedPrefix, + ); + } + + static void _setUdpBridgeSettings(UdpBridgeConnectorSettings settings) { + _udpBridgeSettingsNotifier.value = settings; + } + + static void _ensureUdpBridgeConnectionStateSubscription() { + if (_udpBridgeConnectionStateSubscription != null) { + return; + } + _udpBridgeConnectionStateSubscription = _udpBridgeForwarder + .connectionStateStream + .listen(_setUdpBridgeConnectionState); + } + + static void _setUdpBridgeConnectionState( + SensorForwarderConnectionState state, + ) { + if (state == SensorForwarderConnectionState.unreachable) { + _lastUdpBridgeUnreachableAt = DateTime.now(); + _udpBridgeRecoveryTimer?.cancel(); + _udpBridgeRecoveryTimer = null; + _emitUdpBridgeConnectionState(state); + return; + } + + final lastUnreachableAt = _lastUdpBridgeUnreachableAt; + if (lastUnreachableAt == null) { + _emitUdpBridgeConnectionState(state); + return; + } + + final elapsedSinceLastFailure = + DateTime.now().difference(lastUnreachableAt); + final remainingRecoveryWindow = + _udpBridgeRecoveryStabilization - elapsedSinceLastFailure; + if (remainingRecoveryWindow <= Duration.zero) { + _lastUdpBridgeUnreachableAt = null; + _udpBridgeRecoveryTimer?.cancel(); + _udpBridgeRecoveryTimer = null; + _emitUdpBridgeConnectionState(state); + return; + } + + _udpBridgeRecoveryTimer?.cancel(); + _udpBridgeRecoveryTimer = Timer(remainingRecoveryWindow, () { + _udpBridgeRecoveryTimer = null; + + final latestFailureAt = _lastUdpBridgeUnreachableAt; + if (latestFailureAt == null) { + return; + } + + final recoveredFor = DateTime.now().difference(latestFailureAt); + if (recoveredFor < _udpBridgeRecoveryStabilization) { + return; + } + + _lastUdpBridgeUnreachableAt = null; + _emitUdpBridgeConnectionState(SensorForwarderConnectionState.active); + }); + } + + static void _emitUdpBridgeConnectionState( + SensorForwarderConnectionState state, + ) { + if (_udpBridgeConnectionStateNotifier.value == state) { + return; + } + _udpBridgeConnectionStateNotifier.value = state; + } + + static void _resetUdpBridgeConnectionStateStabilization() { + _lastUdpBridgeUnreachableAt = null; + _udpBridgeRecoveryTimer?.cancel(); + _udpBridgeRecoveryTimer = null; + } +} diff --git a/open_wearable/lib/models/device_name_formatter.dart b/open_wearable/lib/models/device_name_formatter.dart new file mode 100644 index 00000000..eb6cba36 --- /dev/null +++ b/open_wearable/lib/models/device_name_formatter.dart @@ -0,0 +1,26 @@ +String formatWearableDisplayName(String rawName) { + final trimmed = rawName.trim(); + if (trimmed.isEmpty) { + return trimmed; + } + + final replaced = trimmed.replaceFirst( + RegExp(r'^bcl[-_\s]*', caseSensitive: false), + 'OpenRing-', + ); + + if (replaced == 'OpenRing-') { + return 'OpenRing'; + } + + return replaced; +} + +String? formatWearableDisplayNameOrNull(String? rawName) { + final trimmed = rawName?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + return formatWearableDisplayName(trimmed); +} diff --git a/open_wearable/lib/models/fota_post_update_verification.dart b/open_wearable/lib/models/fota_post_update_verification.dart new file mode 100644 index 00000000..ac7c15f7 --- /dev/null +++ b/open_wearable/lib/models/fota_post_update_verification.dart @@ -0,0 +1,553 @@ +import 'dart:async'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; + +class ArmedFotaPostUpdateVerification { + final String verificationId; + final String wearableName; + final String? sideLabel; + + const ArmedFotaPostUpdateVerification({ + required this.verificationId, + required this.wearableName, + this.sideLabel, + }); +} + +class FotaPostUpdateVerificationResult { + final String verificationId; + final String wearableName; + final String? sideLabel; + final String? expectedFirmwareVersion; + final String? detectedFirmwareVersion; + final bool success; + final String message; + + const FotaPostUpdateVerificationResult({ + required this.verificationId, + required this.wearableName, + required this.sideLabel, + required this.expectedFirmwareVersion, + required this.detectedFirmwareVersion, + required this.success, + required this.message, + }); +} + +class FotaPostUpdateVerificationCoordinator { + FotaPostUpdateVerificationCoordinator._(); + + static final FotaPostUpdateVerificationCoordinator instance = + FotaPostUpdateVerificationCoordinator._(); + + static const Duration _maxPendingAge = Duration(minutes: 20); + + final Map _pendingById = {}; + int _nextVerificationId = 0; + + Future armFromUpdateRequest({ + required FirmwareUpdateRequest request, + Wearable? selectedWearable, + String? preResolvedWearableName, + String? preResolvedSideLabel, + }) async { + _cleanupExpired(); + + final rawName = selectedWearable?.name ?? + request.peripheral?.name ?? + preResolvedWearableName; + final displayName = _displayName(preResolvedWearableName ?? rawName); + final expectedName = _normalizeName( + rawName, + ); + final expectedDeviceId = _normalizeId( + selectedWearable?.deviceId ?? request.peripheral?.identifier, + ); + final expectedSideLabel = _normalizeSideLabel( + preResolvedSideLabel ?? + await _resolveWearableSideLabel(selectedWearable) ?? + _resolveSideLabelFromName(selectedWearable?.name) ?? + _resolveSideLabelFromName(request.peripheral?.name), + ); + final expectedFirmwareVersion = + _extractExpectedFirmwareVersion(request.firmware); + + if (expectedName == null && expectedDeviceId == null) { + return null; + } + + final verificationId = + 'fota_${DateTime.now().millisecondsSinceEpoch}_${_nextVerificationId++}'; + + _removeConflictingPending( + expectedDeviceId: expectedDeviceId, + expectedName: expectedName, + expectedSideLabel: expectedSideLabel, + ); + + _pendingById[verificationId] = _PendingPostUpdateVerification( + verificationId: verificationId, + expectedWearableName: expectedName, + displayWearableName: displayName, + expectedDeviceId: expectedDeviceId, + expectedSideLabel: expectedSideLabel, + expectedFirmwareVersion: expectedFirmwareVersion, + armedAt: DateTime.now(), + ); + + return ArmedFotaPostUpdateVerification( + verificationId: verificationId, + wearableName: displayName ?? 'OpenEarable', + sideLabel: expectedSideLabel, + ); + } + + Future verifyOnWearableConnected( + Wearable wearable, + ) async { + _cleanupExpired(); + if (_pendingById.isEmpty) { + return null; + } + + final connectedName = _normalizeName(wearable.name); + final connectedDeviceId = _normalizeId(wearable.deviceId); + final connectedSideLabel = _normalizeSideLabel( + await _resolveWearableSideLabel(wearable) ?? + _resolveSideLabelFromName(wearable.name), + ); + + final pending = _selectMatchingPending( + connectedName: connectedName, + connectedDeviceId: connectedDeviceId, + connectedSideLabel: connectedSideLabel, + ); + + if (pending == null) { + return null; + } + + final detectedFirmwareVersion = + await _readNormalizedFirmwareVersion(wearable); + final expectedFirmwareVersion = pending.expectedFirmwareVersion; + + final success = expectedFirmwareVersion != null && + detectedFirmwareVersion != null && + _firmwareVersionsMatch( + expectedFirmwareVersion, + detectedFirmwareVersion, + ); + + _pendingById.remove(pending.verificationId); + + final displayName = pending.displayWearableName ?? + _displayName(wearable.name) ?? + wearable.name; + final sideLabel = pending.expectedSideLabel ?? connectedSideLabel; + + return FotaPostUpdateVerificationResult( + verificationId: pending.verificationId, + wearableName: displayName, + sideLabel: sideLabel, + expectedFirmwareVersion: expectedFirmwareVersion, + detectedFirmwareVersion: detectedFirmwareVersion, + success: success, + message: _buildMessage( + success: success, + wearableName: displayName, + sideLabel: sideLabel, + expectedFirmwareVersion: expectedFirmwareVersion, + detectedFirmwareVersion: detectedFirmwareVersion, + ), + ); + } + + _PendingPostUpdateVerification? _selectMatchingPending({ + required String? connectedName, + required String? connectedDeviceId, + required String? connectedSideLabel, + }) { + _PendingPostUpdateVerification? best; + var bestScore = -1; + + for (final pending in _pendingById.values) { + final score = _matchScore( + pending: pending, + connectedName: connectedName, + connectedDeviceId: connectedDeviceId, + connectedSideLabel: connectedSideLabel, + ); + if (score < 0) { + continue; + } + if (score > bestScore) { + best = pending; + bestScore = score; + continue; + } + if (score == bestScore && best != null) { + // Ambiguous match: do not dismiss any verification banner. + best = null; + } + } + + return best; + } + + int _matchScore({ + required _PendingPostUpdateVerification pending, + required String? connectedName, + required String? connectedDeviceId, + required String? connectedSideLabel, + }) { + final expectedId = pending.expectedDeviceId; + final expectedName = pending.expectedWearableName; + final expectedSide = pending.expectedSideLabel; + + final exactIdMatch = expectedId != null && + connectedDeviceId != null && + expectedId == connectedDeviceId; + + if (expectedId != null && !exactIdMatch) { + // With known ids, allow a fallback by name for non-stereo devices. + // Stereo devices additionally require a side match. + final nameMatch = _namesMatch(expectedName, connectedName); + final sideMatch = expectedSide != null && + connectedSideLabel != null && + expectedSide == connectedSideLabel; + final connectedAppearsStereo = connectedSideLabel != null; + if (!nameMatch) { + return -1; + } + if (expectedSide != null && !sideMatch) { + return -1; + } + if (expectedSide == null && connectedAppearsStereo) { + return -1; + } + } + + if (!exactIdMatch && !_namesMatch(expectedName, connectedName)) { + return -1; + } + + if (!exactIdMatch && expectedSide != null) { + if (connectedSideLabel == null || connectedSideLabel != expectedSide) { + return -1; + } + } + + var score = 0; + if (exactIdMatch) { + score += 100; + } + if (expectedName != null && + connectedName != null && + expectedName == connectedName) { + score += 20; + } + if (expectedSide != null && + connectedSideLabel != null && + expectedSide == connectedSideLabel) { + score += 30; + } + if (expectedId == null && expectedName != null && expectedSide == null) { + score += 5; + } + + return score; + } + + bool _namesMatch(String? expected, String? connected) { + if (expected == null) { + return true; + } + if (connected == null) { + return false; + } + return expected == connected; + } + + Future _readNormalizedFirmwareVersion(Wearable wearable) async { + DeviceFirmwareVersion? firmwareCap = + wearable.getCapability(); + + if (firmwareCap == null) { + try { + await wearable + .capabilityAvailable() + .first + .timeout(const Duration(seconds: 3)); + } catch (_) { + // Ignore timeout/errors and check capability below. + } + firmwareCap = wearable.getCapability(); + } + + if (firmwareCap == null) { + return null; + } + + for (var attempt = 0; attempt < 3; attempt++) { + try { + final version = await firmwareCap + .readDeviceFirmwareVersion() + .timeout(const Duration(seconds: 4)); + final normalized = _normalizeVersion(version); + if (normalized != null) { + return normalized; + } + } catch (_) { + // Retry below. + } + + if (attempt < 2) { + await Future.delayed(const Duration(milliseconds: 600)); + } + } + + return null; + } + + Future _resolveWearableSideLabel(Wearable? wearable) async { + if (wearable == null) { + return null; + } + + final fallback = _resolveSideLabelFromName(wearable.name); + + StereoDevice? stereoDevice = wearable.getCapability(); + if (stereoDevice == null) { + for (var attempt = 0; attempt < 4 && stereoDevice == null; attempt++) { + try { + await wearable + .capabilityAvailable() + .first + .timeout(const Duration(seconds: 1)); + } catch (_) { + // Ignore timeout/errors and retry below. + } + stereoDevice = wearable.getCapability(); + if (stereoDevice == null && attempt < 3) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + } + + if (stereoDevice == null) { + return fallback; + } + + for (var attempt = 0; attempt < 3; attempt++) { + try { + final position = + await stereoDevice.position.timeout(const Duration(seconds: 2)); + return switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => fallback, + }; + } catch (_) { + if (attempt < 2) { + await Future.delayed(const Duration(milliseconds: 350)); + } + } + } + + return fallback; + } + + void _cleanupExpired() { + if (_pendingById.isEmpty) { + return; + } + + final now = DateTime.now(); + _pendingById.removeWhere( + (_, pending) => now.difference(pending.armedAt) > _maxPendingAge, + ); + } + + void _removeConflictingPending({ + required String? expectedDeviceId, + required String? expectedName, + required String? expectedSideLabel, + }) { + if (_pendingById.isEmpty) { + return; + } + + _pendingById.removeWhere((_, pending) { + if (expectedDeviceId != null && + pending.expectedDeviceId == expectedDeviceId) { + return true; + } + + final sameName = pending.expectedWearableName == expectedName; + final sameSide = pending.expectedSideLabel == expectedSideLabel; + if (expectedDeviceId == null && + expectedName != null && + sameName && + sameSide) { + return true; + } + + return false; + }); + } + + String? _extractExpectedFirmwareVersion(SelectedFirmware? firmware) { + if (firmware is RemoteFirmware) { + return _normalizeVersion(firmware.version); + } + + if (firmware is LocalFirmware) { + final match = + RegExp(r'(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)').firstMatch(firmware.name); + return _normalizeVersion(match?.group(1)); + } + + return null; + } + + bool _firmwareVersionsMatch(String expected, String actual) { + if (actual == expected) { + return true; + } + return actual.contains(expected) || expected.contains(actual); + } + + String _buildMessage({ + required bool success, + required String wearableName, + required String? sideLabel, + required String? expectedFirmwareVersion, + required String? detectedFirmwareVersion, + }) { + final sideSuffix = sideLabel == null ? '' : ' ($sideLabel)'; + + if (success) { + final versionSuffix = detectedFirmwareVersion == null + ? '' + : ' (version $detectedFirmwareVersion)'; + return 'Verification completed for $wearableName$sideSuffix. ' + 'Update verified$versionSuffix.'; + } + + if (detectedFirmwareVersion == null) { + return 'Verification failed for $wearableName$sideSuffix. ' + 'Firmware version could not be read.'; + } + + if (expectedFirmwareVersion == null) { + return 'Verification failed for $wearableName$sideSuffix. ' + 'Expected firmware version is unknown (detected $detectedFirmwareVersion).'; + } + + return 'Verification failed for $wearableName$sideSuffix. ' + 'Expected $expectedFirmwareVersion but detected $detectedFirmwareVersion.'; + } + + String? _normalizeName(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + final normalized = trimmed + .toLowerCase() + .replaceFirst(RegExp(r'\s*\((left|right|l|r)\)$'), '') + .replaceFirst(RegExp(r'[\s_-]+(left|right|l|r)$'), '') + .trim(); + if (normalized.isEmpty) { + return trimmed.toLowerCase(); + } + return normalized; + } + + String? _displayName(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + return formatWearableDisplayName(trimmed); + } + + String? _normalizeId(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + return trimmed.toLowerCase(); + } + + String? _normalizeVersion(String? value) { + final cleaned = value?.replaceAll('\x00', '').trim(); + if (cleaned == null || cleaned.isEmpty) { + return null; + } + return cleaned; + } + + String? _normalizeSideLabel(String? sideLabel) { + if (sideLabel == null || sideLabel.isEmpty) { + return null; + } + final upper = sideLabel.toUpperCase(); + if (upper == 'L' || upper == 'LEFT') { + return 'L'; + } + if (upper == 'R' || upper == 'RIGHT') { + return 'R'; + } + return null; + } + + String? _resolveSideLabelFromName(String? deviceName) { + final value = deviceName?.trim().toLowerCase(); + if (value == null || value.isEmpty) { + return null; + } + + if (value.endsWith('-l') || + value.endsWith('_l') || + value.endsWith('(l)') || + value.endsWith('(left)') || + value.endsWith(' left') || + value.endsWith(' l')) { + return 'L'; + } + + if (value.endsWith('-r') || + value.endsWith('_r') || + value.endsWith('(r)') || + value.endsWith('(right)') || + value.endsWith(' right') || + value.endsWith(' r')) { + return 'R'; + } + + return null; + } +} + +class _PendingPostUpdateVerification { + final String verificationId; + final String? expectedWearableName; + final String? displayWearableName; + final String? expectedDeviceId; + final String? expectedSideLabel; + final String? expectedFirmwareVersion; + final DateTime armedAt; + + const _PendingPostUpdateVerification({ + required this.verificationId, + required this.expectedWearableName, + required this.displayWearableName, + required this.expectedDeviceId, + required this.expectedSideLabel, + required this.expectedFirmwareVersion, + required this.armedAt, + }); +} diff --git a/open_wearable/lib/models/sensor_streams.dart b/open_wearable/lib/models/sensor_streams.dart new file mode 100644 index 00000000..68235fd7 --- /dev/null +++ b/open_wearable/lib/models/sensor_streams.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +/// Shared sensor streams to avoid multiple direct subscriptions to +/// single-subscription sensor streams. +class SensorStreams { + SensorStreams._(); + + static final Map> _sharedStreams = {}; + + static Stream shared(Sensor sensor) { + return _sharedStreams.putIfAbsent( + sensor, + () => sensor.sensorStream.asBroadcastStream(), + ); + } + + static void clearForSensor(Sensor sensor) { + _sharedStreams.remove(sensor); + } +} diff --git a/open_wearable/lib/models/wearable_display_group.dart b/open_wearable/lib/models/wearable_display_group.dart new file mode 100644 index 00000000..353082d0 --- /dev/null +++ b/open_wearable/lib/models/wearable_display_group.dart @@ -0,0 +1,509 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; + +class WearableDisplayGroup { + final Wearable primary; + final Wearable? secondary; + final Wearable? pairCandidate; + final DevicePosition? primaryPosition; + final DevicePosition? secondaryPosition; + final String displayName; + final String? stereoPairKey; + + const WearableDisplayGroup._({ + required this.primary, + required this.secondary, + required this.pairCandidate, + required this.primaryPosition, + required this.secondaryPosition, + required this.displayName, + required this.stereoPairKey, + }); + + factory WearableDisplayGroup.single({ + required Wearable wearable, + DevicePosition? position, + Wearable? pairCandidate, + String? stereoPairKey, + }) { + return WearableDisplayGroup._( + primary: wearable, + secondary: null, + pairCandidate: pairCandidate, + primaryPosition: position, + secondaryPosition: null, + displayName: formatWearableDisplayName(wearable.name), + stereoPairKey: stereoPairKey, + ); + } + + factory WearableDisplayGroup.combined({ + required Wearable left, + required Wearable right, + required String displayName, + String? stereoPairKey, + }) { + return WearableDisplayGroup._( + primary: left, + secondary: right, + pairCandidate: null, + primaryPosition: DevicePosition.left, + secondaryPosition: DevicePosition.right, + displayName: displayName, + stereoPairKey: stereoPairKey ?? stereoPairKeyForDevices(left, right), + ); + } + + bool get isCombined => secondary != null; + + Wearable get representative => primary; + + Wearable? get leftDevice { + if (!isCombined) { + return primaryPosition == DevicePosition.left ? primary : null; + } + return primaryPosition == DevicePosition.left ? primary : secondary; + } + + Wearable? get rightDevice { + if (!isCombined) { + return primaryPosition == DevicePosition.right ? primary : null; + } + return primaryPosition == DevicePosition.right ? primary : secondary; + } + + String get identifiersLabel { + if (!isCombined) { + return primary.deviceId; + } + + final leftId = leftDevice?.deviceId ?? primary.deviceId; + final rightId = rightDevice?.deviceId ?? secondary!.deviceId; + return '$leftId / $rightId'; + } + + List get members => isCombined ? [primary, secondary!] : [primary]; + + static String stereoPairKeyForDevices(Wearable a, Wearable b) { + return stereoPairKeyForIds(a.deviceId, b.deviceId); + } + + static String stereoPairKeyForIds(String aDeviceId, String bDeviceId) { + final ids = [ + Uri.encodeComponent(aDeviceId), + Uri.encodeComponent(bDeviceId), + ]..sort(); + return '${ids.first}|${ids.last}'; + } + + static bool stereoPairKeyContainsDevice(String key, String deviceId) { + final encoded = Uri.encodeComponent(deviceId); + final parts = key.split('|'); + return parts.contains(encoded); + } +} + +/// Orders groups for device-overview style screens. +/// +/// Priority: +/// 1. Combined stereo pairs +/// 2. Left singles +/// 3. Right singles +/// 4. Unknown-position singles +/// Within 1-3, names are sorted alphabetically; ties keep original order. +List orderWearableGroupsForOverview( + List groups, +) { + final indexed = groups.asMap().entries.toList(); + + int rank(WearableDisplayGroup group) { + if (group.isCombined) { + return 0; + } + if (group.primaryPosition == DevicePosition.left) { + return 1; + } + if (group.primaryPosition == DevicePosition.right) { + return 2; + } + return 3; + } + + indexed.sort((a, b) { + final rankA = rank(a.value); + final rankB = rank(b.value); + if (rankA != rankB) { + return rankA.compareTo(rankB); + } + + if (rankA <= 2) { + final byName = a.value.displayName + .toLowerCase() + .compareTo(b.value.displayName.toLowerCase()); + if (byName != 0) { + return byName; + } + } + + return a.key.compareTo(b.key); + }); + + return indexed.map((entry) => entry.value).toList(growable: false); +} + +/// Keeps original order by default, but ensures left/right singles with the +/// same base name appear in deterministic side order. +List orderWearableGroupsByNameAndSide( + List groups, +) { + final indexed = groups.asMap().entries.toList(); + + indexed.sort((a, b) { + final groupA = a.value; + final groupB = b.value; + final sameName = + groupA.displayName.toLowerCase() == groupB.displayName.toLowerCase(); + final bothSingle = !groupA.isCombined && !groupB.isCombined; + if (sameName && bothSingle) { + final sideOrderA = _positionSortRank(groupA.primaryPosition); + final sideOrderB = _positionSortRank(groupB.primaryPosition); + final knownSides = sideOrderA <= 1 && sideOrderB <= 1; + if (knownSides && sideOrderA != sideOrderB) { + return sideOrderA.compareTo(sideOrderB); + } + } + + return a.key.compareTo(b.key); + }); + + return indexed.map((entry) => entry.value).toList(growable: false); +} + +int _positionSortRank(DevicePosition? position) { + if (position == DevicePosition.left) { + return 0; + } + if (position == DevicePosition.right) { + return 1; + } + return 2; +} + +class _StereoMetadata { + final Wearable wearable; + final DevicePosition? position; + final String? pairedDeviceId; + final String? firmwareVersion; + final bool hasFirmwareCapability; + + const _StereoMetadata({ + required this.wearable, + required this.position, + required this.pairedDeviceId, + required this.firmwareVersion, + required this.hasFirmwareCapability, + }); +} + +Future> buildWearableDisplayGroups( + List wearables, { + required bool Function(Wearable left, Wearable right) shouldCombinePair, +}) async { + if (wearables.isEmpty) { + return const []; + } + + final metadataById = await _buildStereoMetadata(wearables); + + final wearablesById = { + for (final wearable in wearables) wearable.deviceId: wearable, + }; + final used = {}; + final groups = []; + + for (final wearable in wearables) { + if (used.contains(wearable.deviceId)) { + continue; + } + + final metadata = metadataById[wearable.deviceId]; + if (metadata == null) { + used.add(wearable.deviceId); + groups.add(WearableDisplayGroup.single(wearable: wearable)); + continue; + } + + final partner = _findPartner( + current: metadata, + wearablesById: wearablesById, + metadataById: metadataById, + wearablesInOrder: wearables, + used: used, + ); + + if (partner != null) { + final left = metadata.position == DevicePosition.left + ? metadata.wearable + : partner.wearable; + final right = metadata.position == DevicePosition.right + ? metadata.wearable + : partner.wearable; + final pairKey = WearableDisplayGroup.stereoPairKeyForDevices(left, right); + final combine = shouldCombinePair(left, right); + + used.add(metadata.wearable.deviceId); + used.add(partner.wearable.deviceId); + + if (combine) { + groups.add( + WearableDisplayGroup.combined( + left: left, + right: right, + displayName: formatWearableDisplayName( + _combinedDisplayName(left.name, right.name), + ), + stereoPairKey: pairKey, + ), + ); + } else { + groups.add( + WearableDisplayGroup.single( + wearable: left, + position: DevicePosition.left, + pairCandidate: right, + stereoPairKey: pairKey, + ), + ); + groups.add( + WearableDisplayGroup.single( + wearable: right, + position: DevicePosition.right, + pairCandidate: left, + stereoPairKey: pairKey, + ), + ); + } + + continue; + } + + used.add(wearable.deviceId); + groups.add( + WearableDisplayGroup.single( + wearable: wearable, + position: metadata.position, + ), + ); + } + + return groups; +} + +Future> _buildStereoMetadata( + List wearables, +) async { + final entries = await Future.wait( + wearables.map((wearable) async { + if (!wearable.hasCapability()) { + return null; + } + + final stereo = wearable.requireCapability(); + final positionFuture = stereo.position; + final pairedFuture = stereo.pairedDevice; + final firmwareFuture = _readFirmwareVersion(wearable); + final hasFirmwareCapability = + wearable.hasCapability(); + final position = await positionFuture; + final paired = await pairedFuture; + final firmwareVersion = await firmwareFuture; + String? pairedDeviceId; + if (paired != null) { + for (final candidate in wearables) { + if (!candidate.hasCapability()) { + continue; + } + if (identical( + candidate.requireCapability(), + paired, + )) { + pairedDeviceId = candidate.deviceId; + break; + } + } + } + + return MapEntry( + wearable.deviceId, + _StereoMetadata( + wearable: wearable, + position: position, + pairedDeviceId: pairedDeviceId, + firmwareVersion: firmwareVersion, + hasFirmwareCapability: hasFirmwareCapability, + ), + ); + }), + ); + + final map = {}; + for (final entry in entries) { + if (entry != null) { + map[entry.key] = entry.value; + } + } + return map; +} + +_StereoMetadata? _findPartner({ + required _StereoMetadata current, + required Map wearablesById, + required Map metadataById, + required List wearablesInOrder, + required Set used, +}) { + final pairedId = current.pairedDeviceId; + if (pairedId != null && !used.contains(pairedId)) { + final pairedWearable = wearablesById[pairedId]; + final pairedMetadata = + pairedWearable == null ? null : metadataById[pairedId]; + if (pairedMetadata != null) { + if (_canCombine( + a: current, + b: pairedMetadata, + requireMutualPairing: false, + )) { + return pairedMetadata; + } + // A known stereo partner exists but is not combinable (e.g. firmware + // mismatch). Do not fall back to other same-name devices. + return null; + } + } + + for (final candidateWearable in wearablesInOrder) { + if (candidateWearable.deviceId == current.wearable.deviceId) { + continue; + } + if (used.contains(candidateWearable.deviceId)) { + continue; + } + + final candidate = metadataById[candidateWearable.deviceId]; + if (candidate == null) { + continue; + } + if (_canCombine( + a: current, + b: candidate, + requireMutualPairing: false, + )) { + return candidate; + } + } + + return null; +} + +bool _canCombine({ + required _StereoMetadata a, + required _StereoMetadata b, + required bool requireMutualPairing, +}) { + final oppositePositions = (a.position == DevicePosition.left && + b.position == DevicePosition.right) || + (a.position == DevicePosition.right && b.position == DevicePosition.left); + if (!oppositePositions) { + return false; + } + + if (!_stereoNamesMatch(a.wearable.name, b.wearable.name)) { + return false; + } + + if (!_firmwareVersionsAreCompatible(a, b)) { + return false; + } + + if (!requireMutualPairing) { + return true; + } + + return a.pairedDeviceId == b.wearable.deviceId && + b.pairedDeviceId == a.wearable.deviceId; +} + +String _combinedDisplayName(String leftName, String rightName) { + final leftBase = _normalizedStereoName(leftName); + final rightBase = _normalizedStereoName(rightName); + + if (leftBase.isNotEmpty && + leftBase.toLowerCase() == rightBase.toLowerCase()) { + return leftBase; + } + return leftName.trim(); +} + +bool _stereoNamesMatch(String a, String b) { + final normalizedA = _normalizedStereoName(a); + final normalizedB = _normalizedStereoName(b); + return normalizedA.toLowerCase() == normalizedB.toLowerCase(); +} + +String _normalizedStereoName(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; +} + +bool _firmwareVersionsAreCompatible(_StereoMetadata a, _StereoMetadata b) { + // If both devices expose firmware capabilities, we only allow combining when + // both versions are readable and exactly match. + if (a.hasFirmwareCapability && b.hasFirmwareCapability) { + final normalizedA = _normalizeFirmwareVersion(a.firmwareVersion); + final normalizedB = _normalizeFirmwareVersion(b.firmwareVersion); + if (normalizedA == null || normalizedB == null) { + return false; + } + return normalizedA == normalizedB; + } + return true; +} + +String? _normalizeFirmwareVersion(String? version) { + if (version == null) { + return null; + } + + var normalized = version.trim().toLowerCase(); + if (normalized.isEmpty) { + return null; + } + + normalized = normalized.replaceFirst(RegExp(r'^v(?=\d)'), ''); + return normalized.isEmpty ? null : normalized; +} + +final Expando> _firmwareVersionFutureCache = + Expando>(); + +Future _readFirmwareVersion(Wearable wearable) { + if (!wearable.hasCapability()) { + return Future.value(null); + } + + final capability = wearable.requireCapability(); + return _firmwareVersionFutureCache[capability] ??= capability + .readDeviceFirmwareVersion() + .timeout(const Duration(seconds: 2)) + .then(_normalizeFirmwareVersion) + .catchError((_) => null); +} diff --git a/open_wearable/lib/models/wearable_status_cache.dart b/open_wearable/lib/models/wearable_status_cache.dart new file mode 100644 index 00000000..f834b133 --- /dev/null +++ b/open_wearable/lib/models/wearable_status_cache.dart @@ -0,0 +1,148 @@ +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +/// Caches stable wearable metadata per device id so views can share it. +class WearableStatusCache { + WearableStatusCache._(); + + static final WearableStatusCache instance = WearableStatusCache._(); + + final Map _stereoPositionByDeviceId = {}; + final Map> _stereoPositionFutureByDeviceId = + {}; + + final Map _firmwareVersionByDeviceId = {}; + final Map> _firmwareVersionFutureByDeviceId = {}; + + final Map _firmwareSupportByDeviceId = {}; + final Map> + _firmwareSupportFutureByDeviceId = {}; + + final Map _hardwareVersionByDeviceId = {}; + final Map> _hardwareVersionFutureByDeviceId = {}; + + DevicePosition? cachedStereoPositionFor(String deviceId) => + _stereoPositionByDeviceId[deviceId]; + + Object? cachedFirmwareVersionFor(String deviceId) => + _firmwareVersionByDeviceId[deviceId]; + + FirmwareSupportStatus? cachedFirmwareSupportFor(String deviceId) => + _firmwareSupportByDeviceId[deviceId]; + + Object? cachedHardwareVersionFor(String deviceId) => + _hardwareVersionByDeviceId[deviceId]; + + Future? ensureStereoPosition(Wearable wearable) { + if (!wearable.hasCapability()) { + return null; + } + + final deviceId = wearable.deviceId; + if (_stereoPositionByDeviceId.containsKey(deviceId)) { + return Future.value(_stereoPositionByDeviceId[deviceId]); + } + + final inFlight = _stereoPositionFutureByDeviceId[deviceId]; + if (inFlight != null) { + return inFlight; + } + + final stereoDevice = wearable.requireCapability(); + final future = stereoDevice.position.then((position) { + _stereoPositionByDeviceId[deviceId] = position; + return position; + }).catchError((Object error, StackTrace stackTrace) { + _stereoPositionFutureByDeviceId.remove(deviceId); + throw error; + }); + + _stereoPositionFutureByDeviceId[deviceId] = future; + return future; + } + + Future? ensureFirmwareVersion(Wearable wearable) { + if (!wearable.hasCapability()) { + return null; + } + + final deviceId = wearable.deviceId; + if (_firmwareVersionByDeviceId.containsKey(deviceId)) { + return Future.value(_firmwareVersionByDeviceId[deviceId]); + } + + final inFlight = _firmwareVersionFutureByDeviceId[deviceId]; + if (inFlight != null) { + return inFlight; + } + + final capability = wearable.requireCapability(); + final future = capability.readDeviceFirmwareVersion().then((version) { + _firmwareVersionByDeviceId[deviceId] = version; + return version; + }).catchError((Object error, StackTrace stackTrace) { + _firmwareVersionFutureByDeviceId.remove(deviceId); + throw error; + }); + + _firmwareVersionFutureByDeviceId[deviceId] = future; + return future; + } + + Future? ensureFirmwareSupport(Wearable wearable) { + if (!wearable.hasCapability()) { + return null; + } + + final deviceId = wearable.deviceId; + if (_firmwareSupportByDeviceId.containsKey(deviceId)) { + return Future.value( + _firmwareSupportByDeviceId[deviceId], + ); + } + + final inFlight = _firmwareSupportFutureByDeviceId[deviceId]; + if (inFlight != null) { + return inFlight; + } + + final capability = wearable.requireCapability(); + final future = capability.checkFirmwareSupport().then((supportStatus) { + _firmwareSupportByDeviceId[deviceId] = supportStatus; + return supportStatus; + }).catchError((Object error, StackTrace stackTrace) { + _firmwareSupportFutureByDeviceId.remove(deviceId); + throw error; + }); + + _firmwareSupportFutureByDeviceId[deviceId] = future; + return future; + } + + Future? ensureHardwareVersion(Wearable wearable) { + if (!wearable.hasCapability()) { + return null; + } + + final deviceId = wearable.deviceId; + if (_hardwareVersionByDeviceId.containsKey(deviceId)) { + return Future.value(_hardwareVersionByDeviceId[deviceId]); + } + + final inFlight = _hardwareVersionFutureByDeviceId[deviceId]; + if (inFlight != null) { + return inFlight; + } + + final capability = wearable.requireCapability(); + final future = capability.readDeviceHardwareVersion().then((version) { + _hardwareVersionByDeviceId[deviceId] = version; + return version; + }).catchError((Object error, StackTrace stackTrace) { + _hardwareVersionFutureByDeviceId.remove(deviceId); + throw error; + }); + + _hardwareVersionFutureByDeviceId[deviceId] = future; + return future; + } +} diff --git a/open_wearable/lib/router.dart b/open_wearable/lib/router.dart index d4293e40..c8ab45a2 100644 --- a/open_wearable/lib/router.dart +++ b/open_wearable/lib/router.dart @@ -7,6 +7,8 @@ import 'package:open_wearable/widgets/fota/firmware_update.dart'; import 'package:open_wearable/widgets/fota/fota_warning_page.dart'; import 'package:open_wearable/widgets/home_page.dart'; import 'package:open_wearable/widgets/logging/log_files_screen.dart'; +import 'package:open_wearable/widgets/settings/general_settings_page.dart'; +import 'package:open_wearable/widgets/settings/connectors_page.dart'; import 'dart:io' show Platform; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart' show kIsWeb; @@ -14,6 +16,70 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; /// Global navigator key for go_router final GlobalKey rootNavigatorKey = GlobalKey(); +bool _unsupportedFotaDialogVisible = false; + +void _showUnsupportedFotaDialog() { + if (_unsupportedFotaDialogVisible) { + return; + } + _unsupportedFotaDialogVisible = true; + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final ctx = rootNavigatorKey.currentContext; + if (ctx == null) { + _unsupportedFotaDialogVisible = false; + return; + } + + try { + await showPlatformDialog( + context: ctx, + builder: (_) => PlatformAlertDialog( + title: PlatformText('Firmware Update'), + content: PlatformText( + 'Firmware update is not supported on this platform. ' + 'Please use an Android device or J-Link to update the firmware.', + ), + actions: [ + PlatformDialogAction( + cupertino: (_, __) => + CupertinoDialogActionData(isDefaultAction: true), + child: PlatformText('OK'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } finally { + _unsupportedFotaDialogVisible = false; + } + }); +} + +int _parseHomeSectionIndex(String? tabParam) { + if (tabParam == null || tabParam.isEmpty) { + return 0; + } + + switch (tabParam.toLowerCase()) { + case 'overview': + return 0; + case 'devices': + return 1; + case 'sensors': + return 2; + case 'apps': + return 3; + case 'settings': + return 4; + default: + final parsed = int.tryParse(tabParam); + if (parsed == null || parsed < 0 || parsed > 4) { + return 0; + } + return parsed; + } +} /// Router configuration for the app final GoRouter router = GoRouter( @@ -23,10 +89,15 @@ final GoRouter router = GoRouter( GoRoute( path: '/', name: 'home', - builder: (context, state) => const HeroMode( - enabled: false, - child: HomePage(), - ), + builder: (context, state) { + final initialSection = _parseHomeSectionIndex( + state.uri.queryParameters['tab'], + ); + return HeroMode( + enabled: false, + child: HomePage(initialSectionIndex: initialSection), + ); + }, ), GoRoute( path: '/connect-devices', @@ -50,6 +121,20 @@ final GoRouter router = GoRouter( name: 'log-files', builder: (context, state) => const LogFilesScreen(), ), + GoRoute( + path: '/connectors', + name: 'connectors', + builder: (context, state) => const ConnectorsPage(), + ), + GoRoute( + path: '/settings/general', + name: 'settings/general', + builder: (context, state) => const GeneralSettingsPage(), + ), + GoRoute( + path: '/settings/app-close', + redirect: (_, __) => '/settings/general', + ), GoRoute( path: '/fota', name: 'fota', @@ -58,32 +143,8 @@ final GoRouter router = GoRouter( final bool isIOS = !kIsWeb && Platform.isIOS; if (!isAndroid && !isIOS) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = rootNavigatorKey.currentContext; - if (ctx == null) return; - - showPlatformDialog( - context: ctx, - builder: (_) => PlatformAlertDialog( - title: PlatformText('Firmware Update'), - content: PlatformText( - 'Firmware update is not supported on this platform. ' - 'Please use an Android device or J-Link to update the firmware.', - ), - actions: [ - PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData( - isDefaultAction: true, - ), - child: PlatformText('OK'), - onPressed: () => Navigator.of(ctx).pop(), - ), - ], - ), - ); - }); - - return state.topRoute?.name; + _showUnsupportedFotaDialog(); + return '/?tab=devices'; } return null; @@ -93,7 +154,28 @@ final GoRouter router = GoRouter( GoRoute( path: '/fota/update', name: 'fota/update', - builder: (context, state) => const FirmwareUpdateWidget(), + pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, + transitionDuration: const Duration(milliseconds: 220), + reverseTransitionDuration: const Duration(milliseconds: 180), + child: const FirmwareUpdateWidget(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + final slideAnimation = Tween( + begin: const Offset(0.06, 0), + end: Offset.zero, + ).animate(curved); + + return FadeTransition( + opacity: curved, + child: SlideTransition(position: slideAnimation, child: child), + ); + }, + ), ), ], ); diff --git a/open_wearable/lib/theme/app_theme.dart b/open_wearable/lib/theme/app_theme.dart new file mode 100644 index 00000000..895534ca --- /dev/null +++ b/open_wearable/lib/theme/app_theme.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const Color _brand = Color(0xFF9A6F6B); + static const Color _onBrand = Color(0xFFFFFFFF); + static const Color _lightBackground = Color(0xFFF4F7FB); + static const Color _darkBackground = Color(0xFF0B1117); + + static ThemeData lightTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: _brand, + brightness: Brightness.light, + ).copyWith( + primary: _brand, + onPrimary: _onBrand, + surfaceTint: _brand, + secondary: const Color(0xFFAA807C), + onSecondary: _onBrand, + tertiary: const Color(0xFFBB938F), + onTertiary: _onBrand, + surface: Colors.white, + ); + + return _buildTheme( + colorScheme: colorScheme, + scaffoldBackgroundColor: _lightBackground, + ); + } + + static ThemeData darkTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: _brand, + brightness: Brightness.dark, + ).copyWith( + primary: const Color(0xFFC79F9B), + surfaceTint: const Color(0xFFC79F9B), + secondary: const Color(0xFFD1ACA8), + tertiary: const Color(0xFFE0C1BE), + surface: const Color(0xFF111A22), + ); + + return _buildTheme( + colorScheme: colorScheme, + scaffoldBackgroundColor: _darkBackground, + ); + } + + static ThemeData _buildTheme({ + required ColorScheme colorScheme, + required Color scaffoldBackgroundColor, + }) { + const switchGreen = Color(0xFF2E7D32); + final base = ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: scaffoldBackgroundColor, + canvasColor: scaffoldBackgroundColor, + ); + + final outlineColor = colorScheme.outline.withValues(alpha: 0.2); + final buttonTextStyle = const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + letterSpacing: 0.2, + ); + final shapeLarge = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide(color: outlineColor), + ); + final shapeMedium = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ); + final shapeSmall = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ); + + return base.copyWith( + textTheme: _textTheme(base.textTheme, colorScheme), + appBarTheme: AppBarTheme( + elevation: 0, + scrolledUnderElevation: 0, + backgroundColor: scaffoldBackgroundColor, + foregroundColor: colorScheme.onSurface, + surfaceTintColor: Colors.transparent, + centerTitle: false, + ), + cardTheme: CardThemeData( + color: colorScheme.surface, + elevation: 0, + surfaceTintColor: Colors.transparent, + shape: shapeLarge, + ), + listTileTheme: ListTileThemeData( + shape: shapeMedium, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 4, + ), + iconColor: colorScheme.primary, + titleTextStyle: base.textTheme.titleSmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + subtitleTextStyle: base.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + dividerTheme: DividerThemeData( + color: outlineColor, + thickness: 1, + space: 1, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surface.withValues(alpha: 0.9), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: outlineColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: outlineColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide( + color: colorScheme.primary, + width: 1.6, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: colorScheme.error), + ), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + type: BottomNavigationBarType.fixed, + backgroundColor: colorScheme.surface, + selectedItemColor: colorScheme.primary, + unselectedItemColor: colorScheme.onSurfaceVariant.withValues( + alpha: 0.92, + ), + selectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 12, + letterSpacing: 0.1, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + letterSpacing: 0.1, + ), + elevation: 0, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: colorScheme.surface, + indicatorColor: colorScheme.primaryContainer.withValues(alpha: 0.72), + elevation: 0, + surfaceTintColor: Colors.transparent, + height: 74, + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return TextStyle( + color: + selected ? colorScheme.primary : colorScheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + fontSize: 12, + ); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return IconThemeData( + color: + selected ? colorScheme.primary : colorScheme.onSurfaceVariant, + ); + }), + ), + navigationRailTheme: NavigationRailThemeData( + backgroundColor: colorScheme.surface, + useIndicator: true, + indicatorColor: colorScheme.primaryContainer.withValues(alpha: 0.72), + selectedLabelTextStyle: TextStyle( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + unselectedLabelTextStyle: TextStyle( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + selectedIconTheme: IconThemeData(color: colorScheme.primary), + unselectedIconTheme: IconThemeData(color: colorScheme.onSurfaceVariant), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + disabledBackgroundColor: + colorScheme.onSurface.withValues(alpha: 0.12), + disabledForegroundColor: colorScheme.onSurface.withValues(alpha: 0.5), + minimumSize: const Size(0, 46), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: shapeSmall, + textStyle: buttonTextStyle, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + disabledBackgroundColor: + colorScheme.onSurface.withValues(alpha: 0.12), + disabledForegroundColor: colorScheme.onSurface.withValues(alpha: 0.5), + minimumSize: const Size(0, 46), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: shapeSmall, + textStyle: buttonTextStyle, + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.primary, + minimumSize: const Size(0, 46), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: shapeSmall, + side: BorderSide(color: outlineColor), + textStyle: buttonTextStyle, + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: colorScheme.primary, + shape: shapeSmall, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + textStyle: buttonTextStyle, + ), + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: colorScheme.primary, + selectionHandleColor: colorScheme.primary, + selectionColor: colorScheme.primary.withValues(alpha: 0.28), + ), + chipTheme: base.chipTheme.copyWith( + backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.5), + side: BorderSide.none, + shape: const StadiumBorder(), + labelStyle: base.textTheme.labelMedium?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + shape: shapeLarge, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: colorScheme.primary, + ), + sliderTheme: base.sliderTheme.copyWith( + activeTrackColor: colorScheme.primary, + inactiveTrackColor: colorScheme.primary.withValues(alpha: 0.2), + thumbColor: colorScheme.primary, + overlayColor: colorScheme.primary.withValues(alpha: 0.14), + ), + switchTheme: SwitchThemeData( + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return switchGreen; + } + return colorScheme.outline.withValues(alpha: 0.28); + }), + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.surface; + } + return colorScheme.surface; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return switchGreen.withValues(alpha: 0.36); + } + return colorScheme.outline.withValues(alpha: 0.28); + }), + overlayColor: WidgetStateProperty.all( + switchGreen.withValues(alpha: 0.12), + ), + ), + ); + } + + static TextTheme _textTheme(TextTheme base, ColorScheme colorScheme) { + return base + .copyWith( + headlineSmall: base.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + titleLarge: base.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + ), + titleMedium: base.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + ), + titleSmall: base.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + bodyLarge: base.bodyLarge?.copyWith( + height: 1.32, + ), + bodyMedium: base.bodyMedium?.copyWith( + height: 1.32, + ), + labelLarge: base.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ) + .apply( + bodyColor: colorScheme.onSurface, + displayColor: colorScheme.onSurface, + ); + } +} diff --git a/open_wearable/lib/view_models/app_banner_controller.dart b/open_wearable/lib/view_models/app_banner_controller.dart index 8965d109..db4e163b 100644 --- a/open_wearable/lib/view_models/app_banner_controller.dart +++ b/open_wearable/lib/view_models/app_banner_controller.dart @@ -22,7 +22,19 @@ class AppBannerController with ChangeNotifier { } void hideBanner(AppBanner banner) { - activeBanners.removeWhere((b) => b.key == banner.key); + final removed = activeBanners.remove(banner); + if (!removed) { + return; + } + notifyListeners(); + } + + void hideBannerByKey(Key key) { + final before = activeBanners.length; + activeBanners.removeWhere((b) => b.key == key); + if (activeBanners.length == before) { + return; + } notifyListeners(); } } diff --git a/open_wearable/lib/view_models/sensor_configuration_provider.dart b/open_wearable/lib/view_models/sensor_configuration_provider.dart index f63bce67..7df8e8f6 100644 --- a/open_wearable/lib/view_models/sensor_configuration_provider.dart +++ b/open_wearable/lib/view_models/sensor_configuration_provider.dart @@ -5,6 +5,22 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import '../models/logger.dart'; +class SensorConfigurationRestoreResult { + final int restoredCount; + final int requestedCount; + final int skippedCount; + final int unknownConfigCount; + + const SensorConfigurationRestoreResult({ + required this.restoredCount, + required this.requestedCount, + required this.skippedCount, + required this.unknownConfigCount, + }); + + bool get hasRestoredValues => restoredCount > 0; +} + class SensorConfigurationProvider with ChangeNotifier { final SensorConfigurationManager _sensorConfigurationManager; @@ -12,6 +28,10 @@ class SensorConfigurationProvider with ChangeNotifier { _sensorConfigurations = {}; final Map> _sensorConfigurationOptions = {}; + final Set _pendingConfigurations = {}; + final Set _lastReportedConfigurationKeys = {}; + final Map _lastReportedConfigurations = {}; + bool _hasReceivedConfigurationReport = false; StreamSubscription>? _sensorConfigurationSubscription; @@ -21,25 +41,72 @@ class SensorConfigurationProvider with ChangeNotifier { }) : _sensorConfigurationManager = sensorConfigurationManager { _sensorConfigurationSubscription = _sensorConfigurationManager.sensorConfigurationStream.listen((event) { + _hasReceivedConfigurationReport = true; + _lastReportedConfigurations + ..clear() + ..addEntries( + event.entries.map( + (entry) => MapEntry( + _configurationIdentityKey(entry.key), + entry.value, + ), + ), + ); + _lastReportedConfigurationKeys + ..clear() + ..addAll(_lastReportedConfigurations.keys); + + var hasStateChange = false; for (final e in event.entries) { final sensorConfiguration = e.key; final sensorConfigurationValue = e.value; + final currentValue = _sensorConfigurations[sensorConfiguration]; + final isPending = _pendingConfigurations.contains(sensorConfiguration); + + if (isPending) { + // Keep optimistic local edits stable until the hardware reports the + // same value; this avoids transient drift in the UI. + if (currentValue != null && + _configurationValuesMatch( + currentValue, + sensorConfigurationValue, + )) { + hasStateChange = + _pendingConfigurations.remove(sensorConfiguration) || + hasStateChange; + } else { + continue; + } + } - // Update the selected configuration value - _sensorConfigurations[sensorConfiguration] = sensorConfigurationValue; + if (currentValue == null || + !_configurationValuesMatch( + currentValue, + sensorConfigurationValue, + )) { + _sensorConfigurations[sensorConfiguration] = sensorConfigurationValue; + hasStateChange = true; + } // Update the selected options for configurable sensor configurations _updateSelectedOptions(sensorConfiguration); } - notifyListeners(); + if (hasStateChange) { + notifyListeners(); + } }); } void addSensorConfiguration( SensorConfiguration sensorConfiguration, - SensorConfigurationValue sensorConfigurationValue, - ) { + SensorConfigurationValue sensorConfigurationValue, { + bool markPending = true, + }) { _sensorConfigurations[sensorConfiguration] = sensorConfigurationValue; + _updateSelectedOptions(sensorConfiguration); + if (markPending) { + _pendingConfigurations.add(sensorConfiguration); + } notifyListeners(); } @@ -50,12 +117,149 @@ class SensorConfigurationProvider with ChangeNotifier { } List<(SensorConfiguration, SensorConfigurationValue)> - getSelectedConfigurations() { + getSelectedConfigurations({ + bool pendingOnly = false, + }) { return _sensorConfigurations.entries + .where( + (entry) => !pendingOnly || _pendingConfigurations.contains(entry.key), + ) .map((entry) => (entry.key, entry.value)) .toList(); } + List<(SensorConfiguration, SensorConfigurationValue)> + getConfigurationsMissingFromLastReport() { + if (!_hasReceivedConfigurationReport) { + return const <(SensorConfiguration, SensorConfigurationValue)>[]; + } + + final missing = <(SensorConfiguration, SensorConfigurationValue)>[]; + for (final config in _sensorConfigurationManager.sensorConfigurations) { + final selected = _sensorConfigurations[config]; + if (selected == null) { + continue; + } + if (_lastReportedConfigurationKeys.contains( + _configurationIdentityKey(config), + )) { + continue; + } + missing.add((config, selected)); + } + return missing; + } + + bool get hasReceivedConfigurationReport => _hasReceivedConfigurationReport; + + SensorConfigurationValue? getLastReportedConfigurationValue( + SensorConfiguration sensorConfiguration, + ) { + final key = _configurationIdentityKey(sensorConfiguration); + return _lastReportedConfigurations[key]; + } + + bool selectedMatchesConfigurationValue( + SensorConfiguration sensorConfiguration, + SensorConfigurationValue expected, + ) { + final selected = _sensorConfigurations[sensorConfiguration]; + if (selected == null) { + return false; + } + return _configurationValuesMatch(selected, expected); + } + + bool isConfigurationApplied(SensorConfiguration sensorConfiguration) { + if (_pendingConfigurations.contains(sensorConfiguration)) { + return false; + } + if (!_hasReceivedConfigurationReport) { + return false; + } + + final selected = _sensorConfigurations[sensorConfiguration]; + if (selected == null) { + return false; + } + + final reported = getLastReportedConfigurationValue(sensorConfiguration); + if (reported == null) { + return false; + } + return _configurationValuesMatch(selected, reported); + } + + String _configurationIdentityKey(SensorConfiguration configuration) { + final dynamic configDynamic = configuration; + try { + final sensorId = configDynamic.sensorId; + if (sensorId is int) { + return 'sensor:$sensorId'; + } + } catch (_) { + // Fall through to structural key. + } + + final valuesKey = configuration.values + .map((value) => value.key) + .toList(growable: false) + ..sort(); + return '${configuration.runtimeType}:${configuration.name}:${valuesKey.join('|')}'; + } + + bool _configurationValuesMatch( + SensorConfigurationValue current, + SensorConfigurationValue expected, + ) { + if (current is SensorFrequencyConfigurationValue && + expected is SensorFrequencyConfigurationValue) { + return current.frequencyHz == expected.frequencyHz && + setEquals(_optionNameSet(current), _optionNameSet(expected)); + } + + if (current is ConfigurableSensorConfigurationValue && + expected is ConfigurableSensorConfigurationValue) { + return _normalizeName(current.withoutOptions().key) == + _normalizeName(expected.withoutOptions().key) && + setEquals(_optionNameSet(current), _optionNameSet(expected)); + } + + return _normalizeName(current.key) == _normalizeName(expected.key); + } + + Set _optionNameSet(SensorConfigurationValue value) { + if (value is! ConfigurableSensorConfigurationValue) { + return const {}; + } + return value.options.map((option) => _normalizeName(option.name)).toSet(); + } + + String _normalizeName(String value) => value.trim().toLowerCase(); + + bool get hasPendingChanges => _pendingConfigurations.isNotEmpty; + + bool isConfigurationPending(SensorConfiguration configuration) { + return _pendingConfigurations.contains(configuration); + } + + void clearPendingChanges({ + Iterable? onlyFor, + }) { + bool changed = false; + if (onlyFor == null) { + changed = _pendingConfigurations.isNotEmpty; + _pendingConfigurations.clear(); + } else { + for (final config in onlyFor) { + changed = _pendingConfigurations.remove(config) || changed; + } + } + if (changed) { + notifyListeners(); + } + } + Set getSelectedConfigurationOptions( SensorConfiguration sensorConfiguration, ) { @@ -68,56 +272,79 @@ class SensorConfigurationProvider with ChangeNotifier { /// to the first possible value that matches the selected options. void addSensorConfigurationOption( SensorConfiguration sensorConfiguration, - SensorConfigurationOption option, - ) { + SensorConfigurationOption option, { + bool markPending = true, + }) { if (_sensorConfigurationOptions[sensorConfiguration] == null) { _sensorConfigurationOptions[sensorConfiguration] = {}; } _sensorConfigurationOptions[sensorConfiguration]?.add(option); - _updateSelectedValue(sensorConfiguration); + _updateSelectedValue( + sensorConfiguration, + markPending: markPending, + ); + if (markPending) { + _pendingConfigurations.add(sensorConfiguration); + } notifyListeners(); } void _updateSelectedValue( - SensorConfiguration sensorConfiguration, - ) { + SensorConfiguration sensorConfiguration, { + bool markPending = true, + }) { List possibleValues = getSensorConfigurationValues(sensorConfiguration, distinct: true); - SensorConfigurationValue? selectedValue = - _sensorConfigurations[sensorConfiguration]; + if (possibleValues.isEmpty) { + return; + } + + final selectedValue = _sensorConfigurations[sensorConfiguration]; if (selectedValue == null) { _sensorConfigurations[sensorConfiguration] = possibleValues.first; + if (markPending) { + _pendingConfigurations.add(sensorConfiguration); + } + return; + } + if (possibleValues.contains(selectedValue)) { + return; } - if (!possibleValues.contains(selectedValue)) { - if (selectedValue is ConfigurableSensorConfigurationValue) { - final SensorConfigurationValue? matchingValue = - getSensorConfigurationValues(sensorConfiguration) - .where((value) { - if (value is ConfigurableSensorConfigurationValue) { - return value.withoutOptions() == - selectedValue.withoutOptions(); - } - return value == selectedValue; - }) - .cast() - .toList() - .firstOrNull; - - if (matchingValue == null) { - logger.w( - "No matching value found for ${sensorConfiguration.name} with options ${_sensorConfigurationOptions[sensorConfiguration]}", - ); - } - addSensorConfiguration( - sensorConfiguration, - matchingValue ?? possibleValues.last, - ); - } else { - logger.e( - "Selected value is not a ConfigurableSensorConfigurationValue and we do not know how to handle it", + if (selectedValue is ConfigurableSensorConfigurationValue) { + final SensorConfigurationValue? matchingValue = + getSensorConfigurationValues(sensorConfiguration) + .where((value) { + if (value is ConfigurableSensorConfigurationValue) { + return value.withoutOptions() == + selectedValue.withoutOptions(); + } + return value == selectedValue; + }) + .cast() + .toList() + .firstOrNull; + + if (matchingValue == null) { + logger.w( + "No matching value found for ${sensorConfiguration.name} with options ${_sensorConfigurationOptions[sensorConfiguration]}", ); } + + _sensorConfigurations[sensorConfiguration] = + matchingValue ?? possibleValues.last; + if (markPending) { + _pendingConfigurations.add(sensorConfiguration); + } + return; + } + + logger.e( + "Selected value is not a ConfigurableSensorConfigurationValue and we do not know how to handle it", + ); + _sensorConfigurations[sensorConfiguration] = possibleValues.first; + if (markPending) { + _pendingConfigurations.add(sensorConfiguration); } } @@ -142,15 +369,23 @@ class SensorConfigurationProvider with ChangeNotifier { void removeSensorConfiguration(SensorConfiguration sensorConfiguration) { _sensorConfigurations.remove(sensorConfiguration); + _pendingConfigurations.remove(sensorConfiguration); notifyListeners(); } void removeSensorConfigurationOption( SensorConfiguration sensorConfiguration, - SensorConfigurationOption option, - ) { + SensorConfigurationOption option, { + bool markPending = true, + }) { _sensorConfigurationOptions[sensorConfiguration]?.remove(option); - _updateSelectedValue(sensorConfiguration); + _updateSelectedValue( + sensorConfiguration, + markPending: markPending, + ); + if (markPending) { + _pendingConfigurations.add(sensorConfiguration); + } notifyListeners(); } @@ -187,7 +422,7 @@ class SensorConfigurationProvider with ChangeNotifier { for (final sensorConfiguration in _sensorConfigurations.keys) { final SensorConfigurationValue? value = sensorConfiguration.offValue; if (value != null) { - addSensorConfiguration(sensorConfiguration, value); + addSensorConfiguration(sensorConfiguration, value, markPending: true); _updateSelectedOptions(sensorConfiguration); sensorConfiguration.setConfiguration(value); } @@ -201,29 +436,85 @@ class SensorConfigurationProvider with ChangeNotifier { ); } - Future restoreFromJson(Map jsonMap) async { - Map restoredConfigurations = - {}; - for (final config in _sensorConfigurations.keys) { + Future restoreFromJson( + Map jsonMap, + ) async { + final restoredConfigurations = + {}; + final shouldMarkPendingByConfiguration = {}; + int requestedCount = 0; + int skippedCount = 0; + + final knownConfigurations = + _sensorConfigurationManager.sensorConfigurations.toList(); + final knownConfigNames = + knownConfigurations.map((config) => config.name).toSet(); + + for (final config in knownConfigurations) { final selectedKey = jsonMap[config.name]; if (selectedKey == null) continue; - try { - final SensorConfigurationValue matchingValue = config.values.firstWhere( - (v) => v.key == selectedKey, + requestedCount += 1; + + final matchingValue = config.values + .where((value) => value.key == selectedKey) + .cast() + .firstOrNull; + + if (matchingValue == null) { + skippedCount += 1; + logger.w( + 'Skipped restoring "${config.name}" because value "$selectedKey" is no longer available.', ); - restoredConfigurations[config] = matchingValue; - } on StateError { - logger.e("Failed to restore configuration for ${config.name}"); - return false; + continue; } + + restoredConfigurations[config] = matchingValue; + final selected = _sensorConfigurations[config]; + shouldMarkPendingByConfiguration[config] = selected == null || + !_configurationValuesMatch(selected, matchingValue); } - for (final config in restoredConfigurations.keys) { - _sensorConfigurations[config] = restoredConfigurations[config]!; + + var hasStateChange = false; + for (final entry in restoredConfigurations.entries) { + final config = entry.key; + final value = entry.value; + final selected = _sensorConfigurations[config]; + final selectedChanged = + selected == null || !_configurationValuesMatch(selected, value); + if (selectedChanged) { + hasStateChange = true; + } + + _sensorConfigurations[config] = value; _updateSelectedOptions(config); + final shouldMarkPending = + shouldMarkPendingByConfiguration[config] ?? true; + if (shouldMarkPending) { + hasStateChange = _pendingConfigurations.add(config) || hasStateChange; + continue; + } + + final reported = getLastReportedConfigurationValue(config); + if (reported != null && _configurationValuesMatch(reported, value)) { + hasStateChange = + _pendingConfigurations.remove(config) || hasStateChange; + } } - notifyListeners(); - return true; + + if (restoredConfigurations.isNotEmpty && hasStateChange) { + notifyListeners(); + } + + final unknownConfigCount = + jsonMap.keys.where((name) => !knownConfigNames.contains(name)).length; + + return SensorConfigurationRestoreResult( + restoredCount: restoredConfigurations.length, + requestedCount: requestedCount, + skippedCount: skippedCount, + unknownConfigCount: unknownConfigCount, + ); } @override diff --git a/open_wearable/lib/view_models/sensor_configuration_storage.dart b/open_wearable/lib/view_models/sensor_configuration_storage.dart index 28586981..017f490e 100644 --- a/open_wearable/lib/view_models/sensor_configuration_storage.dart +++ b/open_wearable/lib/view_models/sensor_configuration_storage.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; class SensorConfigurationStorage { + static const String _scopeSeparator = '__'; + /// Returns the directory where sensor configurations are stored. /// Creates the directory if it does not exist. static Future _getConfigDirectory() async { @@ -18,9 +20,17 @@ class SensorConfigurationStorage { /// Each file is expected to be a JSON file with a specific configuration. static Future> _getAllConfigFiles() async { final configDir = await _getConfigDirectory(); - return configDir.list().where((file) => - file is File && file.path.endsWith('.json'), - ).cast().toList(); + final files = []; + try { + await for (final entity in configDir.list(followLinks: false)) { + if (entity is File && entity.path.toLowerCase().endsWith('.json')) { + files.add(entity); + } + } + } on FileSystemException { + return const []; + } + return files; } /// Returns the file for a specific configuration key. @@ -33,14 +43,17 @@ class SensorConfigurationStorage { /// Saves a configuration for a specific key. /// If the file already exists, it will be overwritten. /// The configuration is expected to be a map of string key-value pairs. - static Future saveConfiguration(String key, Map config) async { + static Future saveConfiguration( + String key, + Map config, + ) async { final File file = await _getConfigFile(key); await file.writeAsString(jsonEncode(config)); } static Future> listConfigurationKeys() async { final files = await _getAllConfigFiles(); - return files.map(_getKeyFromFile).toList(); + return files.map(_getKeyFromFile).where((key) => key.isNotEmpty).toList(); } static String _getKeyFromFile(File file) => @@ -54,7 +67,8 @@ class SensorConfigurationStorage { final configFiles = await _getAllConfigFiles(); for (final file in configFiles) { final contents = await file.readAsString(); - allConfigs[_getKeyFromFile(file)] = Map.from(jsonDecode(contents)); + allConfigs[_getKeyFromFile(file)] = + Map.from(jsonDecode(contents)); } return allConfigs; } @@ -77,5 +91,110 @@ class SensorConfigurationStorage { } } - static String sanitizeKey(String key) => key.replaceAll(RegExp(r'[^\w\-]'), '_'); + static String scopedPrefix(String scope) => + '${sanitizeKey(scope)}$_scopeSeparator'; + + static String normalizeDeviceNameForScope(String deviceName) { + final compact = deviceName.trim().replaceAll(RegExp(r'\s+'), ' '); + if (compact.isEmpty) { + return 'unknown_device'; + } + return sanitizeKey(compact.toLowerCase()); + } + + static String? normalizeFirmwareVersionForScope(String? firmwareVersion) { + if (firmwareVersion == null) { + return null; + } + var normalized = firmwareVersion.trim().toLowerCase(); + if (normalized.isEmpty) { + return null; + } + normalized = normalized.replaceFirst(RegExp(r'^v(?=\d)'), ''); + if (normalized.isEmpty) { + return null; + } + return sanitizeKey(normalized); + } + + static String deviceNameScope(String deviceName) => + 'name_${normalizeDeviceNameForScope(deviceName)}'; + + static String? deviceNameFirmwareScope({ + required String deviceName, + required String? firmwareVersion, + }) { + final normalizedFirmware = + normalizeFirmwareVersionForScope(firmwareVersion); + if (normalizedFirmware == null) { + return null; + } + return '${deviceNameScope(deviceName)}__fw_$normalizedFirmware'; + } + + static String buildScopedKey({ + required String scope, + required String name, + }) { + final sanitizedName = sanitizeKey(name.trim()); + return '${scopedPrefix(scope)}$sanitizedName'; + } + + static bool keyMatchesScope(String key, String scope) { + return key.startsWith(scopedPrefix(scope)); + } + + static String displayNameFromScopedKey( + String key, { + required String scope, + }) { + if (!keyMatchesScope(key, scope)) { + return key.replaceAll('_', ' '); + } + return key.substring(scopedPrefix(scope).length).replaceAll('_', ' '); + } + + static bool isLegacyUnscopedKey(String key) => !key.contains(_scopeSeparator); + + static String sanitizeKey(String key) => + key.replaceAll(RegExp(r'[^\w\-]'), '_'); +} + +class DeviceProfileScopeMatch { + final String nameScope; + final String? firmwareScope; + + const DeviceProfileScopeMatch({ + required this.nameScope, + required this.firmwareScope, + }); + + factory DeviceProfileScopeMatch.forDevice({ + required String deviceName, + String? firmwareVersion, + }) { + return DeviceProfileScopeMatch( + nameScope: SensorConfigurationStorage.deviceNameScope(deviceName), + firmwareScope: SensorConfigurationStorage.deviceNameFirmwareScope( + deviceName: deviceName, + firmwareVersion: firmwareVersion, + ), + ); + } + + String get saveScope => firmwareScope ?? nameScope; + + String? matchingScopeForKey(String key) { + final preferredScope = firmwareScope ?? nameScope; + if (SensorConfigurationStorage.keyMatchesScope(key, preferredScope)) { + return preferredScope; + } + return null; + } + + bool matchesScopedKey(String key) => matchingScopeForKey(key) != null; + + bool allowsKey(String key) => + matchesScopedKey(key) || + SensorConfigurationStorage.isLegacyUnscopedKey(key); } diff --git a/open_wearable/lib/view_models/sensor_data_provider.dart b/open_wearable/lib/view_models/sensor_data_provider.dart index 121715cd..8c2af178 100644 --- a/open_wearable/lib/view_models/sensor_data_provider.dart +++ b/open_wearable/lib/view_models/sensor_data_provider.dart @@ -4,41 +4,120 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/sensor_streams.dart'; class SensorDataProvider with ChangeNotifier { final Sensor sensor; final int timeWindow; // seconds - late final int _timestampCutoffMs; + late final int _timestampUnitsPerSecond; + late final int _timestampCutoff; final Queue sensorValues = Queue(); StreamSubscription? _sensorStreamSubscription; Timer? _throttleTimer; + Timer? _silenceTimer; + Timer? _staleDataTimer; final Duration _throttleDuration = const Duration(milliseconds: 15); + final Duration _staleDataInterval = const Duration(milliseconds: 100); + + int? _lastSensorTimestamp; + DateTime? _lastSensorArrivalTime; SensorDataProvider({ required this.sensor, this.timeWindow = 5, }) { - _timestampCutoffMs = pow(10, -sensor.timestampExponent).toInt() * timeWindow; + _timestampUnitsPerSecond = max( + 1, + pow(10, -sensor.timestampExponent).round(), + ); + _timestampCutoff = _timestampUnitsPerSecond * timeWindow; _listenToStream(); } + int get displayTimestamp { + final lastTimestamp = _lastSensorTimestamp; + final lastArrivalTime = _lastSensorArrivalTime; + if (lastTimestamp == null || lastArrivalTime == null) { + return lastTimestamp ?? 0; + } + final elapsedMicroseconds = + DateTime.now().difference(lastArrivalTime).inMicroseconds; + final elapsedTimestampUnits = + (elapsedMicroseconds * _timestampUnitsPerSecond) ~/ + Duration.microsecondsPerSecond; + return lastTimestamp + elapsedTimestampUnits; + } + void _listenToStream() { - _sensorStreamSubscription = sensor.sensorStream.listen((sensorValue) { + _sensorStreamSubscription = + SensorStreams.shared(sensor).listen((sensorValue) { sensorValues.add(sensorValue); - final cutoff = sensorValue.timestamp - _timestampCutoffMs; - sensorValues.removeWhere((v) => v.timestamp < cutoff); + _lastSensorTimestamp = sensorValue.timestamp; + _lastSensorArrivalTime = DateTime.now(); + _pruneStaleValues(referenceTimestamp: sensorValue.timestamp); + _stopStaleDataTicker(); + _scheduleSilenceWatch(); + + _throttledNotifyListeners(); + }); + } + + void _pruneStaleValues({required int referenceTimestamp}) { + final cutoff = referenceTimestamp - _timestampCutoff; + // Sensor values are timestamp-ordered from the stream, so stale values + // only need to be removed from the queue front. + while (sensorValues.isNotEmpty && sensorValues.first.timestamp < cutoff) { + sensorValues.removeFirst(); + } + } - while (sensorValues.isNotEmpty && sensorValues.first.timestamp < cutoff) { - sensorValues.removeFirst(); + bool get _isSensorSilent { + final lastArrivalTime = _lastSensorArrivalTime; + if (lastArrivalTime == null) { + return true; + } + return DateTime.now().difference(lastArrivalTime) >= _staleDataInterval; + } + + void _scheduleSilenceWatch() { + _silenceTimer?.cancel(); + _silenceTimer = Timer(_staleDataInterval, () { + if (_isSensorSilent) { + _startStaleDataTickerIfNeeded(); } + }); + } - _throttledNotifyListeners(); + void _startStaleDataTickerIfNeeded() { + if ((_staleDataTimer?.isActive ?? false) || sensorValues.isEmpty) return; + + _staleDataTimer = Timer.periodic(_staleDataInterval, (_) { + if (sensorValues.isEmpty || !_isSensorSilent) { + _stopStaleDataTicker(); + return; + } + + final previousLength = sensorValues.length; + _pruneStaleValues(referenceTimestamp: displayTimestamp); + + if (sensorValues.isEmpty) { + _stopStaleDataTicker(); + } + + if (sensorValues.isNotEmpty || previousLength != sensorValues.length) { + _throttledNotifyListeners(); + } }); } + void _stopStaleDataTicker() { + _staleDataTimer?.cancel(); + _staleDataTimer = null; + } + void _throttledNotifyListeners() { if (_throttleTimer?.isActive ?? false) return; @@ -47,6 +126,9 @@ class SensorDataProvider with ChangeNotifier { @override void dispose() { + _throttleTimer?.cancel(); + _silenceTimer?.cancel(); + _stopStaleDataTicker(); _sensorStreamSubscription?.cancel(); super.dispose(); } diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index 11ff9909..5db2df35 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import '../models/logger.dart'; +import '../models/sensor_streams.dart'; class SensorRecorderProvider with ChangeNotifier { final Map> _recorders = {}; @@ -75,7 +76,8 @@ class SensorRecorderProvider with ChangeNotifier { }); if (wearable.hasCapability()) { - for (Sensor sensor in wearable.requireCapability().sensors) { + for (Sensor sensor + in wearable.requireCapability().sensors) { if (!_recorders[wearable]!.containsKey(sensor)) { _recorders[wearable]![sensor] = Recorder(columns: sensor.axisNames); } @@ -147,7 +149,7 @@ class SensorRecorderProvider with ChangeNotifier { File file = await recorder.start( filepath: filepath, - inputStream: sensor.sensorStream, + inputStream: SensorStreams.shared(sensor), ); logger.i( diff --git a/open_wearable/lib/view_models/wearables_provider.dart b/open_wearable/lib/view_models/wearables_provider.dart index 1eb50be0..4383444b 100644 --- a/open_wearable/lib/view_models/wearables_provider.dart +++ b/open_wearable/lib/view_models/wearables_provider.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +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 '../models/logger.dart'; @@ -17,12 +19,12 @@ class NewFirmwareAvailableEvent extends WearableEvent { required this.latestVersion, }) : super( description: - 'Firmware update available for ${wearable.name}: $currentVersion -> $latestVersion', + 'Firmware update available for ${formatWearableDisplayName(wearable.name)}: $currentVersion -> $latestVersion', ); @override String toString() => - 'NewFirmwareAvailableEvent for ${wearable.name}: $currentVersion -> $latestVersion'; + 'NewFirmwareAvailableEvent for ${formatWearableDisplayName(wearable.name)}: $currentVersion -> $latestVersion'; } abstract class UnsupportedFirmwareEvent { @@ -54,11 +56,13 @@ class WearableTimeSynchronizedEvent extends WearableEvent { required super.wearable, String? description, }) : super( - description: description ?? 'Time synchronized for ${wearable.name}', + description: description ?? + 'Time synchronized for ${formatWearableDisplayName(wearable.name)}', ); @override - String toString() => 'WearableTimeSynchronizedEvent for ${wearable.name}'; + String toString() => + 'WearableTimeSynchronizedEvent for ${formatWearableDisplayName(wearable.name)}'; } class WearableErrorEvent extends WearableEvent { @@ -68,13 +72,13 @@ class WearableErrorEvent extends WearableEvent { required this.errorMessage, String? description, }) : super( - description: - description ?? 'Error for ${wearable.name}: $errorMessage', + description: description ?? + 'Error for ${formatWearableDisplayName(wearable.name)}: $errorMessage', ); @override String toString() => - 'WearableErrorEvent for ${wearable.name}: $errorMessage, description: $description'; + 'WearableErrorEvent for ${formatWearableDisplayName(wearable.name)}: $errorMessage, description: $description'; } // MARK: WearablesProvider @@ -83,10 +87,43 @@ class WearablesProvider with ChangeNotifier { final List _wearables = []; final Map _sensorConfigurationProviders = {}; + final Set _splitStereoPairKeys = {}; List get wearables => _wearables; Map get sensorConfigurationProviders => _sensorConfigurationProviders; + bool isStereoPairCombined({ + required Wearable first, + required Wearable second, + }) { + final pairKey = WearableDisplayGroup.stereoPairKeyForDevices(first, second); + return !_splitStereoPairKeys.contains(pairKey); + } + + bool isStereoPairKeyCombined(String pairKey) { + return !_splitStereoPairKeys.contains(pairKey); + } + + void setStereoPairCombined({ + required Wearable first, + required Wearable second, + required bool combined, + }) { + final pairKey = WearableDisplayGroup.stereoPairKeyForDevices(first, second); + setStereoPairKeyCombined(pairKey: pairKey, combined: combined); + } + + void setStereoPairKeyCombined({ + required String pairKey, + required bool combined, + }) { + final changed = combined + ? _splitStereoPairKeys.remove(pairKey) + : _splitStereoPairKeys.add(pairKey); + if (changed) { + notifyListeners(); + } + } final _unsupportedFirmwareEventsController = StreamController.broadcast(); @@ -132,11 +169,38 @@ class WearablesProvider with ChangeNotifier { }); } + Future _wearableNameWithSide(Wearable wearable) async { + final displayName = formatWearableDisplayName(wearable.name); + + if (!wearable.hasCapability()) { + return displayName; + } + + try { + final position = + await wearable.requireCapability().position; + return switch (position) { + DevicePosition.left => '$displayName (Left)', + DevicePosition.right => '$displayName (Right)', + _ => displayName, + }; + } catch (_) { + return displayName; + } + } + Future _syncTimeAndEmit({ required Wearable wearable, - required String successDescription, - required String failureDescription, + required bool fromCapabilityChange, }) async { + final wearableLabel = await _wearableNameWithSide(wearable); + final successDescription = fromCapabilityChange + ? 'Time synchronized for $wearableLabel after capability update' + : 'Time synchronized for $wearableLabel'; + final failureDescription = fromCapabilityChange + ? 'Failed to synchronize time for $wearableLabel after capability update' + : 'Failed to synchronize time for $wearableLabel'; + try { logger.d('Synchronizing time for wearable ${wearable.name}'); await (wearable.requireCapability()) @@ -154,7 +218,7 @@ class WearablesProvider with ChangeNotifier { ); _emitWearableError( wearable: wearable, - errorMessage: 'Failed to synchronize time with ${wearable.name}: $e', + errorMessage: 'Failed to synchronize time with $wearableLabel: $e', description: failureDescription, ); } @@ -175,24 +239,12 @@ class WearablesProvider with ChangeNotifier { }); // Init SensorConfigurationProvider synchronously (no awaits here) - if (wearable.hasCapability()) { - _ensureSensorConfigProvider(wearable); - final notifier = _sensorConfigurationProviders[wearable]!; - for (final config - in (wearable.requireCapability()) - .sensorConfigurations) { - if (notifier.getSelectedConfigurationValue(config) == null && - config.values.isNotEmpty) { - notifier.addSensorConfiguration(config, config.values.first); - } - } - } + _initializeSensorConfigurations(wearable); if (wearable.hasCapability()) { _scheduleMicrotask( () => _syncTimeAndEmit( wearable: wearable, - successDescription: 'Time synchronized for ${wearable.name}', - failureDescription: 'Failed to synchronize time for ${wearable.name}', + fromCapabilityChange: false, ), ); } @@ -246,6 +298,26 @@ class WearablesProvider with ChangeNotifier { } } + void _initializeSensorConfigurations(Wearable wearable) { + if (!wearable.hasCapability()) { + return; + } + + _ensureSensorConfigProvider(wearable); + final notifier = _sensorConfigurationProviders[wearable]!; + final manager = wearable.requireCapability(); + for (final config in manager.sensorConfigurations) { + if (notifier.getSelectedConfigurationValue(config) == null && + config.values.isNotEmpty) { + notifier.addSensorConfiguration( + config, + config.values.first, + markPending: false, + ); + } + } + } + /// Attempts to pair a stereo device with a matching partner among the /// already-known wearables. Runs asynchronously and logs results. /// Non-blocking for the caller. @@ -348,7 +420,50 @@ class WearablesProvider with ChangeNotifier { } } + Future _turnOffSensorsForDeviceWithProvider({ + required Wearable wearable, + required SensorConfigurationProvider provider, + }) async { + try { + await provider.turnOffAllSensors(); + } catch (e, st) { + logger.w( + 'Failed to turn off sensors for ${formatWearableDisplayName(wearable.name)}: $e\n$st', + ); + } + } + + Future turnOffSensorsForDevice(Wearable wearable) async { + final provider = _sensorConfigurationProviders[wearable]; + if (provider == null) { + return; + } + await _turnOffSensorsForDeviceWithProvider( + wearable: wearable, + provider: provider, + ); + } + + Future turnOffSensorsForAllDevices() async { + final providersByWearable = Map.from( + _sensorConfigurationProviders, + ); + + for (final entry in providersByWearable.entries) { + await _turnOffSensorsForDeviceWithProvider( + wearable: entry.key, + provider: entry.value, + ); + } + } + void removeWearable(Wearable wearable) { + _splitStereoPairKeys.removeWhere( + (key) => WearableDisplayGroup.stereoPairKeyContainsDevice( + key, + wearable.deviceId, + ), + ); _wearables.remove(wearable); _sensorConfigurationProviders.remove(wearable); _capabilitySubscriptions.remove(wearable)?.cancel(); @@ -371,18 +486,18 @@ class WearablesProvider with ChangeNotifier { required List addedCapabilites, }) { if (addedCapabilites.contains(SensorConfigurationManager)) { - _ensureSensorConfigProvider(wearable); + _initializeSensorConfigurations(wearable); } if (addedCapabilites.contains(TimeSynchronizable)) { _scheduleMicrotask( () => _syncTimeAndEmit( wearable: wearable, - successDescription: - 'Time synchronized for ${wearable.name} after capability change', - failureDescription: - 'Failed to synchronize time for ${wearable.name} after capability change', + fromCapabilityChange: true, ), ); } + if (addedCapabilites.isNotEmpty) { + notifyListeners(); + } } } diff --git a/open_wearable/lib/widgets/app_banner.dart b/open_wearable/lib/widgets/app_banner.dart index 3d5a105e..fa2a1904 100644 --- a/open_wearable/lib/widgets/app_banner.dart +++ b/open_wearable/lib/widgets/app_banner.dart @@ -1,53 +1,70 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../view_models/app_banner_controller.dart'; class AppBanner extends StatelessWidget { final Widget content; final Color backgroundColor; + final Color? foregroundColor; + final IconData? leadingIcon; const AppBanner({ super.key, required this.content, this.backgroundColor = Colors.blue, + this.foregroundColor, + this.leadingIcon, }); @override Widget build(BuildContext context) { - return Stack( - clipBehavior: Clip.none, - children: [ - Card( - elevation: 3, + final resolvedForeground = foregroundColor ?? Colors.white; + final borderColor = resolvedForeground.withValues(alpha: 0.22); + + return Material( + color: Colors.transparent, + child: DecoratedBox( + decoration: BoxDecoration( color: backgroundColor, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 40, 16), - child: content, - ), - ), - Positioned( - top: 8, - right: 8, - child: IconButton( - padding: const EdgeInsets.all(0), - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.11), + blurRadius: 14, + offset: const Offset(0, 4), ), - style: ButtonStyle( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: resolvedForeground, + fontWeight: FontWeight.w600, + ) ?? + TextStyle( + color: resolvedForeground, + fontWeight: FontWeight.w600, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (leadingIcon != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + leadingIcon, + size: 18, + color: resolvedForeground, + ), + ), + const SizedBox(width: 10), + ], + Expanded(child: content), + ], ), - splashRadius: 18, - icon: const Icon(Icons.close, size: 20, color: Colors.white), - onPressed: () { - final controller = - Provider.of(context, listen: false); - controller.hideBanner(this); - }, ), ), - ], + ), ); } } diff --git a/open_wearable/lib/widgets/app_toast.dart b/open_wearable/lib/widgets/app_toast.dart new file mode 100644 index 00000000..ba2323dd --- /dev/null +++ b/open_wearable/lib/widgets/app_toast.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:open_wearable/view_models/app_banner_controller.dart'; +import 'package:open_wearable/widgets/app_banner.dart'; +import 'package:provider/provider.dart'; + +enum AppToastType { + info, + success, + warning, + error, +} + +class AppToast { + const AppToast._(); + + static void show( + BuildContext context, { + required String message, + AppToastType type = AppToastType.info, + IconData? icon, + Duration duration = const Duration(seconds: 4), + }) { + final normalizedMessage = _ensureTrailingPeriod(message); + _showInternal( + context, + content: Text(normalizedMessage), + type: type, + icon: icon, + duration: duration, + ); + } + + static void showContent( + BuildContext context, { + required Widget content, + AppToastType type = AppToastType.info, + IconData? icon, + Duration duration = const Duration(seconds: 4), + }) { + _showInternal( + context, + content: content, + type: type, + icon: icon, + duration: duration, + ); + } + + static void _showInternal( + BuildContext context, { + required Widget content, + required AppToastType type, + required IconData? icon, + required Duration duration, + }) { + final style = _AppToastStyle.resolve( + context: context, + type: type, + iconOverride: icon, + ); + + final appBannerController = + Provider.of(context, listen: false); + if (appBannerController != null) { + appBannerController.showBanner( + (id) => AppBanner( + key: ValueKey(id), + content: content, + backgroundColor: style.backgroundColor, + foregroundColor: style.foregroundColor, + leadingIcon: style.icon, + ), + duration: duration, + ); + return; + } + + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) { + return; + } + + messenger + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(10, 0, 10, 10), + elevation: 0, + padding: EdgeInsets.zero, + duration: duration, + backgroundColor: Colors.transparent, + content: AppBanner( + content: content, + backgroundColor: style.backgroundColor, + foregroundColor: style.foregroundColor, + leadingIcon: style.icon, + ), + ), + ); + } + + static String _ensureTrailingPeriod(String message) { + final trimmed = message.trimRight(); + if (trimmed.isEmpty) { + return trimmed; + } + + final lastChar = trimmed[trimmed.length - 1]; + if (lastChar == '.' || lastChar == '!' || lastChar == '?') { + return trimmed; + } + + if (trimmed.length > 1 && + (lastChar == '"' || + lastChar == '\'' || + lastChar == ')' || + lastChar == ']')) { + final previousChar = trimmed[trimmed.length - 2]; + if (previousChar == '.' || previousChar == '!' || previousChar == '?') { + return trimmed; + } + } + + return '$trimmed.'; + } +} + +class _AppToastStyle { + final Color backgroundColor; + final Color foregroundColor; + final IconData icon; + + const _AppToastStyle({ + required this.backgroundColor, + required this.foregroundColor, + required this.icon, + }); + + static _AppToastStyle resolve({ + required BuildContext context, + required AppToastType type, + IconData? iconOverride, + }) { + final colorScheme = Theme.of(context).colorScheme; + + final base = switch (type) { + AppToastType.info => const _AppToastStyle( + backgroundColor: Color(0xFFEDE4FF), + foregroundColor: Color(0xFF5A2EA6), + icon: Icons.info_outline_rounded, + ), + AppToastType.success => const _AppToastStyle( + backgroundColor: Color(0xFFE8F5E9), + foregroundColor: Color(0xFF1E6A3A), + icon: Icons.check_circle_outline_rounded, + ), + AppToastType.warning => const _AppToastStyle( + backgroundColor: Color(0xFFFFF3E0), + foregroundColor: Color(0xFF8A4B00), + icon: Icons.warning_amber_rounded, + ), + AppToastType.error => _AppToastStyle( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + icon: Icons.error_outline_rounded, + ), + }; + + if (iconOverride == null) { + return base; + } + + return _AppToastStyle( + backgroundColor: base.backgroundColor, + foregroundColor: base.foregroundColor, + icon: iconOverride, + ); + } +} diff --git a/open_wearable/lib/widgets/common/app_section_card.dart b/open_wearable/lib/widgets/common/app_section_card.dart new file mode 100644 index 00000000..773c8b74 --- /dev/null +++ b/open_wearable/lib/widgets/common/app_section_card.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +class AppSectionCard extends StatelessWidget { + final String title; + final String? subtitle; + final Widget child; + final EdgeInsetsGeometry margin; + final EdgeInsetsGeometry contentPadding; + + const AppSectionCard({ + super.key, + required this.title, + this.subtitle, + required this.child, + this.margin = EdgeInsets.zero, + this.contentPadding = const EdgeInsets.fromLTRB(14, 12, 14, 12), + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + margin: margin, + child: Padding( + padding: contentPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 10), + child, + ], + ), + ), + ); + } +} diff --git a/open_wearable/lib/widgets/common/no_devices_prompt.dart b/open_wearable/lib/widgets/common/no_devices_prompt.dart new file mode 100644 index 00000000..dff97e41 --- /dev/null +++ b/open_wearable/lib/widgets/common/no_devices_prompt.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +class NoDevicesPrompt extends StatelessWidget { + final VoidCallback onScanPressed; + final String title; + final String subtitle; + + const NoDevicesPrompt({ + super.key, + required this.onScanPressed, + this.title = 'No devices connected', + this.subtitle = 'Scan for devices to start streaming and recording data.', + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.bluetooth_searching_rounded, + size: 28, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 14), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + subtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onScanPressed, + icon: const Icon(Icons.search_rounded, size: 18), + label: const Text('Scan for devices'), + ), + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/devices/battery_state.dart b/open_wearable/lib/widgets/devices/battery_state.dart index cbaaa3e7..5a865782 100644 --- a/open_wearable/lib/widgets/devices/battery_state.dart +++ b/open_wearable/lib/widgets/devices/battery_state.dart @@ -1,95 +1,439 @@ +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'; -class BatteryStateView extends StatelessWidget { - final Wearable _device; +class BatteryStateView extends StatefulWidget { + final Wearable device; + final bool showBackground; + final bool liveUpdates; + + const BatteryStateView({ + super.key, + required this.device, + this.showBackground = true, + this.liveUpdates = false, + }); + + @override + State createState() => _BatteryStateViewState(); +} + +class _BatteryStateViewState extends State { + static final Map _batteryCacheByDeviceId = {}; + static final Map _powerCacheByDeviceId = + {}; + static final Set _primedDeviceIds = {}; + static final Map _primeAttemptsByDeviceId = {}; + static final Map> _primeTasksByDeviceId = + >{}; + static const int _maxPrimeAttempts = 3; - const BatteryStateView({super.key, required Wearable device}) - : _device = device; + bool _hasBatteryLevel = false; + bool _hasPowerStatus = false; + bool _isDisconnected = false; + bool _isPriming = false; + Stream? _batteryPercentageStream; + Stream? _powerStatusStream; + StreamSubscription>? _capabilitySubscription; + + @override + void initState() { + super.initState(); + _attachDisconnectListener(); + _attachCapabilityListener(); + _resolveBatteryStreams(); + } + + @override + void didUpdateWidget(covariant BatteryStateView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device || + oldWidget.liveUpdates != widget.liveUpdates) { + _isDisconnected = false; + _attachDisconnectListener(); + _attachCapabilityListener(); + _resolveBatteryStreams(); + } + } + + String get _deviceKey => widget.device.deviceId; + + void _attachDisconnectListener() { + final keyAtListenerRegistration = _deviceKey; + widget.device.addDisconnectListener(() { + if (!mounted) { + return; + } + setState(() { + _isDisconnected = true; + _primeAttemptsByDeviceId.remove(keyAtListenerRegistration); + _batteryPercentageStream = null; + _powerStatusStream = null; + }); + }); + } + + void _attachCapabilityListener() { + _capabilitySubscription?.cancel(); + _capabilitySubscription = + widget.device.capabilityRegistered.listen((addedCapabilities) { + if (!mounted || _isDisconnected) { + return; + } + + final hasBatteryCapability = + addedCapabilities.contains(BatteryLevelStatus) || + addedCapabilities.contains(BatteryLevelStatusService); + if (!hasBatteryCapability) { + return; + } + + _primeAttemptsByDeviceId.remove(_deviceKey); + setState(_resolveBatteryStreams); + }); + } + + int _cacheBatteryLevel(int value) { + _batteryCacheByDeviceId[_deviceKey] = value; + return value; + } + + BatteryPowerStatus _cachePowerStatus(BatteryPowerStatus value) { + _powerCacheByDeviceId[_deviceKey] = value; + return value; + } + + void _resolveBatteryStreams() { + if (_isDisconnected) { + _hasBatteryLevel = false; + _hasPowerStatus = false; + _isPriming = false; + _batteryPercentageStream = null; + _powerStatusStream = null; + return; + } + + _hasBatteryLevel = widget.device.hasCapability(); + _hasPowerStatus = widget.device.hasCapability(); + + if (!widget.liveUpdates) { + _batteryPercentageStream = null; + _powerStatusStream = null; + _primeBatteryCacheOnce(); + return; + } + + _batteryPercentageStream = _hasBatteryLevel + ? widget.device + .requireCapability() + .batteryPercentageStream + .map(_cacheBatteryLevel) + : null; + + _powerStatusStream = _hasPowerStatus + ? widget.device + .requireCapability() + .powerStatusStream + .map(_cachePowerStatus) + : null; + + _primeBatteryCacheOnce(); + } + + void _primeBatteryCacheOnce() { + final key = _deviceKey; + final device = widget.device; + final hasBatteryLevel = _hasBatteryLevel; + final hasPowerStatus = _hasPowerStatus; + final attempts = _primeAttemptsByDeviceId[key] ?? 0; + if (_isDisconnected || + _primedDeviceIds.contains(key) || + _primeTasksByDeviceId.containsKey(key) || + attempts >= _maxPrimeAttempts || + (!hasBatteryLevel && !hasPowerStatus)) { + return; + } + + _primeAttemptsByDeviceId[key] = attempts + 1; + _isPriming = true; + if (mounted) { + setState(() {}); + } + + final task = _primeBatteryCache( + device: device, + hasBatteryLevel: hasBatteryLevel, + hasPowerStatus: hasPowerStatus, + ).then((loadedAnyValue) { + if (loadedAnyValue) { + _primedDeviceIds.add(key); + _primeAttemptsByDeviceId.remove(key); + return; + } + _schedulePrimeRetry(key); + }).whenComplete(() { + _primeTasksByDeviceId.remove(key); + if (!mounted || _deviceKey != key) { + return; + } + _isPriming = false; + setState(() {}); + }); + + _primeTasksByDeviceId[key] = task; + } + + void _schedulePrimeRetry(String key) { + final attempts = _primeAttemptsByDeviceId[key] ?? 0; + if (attempts >= _maxPrimeAttempts || + _primedDeviceIds.contains(key) || + _batteryCacheByDeviceId[key] != null || + _powerCacheByDeviceId[key] != null) { + return; + } + + final delay = Duration(milliseconds: 500 * attempts); + Future.delayed(delay, () { + if (!mounted || _isDisconnected || _deviceKey != key) { + return; + } + setState(_resolveBatteryStreams); + }); + } + + Future _primeBatteryCache({ + required Wearable device, + required bool hasBatteryLevel, + required bool hasPowerStatus, + }) async { + var loadedAnyValue = false; + + if (hasBatteryLevel) { + try { + final level = await device + .requireCapability() + .readBatteryPercentage() + .timeout(const Duration(seconds: 2)); + _cacheBatteryLevel(level); + loadedAnyValue = true; + } catch (_) { + // Keep quiet: fallback to stream updates. + } + } + + if (hasPowerStatus) { + try { + final status = await device + .requireCapability() + .readPowerStatus() + .timeout(const Duration(seconds: 2)); + _cachePowerStatus(status); + loadedAnyValue = true; + } catch (_) { + // Keep quiet: fallback to stream updates. + } + } + + return loadedAnyValue; + } + + @override + void dispose() { + _capabilitySubscription?.cancel(); + _capabilitySubscription = null; + super.dispose(); + } @override Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_device.hasCapability()) - StreamBuilder( - stream: _device.requireCapability().batteryPercentageStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - return PlatformText("${snapshot.data}%"); - } else { - return PlatformCircularProgressIndicator(); - } - }, - ), - if (_device.hasCapability()) - StreamBuilder( - stream: _device.requireCapability().powerStatusStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - if (!snapshot.data!.batteryPresent) { - return Icon(Icons.battery_unknown_rounded); - } - - if (snapshot.data!.chargeState == ChargeState.charging) { - return Icon(Icons.battery_charging_full_rounded); - } - - switch (snapshot.data!.chargeLevel) { - case BatteryChargeLevel.good: - return Icon(Icons.battery_full); - case BatteryChargeLevel.low: - return Icon(Icons.battery_3_bar_rounded); - case BatteryChargeLevel.critical: - return Icon(Icons.battery_1_bar_rounded); - case BatteryChargeLevel.unknown: - return Icon(Icons.battery_unknown); - } - } else { - return PlatformCircularProgressIndicator(); - } - }, - ) - else if (_device.hasCapability()) - StreamBuilder( - stream: _device.requireCapability().batteryPercentageStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - return Icon(getBatteryIcon(snapshot.data!)); - } else { - return PlatformCircularProgressIndicator(); - } + final cachedBattery = _batteryCacheByDeviceId[_deviceKey]; + final cachedPower = _powerCacheByDeviceId[_deviceKey]; + + if (!_hasBatteryLevel && + !_hasPowerStatus && + cachedBattery == null && + cachedPower == null) { + return const SizedBox.shrink(); + } + + if (!widget.liveUpdates || _isDisconnected) { + if (cachedBattery == null && cachedPower == null && !_isPriming) { + return const SizedBox.shrink(); + } + return _BatteryBadge( + batteryLevel: cachedBattery, + powerStatus: cachedPower, + isLoading: _isPriming && cachedBattery == null && cachedPower == null, + showBackground: widget.showBackground, + ); + } + + if (_hasBatteryLevel && _hasPowerStatus) { + return StreamBuilder( + stream: _batteryPercentageStream, + initialData: cachedBattery, + builder: (context, batterySnapshot) { + return StreamBuilder( + stream: _powerStatusStream, + initialData: cachedPower, + builder: (context, powerSnapshot) { + return _BatteryBadge( + batteryLevel: batterySnapshot.data, + powerStatus: powerSnapshot.data, + isLoading: !batterySnapshot.hasData && !powerSnapshot.hasData, + showBackground: widget.showBackground, + ); }, - ), - ], + ); + }, + ); + } + + if (_hasPowerStatus) { + return StreamBuilder( + stream: _powerStatusStream, + initialData: cachedPower, + builder: (context, snapshot) { + return _BatteryBadge( + batteryLevel: null, + powerStatus: snapshot.data, + isLoading: !snapshot.hasData, + showBackground: widget.showBackground, + ); + }, + ); + } + + return StreamBuilder( + stream: _batteryPercentageStream, + initialData: cachedBattery, + builder: (context, snapshot) { + return _BatteryBadge( + batteryLevel: snapshot.data, + isLoading: !snapshot.hasData, + showBackground: widget.showBackground, + ); + }, ); } +} + +class _BatteryBadge extends StatelessWidget { + final int? batteryLevel; + final BatteryPowerStatus? powerStatus; + final bool isLoading; + final bool showBackground; + + const _BatteryBadge({ + required this.batteryLevel, + this.powerStatus, + this.isLoading = false, + this.showBackground = true, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final normalizedLevel = batteryLevel?.clamp(0, 100); + + final batteryPresent = powerStatus?.batteryPresent ?? true; + final charging = powerStatus?.chargeState == ChargeState.charging; + final Color foregroundColor = colors.primary; + + final Color backgroundColor = + showBackground ? colors.surface : Colors.transparent; + final Color borderColor = foregroundColor.withValues(alpha: 0.42); - IconData getBatteryIcon(int batteryLevel) { - int batteryBars = (batteryLevel / 12.5).toInt(); - - switch (batteryBars) { - case 0: - return Icons.battery_0_bar_rounded; - case 1: - return Icons.battery_1_bar_rounded; - case 2: - return Icons.battery_2_bar_rounded; - case 3: - return Icons.battery_3_bar_rounded; - case 4: - return Icons.battery_4_bar_rounded; - case 5: - return Icons.battery_5_bar_rounded; - case 6: - return Icons.battery_6_bar_rounded; - case 7: - case 8: - return Icons.battery_full_rounded; + final IconData icon; + final String label; + + if (normalizedLevel != null) { + icon = charging + ? Icons.battery_charging_full_rounded + : _batteryIconForPercent(normalizedLevel); + label = "$normalizedLevel%"; + } else if (!batteryPresent) { + icon = Icons.battery_unknown_rounded; + label = "No battery"; + } else if (charging) { + icon = Icons.battery_charging_full_rounded; + label = "Charging"; + } else { + icon = _batteryIconForChargeLevel(powerStatus?.chargeLevel); + label = switch (powerStatus?.chargeLevel) { + BatteryChargeLevel.critical => "Critical", + BatteryChargeLevel.low => "Low", + BatteryChargeLevel.good => "Battery", + _ => "--", + }; } - return Icons.battery_unknown_rounded; + final showLoadingPlaceholder = + isLoading && batteryLevel == null && powerStatus == null; + final displayIcon = + showLoadingPlaceholder ? Icons.battery_unknown_rounded : icon; + final displayLabel = showLoadingPlaceholder ? "..." : label; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(displayIcon, size: 15, color: foregroundColor), + const SizedBox(width: 6), + Text( + displayLabel, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], + ), + ); + } +} + +IconData _batteryIconForChargeLevel(BatteryChargeLevel? chargeLevel) { + return switch (chargeLevel) { + BatteryChargeLevel.good => Icons.battery_full_rounded, + BatteryChargeLevel.low => Icons.battery_3_bar_rounded, + BatteryChargeLevel.critical => Icons.battery_1_bar_rounded, + _ => Icons.battery_unknown_rounded, + }; +} + +IconData _batteryIconForPercent(int batteryLevel) { + final batteryBars = (batteryLevel / 12.5).toInt(); + + switch (batteryBars) { + case 0: + return Icons.battery_0_bar_rounded; + case 1: + return Icons.battery_1_bar_rounded; + case 2: + return Icons.battery_2_bar_rounded; + case 3: + return Icons.battery_3_bar_rounded; + case 4: + return Icons.battery_4_bar_rounded; + case 5: + return Icons.battery_5_bar_rounded; + case 6: + return Icons.battery_6_bar_rounded; + case 7: + case 8: + return Icons.battery_full_rounded; + default: + return Icons.battery_unknown_rounded; } } diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index 96bc20ef..201b964b 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -3,7 +3,11 @@ 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' hide logger; +import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/devices/devices_page.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:provider/provider.dart'; import '../../models/logger.dart'; @@ -22,10 +26,14 @@ class ConnectDevicesPage extends StatefulWidget { class _ConnectDevicesPageState extends State { final WearableManager _wearableManager = WearableManager(); - StreamSubscription? _scanSubscription; + StreamSubscription? _scanSubscription; + Timer? _scanIndicatorTimer; - List discoveredDevices = []; - Map connectingDevices = {}; + final List _discoveredDevices = []; + final Map _connectingDevices = {}; + + bool _isScanning = false; + DateTime? _lastScanStartedAt; @override void initState() { @@ -35,56 +43,198 @@ class _ConnectDevicesPageState extends State { @override Widget build(BuildContext context) { - final WearablesProvider wearablesProvider = - Provider.of(context); - - List connectedDevicesWidgets = - wearablesProvider.wearables.map((wearable) { - return PlatformListTile( - title: PlatformText(wearable.name), - subtitle: PlatformText(wearable.deviceId), - trailing: Icon(PlatformIcons(context).checkMark), - ); - }).toList(); - List discoveredDevicesWidgets = discoveredDevices.map((device) { - return PlatformListTile( - title: PlatformText(device.name), - subtitle: PlatformText(device.id), - trailing: _buildTrailingWidget(device.id), - onTap: () { - _connectToDevice(device, context); - }, - ); - }).toList(); + final wearablesProvider = context.watch(); + final connectedWearables = wearablesProvider.wearables; + final connectedDeviceIds = + connectedWearables.map((wearable) => wearable.deviceId).toSet(); + final connectedGroups = orderWearableGroupsForOverview( + connectedWearables + .map( + (wearable) => WearableDisplayGroup.single( + wearable: wearable, + ), + ) + .toList(), + ); + + final availableDevices = _discoveredDevices + .where((device) => !connectedDeviceIds.contains(device.id)) + .toList() + ..sort((a, b) { + final nameCompare = _deviceName(a) + .toLowerCase() + .compareTo(_deviceName(b).toLowerCase()); + if (nameCompare != 0) return nameCompare; + return a.id.compareTo(b.id); + }); return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText('Connect Devices'), + title: const Text('Connect Devices'), + trailingActions: [ + const AppBarRecordingIndicator(), + PlatformIconButton( + icon: _isScanning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.bluetooth_searching), + onPressed: + _isScanning ? null : () => _startScanning(clearPrevious: true), + ), + ], ), - body: Padding( - padding: const EdgeInsets.all(10.0), + body: RefreshIndicator( + onRefresh: () async { + await _startScanning(clearPrevious: true); + }, child: ListView( - shrinkWrap: true, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB( + 12, + 10, + 12, + 16 + MediaQuery.paddingOf(context).bottom, + ), children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText( - 'Connected Devices', - style: Theme.of(context).textTheme.titleSmall, - ), + _buildScanStatusCard( + context, + connectedCount: connectedWearables.length, + discoveredCount: availableDevices.length, + ), + const SizedBox(height: 12), + _buildSectionHeader( + context, + title: 'Connected', + count: connectedWearables.length, ), - ...connectedDevicesWidgets, - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText( - 'Discovered Devices', - style: Theme.of(context).textTheme.titleSmall, + if (connectedWearables.isEmpty) + _buildEmptyCard( + context, + title: 'No devices connected', + subtitle: 'Tap a discovered device below to connect.', + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < connectedGroups.length; i++) ...[ + DeviceRow( + group: connectedGroups[i], + cardMargin: EdgeInsets.zero, + ), + if (i < connectedGroups.length - 1) + const SizedBox(height: 8), + ], + ], ), + const SizedBox(height: 12), + _buildSectionHeader( + context, + title: 'Available', + count: availableDevices.length, ), - ...discoveredDevicesWidgets, + if (availableDevices.isEmpty) + _buildEmptyCard( + context, + title: _isScanning + ? 'Scanning for devices...' + : 'No devices found yet', + subtitle: _isScanning + ? 'Make sure your wearable is turned on and nearby.' + : 'Press scan again or pull to refresh.', + ) + else + ...availableDevices.map( + (device) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: PlatformListTile( + leading: const Icon(Icons.bluetooth), + title: PlatformText(_deviceName(device)), + subtitle: PlatformText(device.id), + trailing: _buildTrailingWidget(device), + onTap: _connectingDevices[device.id] == true + ? null + : () => _connectToDevice(device, context), + ), + ), + ), + const SizedBox(height: 10), PlatformElevatedButton( - onPressed: _startScanning, - child: PlatformText('Scan'), + onPressed: _isScanning + ? null + : () => _startScanning(clearPrevious: true), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_isScanning) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon(Icons.bluetooth_searching), + const SizedBox(width: 8), + Text(_isScanning ? 'Scanning...' : 'Scan Again'), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildScanStatusCard( + BuildContext context, { + required int connectedCount, + required int discoveredCount, + }) { + final statusText = + _isScanning ? 'Scanning for nearby devices' : 'Ready to scan'; + final helperText = _lastScanStartedAt == null + ? 'Use Scan to discover nearby wearables.' + : 'Last scan: ${_formatScanTime(_lastScanStartedAt!)}'; + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _isScanning ? Icons.radar : Icons.bluetooth_searching, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + statusText, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + helperText, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _StatusPill(label: '$connectedCount connected'), + _StatusPill(label: '$discoveredCount available'), + ], ), ], ), @@ -92,73 +242,253 @@ class _ConnectDevicesPageState extends State { ); } - Widget _buildTrailingWidget(String id) { - if (connectingDevices[id] == true) { + Widget _buildSectionHeader( + BuildContext context, { + required String title, + required int count, + }) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Row( + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 8), + _StatusPill(label: '$count'), + ], + ), + ); + } + + Widget _buildEmptyCard( + BuildContext context, { + required String title, + required String subtitle, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: const Icon(Icons.info_outline), + title: Text(title), + subtitle: Text(subtitle), + ), + ); + } + + Widget _buildTrailingWidget(DiscoveredDevice device) { + if (_connectingDevices[device.id] == true) { return SizedBox( height: 24, width: 24, child: PlatformCircularProgressIndicator(), ); } - return const SizedBox.shrink(); + return PlatformTextButton( + onPressed: () => _connectToDevice(device, context), + child: const Text('Connect'), + ); + } + + String _deviceName(DiscoveredDevice device) { + final name = device.name.trim(); + if (name.isEmpty) return 'Unnamed device'; + return formatWearableDisplayName(name); } - void _startScanning() async { - _wearableManager.startScan(); + String _formatScanTime(DateTime startedAt) { + final elapsed = DateTime.now().difference(startedAt); + if (elapsed.inSeconds < 10) return 'just now'; + if (elapsed.inMinutes < 1) return '${elapsed.inSeconds}s ago'; + if (elapsed.inHours < 1) return '${elapsed.inMinutes}m ago'; + return '${elapsed.inHours}h ago'; + } + + void _stopScanning({ + bool clearDiscovered = false, + bool updateUi = true, + }) { + _scanIndicatorTimer?.cancel(); + _scanIndicatorTimer = null; _scanSubscription?.cancel(); - _scanSubscription = _wearableManager.scanStream.listen((incomingDevice) { - if (incomingDevice.name.isNotEmpty && - !discoveredDevices.any((device) => device.id == incomingDevice.id)) { - logger.d('Discovered device: ${incomingDevice.name}'); - setState(() { - discoveredDevices.add(incomingDevice); - }); + _scanSubscription = null; + + if (!updateUi || !mounted) { + _isScanning = false; + if (clearDiscovered) { + _discoveredDevices.clear(); + } + return; + } + + setState(() { + _isScanning = false; + if (clearDiscovered) { + _discoveredDevices.clear(); } }); } + Future _startScanning({bool clearPrevious = false}) async { + _scanIndicatorTimer?.cancel(); + + if (mounted) { + setState(() { + if (clearPrevious) { + _discoveredDevices.clear(); + } + _isScanning = true; + _lastScanStartedAt = DateTime.now(); + }); + } + + await _scanSubscription?.cancel(); + _scanSubscription = _wearableManager.scanStream.listen( + (incomingDevice) { + if (incomingDevice.name.isEmpty) return; + + if (_discoveredDevices + .any((device) => device.id == incomingDevice.id)) { + return; + } + + logger.d('Discovered device: ${incomingDevice.name}'); + if (mounted) { + setState(() { + _discoveredDevices.add(incomingDevice); + }); + } + }, + onError: (error, stackTrace) { + logger.w('Device scan stream error: $error\n$stackTrace'); + _stopScanning(); + }, + ); + + try { + await _wearableManager.startScan(); + } catch (error, stackTrace) { + logger.w('Failed to start scan: $error\n$stackTrace'); + _stopScanning(); + return; + } + + _scanIndicatorTimer = Timer(const Duration(seconds: 8), _stopScanning); + } + Future _connectToDevice( DiscoveredDevice device, BuildContext context, ) async { + if (_connectingDevices[device.id] == true) return; + setState(() { - connectingDevices[device.id] = true; + _connectingDevices[device.id] = true; }); try { - WearableConnector connector = context.read(); + final connector = context.read(); await connector.connect(device); - setState(() { - discoveredDevices.remove(device); - }); - } catch (e) { - String message = _wearableManager.deviceErrorMessage(e, device.name); - logger.e('Failed to connect to device: ${device.name}, error: $message'); + if (mounted) { + setState(() { + _discoveredDevices.removeWhere((d) => d.id == device.id); + }); + } + } catch (e, stackTrace) { + if (_isAlreadyConnectedError(e, device)) { + logger.i( + 'Device ${device.id} already connected. Refreshing connected devices.', + ); + await _pullConnectedSystemDevices(); + if (mounted) { + setState(() { + _discoveredDevices.removeWhere((d) => d.id == device.id); + }); + } + return; + } + + final message = _wearableManager.deviceErrorMessage(e, device.name); + logger.e( + 'Failed to connect to device: ${device.name}, error: $message\n$stackTrace', + ); if (context.mounted) { showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Connection Error'), - content: PlatformText(message), + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Connection Error'), + content: Text(message), actions: [ PlatformDialogAction( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('OK'), + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), ), ], ), ); } } finally { - setState(() { - connectingDevices.remove(device.id); - }); + if (mounted) { + setState(() { + _connectingDevices.remove(device.id); + }); + } + } + } + + bool _isAlreadyConnectedError(Object error, DiscoveredDevice device) { + try { + final message = _wearableManager.deviceErrorMessage(error, device.name); + return message.toLowerCase().contains('already connected'); + } catch (_) { + return error.toString().toLowerCase().contains('already connected'); + } + } + + Future _pullConnectedSystemDevices() async { + if (!mounted) { + return; + } + try { + await context.read().connectToSystemDevices(); + } catch (error, stackTrace) { + logger.w('Failed to pull connected system devices: $error\n$stackTrace'); } } @override void dispose() { - _scanSubscription?.cancel(); + _stopScanning(updateUi: false); super.dispose(); } } + +class _StatusPill extends StatelessWidget { + final String label; + + const _StatusPill({ + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withValues( + alpha: 0.65, + ), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ); + } +} diff --git a/open_wearable/lib/widgets/devices/device_detail/audio_mode_widget.dart b/open_wearable/lib/widgets/devices/device_detail/audio_mode_widget.dart index 9dcd3ec8..25a6f5f2 100644 --- a/open_wearable/lib/widgets/devices/device_detail/audio_mode_widget.dart +++ b/open_wearable/lib/widgets/devices/device_detail/audio_mode_widget.dart @@ -1,14 +1,22 @@ -import 'package:flutter/cupertino.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/view_models/wearables_provider.dart'; +import 'package:provider/provider.dart'; + +enum AudioModeApplyScope { + userSelectable, + individualOnly, + pairOnly, +} class AudioModeWidget extends StatefulWidget { - final AudioModeManager device; + final Wearable device; + final AudioModeApplyScope applyScope; const AudioModeWidget({ super.key, required this.device, + this.applyScope = AudioModeApplyScope.userSelectable, }); @override @@ -17,55 +25,681 @@ class AudioModeWidget extends StatefulWidget { class _AudioModeWidgetState extends State { AudioMode? _selectedAudioMode; + AudioMode? _primaryAudioMode; + AudioMode? _pairedAudioMode; + AudioModeManager? _pairedAudioModeManager; + Wearable? _pairedWearable; + String _primarySideBadge = 'L'; + String _pairedSideBadge = 'R'; + bool _pairModesDiffer = false; + bool _isLoading = true; + bool _isApplying = false; + bool _applyToStereoPair = false; + String? _errorText; + + AudioModeManager get _audioModeManager => + widget.device.requireCapability(); @override void initState() { super.initState(); - _getSelectedAudioMode(); + _loadState(); + } + + @override + void didUpdateWidget(covariant AudioModeWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _loadState(); + } + } + + Future _loadState() async { + setState(() { + _isLoading = true; + _isApplying = false; + _errorText = null; + }); + + final wearables = context.read().wearables; + + try { + final selectedMode = await _audioModeManager.getAudioMode(); + final pairedWearable = await _findPairedWearable(wearables: wearables); + + AudioModeManager? pairedAudioModeManager; + AudioMode? pairedMode; + if (pairedWearable != null && + pairedWearable.hasCapability()) { + pairedAudioModeManager = + pairedWearable.requireCapability(); + pairedMode = await pairedAudioModeManager.getAudioMode(); + } + final positions = await Future.wait([ + _readStereoPosition(widget.device), + if (pairedWearable != null) _readStereoPosition(pairedWearable), + ]); + final primaryPosition = positions.isNotEmpty ? positions.first : null; + final pairedPosition = positions.length > 1 ? positions[1] : null; + + final primarySideLabel = _sideBadgeForPosition( + primaryPosition, + fallback: 'L', + ); + final pairedSideLabel = _sideBadgeForPosition( + pairedPosition, + fallback: 'R', + ); + + final pairModesDiffer = + pairedMode != null && !_modesEqualByKey(selectedMode, pairedMode); + + if (!mounted) { + return; + } + + setState(() { + _selectedAudioMode = + pairModesDiffer && widget.applyScope == AudioModeApplyScope.pairOnly + ? null + : selectedMode; + _primaryAudioMode = selectedMode; + _pairedAudioMode = pairedMode; + _pairedWearable = pairedWearable; + _pairedAudioModeManager = pairedAudioModeManager; + _primarySideBadge = primarySideLabel; + _pairedSideBadge = pairedSideLabel; + _pairModesDiffer = pairModesDiffer; + _applyToStereoPair = switch (widget.applyScope) { + AudioModeApplyScope.pairOnly => pairedAudioModeManager != null, + AudioModeApplyScope.individualOnly => false, + AudioModeApplyScope.userSelectable => pairedAudioModeManager != null, + }; + _isLoading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _errorText = 'Failed to load listening mode: ${_describeError(error)}'; + _isLoading = false; + }); + } + } + + Future _findPairedWearable({ + required List wearables, + }) async { + if (!widget.device.hasCapability()) { + return null; + } + + final pairedStereo = + await widget.device.requireCapability().pairedDevice; + if (pairedStereo == null) { + return null; + } + + for (final candidate in wearables) { + if (candidate.deviceId == widget.device.deviceId) { + continue; + } + if (!candidate.hasCapability()) { + continue; + } + if (identical( + candidate.requireCapability(), + pairedStereo, + )) { + return candidate; + } + } + + return null; + } + + Future _readStereoPosition(Wearable wearable) async { + if (!wearable.hasCapability()) { + return null; + } + try { + return await wearable.requireCapability().position; + } catch (_) { + return null; + } } - Future _getSelectedAudioMode() async { - final mode = await widget.device.getAudioMode(); + Future _onModeSelected(AudioMode mode) async { + if (_isApplying || _isLoading) { + return; + } + + final previousMode = _selectedAudioMode; + final previousPrimaryMode = _primaryAudioMode; + final previousPairedMode = _pairedAudioMode; + final previousPairModesDiffer = _pairModesDiffer; + final shouldApplyToPair = switch (widget.applyScope) { + AudioModeApplyScope.pairOnly => true, + AudioModeApplyScope.individualOnly => false, + AudioModeApplyScope.userSelectable => _applyToStereoPair, + }; + final pairedManager = shouldApplyToPair ? _pairedAudioModeManager : null; + setState(() { _selectedAudioMode = mode; + _primaryAudioMode = mode; + if (pairedManager != null) { + _pairedAudioMode = mode; + } + _pairModesDiffer = false; + _isApplying = true; + _errorText = null; }); + + bool primaryApplied = false; + try { + await Future.sync(() => _audioModeManager.setAudioMode(mode)); + primaryApplied = true; + + if (pairedManager != null) { + if (!_audioModeManagerSupportsMode(pairedManager, mode)) { + throw StateError( + 'Paired device does not support ${_labelForMode(mode)}.', + ); + } + await Future.sync(() => pairedManager.setAudioMode(mode)); + } + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + if (!primaryApplied || pairedManager == null) { + _selectedAudioMode = previousMode; + _primaryAudioMode = previousPrimaryMode; + _pairedAudioMode = previousPairedMode; + _pairModesDiffer = previousPairModesDiffer; + } else if (widget.applyScope == AudioModeApplyScope.pairOnly) { + _selectedAudioMode = null; + _primaryAudioMode = mode; + _pairedAudioMode = previousPairedMode; + _pairModesDiffer = true; + } + _errorText = _buildApplyError( + error: error, + primaryApplied: primaryApplied, + appliedToPair: pairedManager != null, + ); + }); + } finally { + if (mounted) { + setState(() { + _isApplying = false; + }); + } + } + } + + String _buildApplyError({ + required Object error, + required bool primaryApplied, + required bool appliedToPair, + }) { + final detail = _describeError(error); + if (primaryApplied && appliedToPair) { + return 'Applied to this device, but failed on paired device: $detail'; + } + return 'Failed to apply listening mode: $detail'; + } + + bool _modesEqualByKey(AudioMode? a, AudioMode? b) { + if (a == null || b == null) { + return false; + } + return _normalizedModeKey(a) == _normalizedModeKey(b); + } + + bool _audioModeManagerSupportsMode(AudioModeManager manager, AudioMode mode) { + return manager.availableAudioModes.any( + (candidate) => _modesEqualByKey(candidate, mode), + ); + } + + String _sideBadgeForPosition( + DevicePosition? position, { + required String fallback, + }) { + return switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => fallback, + }; + } + + String _labelForMode(AudioMode mode) { + final normalized = _normalizedModeKey(mode); + if (normalized.contains('noise') || normalized.contains('anc')) { + return 'Noise Cancellation'; + } + if (normalized.contains('transparen') || + normalized.contains('ambient') || + normalized.contains('passthrough')) { + return 'Transparency'; + } + if (normalized.contains('normal') || + normalized.contains('off') || + normalized.contains('default')) { + return 'Standard'; + } + return _toTitleCase(mode.key); + } + + String _subtitleForMode(AudioMode mode) { + final normalized = _normalizedModeKey(mode); + if (normalized.contains('noise') || normalized.contains('anc')) { + return 'Reduce background sound'; + } + if (normalized.contains('transparen') || + normalized.contains('ambient') || + normalized.contains('passthrough')) { + return 'Let surrounding sound in'; + } + if (normalized.contains('normal') || + normalized.contains('off') || + normalized.contains('default')) { + return 'No noise cancellation or transparency'; + } + return 'Custom listening profile'; + } + + bool _isNoiseCancellationMode(AudioMode mode) { + final normalized = _normalizedModeKey(mode); + return normalized.contains('noise') || normalized.contains('anc'); + } + + IconData _iconForMode(AudioMode mode) { + final normalized = _normalizedModeKey(mode); + if (normalized.contains('noise') || normalized.contains('anc')) { + return Icons.volume_off_rounded; + } + if (normalized.contains('transparen') || + normalized.contains('ambient') || + normalized.contains('passthrough')) { + return Icons.hearing_rounded; } + if (normalized.contains('normal') || + normalized.contains('off') || + normalized.contains('default')) { + return Icons.equalizer_rounded; + } + return Icons.graphic_eq_rounded; + } + + String _normalizedModeKey(AudioMode mode) { + return mode.key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + } + + String _toTitleCase(String value) { + final spaced = value + .replaceAllMapped( + RegExp(r'([a-z])([A-Z])'), + (match) => '${match.group(1)} ${match.group(2)}', + ) + .replaceAll(RegExp(r'[_-]+'), ' ') + .trim(); + + if (spaced.isEmpty) { + return value; + } + + return spaced.split(RegExp(r'\s+')).map((word) { + if (word.isEmpty) { + return word; + } + if (word.length == 1) { + return word.toUpperCase(); + } + return '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}'; + }).join(' '); + } + + String _describeError(Object error) { + final text = error.toString().trim(); + if (text.startsWith('Exception: ')) { + return text.substring('Exception: '.length); + } + if (text.startsWith('StateError: ')) { + return text.substring('StateError: '.length); + } + return text; + } + + Widget _buildModeOptions(List modes) { + return LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final columns = constraints.maxWidth >= 520 ? 3 : 1; + final itemWidth = columns == 1 + ? constraints.maxWidth + : (constraints.maxWidth - (spacing * (columns - 1))) / columns; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: modes.map((mode) { + final selected = _selectedAudioMode != null && + _modesEqualByKey(_selectedAudioMode, mode); + final showPairSideBadges = + widget.applyScope == AudioModeApplyScope.pairOnly && + _pairModesDiffer; + final sideBadges = [ + if (showPairSideBadges && + _primaryAudioMode != null && + _modesEqualByKey(_primaryAudioMode, mode)) + _primarySideBadge, + if (showPairSideBadges && + _pairedAudioMode != null && + _modesEqualByKey(_pairedAudioMode, mode)) + _pairedSideBadge, + ]; + + return SizedBox( + width: itemWidth, + child: _AudioModeOptionButton( + label: _labelForMode(mode), + subtitle: _subtitleForMode(mode), + badgeText: _isNoiseCancellationMode(mode) ? 'BETA' : null, + icon: _iconForMode(mode), + selected: selected, + sideBadges: sideBadges, + enabled: !_isApplying && !_isLoading, + onTap: () => _onModeSelected(mode), + ), + ); + }).toList(), + ); + }, + ); + } @override Widget build(BuildContext context) { - return PlatformWidget( - cupertino:(context, platform) => CupertinoSlidingSegmentedControl( - children: { - for (var item in widget.device.availableAudioModes) - item : PlatformText(item.key), - }, - onValueChanged: (AudioMode? mode) { - if (mode == null) return; - widget.device.setAudioMode(mode); - setState(() { - _selectedAudioMode = mode; - }); - }, - groupValue: _selectedAudioMode, + final modes = _audioModeManager.availableAudioModes.toList(); + if (modes.isEmpty) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + final pairName = _pairedWearable?.name; + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Listening Mode', + style: theme.textTheme.titleSmall, + ), + ), + if (_isLoading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Choose how much surrounding sound to let in.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + if (_pairedAudioModeManager != null && + widget.applyScope == AudioModeApplyScope.userSelectable) ...[ + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + dense: true, + value: _applyToStereoPair, + onChanged: _isApplying || _isLoading + ? null + : (value) { + setState(() { + _applyToStereoPair = value; + }); + }, + title: const Text('Apply to stereo pair'), + subtitle: Text( + _applyToStereoPair + ? pairName == null + ? 'Left and right devices change together.' + : 'Also update $pairName.' + : 'Only update this device.', + ), + ), + ], + const SizedBox(height: 6), + _buildModeOptions(modes), + if (_isApplying) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(minHeight: 2), + ], + if (_errorText != null) ...[ + const SizedBox(height: 8), + Text( + _errorText!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ], ), - material: (context, platform) => SegmentedButton( - segments: - widget.device.availableAudioModes.map((item) { - return ButtonSegment( - value: item, - label: PlatformText(item.key), - ); - }).toList(), - onSelectionChanged: (Set selected) { - if (selected.isEmpty) return; - widget.device.setAudioMode(selected.first); - setState(() { - _selectedAudioMode = selected.first; - }); - }, - selected: _selectedAudioMode != null ? { _selectedAudioMode! } : {}, - emptySelectionAllowed: true, + ); + } +} + +class _AudioModeOptionButton extends StatelessWidget { + final String label; + final String subtitle; + final String? badgeText; + final IconData icon; + final bool selected; + final List sideBadges; + final bool enabled; + final VoidCallback onTap; + + const _AudioModeOptionButton({ + required this.label, + required this.subtitle, + this.badgeText, + required this.icon, + required this.selected, + this.sideBadges = const [], + required this.enabled, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final foregroundColor = + selected ? colorScheme.primary : colorScheme.onSurfaceVariant; + final iconColor = + enabled ? foregroundColor : foregroundColor.withValues(alpha: 0.55); + final titleColor = enabled + ? (selected ? colorScheme.primary : colorScheme.onSurface) + : colorScheme.onSurface.withValues(alpha: 0.55); + final subtitleColor = enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant.withValues(alpha: 0.55); + + final backgroundColor = selected + ? colorScheme.primaryContainer.withValues(alpha: 0.44) + : colorScheme.surface; + final borderColor = selected + ? colorScheme.primary.withValues(alpha: 0.7) + : colorScheme.outlineVariant.withValues(alpha: 0.7); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: enabled ? onTap : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Icon( + icon, + size: 18, + color: iconColor, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: titleColor, + fontWeight: FontWeight.w700, + ), + ), + ), + if (badgeText != null) ...[ + const SizedBox(width: 6), + _ModePillBadge(label: badgeText!), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: subtitleColor, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + if (sideBadges.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < sideBadges.length; i++) ...[ + if (i > 0) const SizedBox(width: 4), + _ModeSideBadge(label: sideBadges[i]), + ], + ], + ) + else + SizedBox( + width: 18, + height: 18, + child: selected + ? Icon( + Icons.check_circle_rounded, + size: 18, + color: colorScheme.primary, + ) + : null, + ), + ], + ), + ), + ), + ); + } +} + +class _ModePillBadge extends StatelessWidget { + final String label; + + const _ModePillBadge({required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.secondary.withValues(alpha: 0.6), ), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), + ); + } +} + +class _ModeSideBadge extends StatelessWidget { + final String label; + + const _ModeSideBadge({required this.label}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.24), + ), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + ), + ), ); } } diff --git a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart index 677d36d0..59e6380d 100644 --- a/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart +++ b/open_wearable/lib/widgets/devices/device_detail/device_detail_page.dart @@ -1,17 +1,24 @@ - +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; -import 'package:open_wearable/widgets/devices/battery_state.dart'; +import 'package:open_wearable/models/auto_connect_preferences.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/models/wearable_status_cache.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/common/app_section_card.dart'; import 'package:open_wearable/widgets/devices/device_detail/audio_mode_widget.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'; +import 'package:shared_preferences/shared_preferences.dart'; import 'rgb_control.dart'; import 'microphone_selection_widget.dart'; import 'status_led_widget.dart'; -import 'stereo_pos_label.dart'; /// A page that displays the details of a device. /// @@ -26,350 +33,801 @@ class DeviceDetailPage extends StatefulWidget { } class _DeviceDetailPageState extends State { - bool showStatusLED = true; - Microphone? selectedMicrophone; + static const MethodChannel _systemSettingsChannel = MethodChannel( + 'edu.kit.teco.open_wearable/system_settings', + ); + + Future? _deviceIdentifierFuture; + Future? _firmwareVersionFuture; + Future? _firmwareSupportFuture; + Future? _hardwareVersionFuture; @override void initState() { super.initState(); - _initSelectedMicrophone(); + _prepareAsyncData(); + } + + @override + void didUpdateWidget(covariant DeviceDetailPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _prepareAsyncData(); + } + } + + void _prepareAsyncData() { + _deviceIdentifierFuture = widget.device.hasCapability() + ? widget.device + .requireCapability() + .readDeviceIdentifier() + : null; + + final statusCache = WearableStatusCache.instance; + _firmwareVersionFuture = statusCache.ensureFirmwareVersion(widget.device); + _firmwareSupportFuture = statusCache.ensureFirmwareSupport(widget.device); + _hardwareVersionFuture = statusCache.ensureHardwareVersion(widget.device); + } + + bool get _canForgetDevice { + return widget.device.hasCapability() && + widget.device.requireCapability().isConnectedViaSystem; + } + + bool get _opensBluetoothScreenDirectly { + return defaultTargetPlatform == TargetPlatform.android; + } + + Future _openBluetoothSettings() async { + bool opened = false; + try { + opened = await _systemSettingsChannel.invokeMethod( + 'openBluetoothSettings', + ) ?? + false; + } catch (_) { + opened = false; + } + + if (!mounted || opened) { + return; + } + + AppToast.show( + context, + message: _opensBluetoothScreenDirectly + ? 'Could not open Bluetooth settings.' + : 'Could not open Settings.', + type: AppToastType.error, + icon: Icons.bluetooth_disabled_rounded, + ); + } + + void _showForgetDialog() { + showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: const Text('Forget device'), + content: const Text( + "To forget this device, remove it from your phone's Bluetooth devices.", + ), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + PlatformDialogAction( + cupertino: (_, __) => CupertinoDialogActionData( + isDefaultAction: true, + ), + child: Text( + _opensBluetoothScreenDirectly + ? 'Open Bluetooth Settings' + : 'Open Settings', + ), + onPressed: () { + Navigator.of(context).pop(); + _openBluetoothSettings(); + }, + ), + ], + ), + ); } - Future _initSelectedMicrophone() async { - if (widget.device.hasCapability()) { - final mic = await widget.device.requireCapability().getMicrophone(); - setState(() { - selectedMicrophone = mic; - }); + Future _disconnectDevice() async { + final navigator = Navigator.of(context); + final shouldPop = navigator.canPop(); + final device = widget.device; + final wearablesProvider = context.read(); + + await wearablesProvider.turnOffSensorsForDevice(device); + + try { + final prefs = await SharedPreferences.getInstance(); + await AutoConnectPreferences.forgetDeviceName(prefs, device.name); + } catch (_) { + // Disconnect should continue even if preference cleanup fails. + } + + try { + await device.disconnect(); + if (shouldPop) { + navigator.pop(); + } + } catch (_) { + if (!mounted) { + return; + } + AppToast.show( + context, + message: 'Could not disconnect device.', + type: AppToastType.error, + icon: Icons.link_off_rounded, + ); } } + void _openFirmwareUpdate() { + Provider.of( + context, + listen: false, + ).setSelectedPeripheral(widget.device); + context.push('/fota'); + } + @override Widget build(BuildContext context) { - String? wearableIconPath = widget.device.getWearableIconPath(); + final sections = [ + _buildHeaderCard(context), + if (widget.device.hasCapability()) + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: AudioModeWidget( + device: widget.device, + applyScope: AudioModeApplyScope.individualOnly, + ), + ), + ), + if (widget.device.hasCapability()) + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: MicrophoneSelectionWidget( + device: widget.device.requireCapability(), + ), + ), + ), + _buildInfoCard(context), + if (widget.device.hasCapability() && + widget.device.hasCapability()) + AppSectionCard( + title: 'Status LED', + subtitle: 'Customize the status indicator behavior.', + child: StatusLEDControlWidget( + statusLED: widget.device.requireCapability(), + rgbLed: widget.device.requireCapability(), + ), + ) + else if (widget.device.hasCapability()) + AppSectionCard( + title: 'RGB LED', + subtitle: 'Set a custom color for the RGB LED.', + child: _ActionSurface( + title: 'LED Color', + subtitle: 'Choose the active color shown on the device.', + trailing: RgbControlView( + rgbLed: widget.device.requireCapability(), + ), + ), + ), + if (widget.device.hasCapability() || + widget.device.hasCapability()) + _buildBatteryCard(context), + ]; return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText("Device details"), + title: const Text('Device details'), + ), + body: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 12, + 10, + 12, + 14 + MediaQuery.paddingOf(context).bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < sections.length; i++) ...[ + sections[i], + if (i < sections.length - 1) const SizedBox(height: 10), + ], + ], + ), ), - body: Padding( - padding: const EdgeInsets.all(10.0), - child: ListView( + ); + } + + Widget _buildHeaderCard(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final hasWearableIcon = widget.device.getWearableIconPath() != null; + + final statusPills = buildDeviceStatusPills( + wearable: widget.device, + showStereoPosition: true, + batteryLiveUpdates: true, + ); + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // MARK: Device name, icon and battery state - Column( + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformText( - widget.device.name, - style: Theme.of(context).textTheme.titleLarge, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - BatteryStateView(device: widget.device), - if (widget.device.hasCapability()) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: StereoPosLabel(device: widget.device.requireCapability()), - ), - ], - ), - if (wearableIconPath != null) - SvgPicture.asset(wearableIconPath, width: 100, height: 100), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (widget.device.hasCapability() && widget.device.requireCapability().isConnectedViaSystem) - PlatformElevatedButton( - child: PlatformText("Forget Device"), - onPressed: () { - // Show an alert that the device has to be ignored via the system settings - showPlatformDialog( - context: context, - builder: (_) => PlatformAlertDialog( - title: PlatformText('Forget'), - content: PlatformText('To disconnect this device permanently, please go to your system Bluetooth settings and ignore the device from there.'), - actions: [ - PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData(isDefaultAction: true), - child: PlatformText('OK'), - onPressed: () => Navigator.of(context).pop(), - ), - ], + if (hasWearableIcon) + SizedBox( + width: 56, + height: 56, + child: WearableIcon( + wearable: widget.device, + initialVariant: WearableIconVariant.single, + fallback: const Icon(Icons.watch_outlined), + ), + ), + if (hasWearableIcon) const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + formatWearableDisplayName(widget.device.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( + widget.device.deviceId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), - PlatformElevatedButton( - child: PlatformText("Disconnect"), - onPressed: () { - widget.device.disconnect(); - Navigator.of(context).pop(); - }, + if (statusPills.isNotEmpty) ...[ + const SizedBox(height: 8), + DevicePillLine(pills: statusPills), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _canForgetDevice + ? _showForgetDialog + : _disconnectDevice, + style: OutlinedButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: Colors.white, + side: BorderSide.none, ), - ], + icon: const Icon( + Icons.bluetooth_disabled_rounded, + size: 18, + ), + label: const Text('Forget'), + ), ), ], ), - // MARK: Audio Mode - if (widget.device.hasCapability()) - AudioModeWidget(device: widget.device.requireCapability()), - // MARK: Microphone Control - if (widget.device.hasCapability()) - MicrophoneSelectionWidget( - device: widget.device.requireCapability(), + ], + ), + ), + ); + } + + Widget _buildInfoCard(BuildContext context) { + final hasIdentifier = _deviceIdentifierFuture != null; + final hasFirmware = _firmwareVersionFuture != null; + final hasHardware = _hardwareVersionFuture != null; + + return AppSectionCard( + title: 'Device Information', + subtitle: 'Identifiers and software versions.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailInfoRow( + label: 'Bluetooth Address', + value: Text(widget.device.deviceId), + showDivider: hasIdentifier || hasFirmware || hasHardware, + ), + if (hasIdentifier) + _DetailInfoRow( + label: 'Device Identifier', + value: _AsyncValueText( + future: _deviceIdentifierFuture!, ), - // MARK: Device info - PlatformText("Device Info", style: Theme.of(context).textTheme.titleSmall), - PlatformListTile( - title: PlatformText( - "Bluetooth Address", - style: Theme.of(context).textTheme.bodyLarge, + showDivider: hasFirmware || hasHardware, + ), + if (hasFirmware) + _DetailInfoRow( + label: 'Firmware Version', + value: _buildFirmwareVersionValue(), + trailing: _FirmwareTableUpdateHint( + onTap: _openFirmwareUpdate, ), - subtitle: PlatformText(widget.device.deviceId), + showDivider: hasHardware, ), - // MARK: Device Identifier - if (widget.device.hasCapability()) - PlatformListTile( - title: PlatformText( - "Device Identifier", - style: Theme.of(context).textTheme.bodyLarge, - ), - subtitle: FutureBuilder( - future: widget.device.requireCapability() - .readDeviceIdentifier(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return PlatformText(snapshot.data.toString()); - } else { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), - ), - ); - } - }, - ), + if (hasHardware) + _DetailInfoRow( + label: 'Hardware Version', + value: _AsyncValueText( + future: _hardwareVersionFuture!, ), - // MARK: Device Firmware Version - if (widget.device.hasCapability()) - PlatformListTile( - title: PlatformText( - "Firmware Version", - style: Theme.of(context).textTheme.bodyLarge, - ), - subtitle: Row(children: [ - FutureBuilder( - future: widget.device.requireCapability() - .readDeviceFirmwareVersion(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return PlatformText(snapshot.data.toString()); - } else { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), - ), - ); - } - }, - ), - FutureBuilder( - future: widget.device.requireCapability() - .checkFirmwareSupport(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - switch (snapshot.data) { - case FirmwareSupportStatus.supported: - return SizedBox.shrink(); - case FirmwareSupportStatus.tooNew: - case FirmwareSupportStatus.tooOld: - return Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon(Icons.warning, color: Colors.orange), - ); - case FirmwareSupportStatus.unknown: - return Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Icon(Icons.help, color: Colors.grey), - ); - default: - return Container(); - } - } else { - return Container(); - } - }, - ), - ],), - trailing: PlatformIconButton( - icon: Icon(Icons.upload), - onPressed: () { - Provider.of( - context, - listen: false, - ).setSelectedPeripheral(widget.device); - // Show the firmware update dialog - // Navigate to your firmware update screen - context.push('/fota'); - }, - ), + showDivider: false, + ), + ], + ), + ); + } + + Widget _buildFirmwareVersionValue() { + return Row( + children: [ + Flexible( + child: _AsyncValueText( + future: _firmwareVersionFuture!, + ), + ), + if (_firmwareSupportFuture != null) ...[ + const SizedBox(width: 6), + _FirmwareSupportIndicator( + supportFuture: _firmwareSupportFuture!, + ), + ], + ], + ); + } + + Widget _buildBatteryCard(BuildContext context) { + final hasEnergy = widget.device.hasCapability(); + final hasHealth = widget.device.hasCapability(); + + return AppSectionCard( + title: 'Battery', + subtitle: hasEnergy && hasHealth + ? 'Live energy and health metrics.' + : hasEnergy + ? 'Live electrical measurements.' + : 'Lifecycle and thermal status.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasEnergy) + _buildBatteryEnergyContent( + showTrailingDivider: hasHealth, + ), + if (hasHealth) _buildBatteryHealthContent(), + ], + ), + ); + } + + Widget _buildBatteryEnergyContent({ + bool showTrailingDivider = false, + }) { + return StreamBuilder( + stream: widget.device + .requireCapability() + .energyStatusStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _InlineLoading(); + } + if (snapshot.hasError) { + return const _InlineError( + text: 'Unable to read battery energy status.', + ); + } + final energyStatus = snapshot.data; + if (energyStatus == null) { + return const _InlineHint(text: 'No battery energy data available.'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailInfoRow( + label: 'Battery Voltage', + value: Text('${energyStatus.voltage.toStringAsFixed(1)} V'), + ), + _DetailInfoRow( + label: 'Charge Rate', + value: Text('${energyStatus.chargeRate.toStringAsFixed(3)} W'), + ), + _DetailInfoRow( + label: 'Battery Capacity', + value: Text( + '${energyStatus.availableCapacity.toStringAsFixed(2)} Wh', ), - // MARK: Device Hardware Version - if (widget.device.hasCapability()) - PlatformListTile( - title: PlatformText( - "Hardware Version", - style: Theme.of(context).textTheme.bodyLarge, + showDivider: showTrailingDivider, + ), + ], + ); + }, + ); + } + + Widget _buildBatteryHealthContent({ + bool showTrailingDivider = false, + }) { + return StreamBuilder( + stream: widget.device + .requireCapability() + .healthStatusStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const _InlineLoading(); + } + if (snapshot.hasError) { + return const _InlineError( + text: 'Unable to read battery health status.', + ); + } + final healthStatus = snapshot.data; + if (healthStatus == null) { + return const _InlineHint(text: 'No battery health data available.'); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DetailInfoRow( + label: 'Battery Temperature', + value: Text('${healthStatus.currentTemperature} °C'), + showDivider: showTrailingDivider, + ), + ], + ); + }, + ); + } +} + +class _ActionSurface extends StatelessWidget { + final String title; + final String subtitle; + final Widget trailing; + + const _ActionSurface({ + required this.title, + required this.subtitle, + required this.trailing, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), ), - subtitle: FutureBuilder( - future: widget.device.requireCapability() - .readDeviceHardwareVersion(), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return PlatformText(snapshot.data.toString()); - } else { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), - ), - ); - } - }, + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), + ], + ), + ), + const SizedBox(width: 10), + trailing, + ], + ), + ); + } +} - // MARK: Status LED control - if (widget.device.hasCapability() && widget.device.hasCapability()) ...[ - PlatformText( - "Control Status LED", - style: Theme.of(context).textTheme.titleSmall, - ), - StatusLEDControlWidget( - statusLED: widget.device.requireCapability(), - rgbLed: widget.device.requireCapability(), - ), - ] else if (widget.device.hasCapability() && - !widget.device.hasCapability()) ...[ - PlatformText( - "Control RGB LED", - style: Theme.of(context).textTheme.titleSmall, - ), - PlatformListTile( - title: PlatformText( - "LED Color", - style: Theme.of(context).textTheme.bodyLarge, - ), - trailing: RgbControlView(rgbLed: widget.device.requireCapability()), - ), - ], +class _DetailInfoRow extends StatelessWidget { + final String label; + final Widget value; + final Widget? trailing; + final bool showDivider; - // MARK: Device Battery State - if (widget.device.hasCapability()) ...[ - PlatformText( - "Battery Energy Status", - style: Theme.of(context).textTheme.titleSmall, - ), - StreamBuilder( - stream: widget.device.requireCapability() - .energyStatusStream, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), - ), - ); - } else if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } else if (!snapshot.hasData) { - return PlatformText("No data available"); - } else { - final energyStatus = snapshot.data!; - return Column( - children: [ - PlatformListTile( - title: PlatformText("Battery Voltage"), - subtitle: PlatformText( - "${energyStatus.voltage.toStringAsFixed(1)} V", - ), - ), - PlatformListTile( - title: PlatformText("Charge Rate"), - subtitle: PlatformText( - "${energyStatus.chargeRate.toStringAsFixed(3)} W", - ), - ), - PlatformListTile( - title: PlatformText("Battery Capacity"), - subtitle: PlatformText( - "${energyStatus.availableCapacity.toStringAsFixed(2)} Wh", - ), - ), - ], - ); - } - }, - ), - ], + const _DetailInfoRow({ + required this.label, + required this.value, + this.trailing, + this.showDivider = true, + }); - // MARK: Battery Health - if (widget.device.hasCapability()) ...[ - PlatformText( - "Battery Health Status", - style: Theme.of(context).textTheme.titleSmall, - ), - StreamBuilder( - stream: widget.device.requireCapability() - .healthStatusStream, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: 20, - height: 20, - child: PlatformCircularProgressIndicator(), + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, ), - ); - } else if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } else if (!snapshot.hasData) { - return PlatformText("No data available"); - } else { - final healthStatus = snapshot.data!; - return Column( - children: [ - PlatformListTile( - title: PlatformText("Battery Temperature"), - subtitle: - PlatformText("${healthStatus.currentTemperature} °C"), - ), - PlatformListTile( - title: PlatformText("Battery Cycle Count"), - subtitle: PlatformText("${healthStatus.cycleCount} cycles"), - ), - ], - ); - } - }, + ), + const SizedBox(height: 2), + DefaultTextStyle( + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ) ?? + const TextStyle(), + child: value, + ), + ], + ), ), + if (trailing != null) ...[ + const SizedBox(width: 10), + trailing!, + ], ], + ), + if (showDivider) ...[ + const SizedBox(height: 8), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), ], + ], + ), + ); + } +} + +class _AsyncValueText extends StatelessWidget { + final Future future; + + const _AsyncValueText({ + required this.future, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ); + } + + final valueText = + snapshot.hasError ? '--' : (snapshot.data?.toString() ?? '--'); + return Text( + valueText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ); + }, + ); + } +} + +class _InlineLoading extends StatelessWidget { + const _InlineLoading(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, ), ), ); } } + +class _InlineHint extends StatelessWidget { + final String text; + + const _InlineHint({required this.text}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } +} + +class _InlineError extends StatelessWidget { + final String text; + + const _InlineError({required this.text}); + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ); + } +} + +class _FirmwareTableUpdateHint extends StatelessWidget { + final VoidCallback onTap; + + const _FirmwareTableUpdateHint({required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return FilledButton.icon( + onPressed: onTap, + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: const Size(0, 34), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + backgroundColor: colorScheme.primary, + foregroundColor: Colors.white, + ), + icon: Icon( + Icons.system_update_alt_rounded, + size: 15, + color: Colors.white, + ), + label: Text( + 'Update', + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _FirmwareSupportIndicator extends StatelessWidget { + final Future supportFuture; + + const _FirmwareSupportIndicator({required this.supportFuture}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: supportFuture, + builder: (context, snapshot) { + final support = snapshot.data; + if (support == null || support == FirmwareSupportStatus.supported) { + return const SizedBox.shrink(); + } + + final colorScheme = Theme.of(context).colorScheme; + + IconData icon = Icons.help_rounded; + Color color = colorScheme.onSurfaceVariant; + String tooltip = 'Firmware support status is unknown'; + + switch (support) { + case FirmwareSupportStatus.tooOld: + icon = Icons.warning_rounded; + color = Colors.orange; + tooltip = 'Firmware is too old'; + break; + case FirmwareSupportStatus.tooNew: + icon = Icons.warning_rounded; + color = Colors.orange; + tooltip = 'Firmware is newer than supported'; + break; + case FirmwareSupportStatus.unknown: + icon = Icons.help_rounded; + color = colorScheme.onSurfaceVariant; + tooltip = 'Firmware support is unknown'; + break; + case FirmwareSupportStatus.unsupported: + icon = Icons.error_outline_rounded; + color = colorScheme.error; + tooltip = 'Firmware is unsupported'; + break; + case FirmwareSupportStatus.supported: + return const SizedBox.shrink(); + } + + return Tooltip( + message: tooltip, + child: Icon( + icon, + size: 16, + color: color, + ), + ); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart b/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart index df93921b..9dd3c2b9 100644 --- a/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart +++ b/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart @@ -1,6 +1,4 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; class MicrophoneSelectionWidget extends StatefulWidget { @@ -12,60 +10,363 @@ class MicrophoneSelectionWidget extends StatefulWidget { }); @override - State createState() => _MicrophoneSelectionWidgetState(); + State createState() => + _MicrophoneSelectionWidgetState(); } class _MicrophoneSelectionWidgetState extends State { Microphone? _selectedMicrophone; + bool _isLoading = true; + bool _isApplying = false; + String? _errorText; @override void initState() { super.initState(); - _getSelectedMicrophone(); + _loadSelectedMicrophone(); } - Future _getSelectedMicrophone() async { - final mode = await widget.device.getMicrophone(); + @override + void didUpdateWidget(covariant MicrophoneSelectionWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _loadSelectedMicrophone(); + } + } + + Future _loadSelectedMicrophone() async { + setState(() { + _isLoading = true; + _isApplying = false; + _errorText = null; + }); + + try { + final microphone = await widget.device.getMicrophone(); + if (!mounted) { + return; + } + setState(() { + _selectedMicrophone = microphone; + _isLoading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _errorText = + 'Failed to read LE Audio stream source: ${_describeError(error)}'; + _isLoading = false; + }); + } + } + + Future _onMicrophoneSelected(Microphone microphone) async { + if (_isApplying || _isLoading) { + return; + } + + final previousMicrophone = _selectedMicrophone; setState(() { - _selectedMicrophone = mode; + _selectedMicrophone = microphone; + _isApplying = true; + _errorText = null; }); + + try { + await Future.sync(() => widget.device.setMicrophone(microphone)); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _selectedMicrophone = previousMicrophone; + _errorText = + 'Failed to apply LE Audio stream source: ${_describeError(error)}'; + }); + } finally { + if (mounted) { + setState(() { + _isApplying = false; + }); + } + } + } + + String _normalizedMicrophoneKey(Microphone microphone) { + return microphone.key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + } + + bool _microphonesEqualByKey(Microphone? a, Microphone b) { + if (a == null) { + return false; + } + return _normalizedMicrophoneKey(a) == _normalizedMicrophoneKey(b); + } + + String _labelForMicrophone(Microphone microphone) { + final normalized = _normalizedMicrophoneKey(microphone); + if (normalized.contains('inner') || normalized.contains('internal')) { + return 'Inner (In-Ear Sounds)'; + } + if (normalized.contains('outer') || normalized.contains('external')) { + return 'Outer (Ambient Sounds)'; + } + return _toTitleCase(microphone.key); + } + + String _subtitleForMicrophone(Microphone microphone) { + final normalized = _normalizedMicrophoneKey(microphone); + if (normalized.contains('inner') || normalized.contains('internal')) { + return 'Stream the inner mic over LE Audio'; + } + if (normalized.contains('outer') || normalized.contains('external')) { + return 'Stream the outer mic over LE Audio'; + } + return 'Microphone source for LE Audio stream'; + } + + IconData _iconForMicrophone(Microphone microphone) { + final normalized = _normalizedMicrophoneKey(microphone); + if (normalized.contains('inner') || normalized.contains('internal')) { + return Icons.hearing_rounded; + } + if (normalized.contains('outer') || normalized.contains('external')) { + return Icons.surround_sound_rounded; + } + return Icons.mic_rounded; + } + + String _toTitleCase(String value) { + final spaced = value + .replaceAllMapped( + RegExp(r'([a-z])([A-Z])'), + (match) => '${match.group(1)} ${match.group(2)}', + ) + .replaceAll(RegExp(r'[_-]+'), ' ') + .trim(); + + if (spaced.isEmpty) { + return value; + } + + return spaced.split(RegExp(r'\s+')).map((word) { + if (word.isEmpty) { + return word; + } + if (word.length == 1) { + return word.toUpperCase(); + } + return '${word[0].toUpperCase()}${word.substring(1).toLowerCase()}'; + }).join(' '); + } + + String _describeError(Object error) { + final text = error.toString().trim(); + if (text.startsWith('Exception: ')) { + return text.substring('Exception: '.length); } + return text; + } + + Widget _buildMicrophoneOptions(List microphones) { + return LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final columns = constraints.maxWidth >= 420 ? 2 : 1; + final itemWidth = columns == 1 + ? constraints.maxWidth + : (constraints.maxWidth - spacing) / 2; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: microphones.map((microphone) { + final selected = _microphonesEqualByKey( + _selectedMicrophone, + microphone, + ); + return SizedBox( + width: itemWidth, + child: _MicrophoneOptionButton( + label: _labelForMicrophone(microphone), + subtitle: _subtitleForMicrophone(microphone), + icon: _iconForMicrophone(microphone), + selected: selected, + enabled: !_isApplying && !_isLoading, + onTap: () => _onMicrophoneSelected(microphone), + ), + ); + }).toList(), + ); + }, + ); + } @override Widget build(BuildContext context) { - return PlatformWidget( - cupertino:(context, platform) => CupertinoSlidingSegmentedControl( - children: { - for (var item in widget.device.availableMicrophones) - item : PlatformText(item.key), - }, - onValueChanged: (Microphone? mode) { - if (mode == null) return; - widget.device.setMicrophone(mode); - setState(() { - _selectedMicrophone = mode; - }); - }, - groupValue: _selectedMicrophone, + final microphones = widget.device.availableMicrophones.toList(); + if (microphones.isEmpty) { + return const SizedBox.shrink(); + } + + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'LE Audio Microphone Stream', + style: theme.textTheme.titleSmall, + ), + ), + if (_isLoading) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Choose which microphone is streamed via LE Audio.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + _buildMicrophoneOptions(microphones), + if (_isApplying) ...[ + const SizedBox(height: 8), + const LinearProgressIndicator(minHeight: 2), + ], + if (_errorText != null) ...[ + const SizedBox(height: 8), + Text( + _errorText!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ], ), - material: (context, platform) => SegmentedButton( - segments: - widget.device.availableMicrophones.map((item) { - return ButtonSegment( - value: item, - label: PlatformText(item.key), - ); - }).toList(), - onSelectionChanged: (Set selected) { - if (selected.isEmpty) return; - widget.device.setMicrophone(selected.first); - setState(() { - _selectedMicrophone = selected.first; - }); - }, - selected: _selectedMicrophone != null ? { _selectedMicrophone! } : {}, - emptySelectionAllowed: true, + ); + } +} + +class _MicrophoneOptionButton extends StatelessWidget { + final String label; + final String subtitle; + final IconData icon; + final bool selected; + final bool enabled; + final VoidCallback onTap; + + const _MicrophoneOptionButton({ + required this.label, + required this.subtitle, + required this.icon, + required this.selected, + required this.enabled, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final foregroundColor = + selected ? colorScheme.primary : colorScheme.onSurfaceVariant; + final iconColor = + enabled ? foregroundColor : foregroundColor.withValues(alpha: 0.55); + final titleColor = enabled + ? (selected ? colorScheme.primary : colorScheme.onSurface) + : colorScheme.onSurface.withValues(alpha: 0.55); + final subtitleColor = enabled + ? colorScheme.onSurfaceVariant + : colorScheme.onSurfaceVariant.withValues(alpha: 0.55); + + final backgroundColor = selected + ? colorScheme.primaryContainer.withValues(alpha: 0.44) + : colorScheme.surface; + final borderColor = selected + ? colorScheme.primary.withValues(alpha: 0.7) + : colorScheme.outlineVariant.withValues(alpha: 0.7); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: enabled ? onTap : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor), + ), + child: Row( + children: [ + Icon( + icon, + size: 18, + color: iconColor, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: titleColor, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: subtitleColor, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 18, + height: 18, + child: selected + ? Icon( + Icons.check_circle_rounded, + size: 18, + color: colorScheme.primary, + ) + : null, + ), + ], + ), ), + ), ); } } diff --git a/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart b/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart index 42ebd6ee..29f194bd 100644 --- a/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart +++ b/open_wearable/lib/widgets/devices/device_detail/rgb_control.dart @@ -38,9 +38,9 @@ class _RgbControlViewState extends State { child: PlatformText('Done'), onPressed: () { widget.rgbLed.writeLedColor( - r: (255 *_currentColor.r).round(), - g: (255 *_currentColor.g).round(), - b: (255 *_currentColor.b).round(), + r: (255 * _currentColor.r).round(), + g: (255 * _currentColor.g).round(), + b: (255 * _currentColor.b).round(), ); Navigator.of(context).pop(); }, @@ -53,15 +53,37 @@ class _RgbControlViewState extends State { @override Widget build(BuildContext context) { - return ElevatedButton( + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return OutlinedButton.icon( onPressed: _showColorPickerDialog, - style: ElevatedButton.styleFrom( - backgroundColor: _currentColor, - foregroundColor: _currentColor.computeLuminance() > 0.5 - ? Colors.black - : Colors.white, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + minimumSize: const Size(0, 34), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + side: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.65), + ), + foregroundColor: colorScheme.onSurface, + ), + icon: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: _currentColor, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black.withValues(alpha: 0.18), + ), + ), + ), + label: PlatformText( + 'Color', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), - child: PlatformText('Color'), ); } } diff --git a/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart b/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart index a4502060..2092a6de 100644 --- a/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart +++ b/open_wearable/lib/widgets/devices/device_detail/status_led_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'rgb_control.dart'; @@ -7,7 +6,11 @@ import 'rgb_control.dart'; class StatusLEDControlWidget extends StatefulWidget { final StatusLed statusLED; final RgbLed rgbLed; - const StatusLEDControlWidget({super.key, required this.statusLED, required this.rgbLed}); + const StatusLEDControlWidget({ + super.key, + required this.statusLED, + required this.rgbLed, + }); @override State createState() => _StatusLEDControlWidgetState(); @@ -15,27 +18,171 @@ class StatusLEDControlWidget extends StatefulWidget { class _StatusLEDControlWidgetState extends State { bool _overrideColor = false; + bool _disableLed = false; + + Future _setLedBlack() async { + try { + await widget.statusLED.showStatus(false); + await widget.rgbLed.writeLedColor(r: 0, g: 0, b: 0); + } catch (_) { + // LED control is best-effort and should not interrupt UI interactions. + } + } + + Future _resetLedOverride() async { + try { + await widget.statusLED.showStatus(true); + } catch (_) { + // LED control is best-effort and should not interrupt UI interactions. + } + } + + Future _onDisableLedChanged(bool value) async { + setState(() { + _disableLed = value; + if (value) { + _overrideColor = false; + } + }); + + if (value) { + await _setLedBlack(); + return; + } + await _resetLedOverride(); + } + + Future _onOverrideChanged(bool value) async { + setState(() { + _overrideColor = value; + if (value) { + _disableLed = false; + } + }); + + if (value) { + try { + await widget.statusLED.showStatus(false); + } catch (_) { + // LED control is best-effort and should not interrupt UI interactions. + } + return; + } + + await _resetLedOverride(); + } @override Widget build(BuildContext context) { - return PlatformListTile( - title: PlatformText("Override LED Color", style: Theme.of(context).textTheme.bodyLarge), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_overrideColor) - RgbControlView(rgbLed: widget.rgbLed), - PlatformSwitch( - value: _overrideColor, - onChanged: (value) async { - setState(() { - _overrideColor = value; - }); - widget.statusLED.showStatus(!value); - }, - ), - ], - ), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Disable LED', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Turn off LED output.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Switch.adaptive( + value: _disableLed, + onChanged: _onDisableLedChanged, + ), + ], + ), + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Override status LED color', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Use a fixed color instead of the default status.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Switch.adaptive( + value: _overrideColor, + onChanged: _onOverrideChanged, + ), + ], + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 170), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _overrideColor + ? Padding( + padding: const EdgeInsets.only(top: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.35, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.55, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'LED Color', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + RgbControlView(rgbLed: widget.rgbLed), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + ], ); } } diff --git a/open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart b/open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart index e48f950b..3dda1fc8 100644 --- a/open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart +++ b/open_wearable/lib/widgets/devices/device_detail/stereo_pos_label.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; - -import '../../../models/logger.dart'; +import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; class StereoPosLabel extends StatelessWidget { final StereoDevice device; @@ -11,32 +9,6 @@ class StereoPosLabel extends StatelessWidget { @override Widget build(BuildContext context) { - return FutureBuilder( - future: device.position, - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - logger.e("Error fetching device position: ${snapshot.error}"); - return PlatformText("Error: ${snapshot.error}"); - } - if (!snapshot.hasData) { - return PlatformText("N/A"); - } - if (snapshot.data == null) { - return PlatformText("N/A"); - } - switch (snapshot.data) { - case DevicePosition.left: - return PlatformText("Left"); - case DevicePosition.right: - return PlatformText("Right"); - default: - return PlatformText("Unknown"); - } - }, - ); + return StereoPositionBadge(device: device); } } diff --git a/open_wearable/lib/widgets/devices/device_status_pills.dart b/open_wearable/lib/widgets/devices/device_status_pills.dart new file mode 100644 index 00000000..e337a591 --- /dev/null +++ b/open_wearable/lib/widgets/devices/device_status_pills.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/wearable_status_cache.dart'; +import 'package:open_wearable/widgets/devices/battery_state.dart'; + +List buildDeviceStatusPills({ + required Wearable wearable, + String? sideLabel, + bool showStereoPosition = false, + bool batteryLiveUpdates = true, + bool batteryShowBackground = true, + bool showFirmware = true, + bool showHardware = true, +}) { + final hasBatteryStatus = wearable.hasCapability() || + wearable.hasCapability(); + + return [ + if (sideLabel != null) + DeviceMetadataBubble( + label: sideLabel, + highlighted: true, + ) + else if (showStereoPosition && wearable.hasCapability()) + DeviceStereoPositionPill(wearable: wearable), + if (hasBatteryStatus) + BatteryStateView( + device: wearable, + liveUpdates: batteryLiveUpdates, + showBackground: batteryShowBackground, + ), + if (showFirmware && wearable.hasCapability()) + DeviceFirmwarePill(wearable: wearable), + if (showHardware && wearable.hasCapability()) + DeviceHardwarePill(wearable: wearable), + ]; +} + +class DevicePillLine extends StatelessWidget { + final List pills; + + const DevicePillLine({ + super.key, + required this.pills, + }); + + @override + Widget build(BuildContext context) { + if (pills.isEmpty) { + return const SizedBox.shrink(); + } + + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: Row( + children: [ + for (var i = 0; i < pills.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + pills[i], + ], + ], + ), + ), + ), + ); + } +} + +class DeviceStereoPositionPill extends StatelessWidget { + final Wearable wearable; + final bool highlighted; + final bool showUnknownLabel; + + const DeviceStereoPositionPill({ + super.key, + required this.wearable, + this.highlighted = true, + this.showUnknownLabel = false, + }); + + @override + Widget build(BuildContext context) { + final cache = WearableStatusCache.instance; + final future = cache.ensureStereoPosition(wearable); + if (future == null) { + return const SizedBox.shrink(); + } + + return FutureBuilder( + future: future, + initialData: cache.cachedStereoPositionFor(wearable.deviceId), + builder: (context, snapshot) { + final isLoading = snapshot.connectionState == ConnectionState.waiting && + !snapshot.hasData; + final label = _sideLabelForPosition(snapshot.data); + + if (!isLoading && label == null && !showUnknownLabel) { + return const SizedBox.shrink(); + } + + return DeviceMetadataBubble( + label: isLoading ? '...' : (label ?? '--'), + highlighted: highlighted, + ); + }, + ); + } +} + +class DeviceFirmwarePill extends StatelessWidget { + final Wearable wearable; + + const DeviceFirmwarePill({super.key, required this.wearable}); + + @override + Widget build(BuildContext context) { + final cache = WearableStatusCache.instance; + final versionFuture = cache.ensureFirmwareVersion(wearable); + final supportFuture = cache.ensureFirmwareSupport(wearable); + if (versionFuture == null) { + return const DeviceMetadataBubble(label: 'FW', value: '--'); + } + + return FutureBuilder( + future: versionFuture, + initialData: cache.cachedFirmwareVersionFor(wearable.deviceId), + builder: (context, versionSnapshot) { + final isLoading = + versionSnapshot.connectionState == ConnectionState.waiting && + !versionSnapshot.hasData; + if (isLoading) { + return const DeviceMetadataBubble(label: 'FW', isLoading: true); + } + + final versionText = versionSnapshot.hasError + ? '--' + : (versionSnapshot.data?.toString() ?? '--'); + + if (supportFuture == null) { + return DeviceMetadataBubble( + label: 'FW', + value: versionText, + ); + } + + return FutureBuilder( + future: supportFuture, + initialData: cache.cachedFirmwareSupportFor(wearable.deviceId), + builder: (context, supportSnapshot) { + IconData? statusIcon; + Color? statusColor; + switch (supportSnapshot.data) { + case FirmwareSupportStatus.tooOld: + case FirmwareSupportStatus.tooNew: + statusIcon = Icons.warning_rounded; + statusColor = Colors.orange; + break; + case FirmwareSupportStatus.unsupported: + statusIcon = Icons.error_outline_rounded; + statusColor = Theme.of(context).colorScheme.error; + break; + case FirmwareSupportStatus.unknown: + statusIcon = Icons.help_rounded; + statusColor = Theme.of(context).colorScheme.onSurfaceVariant; + break; + default: + break; + } + + return DeviceMetadataBubble( + label: 'FW', + value: versionText, + trailingIcon: statusIcon, + foregroundColor: statusColor, + ); + }, + ); + }, + ); + } +} + +class DeviceHardwarePill extends StatelessWidget { + final Wearable wearable; + + const DeviceHardwarePill({super.key, required this.wearable}); + + @override + Widget build(BuildContext context) { + final cache = WearableStatusCache.instance; + final versionFuture = cache.ensureHardwareVersion(wearable); + if (versionFuture == null) { + return const DeviceMetadataBubble(label: 'HW', value: '--'); + } + + return FutureBuilder( + future: versionFuture, + initialData: cache.cachedHardwareVersionFor(wearable.deviceId), + builder: (context, snapshot) { + final isLoading = snapshot.connectionState == ConnectionState.waiting && + !snapshot.hasData; + if (isLoading) { + return const DeviceMetadataBubble(label: 'HW', isLoading: true); + } + + final versionText = + snapshot.hasError ? '--' : (snapshot.data?.toString() ?? '--'); + + return DeviceMetadataBubble( + label: 'HW', + value: versionText, + ); + }, + ); + } +} + +class DeviceMetadataBubble extends StatelessWidget { + final String label; + final String? value; + final bool isLoading; + final bool highlighted; + final IconData? trailingIcon; + final Color? foregroundColor; + final bool showBackground; + + const DeviceMetadataBubble({ + super.key, + required this.label, + this.value, + this.isLoading = false, + this.highlighted = false, + this.trailingIcon, + this.foregroundColor, + this.showBackground = true, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final defaultForeground = colorScheme.primary; + final resolvedForeground = foregroundColor ?? defaultForeground; + final effectiveForeground = + highlighted ? colorScheme.primary : resolvedForeground; + final backgroundColor = highlighted + ? effectiveForeground.withValues(alpha: 0.12) + : showBackground + ? colorScheme.surface + : Colors.transparent; + final borderColor = highlighted + ? effectiveForeground.withValues(alpha: 0.24) + : resolvedForeground.withValues(alpha: 0.42); + final displayText = + isLoading ? '$label ...' : (value == null ? label : '$label $value'); + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isLoading && trailingIcon != null) + Icon( + trailingIcon, + size: 14, + color: effectiveForeground, + ), + if (!isLoading && trailingIcon != null) const SizedBox(width: 6), + Text( + displayText, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: effectiveForeground, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], + ), + ); + } +} + +String? _sideLabelForPosition(DevicePosition? position) { + return switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; +} diff --git a/open_wearable/lib/widgets/devices/devices_page.dart b/open_wearable/lib/widgets/devices/devices_page.dart index b3b33cd1..bf1a11ce 100644 --- a/open_wearable/lib/widgets/devices/devices_page.dart +++ b/open_wearable/lib/widgets/devices/devices_page.dart @@ -1,17 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; -import 'package:open_wearable/widgets/devices/battery_state.dart'; +import 'package:open_wearable/widgets/common/no_devices_prompt.dart'; import 'package:open_wearable/widgets/devices/connect_devices_page.dart'; +import 'package:open_wearable/widgets/devices/device_detail/audio_mode_widget.dart'; import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; +import 'package:open_wearable/widgets/devices/device_status_pills.dart'; +import 'package:open_wearable/widgets/devices/wearable_icon.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'; -import 'device_detail/stereo_pos_label.dart'; - /// On this page the user can see all connected devices. /// /// Tapping on a device will navigate to the [DeviceDetailPage]. @@ -40,17 +43,15 @@ class _DevicesPageState extends State { ); } - Widget _buildSmallScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildSmallScreenLayout( + BuildContext context, + WearablesProvider wearablesProvider, + ) { return PlatformScaffold( appBar: PlatformAppBar( title: PlatformText("Devices"), trailingActions: [ - PlatformIconButton( - icon: Icon(context.platformIcons.info), - onPressed: () { - context.push('/log-files'); - }, - ), + const AppBarRecordingIndicator(), PlatformIconButton( icon: Icon(context.platformIcons.bluetooth), onPressed: () { @@ -63,21 +64,27 @@ class _DevicesPageState extends State { ); } - Widget _buildSmallScreenContent(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildSmallScreenContent( + BuildContext context, + WearablesProvider wearablesProvider, + ) { if (wearablesProvider.wearables.isEmpty) { return RefreshIndicator( onRefresh: () async { - // await _startBluetooth(); - //TODO: implement refresh logic + final wearables = await WearableManager().connectToSystemDevices(); + for (final wearable in wearables) { + wearablesProvider.addWearable(wearable); + } }, child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), children: [ SizedBox( - height: MediaQuery.of(context).size.height * 0.8, + height: MediaQuery.of(context).size.height * 0.62, child: Center( - child: PlatformText( - "No devices connected", - style: Theme.of(context).textTheme.titleLarge, + child: NoDevicesPrompt( + onScanPressed: () => context.push('/connect-devices'), ), ), ), @@ -86,23 +93,52 @@ class _DevicesPageState extends State { ); } - return RefreshIndicator( - onRefresh: () { - return WearableManager().connectToSystemDevices().then((wearables) { - for (var wearable in wearables) { - wearablesProvider.addWearable(wearable); - } - }); - }, - child: Padding( - padding: EdgeInsets.all(10), - child: ListView.builder( - itemCount: wearablesProvider.wearables.length, - itemBuilder: (context, index) { - return DeviceRow(device: wearablesProvider.wearables[index]); - }, + return FutureBuilder>( + future: buildWearableDisplayGroups( + wearablesProvider.wearables, + shouldCombinePair: (left, right) => + wearablesProvider.isStereoPairCombined( + first: left, + second: right, ), ), + builder: (context, snapshot) { + final groups = orderWearableGroupsForOverview( + snapshot.data ?? + wearablesProvider.wearables + .map( + (wearable) => + WearableDisplayGroup.single(wearable: wearable), + ) + .toList(), + ); + + return RefreshIndicator( + onRefresh: () { + return WearableManager().connectToSystemDevices().then((wearables) { + for (var wearable in wearables) { + wearablesProvider.addWearable(wearable); + } + }); + }, + child: Padding( + padding: const EdgeInsets.all(10), + child: ListView.builder( + itemCount: groups.length, + itemBuilder: (context, index) { + return DeviceRow( + group: groups[index], + onPairCombineChanged: (pairKey, combined) => + wearablesProvider.setStereoPairKeyCombined( + pairKey: pairKey, + combined: combined, + ), + ); + }, + ), + ), + ); + }, ); } @@ -110,216 +146,607 @@ class _DevicesPageState extends State { BuildContext context, WearablesProvider wearablesProvider, ) { - return GridView.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 500, - childAspectRatio: 3, - crossAxisSpacing: 10, - mainAxisSpacing: 10, + return FutureBuilder>( + future: buildWearableDisplayGroups( + wearablesProvider.wearables, + shouldCombinePair: (left, right) => + wearablesProvider.isStereoPairCombined( + first: left, + second: right, + ), ), - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: wearablesProvider.wearables.length + 1, - itemBuilder: (context, index) { - if (index == wearablesProvider.wearables.length) { - return GestureDetector( - onTap: () { - showPlatformModalSheet( - context: context, - builder: (context) => ConnectDevicesPage(), - ); - }, - child: Card( - color: Theme.of(context) - .colorScheme - .surfaceTint - .withValues(alpha: 0.2), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - PlatformIcons(context).add, - color: Theme.of(context).colorScheme.surfaceTint, - ), - PlatformText( - "Connect Device", - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.surfaceTint, - ), - ), - ], + builder: (context, snapshot) { + final groups = orderWearableGroupsForOverview( + snapshot.data ?? + wearablesProvider.wearables + .map( + (wearable) => + WearableDisplayGroup.single(wearable: wearable), + ) + .toList(), + ); + + if (groups.isEmpty) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Padding( + padding: const EdgeInsets.all(20), + child: NoDevicesPrompt( + onScanPressed: () => context.push('/connect-devices'), ), ), ), ); } - return DeviceRow(device: wearablesProvider.wearables[index]); + + return GridView.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 500, + childAspectRatio: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: groups.length + 1, + itemBuilder: (context, index) { + if (index == groups.length) { + return GestureDetector( + onTap: () { + showPlatformModalSheet( + context: context, + builder: (context) => const ConnectDevicesPage(), + ); + }, + child: Card( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.45), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + PlatformIcons(context).add, + color: Theme.of(context).colorScheme.primary, + ), + PlatformText( + "Connect Device", + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ), + ); + } + return DeviceRow( + group: groups[index], + cardMargin: EdgeInsets.zero, + onPairCombineChanged: (pairKey, combined) => + wearablesProvider.setStereoPairKeyCombined( + pairKey: pairKey, + combined: combined, + ), + ); + }, + ); }, ); } } - // MARK: DeviceRow /// This widget represents a single device in the list/grid. /// Tapping on it will navigate to the [DeviceDetailPage]. class DeviceRow extends StatelessWidget { - final Wearable _device; + final WearableDisplayGroup group; + final void Function(String pairKey, bool combined)? onPairCombineChanged; + final void Function(Wearable device)? onSingleDeviceSelected; + final bool showWearableIcon; + final EdgeInsetsGeometry cardMargin; - const DeviceRow({super.key, required Wearable device}) : _device = device; + const DeviceRow({ + super.key, + required this.group, + this.onPairCombineChanged, + this.onSingleDeviceSelected, + this.showWearableIcon = true, + this.cardMargin = + const EdgeInsets.only(bottom: SensorPageSpacing.sectionGap), + }); @override Widget build(BuildContext context) { - String? wearableIconPath = _device.getWearableIconPath(); + final primary = group.representative; + final secondary = group.secondary; + final pairKey = group.stereoPairKey; + final knownIconVariant = _resolveWearableIconVariant(); + final hasWearableIcon = showWearableIcon && + (primary.getWearableIconPath(variant: knownIconVariant)?.isNotEmpty ?? + false); + final topRightIdentifierLabel = _buildTopRightIdentifierLabel(); + final statusPills = _buildDeviceStatusPills( + primary, + showStereoPosition: !group.isCombined, + ); return GestureDetector( - onTap: () { - bool isLargeScreen = MediaQuery.of(context).size.width > 600; - if (isLargeScreen) { - showGeneralDialog( - context: context, - pageBuilder: (context, animation1, animation2) { - return Center( - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - height: MediaQuery.of(context).size.height * 0.5, - child: DeviceDetailPage(device: _device), - ), - ); - }, - ); - return; - } - context.push('/device-detail', extra: _device); - }, + onTap: () => _openDetails(context), child: Card( + margin: cardMargin, child: Padding( - padding: const EdgeInsets.all(10.0), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (wearableIconPath != null) - SvgPicture.asset( - wearableIconPath, - width: 50, - height: 50, - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PlatformText( - _device.name, - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(fontWeight: FontWeight.bold), + if (hasWearableIcon) ...[ + Padding( + padding: const EdgeInsets.only(top: 2), + child: SizedBox( + width: 56, + height: 56, + child: WearableIcon( + wearable: primary, + initialVariant: knownIconVariant, + hideWhileResolvingStereoPosition: true, + hideWhenResolvedVariantIsSingle: true, + fallback: const SizedBox.shrink(), + ), ), - Row(children: [ - BatteryStateView(device: _device), - if (_device.hasCapability()) + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + flex: group.isCombined ? 6 : 7, + child: Text( + group.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (topRightIdentifierLabel != null) ...[ + const SizedBox(width: 8), + Expanded( + flex: group.isCombined ? 5 : 4, + child: _buildIdentifierLabel( + context, + topRightIdentifierLabel, + ), + ), + ], + ], + ), + if (group.isCombined) ...[ + const SizedBox(height: 8), + ..._buildCombinedStatusLines(), + ] else if (statusPills.isNotEmpty) ...[ + const SizedBox(height: 8), + DevicePillLine(pills: statusPills), + ], + if (secondary != null) Padding( - padding: EdgeInsets.only(left: 8.0), - child: StereoPosLabel(device: _device.requireCapability()), + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Tap to choose left or right device controls', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), ), ], ), - ], - ), - Spacer(), - if (_device.hasCapability()) - FutureBuilder( - future: - _device.requireCapability().readDeviceIdentifier(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } - return PlatformText(snapshot.data.toString()); - }, - ) - else - PlatformText(_device.deviceId), + ), ], ), - if (_device.hasCapability()) - Row( - children: [ - PlatformText("Firmware Version: "), - FutureBuilder( - future: _device.requireCapability() - .readDeviceFirmwareVersion(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } - return PlatformText(snapshot.data.toString()); - }, - ), - FutureBuilder( - future: _device.requireCapability() - .checkFirmwareSupport(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } - switch (snapshot.data) { - case FirmwareSupportStatus.supported: - return SizedBox.shrink(); - case FirmwareSupportStatus.tooOld: - case FirmwareSupportStatus.tooNew: - return Icon( - Icons.warning, - color: Colors.orange, - size: 16, - ); - case FirmwareSupportStatus.unknown: - default: - return Icon( - Icons.help, - color: Colors.grey, - size: 16, - ); - } - }, - ), - ], + if (pairKey != null) ...[ + const SizedBox(height: 8), + Divider( + height: 1, + thickness: 0.6, + color: Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + const SizedBox(height: 6), + _buildPairToggleButton( + context, + pairKey: pairKey, + combined: group.isCombined, ), - if (_device.hasCapability()) - Row( - children: [ - PlatformText("Hardware Version: "), - FutureBuilder( - future: _device.requireCapability() - .readDeviceHardwareVersion(), - builder: (context, snapshot) { - if (snapshot.connectionState == - ConnectionState.waiting) { - return PlatformCircularProgressIndicator(); - } - if (snapshot.hasError) { - return PlatformText("Error: ${snapshot.error}"); - } - return PlatformText(snapshot.data.toString()); - }, + ], + ], + ), + ), + ), + ); + } + + WearableIconVariant _resolveWearableIconVariant() { + if (group.isCombined) { + return WearableIconVariant.pair; + } + + switch (group.primaryPosition) { + case DevicePosition.left: + return WearableIconVariant.left; + case DevicePosition.right: + return WearableIconVariant.right; + case null: + return WearableIconVariant.single; + } + } + + String? _buildTopRightIdentifierLabel() { + if (!group.isCombined) { + final label = group.identifiersLabel.trim(); + return label.isEmpty ? null : label; + } + + final leftId = group.leftDevice?.deviceId; + final rightId = group.rightDevice?.deviceId; + if (leftId == null || + leftId.isEmpty || + rightId == null || + rightId.isEmpty) { + return null; + } + + return '${leftId.trim()} / ${rightId.trim()}'; + } + + Widget _buildIdentifierLabel(BuildContext context, String label) { + final style = Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ); + + if (!group.isCombined) { + return Align( + alignment: Alignment.centerRight, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: style, + ), + ); + } + + final parts = label.split(' / '); + if (parts.length != 2) { + return Align( + alignment: Alignment.centerRight, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: style, + ), + ); + } + + return Row( + children: [ + Expanded( + child: Text( + parts[0], + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.right, + style: style, + ), + ), + Text(' / ', style: style), + Expanded( + child: Text( + parts[1], + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.left, + style: style, + ), + ), + ], + ); + } + + Widget _buildPairToggleButton( + BuildContext context, { + required String pairKey, + required bool combined, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final enabled = onPairCombineChanged != null; + return SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + children: [ + Icon( + combined ? Icons.merge_type : Icons.call_split, + size: 16, + color: enabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant.withValues(alpha: 0.7), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Combine stereo pair', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Switch.adaptive( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: combined, + onChanged: enabled + ? (value) => onPairCombineChanged!(pairKey, value) + : null, + ), + ], + ), + ), + ); + } + + List _buildCombinedStatusLines() { + final lines = []; + final left = group.leftDevice; + final right = group.rightDevice; + + if (left != null) { + lines.add( + _buildStatusPillLine( + buildDeviceStatusPills( + wearable: left, + sideLabel: 'L', + batteryLiveUpdates: true, + ), + ), + ); + } + + if (right != null) { + if (lines.isNotEmpty) { + lines.add(const SizedBox(height: 6)); + } + lines.add( + _buildStatusPillLine( + buildDeviceStatusPills( + wearable: right, + sideLabel: 'R', + batteryLiveUpdates: true, + ), + ), + ); + } + + if (lines.isNotEmpty) { + return lines; + } + + return [ + _buildStatusPillLine( + const [DeviceMetadataBubble(label: 'L+R', highlighted: true)], + ), + ]; + } + + List _buildDeviceStatusPills( + Wearable device, { + String? sideLabel, + bool showStereoPosition = false, + }) { + return buildDeviceStatusPills( + wearable: device, + sideLabel: sideLabel, + showStereoPosition: sideLabel == null && showStereoPosition, + batteryLiveUpdates: true, + ); + } + + Widget _buildStatusPillLine(List pills) { + return DevicePillLine(pills: pills); + } + + Future _openDetails(BuildContext context) async { + final devices = group.members; + if (devices.length == 1) { + final device = devices.first; + if (onSingleDeviceSelected != null) { + onSingleDeviceSelected!(device); + } else { + _openDeviceDetail(context, device); + } + return; + } + + final leftDevice = group.leftDevice ?? devices.first; + final rightDevice = group.rightDevice ?? devices.last; + + await showPlatformModalSheet( + context: context, + builder: (sheetContext) => _PairedDeviceSheet( + title: group.displayName, + leftDevice: leftDevice, + rightDevice: rightDevice, + onOpenDeviceDetail: (device) { + Navigator.of(sheetContext).pop(); + if (onSingleDeviceSelected != null) { + onSingleDeviceSelected!(device); + } else { + _openDeviceDetail(context, device); + } + }, + ), + ); + } + + void _openDeviceDetail(BuildContext context, Wearable device) { + final isLargeScreen = MediaQuery.of(context).size.width > 600; + if (isLargeScreen) { + showGeneralDialog( + context: context, + pageBuilder: (context, animation1, animation2) { + return Center( + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + height: MediaQuery.of(context).size.height * 0.5, + child: DeviceDetailPage(device: device), + ), + ); + }, + ); + return; + } + context.push('/device-detail', extra: device); + } +} + +class _PairedDeviceSheet extends StatelessWidget { + final String title; + final Wearable leftDevice; + final Wearable rightDevice; + final void Function(Wearable device) onOpenDeviceDetail; + + const _PairedDeviceSheet({ + required this.title, + required this.leftDevice, + required this.rightDevice, + required this.onOpenDeviceDetail, + }); + + bool _supportsStereoListeningMode(Wearable device) { + return device.hasCapability() && + device.hasCapability(); + } + + Wearable? _resolveListeningModeDevice() { + if (_supportsStereoListeningMode(leftDevice)) { + return leftDevice; + } + if (_supportsStereoListeningMode(rightDevice)) { + return rightDevice; + } + return null; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final listeningModeDevice = _resolveListeningModeDevice(); + + return SafeArea( + child: Material( + color: theme.colorScheme.surface, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Select a device to open details.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), - ], + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded, size: 20), + ), + ], + ), + const SizedBox(height: 10), + DeviceRow( + group: WearableDisplayGroup.single( + wearable: leftDevice, + position: DevicePosition.left, + ), + cardMargin: EdgeInsets.zero, + onSingleDeviceSelected: onOpenDeviceDetail, + ), + const SizedBox(height: 8), + DeviceRow( + group: WearableDisplayGroup.single( + wearable: rightDevice, + position: DevicePosition.right, + ), + cardMargin: EdgeInsets.zero, + onSingleDeviceSelected: onOpenDeviceDetail, + ), + if (listeningModeDevice != null) ...[ + const SizedBox(height: 12), + AudioModeWidget( + key: ValueKey( + 'pair_audio_${leftDevice.deviceId}_${rightDevice.deviceId}', + ), + device: listeningModeDevice, + applyScope: AudioModeApplyScope.pairOnly, + ), + ] else ...[ + const SizedBox(height: 10), + Text( + 'Listening mode is not available for this stereo pair.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), + ], ], ), ), diff --git a/open_wearable/lib/widgets/devices/stereo_position_badge.dart b/open_wearable/lib/widgets/devices/stereo_position_badge.dart new file mode 100644 index 00000000..d77940bc --- /dev/null +++ b/open_wearable/lib/widgets/devices/stereo_position_badge.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/devices/device_status_pills.dart'; + +class StereoPositionBadge extends StatefulWidget { + final StereoDevice device; + + const StereoPositionBadge({super.key, required this.device}); + + @override + State createState() => _StereoPositionBadgeState(); +} + +class _StereoPositionBadgeState extends State { + static final Expando> _positionFutureCache = + Expando>(); + + late Future _positionFuture; + + @override + void initState() { + super.initState(); + _positionFuture = _resolvePositionFuture(widget.device); + } + + @override + void didUpdateWidget(covariant StereoPositionBadge oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.device != widget.device) { + _positionFuture = _resolvePositionFuture(widget.device); + } + } + + Future _resolvePositionFuture(StereoDevice device) { + return _positionFutureCache[device] ??= device.position; + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _positionFuture, + builder: (context, snapshot) { + final isLoading = snapshot.connectionState == ConnectionState.waiting; + final label = switch (snapshot.data) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + + if (!isLoading && label == null) { + return const SizedBox.shrink(); + } + + return DeviceMetadataBubble( + label: isLoading ? '...' : (label ?? '--'), + highlighted: true, + ); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/devices/wearable_icon.dart b/open_wearable/lib/widgets/devices/wearable_icon.dart new file mode 100644 index 00000000..6a11d974 --- /dev/null +++ b/open_wearable/lib/widgets/devices/wearable_icon.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; + +/// Reusable wearable icon renderer with optional stereo-side resolution. +class WearableIcon extends StatefulWidget { + final Wearable wearable; + final WearableIconVariant initialVariant; + final bool resolveStereoPositionWhenSingleVariant; + final bool hideWhileResolvingStereoPosition; + final bool hideWhenResolvedVariantIsSingle; + final BoxFit fit; + final Widget? fallback; + + const WearableIcon({ + super.key, + required this.wearable, + required this.initialVariant, + this.resolveStereoPositionWhenSingleVariant = true, + this.hideWhileResolvingStereoPosition = false, + this.hideWhenResolvedVariantIsSingle = false, + this.fit = BoxFit.contain, + this.fallback, + }); + + @override + State createState() => _WearableIconState(); +} + +class _WearableIconState extends State { + static final Expando> _positionFutureCache = + Expando>(); + + Future? _positionFuture; + + @override + void initState() { + super.initState(); + _configurePositionFuture(); + } + + @override + void didUpdateWidget(covariant WearableIcon oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.wearable, widget.wearable) || + oldWidget.initialVariant != widget.initialVariant || + oldWidget.resolveStereoPositionWhenSingleVariant != + widget.resolveStereoPositionWhenSingleVariant) { + _configurePositionFuture(); + } + } + + void _configurePositionFuture() { + if (!widget.resolveStereoPositionWhenSingleVariant || + widget.initialVariant != WearableIconVariant.single || + !widget.wearable.hasCapability()) { + _positionFuture = null; + return; + } + + final stereoDevice = widget.wearable.requireCapability(); + _positionFuture = + _positionFutureCache[stereoDevice] ??= stereoDevice.position; + } + + WearableIconVariant _variantForPosition(DevicePosition? position) { + return switch (position) { + DevicePosition.left => WearableIconVariant.left, + DevicePosition.right => WearableIconVariant.right, + _ => widget.initialVariant, + }; + } + + String? _resolveIconPath(WearableIconVariant variant) { + final variantPath = widget.wearable.getWearableIconPath(variant: variant); + if (variantPath != null && variantPath.isNotEmpty) { + return variantPath; + } + + if (variant != WearableIconVariant.single) { + final fallbackPath = widget.wearable.getWearableIconPath(); + if (fallbackPath != null && fallbackPath.isNotEmpty) { + return fallbackPath; + } + } + + return null; + } + + Widget _buildFallback() { + return widget.fallback ?? const SizedBox.shrink(); + } + + Widget _buildIcon(WearableIconVariant variant) { + final path = _resolveIconPath(variant); + if (path == null) { + return _buildFallback(); + } + + if (path.toLowerCase().endsWith('.svg')) { + return SvgPicture.asset( + path, + fit: widget.fit, + placeholderBuilder: (_) => _buildFallback(), + ); + } + + return Image.asset( + path, + fit: widget.fit, + errorBuilder: (_, __, ___) => _buildFallback(), + ); + } + + @override + Widget build(BuildContext context) { + if (_positionFuture == null) { + return _buildIcon(widget.initialVariant); + } + + return FutureBuilder( + future: _positionFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting && + widget.hideWhileResolvingStereoPosition) { + return _buildFallback(); + } + + final variant = _variantForPosition(snapshot.data); + if (variant == WearableIconVariant.single && + widget.hideWhenResolvedVariantIsSingle) { + return _buildFallback(); + } + return _buildIcon(variant); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart index b526c687..9da22535 100644 --- a/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart +++ b/open_wearable/lib/widgets/fota/firmware_select/firmware_list.dart @@ -1,12 +1,11 @@ -// ignore_for_file: use_build_context_synchronously - import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:provider/provider.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:provider/provider.dart'; class FirmwareList extends StatefulWidget { const FirmwareList({super.key}); @@ -20,7 +19,6 @@ class _FirmwareListState extends State { final _repository = UnifiedFirmwareRepository(); String? firmwareVersion; bool _expanded = false; - bool _showBeta = false; @override void initState() { @@ -30,93 +28,131 @@ class _FirmwareListState extends State { } void _loadFirmwares() { - _firmwareFuture = _repository.getAllFirmwares(includeBeta: _showBeta); + _firmwareFuture = _loadFirmwaresWithFallback(); } - void _loadFirmwareVersion() async { + Future> _loadFirmwaresWithFallback() async { + List stable = const []; + List beta = const []; + Object? stableError; + Object? betaError; + + try { + stable = await _repository.getStableFirmwares(); + } catch (error) { + stableError = error; + // Keep going to allow beta-only fallback when stable fetch fails. + } + + try { + beta = await _repository.getBetaFirmwares(); + } catch (error) { + betaError = error; + // Beta feed is optional. Ignore failures. + } + + final combined = [ + ...stable, + ...beta, + ]; + + if (stableError != null && betaError != null) { + throw Exception('Could not fetch firmware list from the internet.'); + } + + return combined; + } + + Future _refreshFirmwares() async { + setState(_loadFirmwares); + try { + await _firmwareFuture; + } catch (_) { + // Error state is handled by FutureBuilder. + } + } + + Future _loadFirmwareVersion() async { final wearable = Provider.of(context, listen: false) .selectedWearable; - if (wearable != null && wearable.hasCapability()) { + if (wearable == null || !wearable.hasCapability()) { + return; + } + + try { final version = await wearable .requireCapability() .readDeviceFirmwareVersion(); + if (!mounted) { + return; + } setState(() { firmwareVersion = version; }); + } catch (_) { + // Keep UI usable even when firmware version cannot be read. } } - void _toggleBeta() { - setState(() { - _showBeta = !_showBeta; - _loadFirmwares(); - }); - print(_showBeta ? 'Beta firmware enabled' : 'Beta firmware disabled'); - } - @override Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: GestureDetector( - onLongPress: _toggleBeta, - child: PlatformText('Select Firmware'), - ), + title: const Text('Select Firmware'), trailingActions: [ - IconButton( - onPressed: () => _onCustomFirmwareSelect(context), - icon: Icon(Icons.add), + PlatformIconButton( + onPressed: _onCustomFirmwareSelect, + icon: const Icon(Icons.upload_file_rounded), padding: EdgeInsets.zero, ), ], ), - body: _body(), // Remove Material wrapper + body: _body(), ); } - void _onCustomFirmwareSelect(BuildContext context) async { + void _onCustomFirmwareSelect() async { final confirmed = await showDialog( context: context, - builder: (_) => PlatformAlertDialog( - title: PlatformText('Disclaimer'), - content: PlatformText( + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Custom Firmware'), + content: const Text( 'By selecting a custom firmware file, you acknowledge that you are doing so at your own risk. The developers are not responsible for any damage caused.', ), actions: [ PlatformDialogAction( - child: PlatformText( - 'Cancel', - style: TextStyle(fontWeight: FontWeight.bold), - ), - onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), ), PlatformDialogAction( - child: PlatformText( - 'Continue', - style: TextStyle(color: Colors.red), - ), - onPressed: () => Navigator.of(context).pop(true), + child: const Text('Continue'), + onPressed: () => Navigator.of(dialogContext).pop(true), ), ], ), ); - if (confirmed != true) return; + if (confirmed != true || !mounted) return; FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['zip', 'bin'], ); - if (result == null) return; + if (result == null || !mounted) return; final ext = result.files.first.extension; final fwType = ext == 'zip' ? FirmwareType.multiImage : FirmwareType.singleImage; final firstResult = result.files.first; - final file = File(firstResult.path!); + final path = firstResult.path; + if (path == null || path.isEmpty) { + return; + } + final file = File(path); final bytes = await file.readAsBytes(); + if (!mounted) return; final fw = LocalFirmware( data: bytes, @@ -135,31 +171,115 @@ class _FirmwareListState extends State { if (snapshot.hasData) { final entries = snapshot.data!; if (entries.isEmpty) { - return Center(child: PlatformText('No firmware available')); + return _emptyState(); } return _listBuilder(entries); } else if (snapshot.hasError) { return _errorWidget(); } - return const Center(child: CircularProgressIndicator()); + return _loadingState(); }, ); } + Widget _loadingState() { + final colorScheme = Theme.of(context).colorScheme; + + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Loading firmware versions...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _emptyState() { + final colorScheme = Theme.of(context).colorScheme; + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + child: Text( + 'No firmware is available right now.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ); + } + Widget _errorWidget() { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - PlatformText("Could not fetch firmware, please try again"), - const SizedBox(height: 16), - PlatformElevatedButton( - onPressed: _loadFirmwares, - child: PlatformText('Reload'), + final colorScheme = Theme.of(context).colorScheme; + + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Could not fetch firmware list from the internet.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Please check your internet connection and try again.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _refreshFirmwares, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Reload'), + ), + ), + ], + ), ), - ], - ), + ), + ], ); } @@ -167,23 +287,143 @@ class _FirmwareListState extends State { final stableEntries = entries.where((e) => e.isStable).toList(); final betaEntries = entries.where((e) => e.isBeta).toList(); final latestStable = stableEntries.isNotEmpty ? stableEntries.first : null; + final orderedEntries = [ + ...stableEntries, + ...betaEntries, + ]; + final latestEntry = latestStable ?? + (orderedEntries.isNotEmpty ? orderedEntries.first : entries.first); + final currentEntry = _findCurrentEntry(orderedEntries); + final collapsedEntries = [ + latestEntry, + if (currentEntry != null && currentEntry != latestEntry) currentEntry, + ]; + final visibleEntries = _expanded ? orderedEntries : collapsedEntries; + final visibleStableEntries = + visibleEntries.where((e) => e.isStable).toList(); + final visibleBetaEntries = visibleEntries.where((e) => e.isBeta).toList(); + final canToggleExpanded = orderedEntries.length > collapsedEntries.length; + + final firmwareRows = [ + ...visibleStableEntries.map( + (entry) => Padding( + padding: const EdgeInsets.only(bottom: SensorPageSpacing.sectionGap), + child: _firmwareListItem(entry, latestStable), + ), + ), + if (visibleBetaEntries.isNotEmpty) ...[ + if (visibleStableEntries.isNotEmpty) + const SizedBox(height: SensorPageSpacing.sectionGap), + _betaWarningBanner(), + const SizedBox(height: SensorPageSpacing.sectionGap), + ...visibleBetaEntries.map( + (entry) => Padding( + padding: + const EdgeInsets.only(bottom: SensorPageSpacing.sectionGap), + child: _firmwareListItem(entry, latestStable), + ), + ), + ], + ]; + + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + _summaryCard( + totalCount: entries.length, + stableCount: stableEntries.length, + betaCount: betaEntries.length, + ), + if (firmwareRows.isNotEmpty) ...[ + const SizedBox(height: SensorPageSpacing.sectionGap), + ...firmwareRows, + ], + if (canToggleExpanded || _expanded) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + setState(() { + _expanded = !_expanded; + }); + }, + icon: Icon( + _expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 18, + ), + label: Text( + _expanded ? 'Hide Older Versions' : 'Show Older Versions', + ), + ), + ), + ], + ); + } - final visibleEntries = _expanded ? entries : [entries.first]; - - return SafeArea( - child: SingleChildScrollView( + Widget _summaryCard({ + required int totalCount, + required int stableCount, + required int betaCount, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final installed = _normalizedDeviceVersion; + + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_showBeta && betaEntries.isNotEmpty) _betaWarningBanner(), - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: visibleEntries.length, - itemBuilder: (context, index) => - _firmwareListItem(visibleEntries[index], latestStable, index), + Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.4), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.system_update_alt_rounded, + size: 15, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Available Firmware', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _MetaChip(label: '$totalCount total'), + _MetaChip(label: '$stableCount stable'), + if (betaCount > 0) _MetaChip(label: '$betaCount beta'), + ], + ), + const SizedBox(height: 8), + Text( + installed == null + ? 'Current firmware version could not be read from the device.' + : 'Current device firmware version: $installed', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - if (entries.length > 1) _expandButton(), - const SizedBox(height: 16), // Simple bottom padding ], ), ), @@ -191,22 +431,33 @@ class _FirmwareListState extends State { } Widget _betaWarningBanner() { + final colorScheme = Theme.of(context).colorScheme; return Container( width: double.infinity, - padding: const EdgeInsets.all(12), - color: Colors.orange.withValues(alpha: 0.2), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.52), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.tertiary.withValues(alpha: 0.45), + ), + ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.warning, color: Colors.orange, size: 20), + Icon( + Icons.warning_amber_rounded, + color: colorScheme.tertiary, + size: 18, + ), const SizedBox(width: 8), Expanded( - child: PlatformText( - 'Beta firmware is experimental and may be unstable. Use at your own risk.', - style: TextStyle( - color: Colors.orange.shade900, - fontSize: 12, - fontWeight: FontWeight.w500, - ), + child: Text( + 'Beta firmware is experimental and is not recommended to be used. Use at your own risk.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), ), ), ], @@ -217,175 +468,316 @@ class _FirmwareListState extends State { Widget _firmwareListItem( FirmwareEntry entry, FirmwareEntry? latestStable, - int index, ) { final firmware = entry.firmware; final isBeta = entry.isBeta; final isLatestStable = entry == latestStable; - final remarks = []; - bool isInstalled = false; - - if (firmwareVersion != null && - firmware.version.contains(firmwareVersion!.replaceAll('\x00', ''))) { - remarks.add('Current'); - isInstalled = true; - } - - if (isLatestStable) { - remarks.add('Latest'); - } - - if (isBeta) { - remarks.add('Beta'); - } - - return ListTile( - leading: isBeta ? Icon(Icons.bug_report, color: Colors.orange) : null, - title: PlatformText( - firmware.name, - style: TextStyle( - color: isLatestStable ? Colors.black : Colors.grey, + final isInstalled = _isInstalledFirmware(firmware); + + return Card( + margin: EdgeInsets.zero, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _onFirmwareTap( + firmware, + isInstalled: isInstalled, + isLatest: isLatestStable, + isBeta: isBeta, ), - ), - subtitle: PlatformText( - remarks.join(', '), - style: TextStyle( - color: isLatestStable ? Colors.black : Colors.grey, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 10, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildFirmwareLeading( + isBeta: isBeta, + isInstalled: isInstalled, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + firmware.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Version ${firmware.version}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (isInstalled) + _MetaChip( + label: 'Current', + tone: _ChipTone.success, + ), + if (isLatestStable) + const _MetaChip( + label: 'Latest', + tone: _ChipTone.success, + ), + if (isBeta) + const _MetaChip( + label: 'Beta', + tone: _ChipTone.warning, + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right_rounded, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), ), ), - onTap: () => _onFirmwareTap( - firmware, - index, - isInstalled, - isLatestStable, - isBeta, - ), ); } - Widget _expandButton() { - return PlatformTextButton( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PlatformText( - _expanded ? 'Hide older versions' : 'Show older versions', - style: TextStyle(color: Colors.black), - ), - SizedBox(width: 8), - Icon( - _expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, - ), - ], + Widget _buildFirmwareLeading({ + required bool isBeta, + required bool isInstalled, + }) { + final colorScheme = Theme.of(context).colorScheme; + final iconColor = isBeta + ? colorScheme.tertiary + : isInstalled + ? colorScheme.primary + : colorScheme.onSurfaceVariant; + final icon = isBeta + ? Icons.science_outlined + : isInstalled + ? Icons.check_circle_rounded + : Icons.memory_rounded; + + return Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + icon, + size: 15, + color: iconColor, ), - onPressed: () { - setState(() { - _expanded = !_expanded; - }); - }, ); } void _onFirmwareTap( - RemoteFirmware firmware, - int index, - bool isInstalled, - bool isLatest, - bool isBeta, - ) { + RemoteFirmware firmware, { + required bool isInstalled, + required bool isLatest, + required bool isBeta, + }) { if (isInstalled) { - _showInstalledDialog(firmware, index); + _showInstalledDialog(firmware); } else if (isBeta) { - _showBetaWarningDialog(firmware, index); + _showBetaWarningDialog(firmware); } else if (!isLatest) { - _showOldVersionWarningDialog(firmware, index); + _showOldVersionWarningDialog(firmware); } else { - _installFirmware(firmware, index); + _installFirmware(firmware); } } - void _showInstalledDialog(RemoteFirmware firmware, int index) { - showDialog( + Future _showInstalledDialog(RemoteFirmware firmware) async { + final confirmed = await showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Already Installed'), - content: PlatformText( - 'This firmware version is already installed on the device.', + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Firmware Already Installed'), + content: const Text( + 'This firmware version appears to already be installed. Do you want to install it again?', ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), ), - PlatformTextButton( + PlatformDialogAction( + child: const Text('Install Anyway'), onPressed: () { - _installFirmware(firmware, index); - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(true); }, - child: PlatformText('Install Anyway'), ), ], ), ); + if (confirmed == true) { + _installFirmware(firmware); + } } - void _showBetaWarningDialog(RemoteFirmware firmware, int index) { - showDialog( + Future _showBetaWarningDialog(RemoteFirmware firmware) async { + final confirmed = await showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Beta Firmware Warning'), - content: PlatformText( + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Install Beta Firmware?'), + content: const Text( 'You are about to install beta firmware from a pull request. ' 'This firmware may be unstable or incomplete. ' 'Proceed at your own risk.', ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), ), - PlatformTextButton( + PlatformDialogAction( + cupertino: (_, __) => + CupertinoDialogActionData(isDestructiveAction: true), + child: const Text('Install Beta'), onPressed: () { - _installFirmware(firmware, index); - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(true); }, - child: PlatformText( - 'Install', - style: TextStyle(color: Colors.red), - ), ), ], ), ); + if (confirmed == true) { + _installFirmware(firmware); + } } - void _showOldVersionWarningDialog(RemoteFirmware firmware, int index) { - showDialog( + Future _showOldVersionWarningDialog(RemoteFirmware firmware) async { + final confirmed = await showPlatformDialog( context: context, - builder: (context) => PlatformAlertDialog( - title: PlatformText('Warning'), - content: PlatformText( + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Install Older Version?'), + content: const Text( 'You are selecting an old firmware version. We recommend installing the newest version.', ), - actions: [ - PlatformTextButton( - onPressed: () => Navigator.of(context).pop(), - child: PlatformText('Cancel'), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), ), - PlatformTextButton( + PlatformDialogAction( + child: const Text('Proceed'), onPressed: () { - _installFirmware(firmware, index); - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(true); }, - child: PlatformText('Proceed'), ), ], ), ); + if (confirmed == true) { + _installFirmware(firmware); + } + } + + bool _isInstalledFirmware(RemoteFirmware firmware) { + final version = _normalizedDeviceVersion; + if (version == null) { + return false; + } + return firmware.version == version || firmware.version.contains(version); + } + + FirmwareEntry? _findCurrentEntry(List orderedEntries) { + final version = _normalizedDeviceVersion; + if (version == null) { + return null; + } + + for (final entry in orderedEntries) { + final fwVersion = entry.firmware.version; + if (fwVersion == version || fwVersion.contains(version)) { + return entry; + } + } + return null; + } + + String? get _normalizedDeviceVersion { + final version = firmwareVersion?.replaceAll('\x00', '').trim(); + if (version == null || version.isEmpty) { + return null; + } + return version; } - void _installFirmware(RemoteFirmware firmware, int index) { + void _installFirmware(RemoteFirmware firmware) { + if (!mounted) { + return; + } context.read().setFirmware(firmware); - Navigator.pop(context, 'Firmware $index'); + Navigator.pop(context); + } +} + +enum _ChipTone { neutral, primary, success, warning } + +class _MetaChip extends StatelessWidget { + final String label; + final _ChipTone tone; + + const _MetaChip({ + required this.label, + this.tone = _ChipTone.neutral, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + const successGreen = Color(0xFF2E7D32); + final (foreground, background, border) = switch (tone) { + _ChipTone.primary => ( + colorScheme.primary, + colorScheme.primaryContainer.withValues(alpha: 0.3), + colorScheme.primary.withValues(alpha: 0.35), + ), + _ChipTone.success => ( + successGreen, + successGreen.withValues(alpha: 0.12), + successGreen.withValues(alpha: 0.34), + ), + _ChipTone.warning => ( + colorScheme.tertiary, + colorScheme.tertiaryContainer.withValues(alpha: 0.5), + colorScheme.tertiary.withValues(alpha: 0.45), + ), + _ChipTone.neutral => ( + colorScheme.onSurfaceVariant, + colorScheme.surfaceContainerHighest.withValues(alpha: 0.38), + colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: border), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ); } } diff --git a/open_wearable/lib/widgets/fota/firmware_update.dart b/open_wearable/lib/widgets/fota/firmware_update.dart index 42d3e459..b92714d9 100644 --- a/open_wearable/lib/widgets/fota/firmware_update.dart +++ b/open_wearable/lib/widgets/fota/firmware_update.dart @@ -1,105 +1,399 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; - -import '../fota/stepper_view/update_view.dart'; -import '../fota/stepper_view/firmware_select.dart'; +import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/fota/stepper_view/firmware_select.dart'; +import 'package:open_wearable/widgets/fota/stepper_view/update_view.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; class FirmwareUpdateWidget extends StatefulWidget { const FirmwareUpdateWidget({super.key}); @override - State createState() => _FirmwareUpdateWidget(); + State createState() => _FirmwareUpdateWidgetState(); } -class _FirmwareUpdateWidget extends State { +class _FirmwareUpdateWidgetState extends State { late FirmwareUpdateRequestProvider provider; + bool _isUpdateRunning = false; + bool _hasStartedUpdate = false; + String? _cachedUpdateWearableName; + String? _cachedUpdateSideLabel; @override Widget build(BuildContext context) { provider = context.watch(); - return PlatformScaffold( - appBar: PlatformAppBar(title: PlatformText("Update Firmware")), - body: Material(type: MaterialType.transparency, child: _body(context)), - ); - } + final request = provider.updateParameters; + final shouldRouteBackToDevices = _hasStartedUpdate && !_isUpdateRunning; - Widget _body(BuildContext context) { - return Stepper( - connectorColor: WidgetStateProperty.resolveWith( - (states) { - if (states.contains(WidgetState.selected)) { - return Colors.green; - } - return Colors.grey; - }, - ), - currentStep: provider.currentStep, - onStepContinue: () { - setState(() { - provider.nextStep(); - }); - }, - onStepCancel: () { - setState(() { - provider.previousStep(); - }); + return PopScope( + canPop: !_isUpdateRunning && !shouldRouteBackToDevices, + onPopInvokedWithResult: (didPop, _) { + if (didPop) { + return; + } + + if (_isUpdateRunning) { + _showUpdateInProgressToast(); + return; + } + + if (shouldRouteBackToDevices) { + context.go('/?tab=devices'); + } }, - controlsBuilder: _controlBuilder, - steps: [ - Step( - state: - provider.currentStep > 0 ? StepState.complete : StepState.indexed, - title: PlatformText('Select Firmware'), - content: Center( - child: FirmwareSelect(), - ), - isActive: provider.currentStep >= 0, + child: PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Firmware Update'), ), - Step( - state: - provider.currentStep > 1 ? StepState.complete : StepState.indexed, - title: PlatformText('Update'), - content: PlatformText('Update'), - isActive: provider.currentStep >= 1, + body: ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + _UpdateStepHeader(currentStep: provider.currentStep), + const SizedBox(height: SensorPageSpacing.sectionGap), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: _buildStepContent(context, request), + ), + ), + if (provider.currentStep == 0 && request.firmware != null) ...[ + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () async { + await _captureUpdateTargetMetadataBeforeStart(); + if (!mounted) { + return; + } + setState(() { + _isUpdateRunning = true; + _hasStartedUpdate = true; + }); + provider.nextStep(); + }, + icon: const Icon(Icons.system_update_alt_rounded, size: 18), + label: const Text('Start Update'), + ), + ), + ], + ], ), - ], + ), ); } - Widget _controlBuilder(BuildContext context, ControlsDetails details) { - final provider = context.watch(); - FirmwareUpdateRequest parameters = provider.updateParameters; + Widget _buildStepContent( + BuildContext context, + FirmwareUpdateRequest request, + ) { switch (provider.currentStep) { case 0: - if (parameters.firmware == null) { - return Container(); - } - return Row( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformElevatedButton( - onPressed: details.onStepContinue, - child: PlatformText('Next'), + Text( + 'Select Firmware', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Choose firmware to install on the selected wearable.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), + const SizedBox(height: 12), + const FirmwareSelect(), ], ); case 1: - return BlocProvider( - create: (context) => UpdateBloc(firmwareUpdateRequest: parameters), - child: UpdateStepView(), + if (request.firmware == null) { + return Text( + 'No firmware selected. Go back and select firmware first.', + style: Theme.of(context).textTheme.bodyMedium, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Install Firmware', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Firmware update is running. Do not close the app until it finishes.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + BlocProvider( + create: (context) => UpdateBloc(firmwareUpdateRequest: request), + child: UpdateStepView( + autoStart: true, + onUpdateRunningChanged: _handleUpdateRunningChanged, + preResolvedWearableName: _cachedUpdateWearableName, + preResolvedSideLabel: _cachedUpdateSideLabel, + ), + ), + ], ); default: - throw Exception('Unknown step'); + return const SizedBox.shrink(); } } @override void dispose() { - // Reset the state when this widget is disposed (e.g. popped) + // Reset state after the page has been removed. WidgetsBinding.instance.addPostFrameCallback((_) { provider.reset(); }); super.dispose(); } + + void _handleUpdateRunningChanged(bool running) { + if (!mounted) { + return; + } + + if (running && !_hasStartedUpdate) { + _hasStartedUpdate = true; + } + + if (_isUpdateRunning == running) { + return; + } + setState(() { + _isUpdateRunning = running; + }); + } + + void _showUpdateInProgressToast() { + _showUpdateInProgressToastMessage(); + } + + Future _captureUpdateTargetMetadataBeforeStart() async { + final wearable = provider.selectedWearable; + if (wearable == null) { + _cachedUpdateWearableName = 'OpenEarable'; + _cachedUpdateSideLabel = null; + return; + } + + final displayName = formatWearableDisplayName(wearable.name).trim(); + _cachedUpdateWearableName = + displayName.isNotEmpty ? displayName : 'OpenEarable'; + _cachedUpdateSideLabel = _resolveSideLabelFromName(wearable.name); + + StereoDevice? stereoDevice = wearable.getCapability(); + if (stereoDevice == null) { + for (var attempt = 0; attempt < 4 && stereoDevice == null; attempt++) { + try { + await wearable + .capabilityAvailable() + .first + .timeout(const Duration(seconds: 1)); + } catch (_) { + // Keep fallback from name. + } + stereoDevice = wearable.getCapability(); + if (stereoDevice == null && attempt < 3) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + } + + if (stereoDevice == null) { + return; + } + + for (var attempt = 0; attempt < 3; attempt++) { + try { + final position = + await stereoDevice.position.timeout(const Duration(seconds: 2)); + _cachedUpdateSideLabel = switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => _cachedUpdateSideLabel, + }; + break; + } catch (_) { + if (attempt < 2) { + await Future.delayed(const Duration(milliseconds: 350)); + } + } + } + } + + void _showUpdateInProgressToastMessage() { + final foreground = const Color(0xFF8A4B00); + + AppToast.showContent( + context, + type: AppToastType.warning, + icon: Icons.system_update_alt_rounded, + content: Text.rich( + TextSpan( + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + children: [ + const TextSpan( + text: + 'Firmware update in progress. Stay on this page until it completes.', + ), + ], + ), + ), + ); + } + + String? _resolveSideLabelFromName(String? name) { + final value = name?.trim().toLowerCase(); + if (value == null || value.isEmpty) { + return null; + } + + if (value.endsWith('-l') || + value.endsWith('_l') || + value.endsWith('(l)') || + value.endsWith('(left)') || + value.endsWith(' left') || + value.endsWith(' l')) { + return 'L'; + } + + if (value.endsWith('-r') || + value.endsWith('_r') || + value.endsWith('(r)') || + value.endsWith('(right)') || + value.endsWith(' right') || + value.endsWith(' r')) { + return 'R'; + } + + return null; + } +} + +class _UpdateStepHeader extends StatelessWidget { + final int currentStep; + + const _UpdateStepHeader({required this.currentStep}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _StepPill( + index: 1, + label: 'Select', + isActive: currentStep == 0, + isComplete: currentStep > 0, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _StepPill( + index: 2, + label: 'Update', + isActive: currentStep == 1, + isComplete: false, + ), + ), + ], + ); + } +} + +class _StepPill extends StatelessWidget { + final int index; + final String label; + final bool isActive; + final bool isComplete; + + const _StepPill({ + required this.index, + required this.label, + required this.isActive, + required this.isComplete, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + const completeGreen = Color(0xFF2E7D32); + final foreground = isComplete + ? completeGreen + : isActive + ? colorScheme.primary + : colorScheme.onSurfaceVariant; + final background = isComplete + ? completeGreen.withValues(alpha: 0.14) + : isActive + ? colorScheme.primaryContainer.withValues(alpha: 0.8) + : colorScheme.surfaceContainerHighest.withValues(alpha: 0.45); + final border = isComplete + ? completeGreen.withValues(alpha: 0.36) + : isActive + ? colorScheme.primary.withValues(alpha: 0.45) + : colorScheme.outlineVariant.withValues(alpha: 0.6); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: border), + ), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: foreground.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: isComplete + ? Icon( + Icons.check_rounded, + size: 12, + color: foreground, + ) + : Text( + '$index', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 8), + Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } } diff --git a/open_wearable/lib/widgets/fota/fota_verification_banner.dart b/open_wearable/lib/widgets/fota/fota_verification_banner.dart index 8a6ad72d..b9cc43a5 100644 --- a/open_wearable/lib/widgets/fota/fota_verification_banner.dart +++ b/open_wearable/lib/widgets/fota/fota_verification_banner.dart @@ -1,15 +1,26 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:open_wearable/widgets/app_banner.dart'; import 'package:provider/provider.dart'; import '../../view_models/app_banner_controller.dart'; +final Map _activeFotaVerificationBannerKeys = {}; +final Map _fotaVerificationDeadlinesById = {}; + class FotaVerificationBanner extends StatefulWidget { - final Duration duration; + final DateTime deadline; + final String wearableName; + final String? sideLabel; + final VoidCallback onDismiss; + const FotaVerificationBanner({ super.key, - this.duration = const Duration(minutes: 3), + required this.deadline, + required this.wearableName, + this.sideLabel, + required this.onDismiss, }); @override @@ -23,23 +34,34 @@ class _FotaVerificationBannerState extends State { @override void initState() { super.initState(); - remaining = widget.duration; + remaining = _remainingFromDeadline(widget.deadline); timer = Timer.periodic(const Duration(seconds: 1), (t) { - if (!mounted) return; + if (!mounted) { + return; + } + + final nextRemaining = _remainingFromDeadline(widget.deadline); + if (nextRemaining.inSeconds <= 0) { + t.cancel(); + widget.onDismiss(); + return; + } setState(() { - if (remaining.inSeconds > 0) { - remaining -= const Duration(seconds: 1); - } else { - t.cancel(); - // auto-hide when done - ScaffoldMessenger.of(context).hideCurrentMaterialBanner(); - } + remaining = nextRemaining; }); }); } + Duration _remainingFromDeadline(DateTime deadline) { + final difference = deadline.difference(DateTime.now()); + if (difference.isNegative) { + return Duration.zero; + } + return difference; + } + @override void dispose() { timer?.cancel(); @@ -49,36 +71,213 @@ class _FotaVerificationBannerState extends State { String _format(Duration d) { final mm = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final ss = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return "$mm:$ss"; + return '$mm:$ss'; } @override Widget build(BuildContext context) { - final baseStyle = Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white) ?? const TextStyle(color: Colors.white); + const successBackground = Color(0xFFE8F5E9); + const successForeground = Color(0xFF1E6A3A); + const warningBackground = Color(0xFFFFECEC); + const warningForeground = Color(0xFF8A1C1C); + final successTextStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: successForeground, + fontWeight: FontWeight.w700, + ); + final warningTextStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + color: warningForeground, + fontWeight: FontWeight.w700, + ); - return Text.rich( - TextSpan( - style: baseStyle, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - TextSpan(text: "Firmware verification in progress.\n"), - TextSpan( - text: "Do NOT reset your OpenEarable.\n", - style: baseStyle.copyWith(fontWeight: FontWeight.bold), + AppBanner( + backgroundColor: successBackground, + foregroundColor: successForeground, + leadingIcon: Icons.verified_rounded, + content: Text.rich( + TextSpan( + style: successTextStyle, + children: [ + const TextSpan( + text: 'Firmware upload completed successfully for ', + ), + TextSpan(text: widget.wearableName), + if (widget.sideLabel != null) const TextSpan(text: ' '), + if (widget.sideLabel != null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _FotaSideBadge( + sideLabel: widget.sideLabel!, + accentColor: successForeground, + ), + ), + const TextSpan(text: '.'), + ], + ), + ), + ), + AppBanner( + backgroundColor: warningBackground, + foregroundColor: warningForeground, + leadingIcon: Icons.warning_amber_rounded, + content: Text( + 'Verification in progress, do not reset or power off the device: ${_format(remaining)}.', + softWrap: true, + style: warningTextStyle, + ), ), - TextSpan(text: "Remaining: ${_format(remaining)}"), ], + ); + } +} + +class _FotaSideBadge extends StatelessWidget { + final String sideLabel; + final Color accentColor; + + const _FotaSideBadge({ + required this.sideLabel, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final foreground = accentColor; + final background = foreground.withValues(alpha: 0.16); + final border = foreground.withValues(alpha: 0.34); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: border), + ), + child: Text( + sideLabel, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), ), ); } } -void showFotaVerificationBanner(BuildContext context) { +class _FotaStackedBanner extends AppBanner { + const _FotaStackedBanner({ + super.key, + required super.content, + }); + + @override + Widget build(BuildContext context) { + return content; + } +} + +void showFotaVerificationBanner( + BuildContext context, { + required String verificationId, + required String wearableName, + String? sideLabel, + Duration duration = const Duration(minutes: 3), +}) { + final controller = Provider.of(context, listen: false); + _pruneMissingFotaVerificationBannerKeys(controller); + + final activeKey = _activeFotaVerificationBannerKeys[verificationId]; + if (activeKey != null) { + final alreadyVisible = controller.activeBanners.any( + (banner) => banner.key == activeKey, + ); + if (alreadyVisible) { + return; + } + } + _activeFotaVerificationBannerKeys.remove(verificationId); + final deadline = _fotaVerificationDeadlinesById.putIfAbsent( + verificationId, + () => DateTime.now().add(duration), + ); + + controller.showBanner( + (id) { + final bannerKey = + ValueKey('fota_verification_banner_${verificationId}_$id'); + _activeFotaVerificationBannerKeys[verificationId] = bannerKey; + return _FotaStackedBanner( + key: bannerKey, + content: FotaVerificationBanner( + key: ValueKey('fota_verification_$verificationId'), + deadline: deadline, + wearableName: wearableName, + sideLabel: sideLabel, + onDismiss: () => _dismissFotaVerificationBanner( + controller: controller, + verificationId: verificationId, + key: bannerKey, + ), + ), + ); + }, + ); +} + +void dismissFotaVerificationBannerById( + BuildContext context, + String verificationId, +) { final controller = Provider.of(context, listen: false); - controller.showBanner((id) => AppBanner( - content: FotaVerificationBanner(key: ValueKey(id)), - backgroundColor: Colors.orange, - key: ValueKey(id), - ), - duration: Duration(minutes: 3), + _pruneMissingFotaVerificationBannerKeys(controller); + + final key = _activeFotaVerificationBannerKeys.remove(verificationId); + if (key == null) { + return; + } + + controller.hideBannerByKey(key); + _fotaVerificationDeadlinesById.remove(verificationId); +} + +void dismissFotaVerificationBanner(BuildContext context) { + final controller = Provider.of(context, listen: false); + _pruneMissingFotaVerificationBannerKeys(controller); + + final keys = _activeFotaVerificationBannerKeys.values.toList(); + for (final key in keys) { + controller.hideBannerByKey(key); + } + _activeFotaVerificationBannerKeys.clear(); + _fotaVerificationDeadlinesById.clear(); +} + +void _dismissFotaVerificationBanner({ + required AppBannerController controller, + required String verificationId, + required Key key, +}) { + controller.hideBannerByKey(key); + final activeKey = _activeFotaVerificationBannerKeys[verificationId]; + if (activeKey == key) { + _activeFotaVerificationBannerKeys.remove(verificationId); + } + _fotaVerificationDeadlinesById.remove(verificationId); +} + +void _pruneMissingFotaVerificationBannerKeys(AppBannerController controller) { + final activeKeys = controller.activeBanners + .map((banner) => banner.key) + .whereType() + .toSet(); + _activeFotaVerificationBannerKeys.removeWhere( + (_, key) => !activeKeys.contains(key), + ); + _fotaVerificationDeadlinesById.removeWhere( + (verificationId, _) => + !_activeFotaVerificationBannerKeys.containsKey(verificationId), ); } diff --git a/open_wearable/lib/widgets/fota/fota_warning_page.dart b/open_wearable/lib/widgets/fota/fota_warning_page.dart index 6eb028d9..3cce1d17 100644 --- a/open_wearable/lib/widgets/fota/fota_warning_page.dart +++ b/open_wearable/lib/widgets/fota/fota_warning_page.dart @@ -1,10 +1,14 @@ -import 'package:flutter/gestures.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/common/app_section_card.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; class FotaWarningPage extends StatefulWidget { const FotaWarningPage({super.key}); @@ -15,10 +19,10 @@ class FotaWarningPage extends StatefulWidget { class _FotaWarningPageState extends State { static const int _minimumBatteryThreshold = 50; - + int? _currentBatteryLevel; bool _checkingBattery = true; - + @override void initState() { super.initState(); @@ -32,31 +36,31 @@ class _FotaWarningPageState extends State { listen: false, ); final device = updateProvider.selectedWearable; - + if (device != null && device.hasCapability()) { - // Get the current battery level from the stream - final batteryLevel = await device.requireCapability() - .batteryPercentageStream - .first - .timeout( - const Duration(seconds: 5), - onTimeout: () => 0, - ); - - if (mounted) { - setState(() { - _currentBatteryLevel = batteryLevel; - _checkingBattery = false; - }); + int? batteryLevel; + try { + batteryLevel = await device + .requireCapability() + .batteryPercentageStream + .first + .timeout(const Duration(seconds: 5)); + } on TimeoutException { + batteryLevel = null; } - } else { + if (mounted) { setState(() { + _currentBatteryLevel = batteryLevel; _checkingBattery = false; }); } + } else if (mounted) { + setState(() { + _checkingBattery = false; + }); } - } catch (e) { + } catch (_) { if (mounted) { setState(() { _checkingBattery = false; @@ -69,23 +73,29 @@ class _FotaWarningPageState extends State { final uri = Uri.parse( 'https://github.com/OpenEarable/open-earable-2?tab=readme-ov-file#setup', ); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.platformDefault); - } else { - throw 'Could not launch $uri'; + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (opened || !mounted) { + return; } + + AppToast.show( + context, + message: 'Could not open GitHub instructions.', + type: AppToastType.error, + icon: Icons.link_off_rounded, + ); } void _handleProceed() { if (_currentBatteryLevel == null) { - // Battery level could not be determined showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( - title: const Text('Battery Level Unknown'), + title: const Text('Battery level unknown'), content: Text( - 'Unable to determine the OpenEarable battery level. ' - 'For safety, please ensure your OpenEarable is charged to at least $_minimumBatteryThreshold% before proceeding with the firmware update.\n\n' + 'Unable to read the current battery level.\n\n' + 'Please make sure your OpenEarable is charged to at least ' + '$_minimumBatteryThreshold% before continuing.\n\n' 'Do you want to proceed anyway?', ), actions: [ @@ -95,9 +105,8 @@ class _FotaWarningPageState extends State { onPressed: () => Navigator.of(context).pop(), ), PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData( - isDestructiveAction: true, - ), + cupertino: (_, __) => + CupertinoDialogActionData(isDestructiveAction: true), child: const Text('Proceed Anyway'), onPressed: () { Navigator.of(context).pop(); @@ -108,7 +117,6 @@ class _FotaWarningPageState extends State { ), ); } else if (_currentBatteryLevel! < _minimumBatteryThreshold) { - // Show first warning dialog with option to force update _showLowBatteryWarning(); } else { context.push('/fota/update'); @@ -119,11 +127,12 @@ class _FotaWarningPageState extends State { showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( - title: const Text('Battery Level Too Low'), + title: const Text('Battery level too low'), content: Text( - 'Your OpenEarable battery level is $_currentBatteryLevel%, which is below the required $_minimumBatteryThreshold% minimum for firmware updates.\n\n' - 'Updating with low battery can cause the update to fail and may result in a bricked device.\n\n' - 'It is strongly recommended to charge your device before proceeding.', + 'Your OpenEarable battery level is $_currentBatteryLevel%, which is ' + 'below the recommended $_minimumBatteryThreshold% for firmware updates.\n\n' + 'Updating with low battery can fail and may leave the device unusable, requiring recovery with a J-Link debugger.\n\n' + 'Please charge your device before continuing.', ), actions: [ PlatformDialogAction( @@ -134,9 +143,8 @@ class _FotaWarningPageState extends State { onPressed: () => Navigator.of(context).pop(), ), PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData( - isDestructiveAction: true, - ), + cupertino: (_, __) => + CupertinoDialogActionData(isDestructiveAction: true), child: const Text('Force Update Anyway'), onPressed: () { Navigator.of(context).pop(); @@ -152,10 +160,10 @@ class _FotaWarningPageState extends State { showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( - title: const Text('Critical Warning'), + title: const Text('Critical warning'), content: Text( - 'FINAL WARNING: Proceeding with a firmware update at $_currentBatteryLevel% battery may permanently brick your OpenEarable device.\n\n' - 'You will not be able to recover the device without a J-Link debugger if the update fails due to low battery.\n\n' + 'FINAL WARNING: Proceeding with $_currentBatteryLevel% battery can ' + 'cause the update to fail and leave your OpenEarable unusable until it is recovered with a J-Link debugger.\n\n' 'Are you absolutely sure you want to continue?', ), actions: [ @@ -167,9 +175,8 @@ class _FotaWarningPageState extends State { onPressed: () => Navigator.of(context).pop(), ), PlatformDialogAction( - cupertino: (_, __) => CupertinoDialogActionData( - isDestructiveAction: true, - ), + cupertino: (_, __) => + CupertinoDialogActionData(isDestructiveAction: true), child: const Text('I Understand, Proceed'), onPressed: () { Navigator.of(context).pop(); @@ -184,280 +191,358 @@ class _FotaWarningPageState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final textTheme = theme.textTheme; - final baseTextStyle = textTheme.bodyLarge; // one place to define size + final colorScheme = theme.colorScheme; return PlatformScaffold( appBar: PlatformAppBar( - title: const Text('Firmware Update'), + title: const Text('Update Instructions'), ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: DefaultTextStyle.merge( // <<– base style for everything - style: baseTextStyle ?? const TextStyle(fontSize: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header with warning icon - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.warning_amber_rounded, - color: theme.colorScheme.error, - size: 32, - ), - const SizedBox(width: 12), - Text( - 'Warning', - style: (baseTextStyle ?? const TextStyle()) - .copyWith( - fontWeight: FontWeight.bold, - fontSize: (baseTextStyle?.fontSize ?? 16) + 2, - ), - ), - ], - ), - const SizedBox(height: 16), - - // First paragraph with hyperlink - Text.rich( - TextSpan( - style: baseTextStyle, - children: [ - const TextSpan( - text: - 'Updating OpenEarable via Bluetooth is currently an experimental feature. ' - 'Hence, updating OpenEarable over Bluetooth might sometimes not complete successfully. ' - 'If that happens, you can easily perform a manual update with the help of a J-Link debugger (see ', - ), - TextSpan( - text: 'GitHub instructions', - style: const TextStyle( - color: Colors.blue, - decoration: TextDecoration.underline, - fontWeight: FontWeight.w600, - ), - recognizer: TapGestureRecognizer() - ..onTap = _openGitHubLink, - ), - const TextSpan(text: ').'), - ], + body: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + AppSectionCard( + title: 'Before You Update', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _WarningPill( + label: 'Bluetooth firmware updates are experimental.', + ), + const SizedBox(height: 10), + Text( + 'Following the steps below ensures that updates will not fail. ' + 'In the unlikely event that an update fails, the device must be recovered with a J-Link debugger.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _openGitHubLink, + icon: const Icon(Icons.open_in_new_rounded, size: 18), + label: const Text('Open GitHub Recovery Instructions'), + ), + ), + ], + ), + ), + const SizedBox(height: SensorPageSpacing.sectionGap), + AppSectionCard( + title: 'Checklist', + subtitle: 'Please confirm these points before continuing.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _ChecklistItem( + number: 1, + text: + 'Power cycle your OpenEarable once before starting the update.', + ), + const SizedBox(height: 8), + const _ChecklistItem( + number: 2, + text: + 'Keep the app open in the foreground and disable power-saving mode.', + ), + const SizedBox(height: 8), + _ChecklistItem( + number: 3, + text: + 'Ensure at least $_minimumBatteryThreshold% battery before updating. Full charge is recommended.', + ), + const SizedBox(height: 8), + const _ChecklistItem( + number: 4, + text: 'Keep OpenEarable disconnected from the charger.', + ), + const SizedBox(height: 8), + const _ChecklistItem( + number: 5, + text: + 'If you have two devices, power off the one that is not being updated.', + ), + const SizedBox(height: 8), + const _ChecklistItem( + number: 6, + text: + 'After upload, verification can take up to 3 minutes and is indicated by a blinking red LED. Do not reset during verification, or you may brick it.', + boldFragment: 'Do not reset during verification', + ), + ], + ), + ), + const SizedBox(height: SensorPageSpacing.sectionGap), + _buildBatteryCard(context), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _checkingBattery ? null : _handleProceed, + icon: _checkingBattery + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, ), - ), - const SizedBox(height: 16), + ) + : const Icon(Icons.arrow_forward_rounded, size: 18), + label: Text( + _checkingBattery + ? 'Checking Battery...' + : 'Acknowledge and Proceed', + ), + ), + ), + ], + ), + ); + } - Text( - 'To help ensure a smooth update, please:', - style: baseTextStyle?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 12), - - // Steps in a Card - Card( - elevation: 2, - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const _NumberedStep( - number: '1.', - text: TextSpan( - text: - 'Power cycle your OpenEarable once before you update.', - ), - ), - const SizedBox(height: 8), - const _NumberedStep( - number: '2.', - text: TextSpan( - text: - 'Keep the app open in the foreground and make sure your phone doesn’t enter power-saving mode.', - ), - ), - const SizedBox(height: 8), - _NumberedStep( - number: '3.', - text: TextSpan( - text: - 'Ensure your OpenEarable has at least $_minimumBatteryThreshold% battery charge before starting. Fully charging is recommended.', - ), - ), - const SizedBox(height: 8), - const _NumberedStep( - number: '4.', - text: TextSpan( - text: "Keep OpenEarable disconnected from charger during the update.", - ), - ), - const SizedBox(height: 8), - const _NumberedStep( - number: '5.', - text: TextSpan( - text: - 'If you have two devices, power off the one that’s not being updated.', - ), - ), - const SizedBox(height: 8), - const _NumberedStep( - number: '6.', - text: TextSpan( - children: [ - TextSpan( - text: - 'After the firmware is uploaded, OpenEarable will automatically verify it. ' - 'During this step, the device might seem unresponsive for up to 3 minutes. ' - 'Don’t worry, this is normal. It will start blinking again once the process is complete.\n', - ), - TextSpan( - text: - 'Don‘t reset the device via the button while the firmware is verified by OpenEarable.', - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 24), - - // Battery level warning if below 50% - if (_currentBatteryLevel != null && _currentBatteryLevel! < _minimumBatteryThreshold) - Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.error.withValues(alpha: 0.1), - border: Border.all( - color: theme.colorScheme.error, - width: 2, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - Icons.battery_alert, - color: theme.colorScheme.error, - size: 24, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Battery level is $_currentBatteryLevel%. Please charge to at least $_minimumBatteryThreshold% before updating.', - style: baseTextStyle?.copyWith( - color: theme.colorScheme.error, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - - // Battery level warning if unknown - if (!_checkingBattery && _currentBatteryLevel == null) - Container( - margin: const EdgeInsets.only(bottom: 16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), - border: Border.all( - color: Colors.orange, - width: 2, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - Icons.battery_unknown, - color: Colors.orange, - size: 24, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Unable to determine battery level. Please ensure your device is charged to at least $_minimumBatteryThreshold%.', - style: baseTextStyle?.copyWith( - color: Colors.orange.shade900, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), + Widget _buildBatteryCard(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; - // Proceed button - SizedBox( - width: double.infinity, - child: _checkingBattery - ? const Center(child: CircularProgressIndicator()) - : PlatformElevatedButton( - onPressed: _handleProceed, - child: const Text('Acknowledge and Proceed'), - ), - ), - ], + if (_checkingBattery) { + return AppSectionCard( + title: 'Battery Status', + subtitle: 'Checking current battery level...', + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Reading battery level from the device.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), ), ), - ), + ], ), + ); + } + + if (_currentBatteryLevel == null) { + return AppSectionCard( + title: 'Battery Status', + subtitle: 'Battery level could not be determined.', + child: _StatusNotice( + icon: Icons.battery_unknown_rounded, + text: + 'Please ensure your device is charged to at least $_minimumBatteryThreshold% before updating.', + foregroundColor: colorScheme.tertiary, + backgroundColor: colorScheme.tertiaryContainer.withValues(alpha: 0.5), + borderColor: colorScheme.tertiary.withValues(alpha: 0.45), + ), + ); + } + + final batteryLevel = _currentBatteryLevel!; + final low = batteryLevel < _minimumBatteryThreshold; + + return AppSectionCard( + title: 'Battery Status', + subtitle: low + ? 'Battery is below the recommended update threshold.' + : 'Battery level is sufficient for update.', + child: _StatusNotice( + icon: low ? Icons.battery_alert_rounded : Icons.battery_charging_full, + text: low + ? 'Battery level is $batteryLevel%. Please charge to at least $_minimumBatteryThreshold% before updating.' + : 'Battery level is $batteryLevel%. You can proceed with the update.', + foregroundColor: low ? colorScheme.error : colorScheme.primary, + backgroundColor: low + ? colorScheme.errorContainer.withValues(alpha: 0.45) + : colorScheme.primaryContainer.withValues(alpha: 0.35), + borderColor: low + ? colorScheme.error.withValues(alpha: 0.5) + : colorScheme.primary.withValues(alpha: 0.35), + ), + ); + } +} + +class _WarningPill extends StatelessWidget { + final String label; + + const _WarningPill({required this.label}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = colorScheme.error; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: foreground.withValues(alpha: 0.45), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 15, + color: foreground, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + softWrap: true, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w800, + ), + ), + ), + ], ), ); } } -/// Helper widget for cleanly aligned numbered steps -class _NumberedStep extends StatelessWidget { - final String number; - final InlineSpan text; // now accepts TextSpan / InlineSpan +class _ChecklistItem extends StatelessWidget { + final int number; + final String text; + final String? boldFragment; - const _NumberedStep({ + const _ChecklistItem({ required this.number, required this.text, + this.boldFragment, }); @override Widget build(BuildContext context) { - final baseStyle = Theme.of(context).textTheme.bodyLarge ?? - const TextStyle(fontSize: 16); + final colorScheme = Theme.of(context).colorScheme; + final numberColor = colorScheme.primary; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - number, - style: baseStyle.copyWith(fontWeight: FontWeight.bold), + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: numberColor.withValues(alpha: 0.14), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + '$number', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: numberColor, + fontWeight: FontWeight.w800, + ), + ), ), - const SizedBox(width: 8), + const SizedBox(width: 10), Expanded( - child: RichText( - text: TextSpan( - style: baseStyle, - children: [text], - ), - ), + child: _buildText(context), ), ], ); } + + Widget _buildText(BuildContext context) { + final baseStyle = Theme.of(context).textTheme.bodyMedium; + if (boldFragment == null || boldFragment!.isEmpty) { + return Text( + text, + style: baseStyle, + ); + } + + final start = text.indexOf(boldFragment!); + if (start < 0) { + return Text( + text, + style: baseStyle, + ); + } + + final end = start + boldFragment!.length; + final before = text.substring(0, start); + final bold = text.substring(start, end); + final after = text.substring(end); + + return RichText( + text: TextSpan( + style: baseStyle, + children: [ + if (before.isNotEmpty) TextSpan(text: before), + TextSpan( + text: bold, + style: baseStyle?.copyWith(fontWeight: FontWeight.w800), + ), + if (after.isNotEmpty) TextSpan(text: after), + ], + ), + ); + } +} + +class _StatusNotice extends StatelessWidget { + final IconData icon; + final String text; + final Color foregroundColor; + final Color backgroundColor; + final Color borderColor; + + const _StatusNotice({ + required this.icon, + required this.text, + required this.foregroundColor, + required this.backgroundColor, + required this.borderColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: foregroundColor), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } } diff --git a/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart b/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart index 05767368..ac84ca54 100644 --- a/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart +++ b/open_wearable/lib/widgets/fota/logger_screen/logger_screen.dart @@ -8,8 +8,8 @@ class LoggerScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( + return PlatformScaffold( + appBar: PlatformAppBar( title: PlatformText('Log'), ), body: _logFutureBuilder(), @@ -17,18 +17,22 @@ class LoggerScreen extends StatelessWidget { } Widget _logFutureBuilder() { - return FutureBuilder( + return FutureBuilder>( future: logger.readLogs(), builder: (context, snapshot) { if (snapshot.hasData) { - final messages = (snapshot.data as List) + final messages = (snapshot.data ?? const []) .where((element) => element.level.rawValue >= 1) .toList(); return _messageList(messages); } else if (snapshot.hasError) { - return PlatformText(snapshot.error.toString()); + return Center( + child: PlatformText(snapshot.error.toString()), + ); } - return CircularProgressIndicator(); + return const Center( + child: PlatformCircularProgressIndicator(), + ); }, ); } diff --git a/open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart b/open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart index 902120b9..3e69fa31 100644 --- a/open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart +++ b/open_wearable/lib/widgets/fota/stepper_view/firmware_select.dart @@ -1,34 +1,159 @@ import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; - -import '../firmware_select/firmware_list.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import '../firmware_select/firmware_list.dart'; class FirmwareSelect extends StatelessWidget { const FirmwareSelect({super.key}); @override Widget build(BuildContext context) { - FirmwareUpdateRequest updateParameters = + final updateParameters = context.watch().updateParameters; + final selectedFirmware = updateParameters.firmware; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; - return Column( - children: [ - if (updateParameters.firmware != null) - PlatformText(updateParameters.firmware!.name), - PlatformElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => FirmwareList(), + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _openFirmwareSelection(context), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.45), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Icon( + Icons.memory_rounded, + size: 15, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: selectedFirmware == null + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No firmware selected', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Tap to choose firmware.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + selectedFirmware.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + _subtitleForFirmware(selectedFirmware), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + _StatusChip( + label: selectedFirmware is RemoteFirmware + ? 'Remote' + : 'Local', + ), + ], + ), + ), + const SizedBox(width: 6), + Icon( + Icons.chevron_right_rounded, + size: 20, + color: colorScheme.onSurfaceVariant, ), - ); - }, - child: PlatformText('Select Firmware'), + ], + ), + ), + ), + ); + } + + void _openFirmwareSelection(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const FirmwareList(), + ), + ); + } + + String _subtitleForFirmware(SelectedFirmware firmware) { + if (firmware is RemoteFirmware) { + return 'Version ${firmware.version}'; + } + if (firmware is LocalFirmware) { + final typeLabel = firmware.type == FirmwareType.multiImage + ? 'Multi-image' + : 'Single-image'; + return 'Local file • $typeLabel'; + } + return 'Firmware'; + } +} + +class _StatusChip extends StatelessWidget { + final String label; + + const _StatusChip({required this.label}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.35), ), - ], + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), ); } } diff --git a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart index f65d68cd..32dc92ea 100644 --- a/open_wearable/lib/widgets/fota/stepper_view/update_view.dart +++ b/open_wearable/lib/widgets/fota/stepper_view/update_view.dart @@ -2,13 +2,79 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/fota_post_update_verification.dart'; +import 'package:open_wearable/widgets/app_banner.dart'; import 'package:open_wearable/widgets/fota/fota_verification_banner.dart'; + import '../logger_screen/logger_screen.dart'; -import 'package:open_earable_flutter/open_earable_flutter.dart'; -class UpdateStepView extends StatelessWidget { - const UpdateStepView({super.key}); +class UpdateStepView extends StatefulWidget { + final bool autoStart; + final ValueChanged? onUpdateRunningChanged; + final String? preResolvedWearableName; + final String? preResolvedSideLabel; + + const UpdateStepView({ + super.key, + this.autoStart = true, + this.onUpdateRunningChanged, + this.preResolvedWearableName, + this.preResolvedSideLabel, + }); + + @override + State createState() => _UpdateStepViewState(); +} + +class _UpdateStepViewState extends State { + static const Color _successGreen = Color(0xFF2E7D32); + + bool _lastReportedRunning = false; + bool _startRequested = false; + bool _verificationBannerShown = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final bloc = context.read(); + final state = bloc.state; + if (widget.autoStart && state is UpdateInitial) { + setState(() { + _startRequested = true; + }); + _reportRunningState(true); + bloc.add(BeginUpdateProcess()); + return; + } + _reportRunningState(_isUpdateInProgress(state)); + }); + } + + @override + void dispose() { + if (_lastReportedRunning) { + widget.onUpdateRunningChanged?.call(false); + } + super.dispose(); + } + + void _reportRunningState(bool running) { + if (_lastReportedRunning == running) { + return; + } + _lastReportedRunning = running; + if (!running && _startRequested) { + setState(() { + _startRequested = false; + }); + } + widget.onUpdateRunningChanged?.call(running); + } @override Widget build(BuildContext context) { @@ -16,165 +82,350 @@ class UpdateStepView extends StatelessWidget { final request = provider.updateParameters; return BlocConsumer( - listener: (context, state) { + listener: (context, state) async { + _reportRunningState(_isUpdateInProgress(state)); if (state is UpdateFirmwareStateHistory && state.isComplete && state.history.isNotEmpty && state.history.last is UpdateCompleteSuccess) { - showFotaVerificationBanner(context); + if (_verificationBannerShown) { + return; + } + _verificationBannerShown = true; + final updateProvider = context.read(); + final armedVerification = await FotaPostUpdateVerificationCoordinator + .instance + .armFromUpdateRequest( + request: updateProvider.updateParameters, + selectedWearable: updateProvider.selectedWearable, + preResolvedWearableName: widget.preResolvedWearableName, + preResolvedSideLabel: widget.preResolvedSideLabel, + ); + if (!mounted || armedVerification == null) { + return; + } + showFotaVerificationBanner( + this.context, + verificationId: armedVerification.verificationId, + wearableName: armedVerification.wearableName, + sideLabel: armedVerification.sideLabel, + ); } }, builder: (context, state) { - switch (state) { - case UpdateInitial(): - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _firmwareInfo(context, request.firmware!), - PlatformElevatedButton( - onPressed: () { - context.read().add(BeginUpdateProcess()); - }, - child: PlatformText('Update'), - ), - ], - ); + return switch (state) { + UpdateInitial() => _buildInitial(context, request), + UpdateFirmwareStateHistory() => _buildHistory(context, state), + UpdateFirmware() => _buildPendingState(context, state.stage), + }; + }, + ); + } - case UpdateFirmwareStateHistory(): - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var s in state.history) - Row( - children: [ - _stateIcon( - s, - Colors.green, - ), - const SizedBox(width: 8), - PlatformText(s.stage), - ], - ), - if (state.currentState != null) - Row( - children: [ - const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - padding: EdgeInsets.all(4), - ), - ), - _currentState(state), - ], - ), - const SizedBox(height: 12), - if (state.isComplete && state.updateManager?.logger != null) - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LoggerScreen( - logger: state.updateManager!.logger, - ), - ), - ); - }, - child: PlatformText('Show Log'), - ), - if (state.isComplete) - ElevatedButton( - onPressed: () { - BlocProvider.of(context).add(ResetUpdate()); - provider.reset(); - }, - child: PlatformText('Update Again'), + bool _isUpdateInProgress(UpdateState state) { + if (state is UpdateInitial) { + return false; + } + if (state is UpdateFirmwareStateHistory) { + return !state.isComplete; + } + return true; + } + + Widget _buildInitial( + BuildContext context, + FirmwareUpdateRequest request, + ) { + final firmware = request.firmware; + if (firmware == null) { + return Text( + 'No firmware selected. Go back and choose firmware.', + style: Theme.of(context).textTheme.bodyMedium, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _firmwareInfoCard(context, firmware), + const SizedBox(height: 12), + _buildPendingState(context, 'Starting update...'), + ], + ); + } + + Widget _buildPendingState(BuildContext context, String stage) { + const neutralBackground = Color(0xFFF5F6F7); + const neutralBorder = Color(0xFFD4D8DE); + const neutralForeground = Color(0xFF5E6572); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: neutralBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: neutralBorder), + ), + child: Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: neutralForeground, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + stage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, ), + ), + ), + ], + ), + ); + } - if (state.isComplete && - state.history.last is UpdateCompleteSuccess) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PlatformText( - 'Firmware upload complete.\n\n' - 'The image has been successfully uploaded and is now being verified by the device. ' - 'The device will automatically restart once verification is complete.\n\n' - 'This may take up to 3 minutes. Please keep the device powered on and nearby.', - textAlign: TextAlign.start, - ), - const SizedBox(height: 8), - const _VerificationCountdown(), // you can remove this once the global banner handles the timer - ], + Widget _buildHistory( + BuildContext context, + UpdateFirmwareStateHistory state, + ) { + final history = state.history; + final currentState = state.currentState; + final showSuccessMessage = state.isComplete && + history.isNotEmpty && + history.last is UpdateCompleteSuccess; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final entry in history) ...[ + _historyEntry(context, entry), + const SizedBox(height: 8), + ], + if (currentState != null) ...[ + _currentStatePanel(context, state), + const SizedBox(height: 10), + ], + if (showSuccessMessage) ...[ + _successPanel(context), + const SizedBox(height: 10), + ], + if (state.isComplete && state.updateManager?.logger != null) ...[ + OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LoggerScreen( + logger: state.updateManager!.logger, ), - ], - ); + ), + ); + }, + icon: const Icon(Icons.description_outlined, size: 18), + label: const Text('Show Log'), + ), + const SizedBox(height: 8), + ], + ], + ); + } - default: - return PlatformText('Unknown state'); - } - }, + Widget _historyEntry(BuildContext context, UpdateFirmware state) { + final colorScheme = Theme.of(context).colorScheme; + final failed = state is UpdateCompleteFailure; + final foregroundColor = failed ? colorScheme.error : _successGreen; + final backgroundColor = failed + ? colorScheme.errorContainer.withValues(alpha: 0.35) + : _successGreen.withValues(alpha: 0.12); + final borderColor = failed + ? colorScheme.error.withValues(alpha: 0.45) + : _successGreen.withValues(alpha: 0.34); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + failed ? Icons.error_outline_rounded : Icons.check_circle_rounded, + size: 18, + color: foregroundColor, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + state.stage, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), ); } - Icon _stateIcon(UpdateFirmware state, Color successColor) { - if (state is UpdateCompleteFailure) { - return const Icon(size: 24, Icons.error_outline, color: Colors.red); - } else { - return Icon(size: 24, Icons.check_circle_outline, color: successColor); - } + Widget _currentStatePanel( + BuildContext context, + UpdateFirmwareStateHistory state, + ) { + final currentState = state.currentState; + const neutralBackground = Color(0xFFF5F6F7); + const neutralBorder = Color(0xFFD4D8DE); + const neutralForeground = Color(0xFF5E6572); + final progress = currentState is UpdateProgressFirmware + ? (currentState.progress.clamp(0, 100) / 100.0) + : null; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: neutralBackground, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: neutralBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: neutralForeground, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _currentStateLabel(state), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (progress != null) ...[ + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + minHeight: 4, + color: neutralForeground, + backgroundColor: neutralForeground.withValues(alpha: 0.18), + ), + ], + ], + ), + ); } - PlatformText _currentState(UpdateFirmwareStateHistory state) { + String _currentStateLabel(UpdateFirmwareStateHistory state) { final currentState = state.currentState; if (currentState == null) { - return PlatformText('Unknown state'); - } else if (currentState is UpdateProgressFirmware) { - var core = currentState.imageNumber == 0 ? "application" : "network"; - return PlatformText( - "Uploading $core core (image ${currentState.imageNumber}) ${currentState.progress}%", - ); - } else { - return PlatformText(currentState.stage); + return 'Preparing update...'; } - } - - Widget _firmwareInfo(BuildContext context, SelectedFirmware firmware) { - if (firmware is LocalFirmware) { - return _localFirmwareInfo(context, firmware); - } else if (firmware is RemoteFirmware) { - return _remoteFirmwareInfo(context, firmware); - } else { - return PlatformText('Unknown firmware type'); + if (currentState is UpdateProgressFirmware) { + final core = currentState.imageNumber == 0 ? 'application' : 'network'; + return 'Uploading $core core ${currentState.progress}%'; } + return currentState.stage; } - Widget _localFirmwareInfo(BuildContext context, LocalFirmware firmware) { - return PlatformText('Firmware: ${firmware.name}'); + Widget _successPanel(BuildContext context) { + return const _VerificationWarningPanel(); } - Widget _remoteFirmwareInfo(BuildContext context, RemoteFirmware firmware) { - return Column( - children: [ - PlatformText('Firmware: ${firmware.name}'), - PlatformText('Url: ${firmware.url}'), - ], + Widget _firmwareInfoCard(BuildContext context, SelectedFirmware firmware) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.38), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.memory_rounded, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + firmware.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + _firmwareSubtitle(firmware), + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), ); } + + String _firmwareSubtitle(SelectedFirmware firmware) { + if (firmware is RemoteFirmware) { + return 'Remote firmware • version ${firmware.version}'; + } + if (firmware is LocalFirmware) { + final typeLabel = firmware.type == FirmwareType.multiImage + ? 'Multi-image' + : 'Single-image'; + return 'Local firmware • $typeLabel'; + } + return 'Firmware'; + } } -/// Small stateful widget that starts a 3-minute countdown when built. -/// You can delete this once the global banner shows the timer instead. -class _VerificationCountdown extends StatefulWidget { - const _VerificationCountdown(); +class _VerificationWarningPanel extends StatefulWidget { + const _VerificationWarningPanel(); @override - State<_VerificationCountdown> createState() => _VerificationCountdownState(); + State<_VerificationWarningPanel> createState() => + _VerificationWarningPanelState(); } -class _VerificationCountdownState extends State<_VerificationCountdown> { +class _VerificationWarningPanelState extends State<_VerificationWarningPanel> { static const Duration _total = Duration(minutes: 3); late Duration _remaining; Timer? _timer; @@ -183,7 +434,6 @@ class _VerificationCountdownState extends State<_VerificationCountdown> { void initState() { super.initState(); _remaining = _total; - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) { timer.cancel(); @@ -208,18 +458,28 @@ class _VerificationCountdownState extends State<_VerificationCountdown> { super.dispose(); } - String _format(Duration d) { - final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + String _format(Duration duration) { + final m = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$m:$s'; } @override Widget build(BuildContext context) { - return PlatformText( - 'Estimated remaining: ${_format(_remaining)}', - textAlign: TextAlign.start, - style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 16), + const warningBackground = Color(0xFFFFECEC); + const warningForeground = Color(0xFF8A1C1C); + + return AppBanner( + backgroundColor: warningBackground, + foregroundColor: warningForeground, + leadingIcon: Icons.warning_amber_rounded, + content: Text( + 'Verification in progress, do not reset or power off the device: ${_format(_remaining)}.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: warningForeground, + fontWeight: FontWeight.w700, + ), + ), ); } } diff --git a/open_wearable/lib/widgets/global_app_banner_overlay.dart b/open_wearable/lib/widgets/global_app_banner_overlay.dart index 89363155..8282a1ea 100644 --- a/open_wearable/lib/widgets/global_app_banner_overlay.dart +++ b/open_wearable/lib/widgets/global_app_banner_overlay.dart @@ -30,28 +30,28 @@ class GlobalAppBannerOverlay extends StatelessWidget { child, if (hasBanners) Positioned( - top: MediaQuery.of(context).padding.top, + top: 0, left: 0, right: 0, child: SafeArea( bottom: false, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - for (final banner in banners) - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8), - key: banner.key, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 6), + ...banners.map( + (banner) => Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 10, 8), + child: Dismissible( + key: banner.key ?? UniqueKey(), + direction: DismissDirection.up, + onDismissed: (_) => controller.hideBanner(banner), child: banner, ), - ], - ), + ), + ), + ], ), ), ), diff --git a/open_wearable/lib/widgets/home_page.dart b/open_wearable/lib/widgets/home_page.dart index 54bb8ccf..dccb1560 100644 --- a/open_wearable/lib/widgets/home_page.dart +++ b/open_wearable/lib/widgets/home_page.dart @@ -1,114 +1,1732 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; +import 'package:flutter/foundation.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'; import 'package:open_wearable/apps/widgets/apps_page.dart'; +import 'package:open_wearable/models/connector_settings.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/devices/devices_page.dart'; -import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_view.dart'; -import 'package:open_wearable/widgets/sensors/values/sensor_values_page.dart'; +import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'sensors/sensor_page.dart'; +import 'sensors/sensor_page_spacing.dart'; + +const int _overviewIndex = 0; +const int _devicesIndex = 1; +const int _sensorsIndex = 2; +const int _sectionCount = 5; + +const double _largeScreenBreakpoint = 960; +const double _overviewDevicePillMinHeight = 30; -/// The home page of the app. -/// -/// The home page contains a tab bar and an AppBar. class HomePage extends StatefulWidget { - const HomePage({super.key}); + final int initialSectionIndex; + + const HomePage({super.key, this.initialSectionIndex = _overviewIndex}); @override State createState() => _HomePageState(); } class _HomePageState extends State { - static final titles = ["Devices", "Sensors", "Apps"]; + late final PlatformTabController _tabController; + late final SensorPageController _sensorPageController; + late final List<_HomeDestination> _destinations; + late final List _sections; + int _selectedIndex = _overviewIndex; + + @override + void initState() { + super.initState(); + + final requestedInitial = widget.initialSectionIndex; + final initialIndex = + (requestedInitial >= _overviewIndex && requestedInitial < _sectionCount) + ? requestedInitial + : _overviewIndex; + _selectedIndex = initialIndex; - List items(BuildContext context) { - return [ - BottomNavigationBarItem( - icon: Icon(Icons.devices), - label: titles[0], + _tabController = PlatformTabController(initialIndex: initialIndex); + _sensorPageController = SensorPageController(); + _tabController.addListener(_syncSelectedIndex); + + _destinations = const [ + _HomeDestination( + title: 'Overview', + icon: Icons.home_outlined, + selectedIcon: Icons.home, + ), + _HomeDestination( + title: 'Devices', + icon: Icons.dashboard_outlined, + selectedIcon: Icons.dashboard, + ), + _HomeDestination( + title: 'Sensors', + icon: Icons.ssid_chart_outlined, + selectedIcon: Icons.ssid_chart, ), - BottomNavigationBarItem( - icon: Icon(Icons.ssid_chart_rounded), - label: titles[1], + _HomeDestination( + title: 'Apps', + icon: Icons.apps_outlined, + selectedIcon: Icons.apps, ), - BottomNavigationBarItem( - icon: Icon(Icons.apps_rounded), - label: titles[2], + _HomeDestination( + title: 'Settings', + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, ), ]; - } - - late PlatformTabController _controller; - late List _tabs; - - @override - void initState() { - super.initState(); - _controller = PlatformTabController(initialIndex: 0); - _tabs = [ - DevicesPage(), - SensorPage(), + _sections = [ + _OverviewPage( + onSectionRequested: _jumpToSection, + onConnectRequested: _openConnectDevices, + onSensorTabRequested: _openSensorsTab, + onConnectorsRequested: _openConnectors, + ), + const DevicesPage(), + SensorPage(controller: _sensorPageController), const AppsPage(), + _SettingsPage( + onLogsRequested: _openLogFiles, + onConnectRequested: _openConnectDevices, + onConnectorsRequested: _openConnectors, + onGeneralSettingsRequested: _openGeneralSettings, + ), ]; } + @override + void dispose() { + _tabController.removeListener(_syncSelectedIndex); + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - return _buildSmallScreenLayout(context); + if (constraints.maxWidth >= _largeScreenBreakpoint) { + return _buildLargeScreenLayout(context); + } + return _buildCompactLayout(context); }, ); } - // ignore: unused_element + Widget _buildCompactLayout(BuildContext context) { + return PlatformTabScaffold( + tabController: _tabController, + items: _destinations + .map( + (destination) => BottomNavigationBarItem( + icon: Icon(destination.icon), + activeIcon: Icon(destination.selectedIcon), + label: destination.title, + ), + ) + .toList(), + bodyBuilder: (context, index) => IndexedStack( + index: index, + children: _sections, + ), + ); + } + Widget _buildLargeScreenLayout(BuildContext context) { + final bool useExtendedRail = MediaQuery.of(context).size.width >= 1280; + + return PlatformScaffold( + body: SafeArea( + child: Row( + children: [ + NavigationRail( + selectedIndex: _selectedIndex, + onDestinationSelected: (index) => _selectSection(context, index), + labelType: NavigationRailLabelType.all, + extended: useExtendedRail, + leading: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: useExtendedRail + ? Text( + 'OpenWearable', + style: Theme.of(context).textTheme.titleMedium, + ) + : Icon( + Icons.watch, + color: Theme.of(context).colorScheme.primary, + ), + ), + destinations: _destinations + .map( + (destination) => NavigationRailDestination( + icon: Icon(destination.icon), + selectedIcon: Icon(destination.selectedIcon), + label: Text(destination.title), + ), + ) + .toList(), + ), + const VerticalDivider(width: 1), + Expanded( + child: IndexedStack( + index: _selectedIndex, + children: _sections, + ), + ), + ], + ), + ), + ); + } + + void _syncSelectedIndex() { + if (!mounted) return; + final int controllerIndex = _tabController.index(context); + if (_selectedIndex != controllerIndex) { + setState(() { + _selectedIndex = controllerIndex; + }); + } + } + + void _jumpToSection(int index) { + if (!mounted) return; + _selectSection(context, index); + } + + void _selectSection(BuildContext context, int index) { + if (index < 0 || index >= _sections.length) return; + + if (_selectedIndex != index) { + setState(() { + _selectedIndex = index; + }); + } + _tabController.setIndex(context, index); + } + + void _openConnectDevices() { + if (!mounted) return; + context.push('/connect-devices'); + } + + void _openSensorsTab(int tabIndex) { + if (!mounted) return; + _selectSection(context, _sensorsIndex); + _sensorPageController.openTab(tabIndex); + } + + void _openLogFiles() { + if (!mounted) return; + context.push('/log-files'); + } + + void _openConnectors() { + if (!mounted) return; + context.push('/connectors'); + } + + void _openGeneralSettings() { + if (!mounted) return; + context.push('/settings/general'); + } +} + +class _OverviewPage extends StatelessWidget { + final void Function(int index) onSectionRequested; + final VoidCallback onConnectRequested; + final void Function(int tabIndex) onSensorTabRequested; + final VoidCallback onConnectorsRequested; + + const _OverviewPage({ + required this.onSectionRequested, + required this.onConnectRequested, + required this.onSensorTabRequested, + required this.onConnectorsRequested, + }); + + @override + Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText("OpenWearable"), + title: const Text('Overview'), + trailingActions: [ + const AppBarRecordingIndicator(), + PlatformIconButton( + icon: Icon(context.platformIcons.bluetooth), + onPressed: onConnectRequested, + ), + ], + ), + body: Consumer2( + builder: (context, wearablesProvider, recorderProvider, _) { + final wearables = wearablesProvider.wearables; + final connectedCount = wearables.length; + final isRecording = recorderProvider.isRecording; + final hasSensorStreams = recorderProvider.hasSensorsConnected; + final recordingStart = recorderProvider.recordingStart; + + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + _OverviewHeroCard( + wearables: wearables, + connectedCount: connectedCount, + isRecording: isRecording, + hasSensorStreams: hasSensorStreams, + recordingStart: recordingStart, + onWearableTap: (wearable) => + _openDeviceFromOverview(context, wearable), + ), + ValueListenableBuilder( + valueListenable: ConnectorSettings.udpBridgeSettingsListenable, + builder: (context, udpSettings, _) { + final isActive = + udpSettings.enabled && udpSettings.isConfigured; + if (!isActive) { + return const SizedBox.shrink(); + } + return ValueListenableBuilder( + valueListenable: + ConnectorSettings.udpBridgeConnectionStateListenable, + builder: (context, connectionState, __) { + final hasConnectionProblem = connectionState == + SensorForwarderConnectionState.unreachable; + return _OverviewUdpSummaryCard( + settings: udpSettings, + hasConnectionProblem: hasConnectionProblem, + ); + }, + ); + }, + ), + _OverviewWorkflowIntroCard( + onConnectRequested: onConnectRequested, + onSensorTabRequested: onSensorTabRequested, + onConnectorsRequested: onConnectorsRequested, + ), + ], + ); + }, + ), + ); + } + + static String formatRecordingTime(DateTime? time) { + if (time == null) return 'Recording active'; + final local = time.toLocal(); + String twoDigits(int n) => n.toString().padLeft(2, '0'); + return 'Recording since ${twoDigits(local.hour)}:${twoDigits(local.minute)}'; + } + + void _openDeviceFromOverview(BuildContext context, Wearable wearable) { + onSectionRequested(_devicesIndex); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) { + return; + } + + final isLargeScreen = MediaQuery.of(context).size.width > 600; + if (isLargeScreen) { + showGeneralDialog( + context: context, + pageBuilder: (dialogContext, animation1, animation2) { + return Center( + child: SizedBox( + width: MediaQuery.of(dialogContext).size.width * 0.5, + height: MediaQuery.of(dialogContext).size.height * 0.5, + child: DeviceDetailPage(device: wearable), + ), + ); + }, + ); + return; + } + context.push('/device-detail', extra: wearable); + }); + } +} + +class _OverviewWorkflowIntroCard extends StatelessWidget { + final VoidCallback onConnectRequested; + final void Function(int tabIndex) onSensorTabRequested; + final VoidCallback onConnectorsRequested; + + const _OverviewWorkflowIntroCard({ + required this.onConnectRequested, + required this.onSensorTabRequested, + required this.onConnectorsRequested, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'How the OpenWearables App Works', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Typical workflow: connect devices, configure sensors, validate signal quality, then record.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + _OverviewWorkflowStep( + icon: Icons.bluetooth_connected, + title: 'Connect devices', + detail: 'Pair wearables and confirm connection.', + sectionLabel: 'Devices › Connect', + isLast: false, + onTap: onConnectRequested, + ), + _OverviewWorkflowStep( + icon: Icons.tune_outlined, + title: 'Configure sensors', + detail: 'Set required channels and sampling.', + sectionLabel: 'Sensors › Configure', + isLast: false, + onTap: () => onSensorTabRequested(0), + ), + _OverviewWorkflowStep( + icon: Icons.ssid_chart_outlined, + title: 'View sensor data', + detail: 'Check live signal quality before capture.', + sectionLabel: 'Sensors › Live Data', + isLast: false, + onTap: () => onSensorTabRequested(1), + ), + _OverviewWorkflowStep( + icon: Icons.fiber_smart_record, + title: 'Record', + detail: 'Start and monitor recording.', + sectionLabel: 'Sensors › Recorder', + isLast: false, + onTap: () => onSensorTabRequested(2), + ), + _OverviewWorkflowStep( + icon: Icons.share_rounded, + title: 'Configure Network Relay', + detail: 'Forward sensor data from this app to your computer.', + sectionLabel: 'Settings › Connectors', + isLast: true, + onTap: onConnectorsRequested, + ), + ], + ), ), - body: Padding( - padding: const EdgeInsets.all(10), - child: ListView( + ); + } +} + +class _OverviewUdpSummaryCard extends StatelessWidget { + final UdpBridgeConnectorSettings settings; + final bool hasConnectionProblem; + + const _OverviewUdpSummaryCard({ + required this.settings, + required this.hasConnectionProblem, + }); + + @override + Widget build(BuildContext context) { + const udpGreen = Color(0xFF2E7D32); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final accentColor = hasConnectionProblem ? colorScheme.error : udpGreen; + final detailTextColor = + hasConnectionProblem ? colorScheme.error : colorScheme.onSurfaceVariant; + final infoPillBackground = colorScheme.surface; + final infoPillBorder = colorScheme.outlineVariant.withValues(alpha: 0.6); + final infoPillForeground = colorScheme.onSurfaceVariant; + final statusLine = hasConnectionProblem + ? 'Data forwarding is currently interrupted.' + : 'Data is forwarded via the network in real time.'; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformText( - "Connected Devices", - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), - ), - DevicesPage(), - PlatformText( - "Sensor Configuration", - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), - ), - SensorConfigurationView(), - PlatformText( - "Sensor Values", - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(color: Theme.of(context).colorScheme.surfaceTint), - ), - SensorValuesPage(), + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Icon( + hasConnectionProblem + ? Icons.cloud_off_rounded + : Icons.cloud_done_rounded, + size: 18, + color: accentColor, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hasConnectionProblem + ? 'Network Relay unreachable' + : 'Network Relay is active', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: hasConnectionProblem ? colorScheme.error : null, + ), + ), + const SizedBox(height: 2), + Text( + statusLine, + style: theme.textTheme.bodySmall?.copyWith( + color: detailTextColor, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildRelayInfoPill( + context, + icon: Icons.dns_rounded, + label: 'Host ${settings.host}', + backgroundColor: infoPillBackground, + borderColor: infoPillBorder, + foregroundColor: infoPillForeground, + ), + _buildRelayInfoPill( + context, + icon: Icons.settings_ethernet_rounded, + label: 'Port ${settings.port}', + backgroundColor: infoPillBackground, + borderColor: infoPillBorder, + foregroundColor: infoPillForeground, + ), + ], + ), + if (hasConnectionProblem) ...[ + const SizedBox(height: 6), + Text( + 'Check host and port in Connectors.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ], + ], + ), + ), ], ), ), ); } - Widget _buildSmallScreenLayout(BuildContext context) { - return PlatformTabScaffold( - tabController: _controller, - bodyBuilder: (context, index) => IndexedStack( - index: index, - children: _tabs, + Widget _buildRelayInfoPill( + BuildContext context, { + required IconData icon, + required String label, + required Color backgroundColor, + required Color borderColor, + required Color foregroundColor, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: foregroundColor), + const SizedBox(width: 5), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _OverviewWorkflowStep extends StatelessWidget { + final IconData icon; + final String title; + final String detail; + final String sectionLabel; + final bool isLast; + final VoidCallback onTap; + + const _OverviewWorkflowStep({ + required this.icon, + required this.title, + required this.detail, + required this.sectionLabel, + required this.isLast, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final markerFill = colorScheme.surfaceContainerHighest; + final markerBorder = colorScheme.outlineVariant.withValues(alpha: 0.7); + final timelineColor = colorScheme.outlineVariant.withValues(alpha: 0.65); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 28, + child: Column( + children: [ + Container( + height: 24, + width: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: markerFill, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: markerBorder), + ), + child: Icon( + icon, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 2, + margin: const EdgeInsets.only(top: 6), + decoration: BoxDecoration( + color: timelineColor, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + detail, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + sectionLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + Container( + margin: const EdgeInsets.only(left: 8), + height: 30, + width: 30, + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Icon( + Icons.arrow_forward_rounded, + size: 18, + color: colorScheme.primary.withValues(alpha: 0.9), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _OverviewHeroCard extends StatelessWidget { + final List wearables; + final int connectedCount; + final bool isRecording; + final bool hasSensorStreams; + final DateTime? recordingStart; + final void Function(Wearable wearable) onWearableTap; + + const _OverviewHeroCard({ + required this.wearables, + required this.connectedCount, + required this.isRecording, + required this.hasSensorStreams, + required this.recordingStart, + required this.onWearableTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final isReady = connectedCount > 0 && hasSensorStreams && !isRecording; + + final statusLabel = isRecording + ? 'RECORDING' + : isReady + ? 'READY' + : connectedCount == 0 + ? 'DISCONNECTED' + : 'SETUP'; + final statusColor = isRecording + ? colorScheme.error + : isReady + ? const Color(0xFF2E7D32) + : colorScheme.onSurfaceVariant.withValues(alpha: 0.95); + final statusBackground = statusColor.withValues(alpha: 0.15); + final statusBorder = statusColor.withValues(alpha: 0.35); + + final title = isRecording + ? 'Recording in progress' + : isReady + ? 'Ready for capture' + : connectedCount == 0 + ? 'No devices connected' + : 'Setup required'; + final subtitle = isRecording + ? _OverviewPage.formatRecordingTime(recordingStart) + : connectedCount > 0 + ? 'You can start streaming and recording data.' + : 'Pair at least one wearable to begin.'; + + final visibleWearables = wearables.take(5).toList(growable: false); + final hiddenWearablesCount = wearables.length - visibleWearables.length; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: statusBackground, + border: Border.all(color: statusBorder), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + statusLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + if (isRecording) + const RecordingActivityIndicator( + size: 8, + showIdleOutline: false, + padding: EdgeInsets.zero, + ) + else + Container( + height: 8, + width: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + ), + ), + const SizedBox(width: 8), + Text( + 'Connected Devices ($connectedCount)', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 8), + if (connectedCount == 0) + Text( + 'No devices connected.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ) + else + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final wearable in visibleWearables) + _ConnectedWearablePill.device( + wearable: wearable, + onWearableTap: onWearableTap, + ), + if (hiddenWearablesCount > 0) + _ConnectedWearablePill.summary( + summaryLabel: '+$hiddenWearablesCount more', + ), + ], + ), + ], + ), + ), + ); + } +} + +class _ConnectedWearablePill extends StatefulWidget { + final Wearable? wearable; + final String? summaryLabel; + final void Function(Wearable wearable)? onWearableTap; + + const _ConnectedWearablePill.device({ + required this.wearable, + required this.onWearableTap, + }) : summaryLabel = null; + + const _ConnectedWearablePill.summary({ + required this.summaryLabel, + }) : wearable = null, + onWearableTap = null; + + String get label { + final name = wearable?.name; + if (name != null) { + return formatWearableDisplayName(name); + } + return summaryLabel ?? ''; + } + + @override + State<_ConnectedWearablePill> createState() => _ConnectedWearablePillState(); +} + +class _ConnectedWearablePillState extends State<_ConnectedWearablePill> { + Future? _positionFuture; + + @override + void initState() { + super.initState(); + _positionFuture = _buildPositionFuture(widget.wearable); + } + + @override + void didUpdateWidget(covariant _ConnectedWearablePill oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.wearable != widget.wearable) { + _positionFuture = _buildPositionFuture(widget.wearable); + } + } + + Future? _buildPositionFuture(Wearable? wearable) { + if (wearable == null || !wearable.hasCapability()) { + return null; + } + return wearable.requireCapability().position; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + Widget buildPill(String? sideLabel) { + final pill = Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.6), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: _overviewDevicePillMinHeight, + ), + child: Align( + alignment: Alignment.center, + widthFactor: 1, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (sideLabel != null) ...[ + const SizedBox(width: 6), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.24), + ), + ), + child: Text( + sideLabel, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ], + ), + ), + ), + ); + + final wearable = widget.wearable; + if (wearable == null || widget.onWearableTap == null) { + return pill; + } + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () => widget.onWearableTap!(wearable), + child: pill, + ), + ); + } + + if (_positionFuture == null) { + return buildPill(null); + } + + return FutureBuilder( + future: _positionFuture, + builder: (context, snapshot) { + final sideLabel = switch (snapshot.data) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + return buildPill(sideLabel); + }, + ); + } +} + +class _SettingsPage extends StatelessWidget { + final VoidCallback onLogsRequested; + final VoidCallback onConnectRequested; + final VoidCallback onConnectorsRequested; + final VoidCallback onGeneralSettingsRequested; + + const _SettingsPage({ + required this.onLogsRequested, + required this.onConnectRequested, + required this.onConnectorsRequested, + required this.onGeneralSettingsRequested, + }); + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Settings'), + trailingActions: [ + const AppBarRecordingIndicator(), + PlatformIconButton( + icon: Icon(context.platformIcons.bluetooth), + onPressed: onConnectRequested, + ), + ], + ), + body: ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + _QuickActionTile( + icon: Icons.hub, + title: 'Connectors', + subtitle: + 'Forward sensor data from this app to other platforms, such as your computer', + onTap: onConnectorsRequested, + ), + _QuickActionTile( + icon: Icons.tune_rounded, + title: 'General settings', + subtitle: 'Manage app-wide behavior', + onTap: onGeneralSettingsRequested, + ), + _QuickActionTile( + icon: Icons.receipt_long, + title: 'Log files', + subtitle: 'View, share, and remove diagnostic logs', + onTap: onLogsRequested, + ), + _QuickActionTile( + icon: Icons.info_outline_rounded, + title: 'About', + subtitle: 'App information, version, and licenses', + onTap: () => Navigator.push( + context, + platformPageRoute( + context: context, + builder: (_) => const _AboutPage(), + ), + ), + ), + ], + ), + ); + } +} + +class _AboutPage extends StatelessWidget { + const _AboutPage(); + + static final Uri _repoUri = Uri.parse('https://github.com/OpenEarable/app'); + static final Uri _tecoUri = Uri.parse('https://teco.edu'); + static final Uri _openWearablesUri = Uri.parse('https://openwearables.com'); + static const String _aboutAttribution = + 'The OpenWearables App is developed and maintained by the TECO research group at the Karlsruhe Institute of Technology and OpenWearables GmbH.'; + + Future _openExternalUrl( + BuildContext context, { + required Uri uri, + required String label, + }) async { + final opened = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + if (opened || !context.mounted) { + return; + } + + AppToast.show( + context, + message: 'Could not open $label.', + type: AppToastType.error, + icon: Icons.link_off_rounded, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('About'), + ), + body: ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.asset( + 'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png', + width: 44, + height: 44, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'OpenWearables App', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + _aboutAttribution, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Text.rich( + TextSpan( + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + children: [ + const TextSpan(text: 'Made with'), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), + child: Icon( + Icons.favorite, + size: 15, + color: colorScheme.primary, + ), + ), + ), + const TextSpan(text: 'in Karlsruhe, Germany.'), + ], + ), + ), + const SizedBox(height: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AboutExternalLink( + icon: Icons.code_rounded, + title: 'Source Code', + urlText: 'github.com/OpenEarable/app', + onTap: () => _openExternalUrl( + context, + uri: _repoUri, + label: 'GitHub repository', + ), + ), + const SizedBox(height: 6), + _AboutExternalLink( + icon: Icons.school_outlined, + title: 'TECO Research Group', + urlText: 'teco.edu', + onTap: () => _openExternalUrl( + context, + uri: _tecoUri, + label: 'teco.edu', + ), + ), + const SizedBox(height: 6), + _AboutExternalLink( + icon: Icons.language_rounded, + title: 'OpenWearables GmbH', + urlText: 'openwearables.com', + trailing: const _OpenWearablesFloatingBadge(), + onTap: () => _openExternalUrl( + context, + uri: _openWearablesUri, + label: 'openwearables.com', + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(999), + ), + alignment: Alignment.center, + child: Icon( + Icons.verified_user_outlined, + size: 18, + color: colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Privacy & Data Protection', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Designed for transparency and control.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + const _PrivacyChecklistItem( + text: 'Only data required for app features is processed.', + ), + const SizedBox(height: 8), + const _PrivacyChecklistItem( + text: 'Recorded data stays on your device by default.', + ), + const SizedBox(height: 8), + const _PrivacyChecklistItem( + text: + 'Export and sharing happen only when you explicitly choose it.', + ), + const SizedBox(height: 8), + const _PrivacyChecklistItem( + text: + 'Diagnostic logs are shared only through manual user action.', + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Card( + child: ListTile( + leading: const Icon(Icons.description_outlined), + title: const Text('Open source licenses'), + subtitle: const Text('View third-party software licenses'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + platformPageRoute( + context: context, + builder: (_) => const _OpenSourceLicensesPage(), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _OpenSourceLicensesPage extends StatefulWidget { + const _OpenSourceLicensesPage(); + + @override + State<_OpenSourceLicensesPage> createState() => + _OpenSourceLicensesPageState(); +} + +class _OpenSourceLicensesPageState extends State<_OpenSourceLicensesPage> { + late final Future> _licensesFuture = + _loadLicenses(); + + Future> _loadLicenses() async { + final byPackage = >{}; + + await for (final entry in LicenseRegistry.licenses) { + final licenseText = entry.paragraphs.map((p) => p.text).join('\n').trim(); + if (licenseText.isEmpty) { + continue; + } + + for (final package in entry.packages) { + byPackage.putIfAbsent(package, () => {}).add(licenseText); + } + } + + final items = byPackage.entries + .map( + (entry) => _PackageLicenseEntry( + packageName: entry.key, + licenseTexts: entry.value.toList(growable: false), + ), + ) + .toList() + ..sort( + (a, b) => a.packageName.toLowerCase().compareTo( + b.packageName.toLowerCase(), + ), + ); + + return items; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Open source licenses'), + ), + body: FutureBuilder>( + future: _licensesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'Unable to load licenses.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + final licenses = snapshot.data ?? const <_PackageLicenseEntry>[]; + + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Why this list exists', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'The OpenWearables App uses third-party open source software. ' + 'This list provides the required license notices and ' + 'credits for those dependencies.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + if (licenses.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Text( + 'No licenses found.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else + for (final item in licenses) ...[ + Card( + margin: const EdgeInsets.only(bottom: 8), + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors.transparent, + ), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 2, + ), + childrenPadding: const EdgeInsets.fromLTRB( + 14, + 0, + 14, + 12, + ), + shape: const RoundedRectangleBorder( + side: BorderSide.none, + ), + collapsedShape: const RoundedRectangleBorder( + side: BorderSide.none, + ), + title: Text( + item.packageName, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + '${item.licenseTexts.length} license text${item.licenseTexts.length == 1 ? '' : 's'}', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + children: [ + for (var i = 0; + i < item.licenseTexts.length; + i++) ...[ + SelectableText( + item.licenseTexts[i], + style: theme.textTheme.bodySmall, + ), + if (i < item.licenseTexts.length - 1) ...[ + const SizedBox(height: 10), + Divider( + height: 1, + color: colorScheme.outlineVariant.withValues( + alpha: 0.55, + ), + ), + const SizedBox(height: 10), + ], + ], + ], + ), + ), + ), + ], + ], + ); + }, ), - items: items(context), ); } } + +class _PackageLicenseEntry { + final String packageName; + final List licenseTexts; + + const _PackageLicenseEntry({ + required this.packageName, + required this.licenseTexts, + }); +} + +class _AboutExternalLink extends StatelessWidget { + final IconData icon; + final String title; + final String urlText; + final Widget? trailing; + final VoidCallback onTap; + + const _AboutExternalLink({ + required this.icon, + required this.title, + required this.urlText, + this.trailing, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + child: SizedBox( + width: double.infinity, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 16, + color: colorScheme.primary, + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ), + Text( + urlText, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + if (trailing != null) ...[ + const SizedBox(width: 8), + trailing!, + ], + ], + ), + ), + ), + ); + } +} + +class _OpenWearablesFloatingBadge extends StatelessWidget { + const _OpenWearablesFloatingBadge(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final badgeBorderRadius = BorderRadius.circular(999); + return ClipRRect( + borderRadius: badgeBorderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container( + padding: const EdgeInsets.fromLTRB( + 5, + 5, + 9, + 5, + ), + decoration: BoxDecoration( + color: const Color.fromRGBO(69, 69, 69, 0.40), + borderRadius: badgeBorderRadius, + border: Border.all( + color: Colors.white.withValues(alpha: 0.22), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: const Color(0xFF2FB26F), + shape: BoxShape.circle, + border: Border.all( + color: const Color(0xFF5ED394), + ), + ), + alignment: Alignment.center, + child: const Icon( + Icons.check_rounded, + size: 10, + color: Colors.white, + ), + ), + const SizedBox(width: 7), + Text( + 'OpenWearables', + style: theme.textTheme.labelSmall?.copyWith( + fontSize: theme.textTheme.labelSmall?.fontSize ?? 11, + color: Colors.white, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PrivacyChecklistItem extends StatelessWidget { + final String text; + + const _PrivacyChecklistItem({required this.text}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + const checkColor = Color(0xFF2E7D32); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 20, + height: 20, + alignment: Alignment.center, + child: const Icon( + Icons.check_circle_rounded, + size: 18, + color: checkColor, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.25, + ), + ), + ), + ], + ); + } +} + +class _QuickActionTile extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback? onTap; + + const _QuickActionTile({ + required this.icon, + required this.title, + required this.subtitle, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } +} + +class _HomeDestination { + final String title; + final IconData icon; + final IconData selectedIcon; + + const _HomeDestination({ + required this.title, + required this.icon, + required this.selectedIcon, + }); +} diff --git a/open_wearable/lib/widgets/logging/log_file_detail_screen.dart b/open_wearable/lib/widgets/logging/log_file_detail_screen.dart index f9e2a212..b28299cd 100644 --- a/open_wearable/lib/widgets/logging/log_file_detail_screen.dart +++ b/open_wearable/lib/widgets/logging/log_file_detail_screen.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; class LogFileDetailScreen extends StatefulWidget { const LogFileDetailScreen({ @@ -16,21 +17,31 @@ class LogFileDetailScreen extends StatefulWidget { class _LogFileDetailScreenState extends State { late final Future _contentFuture; + late final ScrollController _verticalController; + late final ScrollController _horizontalController; @override void initState() { super.initState(); // Read the file only once; FutureBuilder will reuse this future. _contentFuture = widget.file.readAsString(); + _verticalController = ScrollController(); + _horizontalController = ScrollController(); + } + + @override + void dispose() { + _verticalController.dispose(); + _horizontalController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { - final fileName = - widget.file.path.split(Platform.pathSeparator).last; + final fileName = widget.file.path.split(Platform.pathSeparator).last; - return Scaffold( - appBar: AppBar( + return PlatformScaffold( + appBar: PlatformAppBar( title: Text(fileName), ), body: FutureBuilder( @@ -38,7 +49,7 @@ class _LogFileDetailScreenState extends State { builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( - child: CircularProgressIndicator(), + child: PlatformCircularProgressIndicator(), ); } @@ -59,23 +70,24 @@ class _LogFileDetailScreenState extends State { ); } - // Scrollbars + both directions scrolling, no line wrapping. - final verticalController = ScrollController(); - final horizontalController = ScrollController(); - return Scrollbar( - controller: verticalController, + controller: _verticalController, thumbVisibility: true, child: SingleChildScrollView( - controller: verticalController, - padding: const EdgeInsets.all(12), + controller: _verticalController, + padding: EdgeInsets.fromLTRB( + 12, + 12, + 12, + 12 + MediaQuery.paddingOf(context).bottom, + ), child: Scrollbar( - controller: horizontalController, + controller: _horizontalController, thumbVisibility: true, notificationPredicate: (notif) => notif.metrics.axis == Axis.horizontal, child: SingleChildScrollView( - controller: horizontalController, + controller: _horizontalController, scrollDirection: Axis.horizontal, child: SelectableText( content, diff --git a/open_wearable/lib/widgets/recording_activity_indicator.dart b/open_wearable/lib/widgets/recording_activity_indicator.dart new file mode 100644 index 00000000..d7268543 --- /dev/null +++ b/open_wearable/lib/widgets/recording_activity_indicator.dart @@ -0,0 +1,189 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:provider/provider.dart'; + +import '../models/connector_settings.dart'; +import '../view_models/sensor_recorder_provider.dart'; + +/// Shared pulse ticker so every recording indicator stays in sync. +class _RecordingPulseTicker { + _RecordingPulseTicker._(); + + static const int _periodMs = 900; + static const Duration tick = Duration(milliseconds: 40); + static final Stream stream = + Stream.periodic(tick, (_) => DateTime.now()) + .asBroadcastStream(); + + static double opacityAt(DateTime now, DateTime origin) { + final elapsedMs = now.difference(origin).inMilliseconds; + final normalized = (elapsedMs % _periodMs) / _periodMs; + final wave = 0.5 - 0.5 * math.cos(2 * math.pi * normalized); + return 0.35 + (0.65 * wave); + } +} + +/// Animated status dot that pulses while sensor recording is active. +class RecordingActivityIndicator extends StatelessWidget { + const RecordingActivityIndicator({ + super.key, + this.size = 16, + this.showIdleOutline = true, + this.padding = const EdgeInsets.symmetric(horizontal: 2), + }); + + final double size; + final bool showIdleOutline; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final isRecording = context.select( + (provider) => provider.isRecording, + ); + final recordingStart = context.select( + (provider) => provider.recordingStart, + ); + + final colorScheme = Theme.of(context).colorScheme; + final color = isRecording + ? colorScheme.error + : colorScheme.onSurfaceVariant.withValues(alpha: 0.85); + final iconData = isRecording || !showIdleOutline + ? Icons.fiber_manual_record + : Icons.fiber_manual_record_outlined; + final icon = Icon( + iconData, + size: size, + color: color, + ); + + if (!isRecording) { + return Padding( + padding: padding, + child: icon, + ); + } + + final anchor = recordingStart ?? DateTime.now(); + return Padding( + padding: padding, + child: StreamBuilder( + stream: _RecordingPulseTicker.stream, + initialData: DateTime.now(), + builder: (context, snapshot) { + final now = snapshot.data ?? DateTime.now(); + final opacity = _RecordingPulseTicker.opacityAt(now, anchor); + return Opacity( + opacity: opacity, + child: icon, + ); + }, + ), + ); + } +} + +class UdpActivityIndicator extends StatelessWidget { + const UdpActivityIndicator({ + super.key, + this.padding = const EdgeInsets.symmetric(horizontal: 2), + }); + + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: ConnectorSettings.udpBridgeSettingsListenable, + builder: (context, settings, _) { + final isActive = settings.enabled && settings.isConfigured; + if (!isActive) { + return const SizedBox.shrink(); + } + return ValueListenableBuilder( + valueListenable: ConnectorSettings.udpBridgeConnectionStateListenable, + builder: (context, connectionState, _) { + const udpGreen = Color(0xFF2E7D32); + final isUnreachable = + connectionState == SensorForwarderConnectionState.unreachable; + final colorScheme = Theme.of(context).colorScheme; + final foreground = isUnreachable ? colorScheme.error : udpGreen; + final background = foreground.withValues( + alpha: isUnreachable ? 0.13 : 0.16, + ); + final border = foreground.withValues( + alpha: isUnreachable ? 0.4 : 0.32, + ); + + return Padding( + padding: padding, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () => context.push('/connectors'), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: background, + border: Border.all(color: border), + ), + child: Icon( + isUnreachable + ? Icons.cloud_off_rounded + : Icons.cloud_done_rounded, + size: 12, + color: foreground, + ), + ), + ), + ), + ); + }, + ); + }, + ); + } +} + +class AppBarRecordingIndicator extends StatelessWidget { + const AppBarRecordingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + final isRecording = context.select( + (provider) => provider.isRecording, + ); + return ValueListenableBuilder( + valueListenable: ConnectorSettings.udpBridgeSettingsListenable, + builder: (context, settings, _) { + final isUdpActive = settings.enabled && settings.isConfigured; + if (!isRecording && !isUdpActive) { + return const SizedBox.shrink(); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isRecording) + const RecordingActivityIndicator( + size: 16, + showIdleOutline: false, + padding: EdgeInsets.only(right: 2), + ), + if (isUdpActive) + const UdpActivityIndicator( + padding: EdgeInsets.only(left: 2, right: 6), + ), + ], + ); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart b/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart index 2b3476cc..7537ad99 100644 --- a/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/edge_recorder_prefix_row.dart @@ -16,29 +16,58 @@ class EdgeRecorderPrefixRow extends StatefulWidget { class _RecorderPrefixRowState extends State { late Future _prefixFuture; + late final TextEditingController _editPrefixController; @override void initState() { super.initState(); + _editPrefixController = TextEditingController(); _loadPrefix(); } + @override + void dispose() { + _editPrefixController.dispose(); + super.dispose(); + } + void _loadPrefix() { _prefixFuture = widget.manager.filePrefix; } Future _showEditDialog(String current) async { - final controller = TextEditingController(text: current); + _editPrefixController.value = TextEditingValue( + text: current, + selection: TextSelection.collapsed(offset: current.length), + ); final result = await showPlatformDialog( context: context, builder: (context) => PlatformAlertDialog( title: PlatformText('Set Recording Prefix'), - content: PlatformTextField( - controller: controller, - autofocus: true, - material: (_, __) => MaterialTextFieldData( - decoration: const InputDecoration(hintText: 'Prefix'), - ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'This prefix is placed before the current time on the device when recordings are created.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + Text( + 'Format: + ', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10), + PlatformTextField( + controller: _editPrefixController, + autofocus: true, + material: (_, __) => MaterialTextFieldData( + decoration: const InputDecoration(hintText: 'Prefix'), + ), + ), + ], ), actions: [ PlatformDialogAction( @@ -54,7 +83,10 @@ class _RecorderPrefixRowState extends State { ); if (result == true) { - await widget.manager.setFilePrefix(controller.text.trim()); + await widget.manager.setFilePrefix(_editPrefixController.text.trim()); + if (!mounted) { + return; + } setState(_loadPrefix); } } @@ -65,15 +97,25 @@ class _RecorderPrefixRowState extends State { future: _prefixFuture, builder: (context, snapshot) { final isDone = snapshot.connectionState == ConnectionState.done; - final prefix = snapshot.data ?? ''; + final rawPrefix = snapshot.data ?? ''; + final prefix = rawPrefix.trim(); + final hasPrefix = prefix.isNotEmpty; return PlatformListTile( title: PlatformText('On-Device Filename Prefix'), + subtitle: Text( + hasPrefix + ? 'Used as: "$prefix" + ' + : 'Used as: + ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), trailing: isDone ? Row( mainAxisSize: MainAxisSize.min, children: [ - PlatformText(prefix), + PlatformText(hasPrefix ? prefix : '(empty)'), const SizedBox(width: 8), GestureDetector( onTap: () => _showEditDialog(prefix), diff --git a/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart b/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart index 6c8a091e..8e16a1f2 100644 --- a/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/save_config_row.dart @@ -1,13 +1,29 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; import 'package:provider/provider.dart'; +import '../../../models/logger.dart'; import '../../../view_models/sensor_configuration_provider.dart'; import '../../../view_models/sensor_configuration_storage.dart'; -import '../../../models/logger.dart'; class SaveConfigRow extends StatefulWidget { - const SaveConfigRow({super.key}); + final String storageScope; + final String? uniqueNameScope; + final Set reservedProfileNames; + final Map> reservedProfilesByName; + final String? defaultName; + final VoidCallback? onSaved; + + const SaveConfigRow({ + super.key, + required this.storageScope, + this.uniqueNameScope, + this.reservedProfileNames = const {}, + this.reservedProfilesByName = const >{}, + this.defaultName, + this.onSaved, + }); @override State createState() => _SaveConfigRowState(); @@ -15,59 +31,467 @@ class SaveConfigRow extends StatefulWidget { class _SaveConfigRowState extends State { String _configName = ''; + bool _isSaving = false; + late final TextEditingController _nameController; + + @override + void initState() { + super.initState(); + _configName = widget.defaultName?.trim() ?? ''; + _nameController = TextEditingController(text: _configName); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return PlatformListTile( - title: PlatformTextField( - onChanged: (value) { - setState(() { - _configName = value; - }); - }, - onSubmitted: (value) async { - setState(() { - _configName = value.trim(); - }); - }, - onTapOutside: (event) => FocusScope.of(context).unfocus(), - hintText: "Save as...", - ), - trailing: PlatformElevatedButton( - onPressed: () async { - SensorConfigurationProvider provider = - Provider.of(context, listen: false); - Map config = provider.toJson(); - - logger.d("Saving configuration: $_configName with data: $config"); - - if (_configName.isNotEmpty) { - await SensorConfigurationStorage.saveConfiguration( - _configName.trim(), - config, - ); - } else { - showPlatformDialog( - context: context, - builder: (context) { - return PlatformAlertDialog( - title: PlatformText("Configuration Name Required"), - content: PlatformText( - "Please enter a name for the configuration.", - ), - actions: [ - PlatformDialogAction( - child: PlatformText("OK"), - onPressed: () => Navigator.of(context).pop(), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: PlatformTextField( + controller: _nameController, + onChanged: (value) { + setState(() { + _configName = value; + }); + }, + onTapOutside: (_) => FocusScope.of(context).unfocus(), + hintText: 'Profile name', + ), + ), + const SizedBox(width: 12), + _isSaving + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : PlatformElevatedButton( + onPressed: _saveConfiguration, + child: const Text('Save Profile'), ), - ], - ); - }, - ); + ], + ), + const SizedBox(height: 8), + Text( + 'Save current settings as a reusable profile for this device.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } + + Future _saveConfiguration() async { + final String profileName = _configName.trim(); + if (profileName.isEmpty) { + await _showInfoDialog( + title: 'Profile name required', + message: 'Enter a profile name before saving.', + ); + return; + } + if (_isReservedProfileName(profileName)) { + await _showInfoDialog( + title: 'Reserved profile name', + message: '"$profileName" is reserved and cannot be overwritten.', + ); + return; + } + + setState(() { + _isSaving = true; + }); + + try { + final SensorConfigurationProvider provider = + Provider.of(context, listen: false); + final Map config = provider.toJson(); + + final reservedDuplicate = _findReservedDuplicateSettingsProfile( + currentConfig: config, + profileName: profileName, + ); + if (reservedDuplicate != null) { + await _showInfoDialog( + title: 'Profile already exists', + message: + 'These settings already exist as "$reservedDuplicate". This built-in profile cannot be renamed or overwritten.', + ); + return; + } + + final duplicateSettingsMatch = await _findStoredDuplicateSettingsProfile( + currentConfig: config, + profileName: profileName, + ); + if (duplicateSettingsMatch != null) { + final action = await _showDuplicateSettingsDialog( + existingProfileName: duplicateSettingsMatch.displayName, + requestedProfileName: profileName, + ); + if (action != _DuplicateSettingsAction.renameExisting) { + return; + } + + final renamed = await _renameDuplicateProfileToRequestedName( + duplicateSettingsMatch: duplicateSettingsMatch, + requestedProfileName: profileName, + config: config, + ); + if (!renamed || !mounted) { + return; + } + FocusScope.of(context).unfocus(); + _showToast( + 'Renamed profile "${duplicateSettingsMatch.displayName}" to "$profileName".', + ); + widget.onSaved?.call(); + return; + } + + final String storageKey = SensorConfigurationStorage.buildScopedKey( + scope: widget.storageScope, + name: profileName, + ); + + final existingKeys = + await SensorConfigurationStorage.listConfigurationKeys(); + final conflictingKeys = _collectConflictingKeys( + existingKeys: existingKeys, + storageKey: storageKey, + profileName: profileName, + ); + if (conflictingKeys.isNotEmpty) { + final shouldOverwrite = await _confirmOverwrite(profileName); + if (!shouldOverwrite) return; + for (final conflictKey in conflictingKeys) { + if (conflictKey == storageKey) { + continue; } - }, - child: PlatformText("Save"), + await SensorConfigurationStorage.deleteConfiguration(conflictKey); + } + } + + logger.d('Saving sensor profile "$profileName" to "$storageKey".'); + await SensorConfigurationStorage.saveConfiguration(storageKey, config); + + if (!mounted) return; + FocusScope.of(context).unfocus(); + _showToast('Saved profile "$profileName".'); + widget.onSaved?.call(); + } catch (e) { + logger.e('Failed to save sensor profile: $e'); + if (!mounted) return; + await _showInfoDialog( + title: 'Save failed', + message: 'Could not save this profile. Please try again.', + ); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _confirmOverwrite(String profileName) async { + final bool? confirmed = await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Overwrite profile?'), + content: Text( + 'A profile named "$profileName" already exists for this device name.', + ), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + PlatformDialogAction( + child: const Text('Overwrite'), + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ), + ); + + return confirmed ?? false; + } + + Future<_DuplicateSettingsAction> _showDuplicateSettingsDialog({ + required String existingProfileName, + required String requestedProfileName, + }) async { + final action = await showPlatformDialog<_DuplicateSettingsAction>( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Profile already exists'), + content: Text( + 'A profile named "$existingProfileName" already uses these settings. Rename that existing profile to "$requestedProfileName", or cancel.', + ), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext) + .pop(_DuplicateSettingsAction.cancel), + ), + PlatformDialogAction( + child: const Text('Change name'), + onPressed: () => Navigator.of(dialogContext).pop( + _DuplicateSettingsAction.renameExisting, + ), + ), + ], ), ); + + return action ?? _DuplicateSettingsAction.cancel; } + + Future _renameDuplicateProfileToRequestedName({ + required _StoredProfileMatch duplicateSettingsMatch, + required String requestedProfileName, + required Map config, + }) async { + final targetKey = SensorConfigurationStorage.buildScopedKey( + scope: widget.storageScope, + name: requestedProfileName, + ); + final existingKeys = + await SensorConfigurationStorage.listConfigurationKeys(); + final conflictingKeys = _collectConflictingKeys( + existingKeys: existingKeys, + storageKey: targetKey, + profileName: requestedProfileName, + ); + final conflictingWithoutSource = conflictingKeys + .where((key) => key != duplicateSettingsMatch.key) + .toList(growable: false); + if (conflictingWithoutSource.isNotEmpty) { + final shouldOverwrite = await _confirmOverwrite(requestedProfileName); + if (!shouldOverwrite) { + return false; + } + for (final key in conflictingWithoutSource) { + await SensorConfigurationStorage.deleteConfiguration(key); + } + } + + await SensorConfigurationStorage.saveConfiguration(targetKey, config); + if (duplicateSettingsMatch.key != targetKey) { + await SensorConfigurationStorage.deleteConfiguration( + duplicateSettingsMatch.key, + ); + } + return true; + } + + List _collectConflictingKeys({ + required List existingKeys, + required String storageKey, + required String profileName, + }) { + final conflicts = {storageKey}; + final sanitizedProfileName = SensorConfigurationStorage.sanitizeKey( + profileName, + ); + final normalizedSanitizedProfileName = sanitizedProfileName.toLowerCase(); + + final uniqueNameScope = widget.uniqueNameScope?.trim(); + if (uniqueNameScope != null && uniqueNameScope.isNotEmpty) { + conflicts.addAll( + existingKeys.where( + (key) => _isScopedNameConflict( + key: key, + uniqueNameScope: uniqueNameScope, + sanitizedProfileName: sanitizedProfileName, + ), + ), + ); + } + + conflicts.addAll( + existingKeys.where( + (key) => + SensorConfigurationStorage.isLegacyUnscopedKey(key) && + SensorConfigurationStorage.sanitizeKey(key).toLowerCase() == + normalizedSanitizedProfileName, + ), + ); + + return conflicts.where(existingKeys.contains).toList(growable: false); + } + + String? _findReservedDuplicateSettingsProfile({ + required Map currentConfig, + required String profileName, + }) { + final normalizedName = _normalizeProfileName(profileName); + for (final entry in widget.reservedProfilesByName.entries) { + if (_normalizeProfileName(entry.key) == normalizedName) { + continue; + } + if (_mapsEqual(entry.value, currentConfig)) { + return entry.key; + } + } + return null; + } + + Future<_StoredProfileMatch?> _findStoredDuplicateSettingsProfile({ + required Map currentConfig, + required String profileName, + }) async { + final existingKeys = + await SensorConfigurationStorage.listConfigurationKeys(); + final normalizedName = _normalizeProfileName(profileName); + final keysToCheck = _keysInNameFamilyOrLegacy(existingKeys); + for (final key in keysToCheck) { + final existingName = _profileNameFromKey(key); + if (_normalizeProfileName(existingName) == normalizedName) { + continue; + } + final savedConfig = + await SensorConfigurationStorage.loadConfiguration(key); + if (_mapsEqual(savedConfig, currentConfig)) { + return _StoredProfileMatch( + key: key, + displayName: existingName, + ); + } + } + return null; + } + + Iterable _keysInNameFamilyOrLegacy(List existingKeys) { + final uniqueNameScope = widget.uniqueNameScope?.trim(); + final nameFamilyPrefix = uniqueNameScope == null || uniqueNameScope.isEmpty + ? null + : SensorConfigurationStorage.scopedPrefix(uniqueNameScope); + return existingKeys.where((key) { + if (SensorConfigurationStorage.isLegacyUnscopedKey(key)) { + return true; + } + if (nameFamilyPrefix == null) { + return false; + } + return key.startsWith(nameFamilyPrefix); + }); + } + + String _profileNameFromKey(String key) { + if (SensorConfigurationStorage.isLegacyUnscopedKey(key)) { + return key.replaceAll('_', ' '); + } + final lastSeparatorIndex = key.lastIndexOf('__'); + if (lastSeparatorIndex < 0 || lastSeparatorIndex + 2 >= key.length) { + return key.replaceAll('_', ' '); + } + return key.substring(lastSeparatorIndex + 2).replaceAll('_', ' '); + } + + String _normalizeProfileName(String value) => value.trim().toLowerCase(); + + bool _mapsEqual(Map left, Map right) { + if (left.length != right.length) { + return false; + } + for (final entry in left.entries) { + if (right[entry.key] != entry.value) { + return false; + } + } + return true; + } + + bool _isScopedNameConflict({ + required String key, + required String uniqueNameScope, + required String sanitizedProfileName, + }) { + if (!SensorConfigurationStorage.keyMatchesScope(key, uniqueNameScope)) { + return false; + } + if (key == + SensorConfigurationStorage.buildScopedKey( + scope: uniqueNameScope, + name: sanitizedProfileName, + )) { + return true; + } + + final prefix = SensorConfigurationStorage.scopedPrefix(uniqueNameScope); + if (!key.startsWith(prefix)) { + return false; + } + final remainder = key.substring(prefix.length); + if (!remainder.startsWith('fw_')) { + return false; + } + return remainder.endsWith('__$sanitizedProfileName'); + } + + bool _isReservedProfileName(String profileName) { + final normalized = _normalizeProfileName(profileName); + if (normalized.isEmpty) { + return false; + } + return widget.reservedProfileNames.any( + (name) => _normalizeProfileName(name) == normalized, + ); + } + + Future _showInfoDialog({ + required String title, + required String message, + }) async { + await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: Text(title), + content: Text(message), + actions: [ + PlatformDialogAction( + child: const Text('OK'), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ], + ), + ); + } + + void _showToast(String message) { + AppToast.show( + context, + message: message, + type: AppToastType.success, + icon: Icons.check_circle_outline_rounded, + ); + } +} + +enum _DuplicateSettingsAction { + cancel, + renameExisting, +} + +class _StoredProfileMatch { + final String key; + final String displayName; + + const _StoredProfileMatch({ + required this.key, + required this.displayName, + }); } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart index 608424ab..0f7d46d1 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart @@ -1,91 +1,426 @@ +import 'package:flutter/foundation.dart' show setEquals; 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/view_models/sensor_configuration_provider.dart'; import 'package:provider/provider.dart'; import 'sensor_config_option_icon_factory.dart'; -class SensorConfigurationDetailView extends StatefulWidget { +class SensorConfigurationDetailView extends StatelessWidget { final SensorConfiguration sensorConfiguration; + final SensorConfiguration? pairedSensorConfiguration; + final SensorConfigurationProvider? pairedProvider; const SensorConfigurationDetailView({ super.key, required this.sensorConfiguration, + this.pairedSensorConfiguration, + this.pairedProvider, }); - - @override - State createState() { - return _SensorConfigurationDetailViewState(); - } -} - -class _SensorConfigurationDetailViewState extends State { - SensorConfigurationValue? _selectedValue; @override Widget build(BuildContext context) { - SensorConfigurationProvider sensorConfigNotifier = Provider.of(context); - _selectedValue = sensorConfigNotifier.getSelectedConfigurationValue(widget.sensorConfiguration); + const sensorOnGreen = Color(0xFF2E7D32); + final sensorConfigNotifier = context.watch(); + final selectedValue = + sensorConfigNotifier.getSelectedConfigurationValue(sensorConfiguration); + final isApplied = sensorConfigNotifier.isConfigurationApplied( + sensorConfiguration, + ); + final selectableValues = sensorConfigNotifier + .getSensorConfigurationValues(sensorConfiguration, distinct: true) + .where((value) => _isVisibleValue(value, selectedValue)) + .toList(growable: false); + final dropdownSelection = + _resolveSelection(selectableValues, selectedValue); + final colorScheme = Theme.of(context).colorScheme; + final accentColor = isApplied ? sensorOnGreen : colorScheme.primary; + final targetOptions = sensorConfiguration is ConfigurableSensorConfiguration + ? (sensorConfiguration as ConfigurableSensorConfiguration) + .availableOptions + .toList(growable: false) + : const []; return ListView( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), children: [ - if (widget.sensorConfiguration is ConfigurableSensorConfiguration) - ...(widget.sensorConfiguration as ConfigurableSensorConfiguration).availableOptions.map((option) { - return PlatformListTile( - leading: Icon(getSensorConfigurationOptionIcon(option)), - title: PlatformText(option.name), - trailing: PlatformSwitch( - value: sensorConfigNotifier.getSelectedConfigurationOptions(widget.sensorConfiguration).contains(option), + if (targetOptions.isNotEmpty) ...[ + Text( + 'Data Targets', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + 'Select where this sensor output is sent.', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Column( + children: [ + for (var i = 0; i < targetOptions.length; i++) ...[ + _OptionToggleTile( + option: targetOptions[i], + accentColor: accentColor, + selected: sensorConfigNotifier + .getSelectedConfigurationOptions( + sensorConfiguration, + ) + .contains(targetOptions[i]), + onChanged: (enabled) { + _updatePrimaryAndPair( + primaryProvider: sensorConfigNotifier, + updatePrimary: () { + if (enabled) { + sensorConfigNotifier.addSensorConfigurationOption( + sensorConfiguration, + targetOptions[i], + ); + } else { + sensorConfigNotifier.removeSensorConfigurationOption( + sensorConfiguration, + targetOptions[i], + ); + } + }, + ); + }, + ), + if (i < targetOptions.length - 1) const SizedBox(height: 8), + ], + ], + ), + const SizedBox(height: 12), + ], + Text( + 'Sampling Rate', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + 'Set how often this sensor is sampled.', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + selectableValues.isEmpty + ? Text( + 'No sampling rates are available for this sensor.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ) + : DropdownButtonFormField( + initialValue: dropdownSelection, + isExpanded: true, + decoration: InputDecoration( + isDense: true, + filled: false, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.primary.withValues(alpha: 0.6), + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 7, + ), + ), + items: selectableValues + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(_samplingRateLabel(value)), + ), + ) + .toList(growable: false), onChanged: (value) { - if (value) { - sensorConfigNotifier.addSensorConfigurationOption(widget.sensorConfiguration, option); - } else { - sensorConfigNotifier.removeSensorConfigurationOption(widget.sensorConfiguration, option); + if (value == null) { + return; } + _updatePrimaryAndPair( + primaryProvider: sensorConfigNotifier, + updatePrimary: () { + sensorConfigNotifier.addSensorConfiguration( + sensorConfiguration, + value, + ); + }, + ); }, ), - ); - }), - PlatformListTile( - leading: Icon(Icons.speed_outlined), - title: PlatformText("Sampling Rate"), - trailing: Material( - child: DropdownButton( - value: sensorConfigNotifier.getSelectedConfigurationValue(widget.sensorConfiguration), - items: sensorConfigNotifier.getSensorConfigurationValues(widget.sensorConfiguration, distinct: true).where( - (value) { - if (value is SensorFrequencyConfigurationValue) { - return value.frequencyHz >= 0.1 - || value.frequencyHz == 0 - || sensorConfigNotifier.getSelectedConfigurationValue(widget.sensorConfiguration) == value; - } - return true; - }, - ).map((value) { - if (value is SensorFrequencyConfigurationValue) { - return DropdownMenuItem( - value: value, - child: PlatformText(value.frequencyHz.toStringAsFixed(2)), - ); - } - return DropdownMenuItem( - value: value, - child: PlatformText(value.key), - ); - }).toList(), - onChanged: (value) { - setState(() { - _selectedValue = value; - }); - if (_selectedValue != null) { - sensorConfigNotifier.addSensorConfiguration(widget.sensorConfiguration, _selectedValue!); - } - }, + ], + ); + } + + void _updatePrimaryAndPair({ + required SensorConfigurationProvider primaryProvider, + required VoidCallback updatePrimary, + }) { + updatePrimary(); + _syncPairedSelection(primaryProvider); + } + + void _syncPairedSelection(SensorConfigurationProvider primaryProvider) { + final pairedNotifier = pairedProvider; + final mirroredConfig = pairedSensorConfiguration; + if (pairedNotifier == null || mirroredConfig == null) { + return; + } + + final selectedPrimaryValue = + primaryProvider.getSelectedConfigurationValue(sensorConfiguration); + if (selectedPrimaryValue == null) { + return; + } + + final mirroredValue = _findMirroredValue( + mirroredConfig: mirroredConfig, + sourceValue: selectedPrimaryValue, + ); + if (mirroredValue == null) { + return; + } + + pairedNotifier.addSensorConfiguration( + mirroredConfig, + mirroredValue, + markPending: true, + ); + } + + bool _isVisibleValue( + SensorConfigurationValue value, + SensorConfigurationValue? selectedValue, + ) { + if (value is! SensorFrequencyConfigurationValue) { + return true; + } + if (value.frequencyHz == 0 || value.frequencyHz >= 0.1) { + return true; + } + if (selectedValue is! SensorFrequencyConfigurationValue) { + return false; + } + return value.frequencyHz == selectedValue.frequencyHz; + } + + SensorConfigurationValue? _resolveSelection( + List values, + SensorConfigurationValue? selected, + ) { + if (selected == null) { + return null; + } + for (final value in values) { + if (_sameValue(value, selected)) { + return value; + } + } + return null; + } + + bool _sameValue(SensorConfigurationValue a, SensorConfigurationValue b) { + if (a.runtimeType != b.runtimeType) { + return false; + } + if (a is SensorFrequencyConfigurationValue && + b is SensorFrequencyConfigurationValue) { + return a.frequencyHz == b.frequencyHz; + } + return a.key == b.key; + } + + String _samplingRateLabel(SensorConfigurationValue value) { + if (value is SensorFrequencyConfigurationValue) { + return '${value.frequencyHz.toStringAsFixed(2)} Hz'; + } + return value.key; + } + + SensorConfigurationValue? _findMirroredValue({ + required SensorConfiguration mirroredConfig, + required SensorConfigurationValue sourceValue, + }) { + for (final candidate in mirroredConfig.values) { + if (_normalizeName(candidate.key) == _normalizeName(sourceValue.key)) { + return candidate; + } + } + + if (sourceValue is SensorFrequencyConfigurationValue) { + final sourceOptions = _optionNameSet(sourceValue); + final candidates = mirroredConfig.values + .whereType() + .toList(growable: false); + if (candidates.isNotEmpty) { + final sameOptionCandidates = candidates + .where( + (candidate) => + setEquals(_optionNameSet(candidate), sourceOptions), + ) + .toList(growable: false); + final scoped = + sameOptionCandidates.isNotEmpty ? sameOptionCandidates : candidates; + SensorFrequencyConfigurationValue? best; + double? bestDistance; + for (final candidate in scoped) { + final distance = + (candidate.frequencyHz - sourceValue.frequencyHz).abs(); + if (best == null || distance < bestDistance!) { + best = candidate; + bestDistance = distance; + } + } + if (best != null) { + return best; + } + } + } + + if (sourceValue is ConfigurableSensorConfigurationValue) { + final sourceWithoutOptions = sourceValue.withoutOptions(); + final sourceOptions = _optionNameSet(sourceValue); + for (final candidate in mirroredConfig.values + .whereType()) { + if (!setEquals(_optionNameSet(candidate), sourceOptions)) { + continue; + } + if (_normalizeName(candidate.withoutOptions().key) == + _normalizeName(sourceWithoutOptions.key)) { + return candidate; + } + } + } + + return null; + } + + Set _optionNameSet(SensorConfigurationValue value) { + if (value is! ConfigurableSensorConfigurationValue) { + return const {}; + } + return value.options.map((option) => _normalizeName(option.name)).toSet(); + } + + String _normalizeName(String value) => value.trim().toLowerCase(); +} + +class _OptionToggleTile extends StatelessWidget { + final SensorConfigurationOption option; + final Color accentColor; + final bool selected; + final ValueChanged onChanged; + + const _OptionToggleTile({ + required this.option, + required this.accentColor, + required this.selected, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = selected ? accentColor : colorScheme.onSurface; + final (title, subtitle) = _copyForOption(option); + + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: selected + ? accentColor.withValues(alpha: 0.06) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: (selected ? accentColor : colorScheme.outlineVariant) + .withValues(alpha: selected ? 0.35 : 0.25), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + getSensorConfigurationOptionIcon(option), + size: 14, + color: foreground, + ), + const SizedBox(width: 7), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 1), + Text( + subtitle, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.15, + ), + ), + ], + ], ), ), - ), - ], + const SizedBox(width: 8), + Switch.adaptive( + value: selected, + activeThumbColor: colorScheme.surface, + activeTrackColor: accentColor, + inactiveThumbColor: colorScheme.surface, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: onChanged, + ), + ], + ), ); } + + (String, String?) _copyForOption(SensorConfigurationOption option) { + if (option is StreamSensorConfigOption) { + return ( + 'Live stream to phone', + 'Send to app via Bluetooth.', + ); + } + if (option is RecordSensorConfigOption) { + return ( + 'Record to SD card', + 'Include this sensor in on-device recordings. Turn data target off to complete and close the file.', + ); + } + return (option.name, null); + } } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 78224085..9524724d 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -1,20 +1,36 @@ +import 'package:flutter/foundation.dart' show setEquals; +import 'package:flutter/cupertino.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/view_models/sensor_configuration_storage.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart'; +import 'package:open_wearable/widgets/sensors/configuration/sensor_config_option_icon_factory.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_value_row.dart'; import 'package:provider/provider.dart'; import '../../../view_models/sensor_configuration_provider.dart'; -import '../../devices/device_detail/stereo_pos_label.dart'; -/// A widget that displays a list of sensor configurations for a device. +/// A widget that displays and manages sensor configuration for a single device. class SensorConfigurationDeviceRow extends StatefulWidget { final Wearable device; + final Wearable? pairedDevice; + final SensorConfigurationProvider? pairedProvider; + final String? displayName; + final String? storageScope; - const SensorConfigurationDeviceRow({super.key, required this.device}); + const SensorConfigurationDeviceRow({ + super.key, + required this.device, + this.pairedDevice, + this.pairedProvider, + this.displayName, + this.storageScope, + }); @override State createState() => @@ -24,104 +40,351 @@ class SensorConfigurationDeviceRow extends StatefulWidget { class _SensorConfigurationDeviceRowState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; - List _content = []; + static const String _builtInOffProfileKey = '__builtin_off_profile__'; + static const String _builtInOffProfileTitle = 'Off'; + + late final TabController _tabController; + late Future _profileScopeMatchFuture; + List _content = const []; + final Map>> _profileConfigFutures = {}; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); - _tabController.addListener(() { - if (!_tabController.indexIsChanging) { - _updateContent(); - } - }); - _content = [PlatformCircularProgressIndicator()]; + _tabController.addListener(_onTabChanged); + _profileScopeMatchFuture = _resolveProfileScopeMatch(); + _content = const [Center(child: CircularProgressIndicator())]; _updateContent(); } @override void dispose() { + _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); } + @override + void didUpdateWidget(covariant SensorConfigurationDeviceRow oldWidget) { + super.didUpdateWidget(oldWidget); + final deviceChanged = oldWidget.device.deviceId != widget.device.deviceId; + final pairedDeviceChanged = + oldWidget.pairedDevice?.deviceId != widget.pairedDevice?.deviceId; + final pairedProviderChanged = + oldWidget.pairedProvider != widget.pairedProvider; + final displayNameChanged = oldWidget.displayName != widget.displayName; + final scopeChanged = oldWidget.storageScope != widget.storageScope; + if (deviceChanged || + pairedDeviceChanged || + pairedProviderChanged || + displayNameChanged || + scopeChanged) { + _profileConfigFutures.clear(); + _profileScopeMatchFuture = _resolveProfileScopeMatch(); + _updateContent(); + } + } + @override Widget build(BuildContext context) { final device = widget.device; + final tabBar = _buildTabBar(context); + final isCombinedPair = widget.pairedDevice != null; + final title = widget.displayName ?? formatWearableDisplayName(device.name); return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PlatformListTile( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( children: [ - PlatformText( - device.name, - style: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith(fontWeight: FontWeight.bold), + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: PlatformText( + title, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isCombinedPair) + const Padding( + padding: EdgeInsets.only(left: 8), + child: _CombinedStereoBadge(), + ) + else if (device.hasCapability()) + Padding( + padding: const EdgeInsets.only(left: 8), + child: StereoPositionBadge( + device: device.requireCapability(), + ), + ), + ], + ), ), - if (device.hasCapability()) - StereoPosLabel(device: device.requireCapability()), + if (tabBar != null) ...[ + const SizedBox(width: 8), + Align( + alignment: Alignment.centerLeft, + widthFactor: 1, + child: tabBar, + ), + ], ], ), - trailing: _buildTabBar(context), ), + if (isCombinedPair) + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Text( + 'Settings are applied to both paired devices.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), ..._content, ], ), ); } + void _onTabChanged() { + if (!_tabController.indexIsChanging) { + _updateContent(); + } + } + + Future _resolveProfileScopeMatch() async { + final explicitScope = widget.storageScope?.trim(); + if (explicitScope != null && explicitScope.isNotEmpty) { + return DeviceProfileScopeMatch( + nameScope: explicitScope, + firmwareScope: null, + ); + } + + final firmwareVersion = + await _readFirmwareVersionForProfiles(widget.device); + return DeviceProfileScopeMatch.forDevice( + deviceName: _profileDeviceName(), + firmwareVersion: firmwareVersion, + ); + } + + String _profileDeviceName() { + final name = + widget.displayName ?? formatWearableDisplayName(widget.device.name); + final trimmed = name.trim(); + return trimmed.isEmpty ? widget.device.name : trimmed; + } + + Future _readFirmwareVersionForProfiles(Wearable wearable) async { + if (!wearable.hasCapability()) { + return null; + } + try { + final version = await wearable + .requireCapability() + .readDeviceFirmwareVersion() + .timeout(const Duration(seconds: 2)); + return SensorConfigurationStorage.normalizeFirmwareVersionForScope( + version, + ); + } catch (_) { + return null; + } + } + + Future _readCurrentScopeMatch() async { + final future = _profileScopeMatchFuture; + final match = await future; + return match; + } + + bool _isBuiltInProfileKey(String key) => key == _builtInOffProfileKey; + + Future> _loadProfileConfiguration(String key) async { + if (_isBuiltInProfileKey(key)) { + return _buildBuiltInOffProfileConfig(widget.device); + } + return SensorConfigurationStorage.loadConfiguration(key); + } + + Map _buildBuiltInOffProfileConfig(Wearable device) { + if (!device.hasCapability()) { + return const {}; + } + + final manager = device.requireCapability(); + final config = {}; + for (final sensorConfig in manager.sensorConfigurations) { + final offValue = _resolveBuiltInOffValue(sensorConfig); + if (offValue != null) { + config[sensorConfig.name] = offValue.key; + } + } + return config; + } + + SensorConfigurationValue? _resolveBuiltInOffValue( + SensorConfiguration sensorConfig, + ) { + final offValue = sensorConfig.offValue; + if (sensorConfig is ConfigurableSensorConfiguration) { + final configurableValues = sensorConfig.values + .whereType() + .toList(growable: false); + + if (offValue is ConfigurableSensorConfigurationValue) { + for (final candidate in configurableValues) { + if (_normalizeName(candidate.withoutOptions().key) == + _normalizeName(offValue.withoutOptions().key) && + candidate.options.isEmpty) { + return candidate; + } + } + } + + final withoutTargets = configurableValues + .where((candidate) => candidate.options.isEmpty) + .toList(growable: false); + if (withoutTargets.isNotEmpty) { + final frequencyCandidates = + withoutTargets.whereType(); + if (frequencyCandidates.isNotEmpty) { + var best = frequencyCandidates.first; + for (final candidate in frequencyCandidates.skip(1)) { + if (candidate.frequencyHz < best.frequencyHz) { + best = candidate; + } + } + return best; + } + return withoutTargets.first; + } + } + + if (offValue != null) { + return offValue; + } + + if (sensorConfig.values.isEmpty) { + return null; + } + + final frequencyValues = + sensorConfig.values.whereType(); + if (frequencyValues.isNotEmpty) { + var best = frequencyValues.first; + for (final candidate in frequencyValues.skip(1)) { + if (candidate.frequencyHz < best.frequencyHz) { + best = candidate; + } + } + return best; + } + + return sensorConfig.values.first; + } + + Future _ensureProfileKeyMatchesCurrentDevice({ + required String key, + required String profileTitle, + }) async { + if (_isBuiltInProfileKey(key)) { + return true; + } + final scopeMatch = await _readCurrentScopeMatch(); + if (scopeMatch.allowsKey(key)) { + return true; + } + if (!mounted) { + return false; + } + + await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Profile mismatch'), + content: Text( + 'Profile "$profileTitle" no longer matches this device name/firmware and cannot be used.', + ), + actions: [ + PlatformDialogAction( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + + _refreshProfiles(); + return false; + } + Future _updateContent() async { - final Wearable device = widget.device; + final device = widget.device; if (!device.hasCapability()) { if (!mounted) return; setState(() { _content = [ - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText("This device does not support configuring sensors."), + const Padding( + padding: EdgeInsets.all(12), + child: Text('This device does not support sensor configuration.'), ), ]; }); return; } - final SensorConfigurationManager sensorManager = - device.requireCapability(); - if (_tabController.index == 0) { - _buildNewTabContent(device); + _buildSettingsTabContent(device); } else { - await _buildLoadTabContent(sensorManager); + await _buildProfilesTabContent(); } } - void _buildNewTabContent(Wearable device) { - SensorConfigurationManager sensorManager = + void _buildSettingsTabContent(Wearable device) { + final sensorManager = device.requireCapability(); - final List content = sensorManager.sensorConfigurations - .map( - (config) => SensorConfigurationValueRow(sensorConfiguration: config), - ) - .cast() - .toList(); - - content.addAll([ - const Divider(), - const SaveConfigRow(), - ]); + final pairedManager = widget.pairedDevice != null && + widget.pairedProvider != null && + widget.pairedDevice!.hasCapability() + ? widget.pairedDevice!.requireCapability() + : null; + + final content = [ + ...sensorManager.sensorConfigurations.map( + (config) => SensorConfigurationValueRow( + sensorConfiguration: config, + pairedSensorConfiguration: pairedManager == null + ? null + : _findMirroredConfiguration( + manager: pairedManager, + sourceConfig: config, + ), + pairedProvider: widget.pairedProvider, + ), + ), + ]; if (device.hasCapability()) { content.addAll([ - const Divider(), - EdgeRecorderPrefixRow(manager: device.requireCapability()), + const _InsetSectionDivider(), + EdgeRecorderPrefixRow( + manager: device.requireCapability(), + ), ]); } @@ -131,84 +394,1575 @@ class _SensorConfigurationDeviceRowState }); } - Future _buildLoadTabContent(SensorConfigurationManager device) async { + Future _buildProfilesTabContent() async { if (!mounted) return; setState(() { - _content = [PlatformCircularProgressIndicator()]; + _content = const [Center(child: CircularProgressIndicator())]; }); - final configKeys = await SensorConfigurationStorage.listConfigurationKeys(); - - if (!mounted) return; + final scopeFuture = _profileScopeMatchFuture; + final scopeMatch = await scopeFuture; + if (!mounted || !identical(scopeFuture, _profileScopeMatchFuture)) { + return; + } - if (configKeys.isEmpty) { + List allConfigKeys; + try { + allConfigKeys = await SensorConfigurationStorage.listConfigurationKeys() + .timeout(const Duration(seconds: 8)); + } catch (error, stackTrace) { + debugPrint( + 'Failed to load sensor profiles for ${scopeMatch.saveScope}: ' + '$error\n$stackTrace', + ); + if (!mounted) return; setState(() { _content = [ - PlatformListTile(title: PlatformText("No configurations found")), + SaveConfigRow( + storageScope: scopeMatch.saveScope, + uniqueNameScope: scopeMatch.nameScope, + reservedProfileNames: const {_builtInOffProfileTitle}, + reservedProfilesByName: { + _builtInOffProfileTitle: _buildBuiltInOffProfileConfig( + widget.device, + ), + }, + onSaved: _refreshProfiles, + ), + const Divider(), + const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'Could not load saved profiles. Please try again.', + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: _refreshProfiles, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Retry'), + ), + ), + ), ]; }); return; } + final scopedKeys = allConfigKeys.where(scopeMatch.matchesScopedKey).toList() + ..sort(); + final legacyKeys = allConfigKeys + .where(SensorConfigurationStorage.isLegacyUnscopedKey) + .toList() + ..sort(); + final profileKeys = [ + ...scopedKeys.where((key) => key != _builtInOffProfileKey), + ...legacyKeys.where((key) => key != _builtInOffProfileKey), + _builtInOffProfileKey, + ]; - final widgets = configKeys.map((key) { - return PlatformListTile( - title: PlatformText(key), - onTap: () async { - final config = - await SensorConfigurationStorage.loadConfiguration(key); - if (!mounted) return; - - final result = await Provider.of( - context, - listen: false, - ).restoreFromJson(config); - - if (!result && mounted) { - showPlatformDialog( - context: context, - builder: (_) => PlatformAlertDialog( - title: PlatformText("Error"), - content: PlatformText("Failed to load configuration: $key"), - actions: [ - PlatformDialogAction( - child: PlatformText("OK"), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - ); - return; - } + if (!mounted) return; - _tabController.index = 0; - _updateContent(); + final content = [ + SaveConfigRow( + storageScope: scopeMatch.saveScope, + uniqueNameScope: scopeMatch.nameScope, + reservedProfileNames: const {_builtInOffProfileTitle}, + reservedProfilesByName: { + _builtInOffProfileTitle: _buildBuiltInOffProfileConfig( + widget.device, + ), }, - trailing: PlatformIconButton( - icon: Icon(context.platformIcons.delete), - onPressed: () async { - await SensorConfigurationStorage.deleteConfiguration(key); - if (mounted) _updateContent(); - }, + onSaved: _refreshProfiles, + ), + const Divider(), + ]; + + if (profileKeys.isEmpty) { + content.add( + const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'No profiles saved yet. Save current settings above, then tap a profile to load as current.', + ), + ), + ); + } else { + content.addAll( + profileKeys.map( + (key) => _buildProfileTile( + key, + scopeMatch: scopeMatch, + ), ), ); - }).toList(); + } setState(() { - _content = widgets; + _content = content; }); } + Widget _buildProfileTile( + String key, { + required DeviceProfileScopeMatch scopeMatch, + }) { + final isBuiltIn = _isBuiltInProfileKey(key); + final matchedScope = scopeMatch.matchingScopeForKey(key); + final isDeviceScoped = isBuiltIn || matchedScope != null; + + final title = switch ((isBuiltIn, matchedScope)) { + (true, _) => _builtInOffProfileTitle, + (false, final scope?) => + SensorConfigurationStorage.displayNameFromScopedKey( + key, + scope: scope, + ), + _ => key, + }; + + return Consumer( + builder: (context, provider, _) { + return FutureBuilder>( + future: _profileConfigFutures.putIfAbsent( + key, + () => _loadProfileConfiguration(key), + ), + builder: (context, snapshot) { + final profileConfig = snapshot.data; + final state = _resolveProfileApplicationState( + provider: provider, + pairedProvider: widget.pairedProvider, + profileConfig: profileConfig, + ); + final colorScheme = Theme.of(context).colorScheme; + const appliedGreen = Color(0xFF2E7D32); + final stateColor = switch (state) { + _ProfileApplicationState.none => colorScheme.onSurface, + _ProfileApplicationState.selected => colorScheme.primary, + _ProfileApplicationState.applied => appliedGreen, + _ProfileApplicationState.mixed => colorScheme.error, + }; + final titleStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: state == _ProfileApplicationState.none + ? FontWeight.w500 + : FontWeight.w700, + color: state == _ProfileApplicationState.none + ? null + : stateColor, + ); + final tileDecoration = switch (state) { + _ProfileApplicationState.selected => null, + _ProfileApplicationState.applied => null, + _ProfileApplicationState.mixed => null, + _ProfileApplicationState.none => null, + }; + + final subtitle = switch (state) { + _ProfileApplicationState.selected => 'Selected, not applied', + _ProfileApplicationState.applied => widget.pairedProvider == null + ? 'Applied on device' + : 'Applied on both devices', + _ProfileApplicationState.mixed => + 'Mixed state across paired devices', + _ProfileApplicationState.none => isBuiltIn + ? 'Built-in default profile' + : isDeviceScoped + ? 'Tap to load as current' + : 'Legacy shared profile', + }; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: DecoratedBox( + decoration: tileDecoration ?? const BoxDecoration(), + child: PlatformListTile( + leading: Icon( + Icons.view_list_rounded, + color: state == _ProfileApplicationState.none + ? colorScheme.onSurfaceVariant + : stateColor, + ), + title: PlatformText( + title, + style: titleStyle, + ), + subtitle: PlatformText(subtitle), + onTap: () => _loadProfile(key: key, title: title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (state != _ProfileApplicationState.none) + _ProfileApplicationBadge(state: state), + PlatformIconButton( + icon: const Icon(Icons.more_horiz), + onPressed: () => _showProfileActions( + key: key, + title: title, + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + } + + _ProfileApplicationState _resolveProfileApplicationState({ + required SensorConfigurationProvider provider, + required SensorConfigurationProvider? pairedProvider, + required Map? profileConfig, + }) { + if (profileConfig == null || profileConfig.isEmpty) { + return _ProfileApplicationState.none; + } + + final primaryState = _resolveSingleDeviceProfileState( + device: widget.device, + provider: provider, + expectedConfig: profileConfig, + ); + + final pairedDevice = widget.pairedDevice; + if (pairedDevice == null || pairedProvider == null) { + return primaryState; + } + + final mirroredProfile = _buildMirroredProfileConfig( + sourceDevice: widget.device, + targetDevice: pairedDevice, + sourceProfileConfig: profileConfig, + ); + if (mirroredProfile == null || mirroredProfile.isEmpty) { + return _ProfileApplicationState.mixed; + } + + final secondaryState = _resolveSingleDeviceProfileState( + device: pairedDevice, + provider: pairedProvider, + expectedConfig: mirroredProfile, + ); + + if (primaryState == _ProfileApplicationState.none && + secondaryState == _ProfileApplicationState.none) { + return _ProfileApplicationState.none; + } + if (primaryState == secondaryState) { + return primaryState; + } + return _ProfileApplicationState.mixed; + } + + _ProfileApplicationState _resolveSingleDeviceProfileState({ + required Wearable device, + required SensorConfigurationProvider provider, + required Map expectedConfig, + }) { + if (!device.hasCapability()) { + return _ProfileApplicationState.none; + } + + final manager = device.requireCapability(); + var allSelected = true; + var allApplied = provider.hasReceivedConfigurationReport; + for (final entry in expectedConfig.entries) { + final config = _findConfigurationByName( + manager: manager, + configName: entry.key, + ); + if (config == null) { + return _ProfileApplicationState.none; + } + + final expectedValue = _findConfigurationValueByKey( + config: config, + valueKey: entry.value, + ); + if (expectedValue == null) { + return _ProfileApplicationState.none; + } + + if (!provider.selectedMatchesConfigurationValue(config, expectedValue)) { + allSelected = false; + } + + if (allApplied) { + final reportedValue = + provider.getLastReportedConfigurationValue(config); + if (reportedValue == null || + !_configurationValuesMatch(reportedValue, expectedValue)) { + allApplied = false; + } + } else { + allApplied = false; + } + } + + if (allApplied) { + return _ProfileApplicationState.applied; + } + if (allSelected) { + return _ProfileApplicationState.selected; + } + return _ProfileApplicationState.none; + } + + Map? _buildMirroredProfileConfig({ + required Wearable sourceDevice, + required Wearable targetDevice, + required Map sourceProfileConfig, + }) { + if (!sourceDevice.hasCapability() || + !targetDevice.hasCapability()) { + return null; + } + + final sourceManager = + sourceDevice.requireCapability(); + final targetManager = + targetDevice.requireCapability(); + final mirrored = {}; + + for (final entry in sourceProfileConfig.entries) { + final sourceConfig = _findConfigurationByName( + manager: sourceManager, + configName: entry.key, + ); + if (sourceConfig == null) { + continue; + } + final sourceValue = _findConfigurationValueByKey( + config: sourceConfig, + valueKey: entry.value, + ); + if (sourceValue == null) { + continue; + } + + final mirroredConfig = _findMirroredConfiguration( + manager: targetManager, + sourceConfig: sourceConfig, + ); + if (mirroredConfig == null) { + continue; + } + final mirroredValue = _findMirroredValue( + mirroredConfig: mirroredConfig, + sourceValue: sourceValue, + ); + if (mirroredValue == null) { + continue; + } + mirrored[mirroredConfig.name] = mirroredValue.key; + } + + return mirrored; + } + Widget? _buildTabBar(BuildContext context) { if (!widget.device.hasCapability()) return null; - return SizedBox( - width: MediaQuery.of(context).size.width * 0.4, - child: TabBar.secondary( - controller: _tabController, - tabs: const [ - Tab(text: 'New'), - Tab(text: 'Load'), - ], + return TabBar.secondary( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + padding: EdgeInsets.zero, + dividerHeight: 1, + labelPadding: const EdgeInsets.symmetric(horizontal: 8), + tabs: const [ + Tab(text: 'Current'), + Tab(text: 'Profiles'), + ], + ); + } + + Future _loadProfile({ + required String key, + required String title, + }) async { + final keyMatches = await _ensureProfileKeyMatchesCurrentDevice( + key: key, + profileTitle: title, + ); + if (!keyMatches || !mounted) { + return; + } + + final config = await _loadProfileConfiguration(key); + if (!mounted) return; + + final provider = context.read(); + final result = await provider.restoreFromJson(config); + SensorConfigurationRestoreResult? pairedResult; + final pairedProvider = widget.pairedProvider; + final pairedDevice = widget.pairedDevice; + if (pairedProvider != null && pairedDevice != null) { + final mirroredConfig = _isBuiltInProfileKey(key) + ? _buildBuiltInOffProfileConfig(pairedDevice) + : _buildMirroredProfileConfig( + sourceDevice: widget.device, + targetDevice: pairedDevice, + sourceProfileConfig: config, + ); + if (mirroredConfig != null && mirroredConfig.isNotEmpty) { + pairedResult = await pairedProvider.restoreFromJson(mirroredConfig); + } + } + if (!mounted) return; + + final hasPrimaryValues = result.hasRestoredValues; + final hasPairedValues = + pairedResult == null || pairedResult.hasRestoredValues; + if (!hasPrimaryValues || !hasPairedValues) { + await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Profile error'), + content: Text( + pairedResult == null + ? 'No compatible values from "$title" could be restored for this device.' + : 'Profile "$title" could not be restored on both paired devices.', + ), + actions: [ + PlatformDialogAction( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('OK'), + ), + ], + ), + ); + return; + } + + final pairedSkipped = (pairedResult?.skippedCount ?? 0) + + (pairedResult?.unknownConfigCount ?? 0); + if (result.skippedCount > 0 || + result.unknownConfigCount > 0 || + pairedSkipped > 0) { + final skippedTotal = + result.skippedCount + result.unknownConfigCount + pairedSkipped; + _showSnackBar( + 'Loaded "$title" (${result.restoredCount + (pairedResult?.restoredCount ?? 0)} restored, $skippedTotal skipped). Tap "Apply Profiles" to push.', + type: AppToastType.warning, + icon: Icons.rule_rounded, + ); + } else { + _showSnackBar( + 'Loaded profile "$title". Tap "Apply Profiles" at the bottom to push to hardware.', + type: AppToastType.info, + icon: Icons.download_done_rounded, + ); + } + + _tabController.index = 0; + _updateContent(); + } + + Future _overwriteProfile({ + required String key, + required String title, + }) async { + if (_isBuiltInProfileKey(key)) { + _showSnackBar( + 'Built-in profile "$title" cannot be overwritten.', + type: AppToastType.info, + icon: Icons.info_outline_rounded, + ); + return; + } + + final keyMatches = await _ensureProfileKeyMatchesCurrentDevice( + key: key, + profileTitle: title, + ); + if (!keyMatches || !mounted) { + return; + } + + final confirmed = await _confirmOverwrite(title); + if (!confirmed) return; + if (!mounted) return; + + final provider = context.read(); + await SensorConfigurationStorage.saveConfiguration(key, provider.toJson()); + if (!mounted) return; + _profileConfigFutures.remove(key); + _showSnackBar( + 'Updated profile "$title" with current settings.', + type: AppToastType.success, + icon: Icons.check_circle_outline_rounded, + ); + _updateContent(); + } + + Future _deleteProfile({ + required String key, + required String title, + }) async { + if (_isBuiltInProfileKey(key)) { + _showSnackBar( + 'Built-in profile "$title" cannot be deleted.', + type: AppToastType.info, + icon: Icons.lock_outline_rounded, + ); + return; + } + + final keyMatches = await _ensureProfileKeyMatchesCurrentDevice( + key: key, + profileTitle: title, + ); + if (!keyMatches) { + return; + } + + final confirmed = await _confirmDelete(title); + if (!confirmed) return; + + await SensorConfigurationStorage.deleteConfiguration(key); + if (!mounted) return; + _profileConfigFutures.remove(key); + _showSnackBar( + 'Deleted profile "$title".', + type: AppToastType.success, + icon: Icons.delete_outline_rounded, + ); + _updateContent(); + } + + void _showProfileActions({ + required String key, + required String title, + }) { + final isBuiltIn = _isBuiltInProfileKey(key); + showPlatformModalSheet( + context: context, + builder: (sheetContext) => PlatformWidget( + material: (_, __) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('View details'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _viewProfileDetails(key: key, title: title); + }, + ), + ListTile( + leading: const Icon(Icons.download), + title: const Text('Load'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _loadProfile(key: key, title: title); + }, + ), + if (!isBuiltIn) + ListTile( + leading: const Icon(Icons.save), + title: const Text('Overwrite with current settings'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _overwriteProfile(key: key, title: title); + }, + ), + if (!isBuiltIn) + ListTile( + leading: const Icon(Icons.delete), + title: const Text('Delete'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _deleteProfile(key: key, title: title); + }, + ), + ], + ), + ), + cupertino: (_, __) => CupertinoActionSheet( + title: Text(title), + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _viewProfileDetails(key: key, title: title); + }, + child: const Text('View details'), + ), + CupertinoActionSheetAction( + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _loadProfile(key: key, title: title); + }, + child: const Text('Load'), + ), + if (!isBuiltIn) + CupertinoActionSheetAction( + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _overwriteProfile(key: key, title: title); + }, + child: const Text('Overwrite with current settings'), + ), + if (!isBuiltIn) + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () async { + Navigator.of(sheetContext).pop(); + await _deleteProfile(key: key, title: title); + }, + child: const Text('Delete'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(sheetContext).pop(), + child: const Text('Cancel'), + ), + ), + ), + ); + } + + Future _viewProfileDetails({ + required String key, + required String title, + }) async { + final keyMatches = await _ensureProfileKeyMatchesCurrentDevice( + key: key, + profileTitle: title, + ); + if (!keyMatches || !mounted) { + return; + } + + final profileConfig = await _loadProfileConfiguration(key); + if (!mounted) return; + + if (!widget.device.hasCapability()) { + _showSnackBar( + 'Profile details are unavailable for this device.', + type: AppToastType.warning, + icon: Icons.info_outline_rounded, + ); + return; + } + + final primaryProvider = context.read(); + final sensorManager = + widget.device.requireCapability(); + final pairedDevice = widget.pairedDevice; + final pairedProvider = widget.pairedProvider; + final pairedManager = pairedDevice != null && + pairedDevice.hasCapability() + ? pairedDevice.requireCapability() + : null; + + final details = profileConfig.entries.map((entry) { + final sourceConfig = _findConfigurationByName( + manager: sensorManager, + configName: entry.key, + ); + if (sourceConfig == null) { + return _ProfileDetailEntry( + configName: entry.key, + status: _ProfileDetailStatus.unavailable, + detailText: 'Configuration not available on this device.', + ); + } + + final sourceValue = _findConfigurationValueByKey( + config: sourceConfig, + valueKey: entry.value, + ); + if (sourceValue == null) { + return _ProfileDetailEntry( + configName: entry.key, + status: _ProfileDetailStatus.unavailable, + detailText: 'Saved value is not available on this firmware.', + ); + } + + if (pairedManager != null && pairedProvider != null) { + return _buildPairedProfileDetailEntry( + configName: entry.key, + primaryConfig: sourceConfig, + primaryProfileValue: sourceValue, + primaryProvider: primaryProvider, + pairedManager: pairedManager, + pairedProvider: pairedProvider, + ); + } + + return _buildSingleProfileDetailEntry( + configName: entry.key, + sensorConfig: sourceConfig, + profileValue: sourceValue, + provider: primaryProvider, + ); + }).toList() + ..sort((a, b) => a.configName.compareTo(b.configName)); + + await showPlatformModalSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: SizedBox( + height: MediaQuery.of(sheetContext).size.height * 0.82, + child: Material( + color: Theme.of(sheetContext).colorScheme.surface, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(sheetContext) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(sheetContext).pop(), + icon: const Icon(Icons.close_rounded, size: 20), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: details.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('This profile has no saved settings.'), + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 14), + itemCount: details.length, + itemBuilder: (context, index) => _ProfileDetailCard( + entry: details[index], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + _ProfileDetailEntry _buildSingleProfileDetailEntry({ + required String configName, + required SensorConfiguration sensorConfig, + required SensorConfigurationValue profileValue, + required SensorConfigurationProvider provider, + }) { + final resolved = _describeSensorConfigurationValue(profileValue); + final selectedMatches = + provider.selectedMatchesConfigurationValue(sensorConfig, profileValue); + final applied = + selectedMatches && provider.isConfigurationApplied(sensorConfig); + final status = switch ((selectedMatches, applied)) { + (true, true) => _ProfileDetailStatus.applied, + (true, false) => _ProfileDetailStatus.selected, + _ => _ProfileDetailStatus.notSelected, + }; + + return _ProfileDetailEntry( + configName: configName, + status: status, + samplingLabel: resolved.samplingLabel, + dataTargetOptions: resolved.dataTargetOptions, + detailText: status == _ProfileDetailStatus.notSelected + ? 'Current setting differs from this profile.' + : null, + ); + } + + _ProfileDetailEntry _buildPairedProfileDetailEntry({ + required String configName, + required SensorConfiguration primaryConfig, + required SensorConfigurationValue primaryProfileValue, + required SensorConfigurationProvider primaryProvider, + required SensorConfigurationManager pairedManager, + required SensorConfigurationProvider pairedProvider, + }) { + final resolved = _describeSensorConfigurationValue(primaryProfileValue); + final mirroredConfig = _findMirroredConfiguration( + manager: pairedManager, + sourceConfig: primaryConfig, + ); + if (mirroredConfig == null) { + return _ProfileDetailEntry( + configName: configName, + status: _ProfileDetailStatus.mixed, + detailText: 'Configuration is unavailable on the paired device.', + ); + } + + final mirroredProfileValue = _findMirroredValue( + mirroredConfig: mirroredConfig, + sourceValue: primaryProfileValue, + ); + if (mirroredProfileValue == null) { + return _ProfileDetailEntry( + configName: configName, + status: _ProfileDetailStatus.mixed, + detailText: 'Saved value is unavailable on the paired device.', + ); + } + + final primarySnapshot = _buildDeviceConfigSnapshot( + provider: primaryProvider, + config: primaryConfig, + expectedValue: primaryProfileValue, + ); + final secondarySnapshot = _buildDeviceConfigSnapshot( + provider: pairedProvider, + config: mirroredConfig, + expectedValue: mirroredProfileValue, + ); + + final statesMatch = primarySnapshot.state == secondarySnapshot.state; + final selectedValuesMatch = _configurationValuesMatchNullable( + primarySnapshot.selectedValue, + secondarySnapshot.selectedValue, + ); + + if (!statesMatch || !selectedValuesMatch) { + return _ProfileDetailEntry( + configName: configName, + status: _ProfileDetailStatus.mixed, + detailText: + 'Paired devices differ in selected/apply state, sampling rate, or data targets.', + ); + } + + final status = switch (primarySnapshot.state) { + _DeviceProfileConfigState.applied => _ProfileDetailStatus.applied, + _DeviceProfileConfigState.selected => _ProfileDetailStatus.selected, + _DeviceProfileConfigState.notSelected => _ProfileDetailStatus.notSelected, + _DeviceProfileConfigState.unavailable => _ProfileDetailStatus.unavailable, + }; + + return _ProfileDetailEntry( + configName: configName, + status: status, + samplingLabel: resolved.samplingLabel, + dataTargetOptions: resolved.dataTargetOptions, + detailText: status == _ProfileDetailStatus.notSelected + ? 'Current paired setting differs from this profile.' + : null, + ); + } + + _DeviceConfigSnapshot _buildDeviceConfigSnapshot({ + required SensorConfigurationProvider provider, + required SensorConfiguration config, + required SensorConfigurationValue expectedValue, + }) { + final selectedValue = provider.getSelectedConfigurationValue(config); + if (selectedValue == null) { + return const _DeviceConfigSnapshot( + state: _DeviceProfileConfigState.notSelected, + selectedValue: null, + ); + } + + if (!provider.selectedMatchesConfigurationValue(config, expectedValue)) { + return _DeviceConfigSnapshot( + state: _DeviceProfileConfigState.notSelected, + selectedValue: selectedValue, + ); + } + + if (provider.isConfigurationApplied(config)) { + return _DeviceConfigSnapshot( + state: _DeviceProfileConfigState.applied, + selectedValue: selectedValue, + ); + } + + return _DeviceConfigSnapshot( + state: _DeviceProfileConfigState.selected, + selectedValue: selectedValue, + ); + } + + _ResolvedProfileValue _describeSensorConfigurationValue( + SensorConfigurationValue value, + ) { + final baseValue = value is SensorFrequencyConfigurationValue + ? _formatFrequency(value.frequencyHz) + : value.key; + + if (value is! ConfigurableSensorConfigurationValue) { + return _ResolvedProfileValue( + samplingLabel: baseValue, + dataTargetOptions: const [], + ); + } + + final dataTargets = + value.options.where(_isDataTargetOption).toSet().toList(growable: false) + ..sort( + (a, b) => _normalizeName(a.name).compareTo(_normalizeName(b.name)), + ); + + return _ResolvedProfileValue( + samplingLabel: dataTargets.isEmpty ? 'Off' : baseValue, + dataTargetOptions: dataTargets, + ); + } + + String _formatFrequency(double hz) { + if ((hz - hz.roundToDouble()).abs() < 0.01) { + return '${hz.round()} Hz'; + } + if (hz >= 10) { + return '${hz.toStringAsFixed(1)} Hz'; + } + return '${hz.toStringAsFixed(2)} Hz'; + } + + bool _isDataTargetOption(SensorConfigurationOption option) { + return option is StreamSensorConfigOption || + option is RecordSensorConfigOption; + } + + SensorConfiguration? _findConfigurationByName({ + required SensorConfigurationManager manager, + required String configName, + }) { + for (final config in manager.sensorConfigurations) { + if (config.name == configName) { + return config; + } + } + + final normalized = _normalizeName(configName); + for (final config in manager.sensorConfigurations) { + if (_normalizeName(config.name) == normalized) { + return config; + } + } + return null; + } + + SensorConfigurationValue? _findConfigurationValueByKey({ + required SensorConfiguration config, + required String valueKey, + }) { + for (final value in config.values) { + if (value.key == valueKey) { + return value; + } + } + + final normalized = _normalizeName(valueKey); + for (final value in config.values) { + if (_normalizeName(value.key) == normalized) { + return value; + } + } + return null; + } + + SensorConfiguration? _findMirroredConfiguration({ + required SensorConfigurationManager manager, + required SensorConfiguration sourceConfig, + }) { + for (final candidate in manager.sensorConfigurations) { + if (candidate.name == sourceConfig.name) { + return candidate; + } + } + + final normalizedSource = _normalizeName(sourceConfig.name); + for (final candidate in manager.sensorConfigurations) { + if (_normalizeName(candidate.name) == normalizedSource) { + return candidate; + } + } + return null; + } + + SensorConfigurationValue? _findMirroredValue({ + required SensorConfiguration mirroredConfig, + required SensorConfigurationValue sourceValue, + }) { + for (final candidate in mirroredConfig.values) { + if (_normalizeName(candidate.key) == _normalizeName(sourceValue.key)) { + return candidate; + } + } + + if (sourceValue is SensorFrequencyConfigurationValue) { + final sourceOptions = _optionNameSet(sourceValue); + final candidates = mirroredConfig.values + .whereType() + .toList(growable: false); + if (candidates.isNotEmpty) { + final sameOptionCandidates = candidates + .where( + (candidate) => + setEquals(_optionNameSet(candidate), sourceOptions), + ) + .toList(growable: false); + final scoped = + sameOptionCandidates.isNotEmpty ? sameOptionCandidates : candidates; + SensorFrequencyConfigurationValue? best; + double? bestDistance; + for (final candidate in scoped) { + final distance = + (candidate.frequencyHz - sourceValue.frequencyHz).abs(); + if (best == null || distance < bestDistance!) { + best = candidate; + bestDistance = distance; + } + } + if (best != null) { + return best; + } + } + } + + if (sourceValue is ConfigurableSensorConfigurationValue) { + final sourceWithoutOptions = sourceValue.withoutOptions(); + final sourceOptions = _optionNameSet(sourceValue); + for (final candidate in mirroredConfig.values + .whereType()) { + if (!setEquals(_optionNameSet(candidate), sourceOptions)) { + continue; + } + if (_normalizeName(candidate.withoutOptions().key) == + _normalizeName(sourceWithoutOptions.key)) { + return candidate; + } + } + } + + return null; + } + + bool _configurationValuesMatchNullable( + SensorConfigurationValue? left, + SensorConfigurationValue? right, + ) { + if (left == null || right == null) { + return left == null && right == null; + } + return _configurationValuesMatch(left, right); + } + + bool _configurationValuesMatch( + SensorConfigurationValue left, + SensorConfigurationValue right, + ) { + if (left is SensorFrequencyConfigurationValue && + right is SensorFrequencyConfigurationValue) { + return left.frequencyHz == right.frequencyHz && + setEquals(_optionNameSet(left), _optionNameSet(right)); + } + + if (left is ConfigurableSensorConfigurationValue && + right is ConfigurableSensorConfigurationValue) { + return _normalizeName(left.withoutOptions().key) == + _normalizeName(right.withoutOptions().key) && + setEquals(_optionNameSet(left), _optionNameSet(right)); + } + + return _normalizeName(left.key) == _normalizeName(right.key); + } + + Set _optionNameSet(SensorConfigurationValue value) { + if (value is! ConfigurableSensorConfigurationValue) { + return const {}; + } + return value.options.map((option) => _normalizeName(option.name)).toSet(); + } + + String _normalizeName(String value) => value.trim().toLowerCase(); + + Future _confirmDelete(String title) async { + final bool? confirmed = await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Delete profile?'), + content: Text('Delete "$title" permanently?'), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + PlatformDialogAction( + child: const Text('Delete'), + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ), + ); + return confirmed ?? false; + } + + Future _confirmOverwrite(String title) async { + final bool? confirmed = await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: const Text('Overwrite profile?'), + content: Text( + 'Replace profile "$title" with current settings from this device?', + ), + actions: [ + PlatformDialogAction( + child: const Text('Cancel'), + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + PlatformDialogAction( + child: const Text('Overwrite'), + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ), + ); + return confirmed ?? false; + } + + void _refreshProfiles() { + _profileConfigFutures.clear(); + _updateContent(); + } + + void _showSnackBar( + String message, { + AppToastType type = AppToastType.info, + IconData? icon, + }) { + AppToast.show( + context, + message: message, + type: type, + icon: icon, + ); + } +} + +enum _ProfileApplicationState { + none, + selected, + applied, + mixed, +} + +class _ProfileApplicationBadge extends StatelessWidget { + final _ProfileApplicationState state; + + const _ProfileApplicationBadge({ + required this.state, + }); + + @override + Widget build(BuildContext context) { + const appliedGreen = Color(0xFF2E7D32); + final colorScheme = Theme.of(context).colorScheme; + final (label, foreground, background, border) = switch (state) { + _ProfileApplicationState.selected => ( + 'Selected', + colorScheme.primary, + colorScheme.primary.withValues(alpha: 0.10), + colorScheme.primary.withValues(alpha: 0.30), + ), + _ProfileApplicationState.applied => ( + 'Applied', + appliedGreen, + appliedGreen.withValues(alpha: 0.12), + appliedGreen.withValues(alpha: 0.34), + ), + _ProfileApplicationState.mixed => ( + 'Mixed', + colorScheme.error, + colorScheme.error.withValues(alpha: 0.12), + colorScheme.error.withValues(alpha: 0.34), + ), + _ProfileApplicationState.none => ( + '', + colorScheme.onSurfaceVariant, + Colors.transparent, + Colors.transparent, + ), + }; + + if (state == _ProfileApplicationState.none) { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.only(right: 2), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: border), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +enum _ProfileDetailStatus { + notSelected, + selected, + applied, + mixed, + unavailable, +} + +enum _DeviceProfileConfigState { + notSelected, + selected, + applied, + unavailable, +} + +class _DeviceConfigSnapshot { + final _DeviceProfileConfigState state; + final SensorConfigurationValue? selectedValue; + + const _DeviceConfigSnapshot({ + required this.state, + required this.selectedValue, + }); +} + +class _ProfileDetailEntry { + final String configName; + final _ProfileDetailStatus status; + final String? samplingLabel; + final List dataTargetOptions; + final String? detailText; + + const _ProfileDetailEntry({ + required this.configName, + required this.status, + this.samplingLabel, + this.dataTargetOptions = const [], + this.detailText, + }); +} + +class _ResolvedProfileValue { + final String samplingLabel; + final List dataTargetOptions; + + const _ResolvedProfileValue({ + required this.samplingLabel, + required this.dataTargetOptions, + }); +} + +class _ProfileDetailCard extends StatelessWidget { + final _ProfileDetailEntry entry; + + const _ProfileDetailCard({ + required this.entry, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final neutralAccent = colorScheme.onSurfaceVariant; + final indicatorColor = colorScheme.outlineVariant.withValues(alpha: 0.72); + final icon = switch (entry.status) { + _ProfileDetailStatus.mixed => Icons.sync_problem_rounded, + _ProfileDetailStatus.unavailable => Icons.warning_amber_outlined, + _ => Icons.sensors_rounded, + }; + final showMixedBubble = entry.status == _ProfileDetailStatus.mixed; + final showValueBubbles = + !showMixedBubble && entry.status != _ProfileDetailStatus.unavailable; + final showHelperText = entry.detailText != null && + (entry.status == _ProfileDetailStatus.notSelected || + entry.status == _ProfileDetailStatus.mixed || + entry.status == _ProfileDetailStatus.unavailable); + final titleColor = colorScheme.onSurface; + + return Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 8), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 2, + height: 30, + decoration: BoxDecoration( + color: indicatorColor, + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(width: 6), + Icon( + icon, + size: 15, + color: neutralAccent, + ), + const SizedBox(width: 7), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + entry.configName, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: titleColor, + fontWeight: FontWeight.w700, + ), + ), + ), + if (showMixedBubble) ...[ + const SizedBox(width: 8), + const _ProfileMixedStateBubble(), + ] else if (showValueBubbles && + entry.dataTargetOptions.isNotEmpty) ...[ + const SizedBox(width: 6), + _ProfileOptionsCompactBadge( + options: entry.dataTargetOptions, + accentColor: neutralAccent, + ), + ], + if (showValueBubbles && entry.samplingLabel != null) ...[ + const SizedBox(width: 8), + _ProfileSamplingRatePill( + label: entry.samplingLabel!, + foreground: neutralAccent, + ), + ], + ], + ), + if (showHelperText) ...[ + const SizedBox(height: 2), + Text( + entry.detailText!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ProfileOptionsCompactBadge extends StatelessWidget { + final List options; + final Color accentColor; + + const _ProfileOptionsCompactBadge({ + required this.options, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final visibleCount = options.length > 2 ? 2 : options.length; + final remainingCount = options.length - visibleCount; + + return SizedBox( + height: 22, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: accentColor.withValues(alpha: 0.38), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < visibleCount; i++) ...[ + Icon( + getSensorConfigurationOptionIcon(options[i]) ?? + Icons.tune_rounded, + size: 10, + color: accentColor, + ), + if (i < visibleCount - 1) const SizedBox(width: 3), + ], + if (remainingCount > 0) ...[ + const SizedBox(width: 4), + Text( + '+$remainingCount', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _ProfileSamplingRatePill extends StatelessWidget { + final String label; + final Color foreground; + + const _ProfileSamplingRatePill({ + required this.label, + required this.foreground, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + height: 22, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: foreground.withValues(alpha: 0.42), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 38), + child: Text( + label, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} + +class _ProfileMixedStateBubble extends StatelessWidget { + const _ProfileMixedStateBubble(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return SizedBox( + height: 22, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.82), + ), + ), + child: Text( + 'Mixed', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} + +class _CombinedStereoBadge extends StatelessWidget { + const _CombinedStereoBadge(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foregroundColor = colorScheme.primary; + final backgroundColor = foregroundColor.withValues(alpha: 0.12); + final borderColor = foregroundColor.withValues(alpha: 0.24); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: borderColor), + ), + child: Text( + 'L+R', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ), + ); + } +} + +class _InsetSectionDivider extends StatelessWidget { + const _InsetSectionDivider(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Divider( + height: 1, + thickness: 0.6, + color: Theme.of(context).colorScheme.outlineVariant.withValues( + alpha: 0.55, + ), ), ); } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart index ae71111e..1ed81ef6 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_value_row.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; @@ -7,101 +8,274 @@ import 'package:provider/provider.dart'; import 'sensor_config_option_icon_factory.dart'; +const double _kSensorStatusPillHeight = 22; + /// A row that displays a sensor configuration and allows the user to select a value. /// /// The selected value is added to the [SensorConfigurationProvider]. class SensorConfigurationValueRow extends StatelessWidget { final SensorConfiguration sensorConfiguration; + final SensorConfiguration? pairedSensorConfiguration; + final SensorConfigurationProvider? pairedProvider; const SensorConfigurationValueRow({ super.key, required this.sensorConfiguration, + this.pairedSensorConfiguration, + this.pairedProvider, }); @override Widget build(BuildContext context) { - final sensorConfigNotifier = - Provider.of(context); - - return PlatformListTile( - onTap: () { - showPlatformModalSheet( - context: context, - builder: (modalContext) { - return ChangeNotifierProvider.value( - value: sensorConfigNotifier, - child: PlatformScaffold( - appBar: PlatformAppBar( - title: PlatformText(sensorConfiguration.name), - leading: IconButton( - icon: Icon(Icons.close), - onPressed: () => Navigator.of(modalContext).pop(), + final primaryProvider = context.watch(); + final secondaryProvider = pairedProvider; + if (secondaryProvider == null) { + return _buildRow( + context, + primaryProvider: primaryProvider, + ); + } + + return ListenableBuilder( + listenable: secondaryProvider, + builder: (context, _) => _buildRow( + context, + primaryProvider: primaryProvider, + secondaryProvider: secondaryProvider, + ), + ); + } + + Widget _buildRow( + BuildContext context, { + required SensorConfigurationProvider primaryProvider, + SensorConfigurationProvider? secondaryProvider, + }) { + const sensorOnGreen = Color(0xFF2E7D32); + final colorScheme = Theme.of(context).colorScheme; + final selectedValue = + primaryProvider.getSelectedConfigurationValue(sensorConfiguration); + final selectedOptions = + sensorConfiguration is ConfigurableSensorConfiguration + ? primaryProvider + .getSelectedConfigurationOptions( + sensorConfiguration, + ) + .toList(growable: false) + : const []; + + bool isApplied = primaryProvider.isConfigurationApplied( + sensorConfiguration, + ); + bool isMixed = false; + if (secondaryProvider != null) { + final mirroredConfig = pairedSensorConfiguration; + if (mirroredConfig == null) { + isMixed = true; + } else { + final mirroredValue = + secondaryProvider.getSelectedConfigurationValue(mirroredConfig); + final mirroredApplied = + secondaryProvider.isConfigurationApplied(mirroredConfig); + final valuesMatch = + _configurationValuesMatchNullable(selectedValue, mirroredValue); + final applyStateMatches = isApplied == mirroredApplied; + if (!valuesMatch || !applyStateMatches) { + isMixed = true; + } else { + isApplied = isApplied && mirroredApplied; + } + } + } + + final isOn = isMixed ? true : _isOn(primaryProvider, sensorConfiguration); + final accentColor = isMixed + ? colorScheme.error + : (isApplied ? sensorOnGreen : colorScheme.primary); + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 2, 12, 2), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => _openConfigurationSheet(context, primaryProvider), + child: Padding( + padding: const EdgeInsets.fromLTRB(2, 8, 2, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isOn ? 3 : 2, + height: 26, + decoration: BoxDecoration( + color: (isOn + ? accentColor + : (isApplied + ? colorScheme.outlineVariant + : colorScheme.primary)) + .withValues(alpha: isOn ? 0.7 : 0.6), + borderRadius: BorderRadius.circular(999), ), ), - body: SensorConfigurationDetailView( - sensorConfiguration: sensorConfiguration, + const SizedBox(width: 6), + Icon( + isMixed + ? Icons.sync_problem_rounded + : (isOn + ? Icons.sensors_rounded + : Icons.sensors_off_rounded), + size: 14, + color: isOn + ? accentColor + : (isApplied ? colorScheme.outline : colorScheme.primary), ), - ), - ); - }, - ); - }, - title: PlatformText(sensorConfiguration.name), - trailing: _isOn(sensorConfigNotifier, sensorConfiguration) - ? () { - if (sensorConfigNotifier - .getSelectedConfigurationValue(sensorConfiguration) == - null) { - return PlatformText( - "Internal Error", - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, + const SizedBox(width: 7), + Expanded( + child: Text( + sensorConfiguration.name, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: (isOn || isMixed) ? accentColor : null, + fontWeight: FontWeight.w700, + ), + ), + ), + if (isMixed) ...[ + const SizedBox(width: 6), + const _MixedStatePill(), + ] else if (selectedOptions.isNotEmpty) ...[ + const SizedBox(width: 6), + _OptionsCompactBadge( + options: selectedOptions, + accentColor: accentColor, + ), + ], + if (!isMixed) ...[ + const SizedBox(width: 6), + _SamplingRatePill( + label: _statusPillLabel(selectedValue, isOn: isOn), + foreground: isOn + ? accentColor + : (isApplied + ? colorScheme.onSurfaceVariant + : colorScheme.primary), ), - ); - } - SensorConfigurationValue value = sensorConfigNotifier - .getSelectedConfigurationValue(sensorConfiguration)!; - if (value is SensorFrequencyConfigurationValue) { - SensorFrequencyConfigurationValue freqValue = value; - - return Row( - mainAxisSize: MainAxisSize.min, + ], + const SizedBox(width: 2), + Icon( + Icons.chevron_right_rounded, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ); + } + + void _openConfigurationSheet( + BuildContext context, + SensorConfigurationProvider sensorConfigNotifier, + ) { + showPlatformModalSheet( + context: context, + builder: (modalContext) { + return ChangeNotifierProvider.value( + value: sensorConfigNotifier, + child: SafeArea( + child: SizedBox( + height: MediaQuery.of(modalContext).size.height * 0.82, + child: Material( + color: Theme.of(modalContext).colorScheme.surface, + child: Column( children: [ - if (sensorConfiguration is ConfigurableSensorConfiguration) - ...(sensorConfigNotifier.getSelectedConfigurationOptions( - sensorConfiguration, - )).map( - (option) { - return Icon( - getSensorConfigurationOptionIcon(option), - color: Theme.of(context).colorScheme.secondary, - ); - }, + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sensorConfiguration.name, + style: Theme.of(modalContext) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Adjust data targets and sampling rate.', + style: Theme.of(modalContext) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(modalContext) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(modalContext).pop(), + icon: const Icon(Icons.close_rounded, size: 20), + ), + ], ), - PlatformText( - "${freqValue.frequencyHz} Hz", - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, + ), + Expanded( + child: SensorConfigurationDetailView( + sensorConfiguration: sensorConfiguration, + pairedSensorConfiguration: pairedSensorConfiguration, + pairedProvider: pairedProvider, ), ), ], - ); - } - - return PlatformText( - value.toString(), - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, ), - ); - }() - : PlatformText( - "Off", - style: TextStyle(color: Theme.of(context).colorScheme.secondary), + ), ), + ), + ); + }, ); } + String _statusPillLabel( + SensorConfigurationValue? value, { + required bool isOn, + }) { + if (!isOn) { + return 'Off'; + } + if (value is SensorFrequencyConfigurationValue) { + return _formatFrequency(value.frequencyHz); + } + return 'On'; + } + + String _formatFrequency(double hz) { + if ((hz - hz.roundToDouble()).abs() < 0.01) { + return '${hz.round()} Hz'; + } + if (hz >= 10) { + return '${hz.toStringAsFixed(1)} Hz'; + } + return '${hz.toStringAsFixed(2)} Hz'; + } + bool _isOn(SensorConfigurationProvider notifier, SensorConfiguration config) { bool isOn = false; if (config is ConfigurableSensorConfiguration) { @@ -117,4 +291,169 @@ class SensorConfigurationValueRow extends StatelessWidget { return isOn; } + + bool _configurationValuesMatchNullable( + SensorConfigurationValue? left, + SensorConfigurationValue? right, + ) { + if (left == null || right == null) { + return left == null && right == null; + } + return _configurationValuesMatch(left, right); + } + + bool _configurationValuesMatch( + SensorConfigurationValue left, + SensorConfigurationValue right, + ) { + if (left is SensorFrequencyConfigurationValue && + right is SensorFrequencyConfigurationValue) { + return left.frequencyHz == right.frequencyHz && + setEquals(_optionNameSet(left), _optionNameSet(right)); + } + + if (left is ConfigurableSensorConfigurationValue && + right is ConfigurableSensorConfigurationValue) { + return _normalizeName(left.withoutOptions().key) == + _normalizeName(right.withoutOptions().key) && + setEquals(_optionNameSet(left), _optionNameSet(right)); + } + + return _normalizeName(left.key) == _normalizeName(right.key); + } + + Set _optionNameSet(SensorConfigurationValue value) { + if (value is! ConfigurableSensorConfigurationValue) { + return const {}; + } + return value.options.map((option) => _normalizeName(option.name)).toSet(); + } + + String _normalizeName(String value) => value.trim().toLowerCase(); +} + +class _OptionsCompactBadge extends StatelessWidget { + final List options; + final Color accentColor; + + const _OptionsCompactBadge({ + required this.options, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final visibleCount = options.length > 2 ? 2 : options.length; + final remainingCount = options.length - visibleCount; + + return SizedBox( + height: _kSensorStatusPillHeight, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: accentColor.withValues(alpha: 0.38), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < visibleCount; i++) ...[ + Icon( + getSensorConfigurationOptionIcon(options[i]) ?? + Icons.tune_rounded, + size: 10, + color: accentColor, + ), + if (i < visibleCount - 1) const SizedBox(width: 3), + ], + if (remainingCount > 0) ...[ + const SizedBox(width: 4), + Text( + '+$remainingCount', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _SamplingRatePill extends StatelessWidget { + final String label; + final Color foreground; + + const _SamplingRatePill({ + required this.label, + required this.foreground, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + height: _kSensorStatusPillHeight, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: foreground.withValues(alpha: 0.42), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 38), + child: Text( + label, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} + +class _MixedStatePill extends StatelessWidget { + const _MixedStatePill(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return SizedBox( + height: _kSensorStatusPillHeight, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: colorScheme.error.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.error.withValues(alpha: 0.38), + ), + ), + child: Text( + 'Mixed', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.error, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index 2dedc189..cc9f6411 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -2,15 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +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/app_toast.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_device_row.dart'; import 'package:provider/provider.dart'; import '../../../models/logger.dart'; /// A view that displays the sensor configurations of all connected wearables. -/// +/// /// The specific sensor configurations should be made available via the [SensorConfigurationProvider]. class SensorConfigurationView extends StatelessWidget { final VoidCallback? onSetConfigPressed; @@ -26,140 +29,527 @@ class SensorConfigurationView extends StatelessWidget { ); } - Widget _buildSmallScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildSmallScreenLayout( + BuildContext context, + WearablesProvider wearablesProvider, + ) { if (wearablesProvider.wearables.isEmpty) { return Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), + child: PlatformText( + "No devices connected", + style: Theme.of(context).textTheme.titleLarge, + ), ); } - return Padding( - padding: EdgeInsets.all(10), - child: wearablesProvider.wearables.isEmpty - ? Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), - ) - : ListView( - children: [ - ...wearablesProvider.wearables.map((wearable) { - if (wearable.hasCapability()) { - return ChangeNotifierProvider.value( - value: wearablesProvider.getSensorConfigurationProvider(wearable), - child: SensorConfigurationDeviceRow(device: wearable), - ); - } else { - return SensorConfigurationDeviceRow(device: wearable); - } - }), - _buildThroughputWarningBanner(context), - _buildSetConfigButton( - configProviders: wearablesProvider.wearables - // ignore: prefer_iterable_wheretype - .where((wearable) => wearable.hasCapability()) - .map( - (wearable) => wearablesProvider.getSensorConfigurationProvider(wearable), - ).toList(), - ), - ], + return FutureBuilder>( + future: buildWearableDisplayGroups( + wearablesProvider.wearables, + shouldCombinePair: (left, right) => + wearablesProvider.isStereoPairCombined( + first: left, + second: right, ), + ), + builder: (context, snapshot) { + final groups = orderWearableGroupsByNameAndSide( + snapshot.data ?? + wearablesProvider.wearables + .map( + (wearable) => + WearableDisplayGroup.single(wearable: wearable), + ) + .toList(), + ); + final applyTargets = _buildApplyTargets( + groups: groups, + wearablesProvider: wearablesProvider, + ); + final sections = [ + ...groups.map( + (group) => _buildGroupConfigurationRow( + group: group, + wearablesProvider: wearablesProvider, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: _buildApplyConfigButton( + context, + targets: applyTargets, + ), + ), + _buildThroughputWarningBanner(context), + ]; + + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: sections, + ); + }, ); } - Widget _buildSetConfigButton({required List configProviders}) { - return PlatformElevatedButton( - onPressed: () { - for (SensorConfigurationProvider notifier in configProviders) { - logger.d("Setting sensor configurations for notifier: $notifier"); - notifier.getSelectedConfigurations().forEach((entry) { - SensorConfiguration config = entry.$1; - SensorConfigurationValue value = entry.$2; - config.setConfiguration(value); - }); + Widget _buildGroupConfigurationRow({ + required WearableDisplayGroup group, + required WearablesProvider wearablesProvider, + }) { + final primary = _resolvePrimaryForConfiguration(group); + final secondary = _resolveMirroredDevice(group, primary); + final pairedProvider = secondary != null && + secondary.hasCapability() + ? _tryGetSensorConfigurationProvider( + wearablesProvider: wearablesProvider, + wearable: secondary, + ) + : null; + final rowKey = ValueKey( + _configurationRowIdentity( + group: group, + primary: primary, + secondary: secondary, + ), + ); + final supportsConfig = primary.hasCapability(); + + if (!supportsConfig) { + return SensorConfigurationDeviceRow( + key: rowKey, + device: primary, + pairedDevice: secondary, + pairedProvider: pairedProvider, + displayName: group.displayName, + ); + } + + final primaryProvider = _tryGetSensorConfigurationProvider( + wearablesProvider: wearablesProvider, + wearable: primary, + ); + if (primaryProvider == null) { + return const SizedBox.shrink(); + } + + return ChangeNotifierProvider.value( + value: primaryProvider, + child: SensorConfigurationDeviceRow( + key: rowKey, + device: primary, + pairedDevice: secondary, + pairedProvider: pairedProvider, + displayName: group.displayName, + ), + ); + } + + String _configurationRowIdentity({ + required WearableDisplayGroup group, + required Wearable primary, + required Wearable? secondary, + }) { + if (!group.isCombined) { + return 'single:${primary.deviceId}'; + } + + final pairKey = group.stereoPairKey ?? + WearableDisplayGroup.stereoPairKeyForIds( + primary.deviceId, + secondary?.deviceId ?? '', + ); + return 'pair:$pairKey:primary:${primary.deviceId}:secondary:${secondary?.deviceId ?? 'none'}'; + } + + List<_ConfigApplyTarget> _buildApplyTargets({ + required List groups, + required WearablesProvider wearablesProvider, + }) { + final targets = <_ConfigApplyTarget>[]; + for (final group in groups) { + final primary = _resolvePrimaryForConfiguration(group); + if (!primary.hasCapability()) { + continue; + } + + final partner = _resolveMirroredDevice(group, primary); + final mirrorTarget = + partner != null && partner.hasCapability() + ? partner + : null; + final mirrorProvider = mirrorTarget == null + ? null + : _tryGetSensorConfigurationProvider( + wearablesProvider: wearablesProvider, + wearable: mirrorTarget, + ); + + final primaryProvider = _tryGetSensorConfigurationProvider( + wearablesProvider: wearablesProvider, + wearable: primary, + ); + if (primaryProvider == null) { + continue; + } + + targets.add( + _ConfigApplyTarget( + primaryDevice: primary, + mirroredDevice: mirrorTarget, + mirroredProvider: mirrorProvider, + provider: primaryProvider, + ), + ); + } + return targets; + } + + SensorConfigurationProvider? _tryGetSensorConfigurationProvider({ + required WearablesProvider wearablesProvider, + required Wearable wearable, + }) { + try { + return wearablesProvider.getSensorConfigurationProvider(wearable); + } catch (_) { + return null; + } + } + + Wearable _resolvePrimaryForConfiguration(WearableDisplayGroup group) { + if (group.isCombined) { + for (final member in group.members) { + if (member.hasCapability()) { + return member; } - (onSetConfigPressed ?? () {})(); - }, - child: PlatformText('Set Sensor Configurations'), + } + } + return group.representative; + } + + Wearable? _resolveMirroredDevice( + WearableDisplayGroup group, + Wearable primary, + ) { + if (!group.isCombined) { + return null; + } + for (final member in group.members) { + if (member.deviceId != primary.deviceId) { + return member; + } + } + return null; + } + + Widget _buildApplyConfigButton( + BuildContext context, { + required List<_ConfigApplyTarget> targets, + }) { + return PlatformElevatedButton( + onPressed: () => _applyConfigurations(context, targets: targets), + child: PlatformText('Apply Profiles'), ); } + Future _applyConfigurations( + BuildContext context, { + required List<_ConfigApplyTarget> targets, + }) async { + if (targets.isEmpty) { + await showPlatformDialog( + context: context, + builder: (dialogContext) => PlatformAlertDialog( + title: PlatformText('No configurable devices'), + content: PlatformText( + 'Connect a wearable with configurable sensors to apply settings.', + ), + actions: [ + PlatformDialogAction( + child: PlatformText('OK'), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + ], + ), + ); + return; + } + + int actionableCount = 0; + + for (final target in targets) { + final primaryEntriesToApply = _entriesToApplyForProvider(target.provider); + final mirroredEntriesToApply = _entriesToApplyForMirroredTarget(target); + if (primaryEntriesToApply.isEmpty && mirroredEntriesToApply.isEmpty) { + continue; + } + + actionableCount += + primaryEntriesToApply.length + mirroredEntriesToApply.length; + for (final entry in primaryEntriesToApply) { + final SensorConfiguration config = entry.$1; + final SensorConfigurationValue value = entry.$2; + // Always push the selected canonical value to the primary device on + // apply. This also heals primary-side drift/unknown states. + config.setConfiguration(value); + } + + for (final entry in mirroredEntriesToApply) { + final SensorConfiguration config = entry.$1; + final SensorConfigurationValue value = entry.$2; + config.setConfiguration(value); + } + + logger.d( + "Applied ${primaryEntriesToApply.length} primary and ${mirroredEntriesToApply.length} mirrored sensor settings for ${target.primaryDevice.name}", + ); + } + + if (actionableCount == 0) { + AppToast.show( + context, + message: 'No pending sensor settings to apply.', + type: AppToastType.info, + icon: Icons.info_outline_rounded, + ); + return; + } + + AppToast.show( + context, + message: 'Sensor settings applied.', + type: AppToastType.success, + icon: Icons.check_circle_outline_rounded, + ); + + (onSetConfigPressed ?? () {})(); + } + Widget _buildThroughputWarningBanner(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( - color: Theme.of(context).colorScheme.surfaceContainer, + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), child: Padding( - padding: const EdgeInsets.all(16.0), - child: RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodyLarge - ?? TextStyle(color: Colors.black, fontSize: 16), - children: [ - const TextSpan( - text: "Info: ", - style: TextStyle(fontWeight: FontWeight.bold), - ), - const TextSpan( - text: "Using too many sensors or setting high sampling rates can exceed the system’s " - "available bandwidth, causing data drops. Limit the number of active sensors and their " - "sampling rates, and record high-rate data directly to the SD card.", - ), - ], - ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.insights_outlined, + size: 20, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Sampling & bandwidth guidance', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'High sensor counts and aggressive sampling rates can exceed bandwidth and cause dropped samples.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 10), + _buildGuidanceItem( + context, + 'Enable only the sensors needed for this session.', + ), + _buildGuidanceItem( + context, + 'Lower sampling rates for non-critical signals.', + ), + _buildGuidanceItem( + context, + 'For high-rate recordings, recording to the on-board memory of the device is preferred (if available).', + ), + ], ), ), ); } + Widget _buildGuidanceItem(BuildContext context, String text) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.check_circle_outline, + size: 16, + color: colorScheme.primary.withValues(alpha: 0.9), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + } + + List<(SensorConfiguration, SensorConfigurationValue)> + _entriesToApplyForMirroredTarget( + _ConfigApplyTarget target, + ) { + final mirroredDevice = target.mirroredDevice; + final mirroredProvider = target.mirroredProvider; + if (mirroredDevice == null || mirroredProvider == null) { + return const <(SensorConfiguration, SensorConfigurationValue)>[]; + } + if (!mirroredDevice.hasCapability()) { + return const <(SensorConfiguration, SensorConfigurationValue)>[]; + } + return _entriesToApplyForProvider(mirroredProvider); + } + + List<(SensorConfiguration, SensorConfigurationValue)> + _entriesToApplyForProvider( + SensorConfigurationProvider provider, + ) { + return _mergeConfigurationEntries( + provider.getSelectedConfigurations(pendingOnly: true), + provider.getConfigurationsMissingFromLastReport(), + ); + } + + List<(SensorConfiguration, SensorConfigurationValue)> + _mergeConfigurationEntries( + List<(SensorConfiguration, SensorConfigurationValue)> first, + List<(SensorConfiguration, SensorConfigurationValue)> second, + ) { + final merged = <(SensorConfiguration, SensorConfigurationValue)>[]; + final seen = {}; + + for (final entry in first) { + if (seen.add(_configurationIdentityKey(entry.$1))) { + merged.add(entry); + } + } + for (final entry in second) { + if (seen.add(_configurationIdentityKey(entry.$1))) { + merged.add(entry); + } + } + return merged; + } + + String _configurationIdentityKey(SensorConfiguration configuration) { + final dynamic configDynamic = configuration; + try { + final sensorId = configDynamic.sensorId; + if (sensorId is int) { + return 'sensor:$sensorId'; + } + } catch (_) { + // Fall through to structural key. + } + + final valuesKey = configuration.values + .map((value) => value.key) + .toList(growable: false) + ..sort(); + return '${configuration.runtimeType}:${configuration.name}:${valuesKey.join('|')}'; + } + // ignore: unused_element - Widget _buildLargeScreenLayout(BuildContext context, WearablesProvider wearablesProvider) { + Widget _buildLargeScreenLayout( + BuildContext context, + WearablesProvider wearablesProvider, + ) { final List devices = wearablesProvider.wearables; - List tiles = _generateTiles(devices, wearablesProvider.sensorConfigurationProviders); + List tiles = + _generateTiles(devices, wearablesProvider.sensorConfigurationProviders); if (tiles.isNotEmpty) { - tiles.addAll([ - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 230.0, - child: _buildThroughputWarningBanner(context), - ), - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 100.0, - child: _buildSetConfigButton( - configProviders: devices.map((device) => wearablesProvider.getSensorConfigurationProvider(device)).toList(), + tiles.addAll( + [ + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 100.0, + child: _buildApplyConfigButton( + context, + targets: devices + .where( + (device) => + device.hasCapability(), + ) + .map( + (device) => _ConfigApplyTarget( + primaryDevice: device, + mirroredDevice: null, + mirroredProvider: null, + provider: wearablesProvider + .getSensorConfigurationProvider(device), + ), + ) + .toList(), + ), + ), + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 230.0, + child: _buildThroughputWarningBanner(context), ), - ),], + ], ); } return StaggeredGrid.count( - crossAxisCount: (MediaQuery.of(context).size.width / 250).floor().clamp(1, 4), // Adaptive grid - mainAxisSpacing: 10, - crossAxisSpacing: 10, - children: tiles.isNotEmpty ? tiles : [ - StaggeredGridTile.extent( - crossAxisCellCount: 1, - mainAxisExtent: 100.0, - child: Card( - shape: RoundedRectangleBorder( - side: BorderSide( - color: Colors.grey, - width: 1, - style: BorderStyle.solid, - strokeAlign: -1, + crossAxisCount: (MediaQuery.of(context).size.width / 250) + .floor() + .clamp(1, 4), // Adaptive grid + mainAxisSpacing: SensorPageSpacing.gridGap, + crossAxisSpacing: SensorPageSpacing.gridGap, + children: tiles.isNotEmpty + ? tiles + : [ + StaggeredGridTile.extent( + crossAxisCellCount: 1, + mainAxisExtent: 100.0, + child: Card( + shape: RoundedRectangleBorder( + side: BorderSide( + color: Colors.grey, + width: 1, + style: BorderStyle.solid, + strokeAlign: -1, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: PlatformText( + "No devices connected", + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), ), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: PlatformText("No devices connected", style: Theme.of(context).textTheme.titleLarge), - ), - ), - ), - ], + ], ); } /// Generates a dynamic quilted grid layout based on the device properties - List _generateTiles(List devices, Map notifiers) { + List _generateTiles( + List devices, + Map notifiers, + ) { // Sort devices by size dynamically for a balanced layout - devices.sort((a, b) => _getGridSpanForDevice(b) - _getGridSpanForDevice(a)); + devices.sort( + (a, b) => _getGridSpanForDevice(b) - _getGridSpanForDevice(a), + ); return devices.map((device) { int span = _getGridSpanForDevice(device); @@ -181,8 +571,25 @@ class SensorConfigurationView extends StatelessWidget { return 1; // Default size } - int sensorConfigCount = device.requireCapability().sensorConfigurations.length; + int sensorConfigCount = device + .requireCapability() + .sensorConfigurations + .length; return sensorConfigCount.clamp(1, 4); } } + +class _ConfigApplyTarget { + final Wearable primaryDevice; + final Wearable? mirroredDevice; + final SensorConfigurationProvider? mirroredProvider; + final SensorConfigurationProvider provider; + + const _ConfigApplyTarget({ + required this.primaryDevice, + required this.mirroredDevice, + required this.mirroredProvider, + required this.provider, + }); +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index 8af6147c..99b7a4a2 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -13,9 +13,16 @@ import 'package:share_plus/share_plus.dart'; import 'package:flutter_archive/flutter_archive.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.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'; Logger _logger = Logger(); +enum _StopRecordingMode { + stopOnly, + stopAndTurnOffSensors, +} + class LocalRecorderView extends StatefulWidget { const LocalRecorderView({super.key}); @@ -25,6 +32,7 @@ class LocalRecorderView extends StatefulWidget { class _LocalRecorderViewState extends State { static const MethodChannel platform = MethodChannel('edu.teco.open_folder'); + final ScrollController _recordingsScrollController = ScrollController(); List _recordings = []; final Set _expandedFolders = {}; // Track which folders are expanded Timer? _recordingTimer; @@ -34,6 +42,23 @@ class _LocalRecorderViewState extends State { DateTime? _activeRecordingStart; SensorRecorderProvider? _recorder; + bool get _isIOS => !kIsWeb && Platform.isIOS; + bool get _isAndroid => !kIsWeb && Platform.isAndroid; + + String _basename(String path) => path.split(RegExp(r'[\\/]+')).last; + + void _scrollRecordingsFromHeaderDrag(DragUpdateDetails details) { + if (!_recordingsScrollController.hasClients) return; + final position = _recordingsScrollController.position; + final nextOffset = (position.pixels - details.delta.dy).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if (nextOffset != position.pixels) { + _recordingsScrollController.jumpTo(nextOffset); + } + } + @override void initState() { super.initState(); @@ -59,33 +84,56 @@ class _LocalRecorderViewState extends State { } Future _openFolder(String path) async { + if (kIsWeb) { + return; + } + try { - if (Platform.isIOS) { + if (_isIOS) { await platform .invokeMethod('openFolder', {'path': "shareddocuments://$path"}); - } else if (Platform.isAndroid) { + } else if (_isAndroid) { await platform.invokeMethod('openFolder', {'path': path}); } } on PlatformException catch (e) { - print("Failed to open folder: '${e.message}'."); - // Optional: Show error dialog here too if needed + _logger.w("Failed to open folder: '${e.message}'."); + await _showErrorDialog('Failed to open recording folder.'); } } Future _listRecordings() async { + if (kIsWeb) { + if (!mounted) return; + setState(() { + _recordings = []; + }); + return; + } + Directory recordingsDir; - if (Platform.isAndroid) { + if (_isAndroid) { Directory? dir = await getExternalStorageDirectory(); - if (dir == null) return; + if (dir == null) { + if (!mounted) return; + setState(() { + _recordings = []; + }); + return; + } recordingsDir = dir; - } else if (Platform.isIOS) { + } else if (_isIOS) { recordingsDir = await getIOSDirectory(); } else { + if (!mounted) return; + setState(() { + _recordings = []; + }); return; } if (!await recordingsDir.exists()) { + if (!mounted) return; setState(() { _recordings = []; }); @@ -95,7 +143,7 @@ class _LocalRecorderViewState extends State { List entities = recordingsDir.listSync(); // Filter only directories that start with "OpenWearable_Recording" - _recordings = entities + final recordings = entities .where( (entity) => entity is Directory && @@ -104,19 +152,20 @@ class _LocalRecorderViewState extends State { .toList(); // Sort by modification time (newest first) - _recordings.sort((a, b) { + recordings.sort((a, b) { return b.statSync().changed.compareTo(a.statSync().changed); }); - setState(() {}); + if (!mounted) return; + setState(() { + _recordings = recordings; + }); } List _getFilesInFolder(Directory folder) { try { return folder.listSync(recursive: false).whereType().toList() - ..sort( - (a, b) => a.path.split('/').last.compareTo(b.path.split('/').last), - ); + ..sort((a, b) => _basename(a.path).compareTo(_basename(b.path))); } catch (e) { _logger.e('Error listing files in folder: $e'); return []; @@ -125,7 +174,7 @@ class _LocalRecorderViewState extends State { Future _confirmAndDeleteRecording(FileSystemEntity entity) async { if (!mounted) return; - final name = entity.path.split('/').last; + final name = _basename(entity.path); final shouldDelete = await showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( @@ -169,7 +218,7 @@ class _LocalRecorderViewState extends State { Future _handleStopRecording( SensorRecorderProvider recorder, { - required bool turnOffSensors, + required _StopRecordingMode mode, }) async { if (_isHandlingStopAction) return; setState(() { @@ -178,7 +227,7 @@ class _LocalRecorderViewState extends State { try { recorder.stopRecording(); - if (turnOffSensors) { + if (mode == _StopRecordingMode.stopAndTurnOffSensors) { final wearablesProvider = context.read(); final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); @@ -235,6 +284,7 @@ class _LocalRecorderViewState extends State { void dispose() { _recordingTimer?.cancel(); _recorder?.removeListener(_handleRecorderUpdate); + _recordingsScrollController.dispose(); super.dispose(); } @@ -300,7 +350,7 @@ class _LocalRecorderViewState extends State { _logger.i('Creating zip file for ${folder.path}...'); final tempDir = await getTemporaryDirectory(); - final zipPath = '${tempDir.path}/${folder.path.split("/").last}.zip'; + final zipPath = '${tempDir.path}/${_basename(folder.path)}.zip'; final zipFile = File(zipPath); await ZipFile.createFromDirectory( @@ -329,6 +379,424 @@ class _LocalRecorderViewState extends State { } } + String _formatDateTime(DateTime value) { + final local = value.toLocal(); + String twoDigits(int n) => n.toString().padLeft(2, '0'); + return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' + '${twoDigits(local.hour)}:${twoDigits(local.minute)}'; + } + + Future _startRecording(SensorRecorderProvider recorder) async { + final dir = await _pickDirectory(); + if (dir == null) { + await _showErrorDialog('Could not create a recording directory.'); + return; + } + + if (!await _isDirectoryEmpty(dir)) { + if (!mounted) return; + final proceed = await _askOverwriteConfirmation(context, dir); + if (!proceed) return; + } + + recorder.startRecording(dir); + await _listRecordings(); + } + + Future _openRecordingFile(File file) async { + final result = await OpenFile.open( + file.path, + type: 'text/comma-separated-values', + ); + if (result.type != ResultType.done) { + await _showErrorDialog('Could not open file: ${result.message}'); + } + } + + Widget _buildRecorderCard( + BuildContext context, { + required SensorRecorderProvider recorder, + required bool isRecording, + required bool canStartRecording, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final hasSensorsConnected = recorder.hasSensorsConnected; + final statusIcon = isRecording + ? Icons.fiber_manual_record + : hasSensorsConnected + ? Icons.sensors + : Icons.sensors_off; + final statusColor = isRecording + ? colorScheme.error + : hasSensorsConnected + ? colorScheme.primary + : colorScheme.onSurfaceVariant; + final statusTitle = isRecording + ? 'Recording in progress' + : hasSensorsConnected + ? 'Ready to record' + : 'No active sensors'; + final statusSubtitle = isRecording + ? 'Capturing live Bluetooth sensor data.' + : hasSensorsConnected + ? 'Start a session to capture live Bluetooth sensor data.' + : 'Connect a wearable and enable sensors to start recording.'; + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: isRecording + ? const Center( + child: RecordingActivityIndicator( + size: 20, + showIdleOutline: false, + padding: EdgeInsets.zero, + ), + ) + : Icon(statusIcon, color: statusColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Local Recorder', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + statusTitle, + style: theme.textTheme.titleSmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + statusSubtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (isRecording) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + _formatDuration(_elapsedRecording), + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + if (!isRecording) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: canStartRecording + ? () => _startRecording(recorder) + : null, + icon: const Icon(Icons.play_arrow), + label: const Text('Start Recording'), + ), + ), + if (!isRecording && !recorder.hasSensorsConnected) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'No connected sensors detected yet.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + if (isRecording) ...[ + Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + style: FilledButton.styleFrom( + foregroundColor: colorScheme.error, + backgroundColor: + colorScheme.errorContainer.withValues(alpha: 0.45), + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + recorder, + mode: _StopRecordingMode.stopAndTurnOffSensors, + ), + icon: const Icon(Icons.power_settings_new), + label: const Text('Stop + Off'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + recorder, + mode: _StopRecordingMode.stopOnly, + ), + icon: const Icon(Icons.stop), + label: const Text('Stop Recording'), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Widget _buildRecordingsHeaderCard(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final recordingCount = _recordings.length; + final subtitle = recordingCount == 1 + ? '1 recording folder' + : '$recordingCount recording folders'; + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + child: Row( + children: [ + Expanded( + child: ListTile( + contentPadding: EdgeInsets.zero, + minLeadingWidth: 26, + leading: const Icon(Icons.folder_copy_outlined), + title: const Text('Recordings'), + subtitle: Text(subtitle), + ), + ), + IconButton( + tooltip: 'Refresh recordings', + onPressed: _listRecordings, + icon: const Icon(Icons.refresh), + ), + if (_isIOS) + IconButton( + tooltip: 'Open recording folder', + onPressed: () async { + final recordDir = await getIOSDirectory(); + _openFolder(recordDir.path); + }, + icon: Icon( + Icons.folder_open, + color: colorScheme.primary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyRecordingsState(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + children: [ + Icon( + Icons.folder_open_outlined, + size: 36, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 10), + Text( + 'No recordings yet', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Start a recording session to create your first export.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + Widget _buildRecordingFileTile(BuildContext context, File file) { + final fileName = _basename(file.path); + final fileSize = _formatFileSize(file); + final isCsv = fileName.toLowerCase().endsWith('.csv'); + + return ListTile( + contentPadding: const EdgeInsets.fromLTRB(58, 2, 10, 2), + dense: true, + leading: Icon( + isCsv ? Icons.table_chart_outlined : Icons.insert_drive_file_outlined, + size: 20, + ), + title: Text( + fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + subtitle: Text(fileSize), + trailing: IconButton( + tooltip: 'Share file', + icon: const Icon(Icons.ios_share, size: 20), + onPressed: () => _shareFile(file), + ), + onTap: () => _openRecordingFile(file), + ); + } + + Widget _buildRecordingCard( + BuildContext context, + Directory folder, { + required bool isRecording, + required int index, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final folderName = _basename(folder.path); + final isCurrentRecording = isRecording && index == 0; + final isExpanded = _expandedFolders.contains(folder.path); + final files = isExpanded ? _getFilesInFolder(folder) : []; + final modified = folder.statSync().changed; + + return Card( + margin: const EdgeInsets.only(bottom: SensorPageSpacing.sectionGap), + child: Column( + children: [ + ListTile( + leading: Icon( + isExpanded ? Icons.folder_open : Icons.folder_outlined, + color: isCurrentRecording + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + title: Text( + folderName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + isCurrentRecording + ? 'Active recording' + : 'Updated ${_formatDateTime(modified)}', + ), + trailing: isCurrentRecording + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.error, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Share folder', + onPressed: () => _shareFolder(folder), + icon: Icon(Icons.ios_share, color: colorScheme.primary), + ), + IconButton( + tooltip: 'Delete folder', + onPressed: () => _confirmAndDeleteRecording(folder), + icon: Icon( + Icons.delete_outline, + color: colorScheme.error, + ), + ), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + onTap: () { + if (isCurrentRecording) return; + setState(() { + if (isExpanded) { + _expandedFolders.remove(folder.path); + } else { + _expandedFolders.add(folder.path); + } + }); + }, + ), + if (isExpanded) const Divider(height: 1), + if (isExpanded && files.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(58, 10, 10, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'No files in this folder', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + if (isExpanded) + ...files.map( + (file) => _buildRecordingFileTile( + context, + file, + ), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Consumer( @@ -341,327 +809,47 @@ class _LocalRecorderViewState extends State { child: Column( children: [ Padding( - padding: const EdgeInsets.all(10), - child: Padding( - padding: const EdgeInsets.all(16), + padding: SensorPageSpacing.pageHeaderPadding, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragUpdate: _scrollRecordingsFromHeaderDrag, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformText( - 'Local Recorder', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 6), - PlatformText( - "Only records sensor data streamed over Bluetooth.", - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: !isRecording - ? ElevatedButton.icon( - icon: const Icon(Icons.play_arrow), - style: ElevatedButton.styleFrom( - backgroundColor: canStartRecording - ? Colors.green.shade600 - : Colors.grey.shade400, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(48), - ), - label: const Text( - 'Start Recording', - style: TextStyle(fontSize: 18), - ), - onPressed: !canStartRecording - ? null - : () async { - final dir = await _pickDirectory(); - if (dir == null) return; - - // Check if directory is empty - if (!await _isDirectoryEmpty(dir)) { - if (!context.mounted) return; - final proceed = - await _askOverwriteConfirmation( - context, - dir, - ); - if (!proceed) return; - } - - recorder.startRecording(dir); - await _listRecordings(); // Refresh list - }, - ) - : Column( - children: [ - Row( - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.stop), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - minimumSize: - const Size.fromHeight(48), - ), - label: const Text( - 'Stop Recording', - style: TextStyle(fontSize: 18), - ), - onPressed: _isHandlingStopAction - ? null - : () => _handleStopRecording( - recorder, - turnOffSensors: false, - ), - ), - ), - const SizedBox(width: 8), - ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 90, - ), - child: Text( - _formatDuration(_elapsedRecording), - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - ElevatedButton.icon( - icon: const Icon(Icons.power_settings_new), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red[800], - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(48), - ), - label: const Text( - 'Stop & Turn Off Sensors', - style: TextStyle(fontSize: 18), - ), - onPressed: _isHandlingStopAction - ? null - : () => _handleStopRecording( - recorder, - turnOffSensors: true, - ), - ), - ], - ), + _buildRecorderCard( + context, + recorder: recorder, + isRecording: isRecording, + canStartRecording: canStartRecording, ), + const SizedBox(height: SensorPageSpacing.sectionGap), + _buildRecordingsHeaderCard(context), ], ), ), ), Expanded( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Recordings", - style: TextStyle( - fontSize: 20.0, - fontWeight: FontWeight.bold, - ), - ), - if (Platform.isIOS) - IconButton( - icon: Icon(Icons.folder_open), - onPressed: () async { - Directory recordDir = await getIOSDirectory(); - _openFolder(recordDir.path); - }, - ), - ], - ), - ), - Divider(thickness: 2), - Expanded( - child: _recordings.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.warning, - size: 48, - color: Colors.grey, - ), - SizedBox(height: 16), - Text( - "No recordings found", - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ) - : ListView.builder( - padding: EdgeInsets.zero, - itemCount: _recordings.length, - itemBuilder: (context, index) { - Directory folder = - _recordings[index] as Directory; - String folderName = folder.path.split("/").last; - bool isCurrentRecording = - isRecording && index == 0; - bool isExpanded = - _expandedFolders.contains(folder.path); - List files = - isExpanded ? _getFilesInFolder(folder) : []; - - return Column( - children: [ - ListTile( - leading: Icon( - isExpanded - ? Icons.folder_open - : Icons.folder, - color: Colors.grey, - ), - title: Text( - folderName, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 14), - ), - trailing: isCurrentRecording - ? Padding( - padding: EdgeInsets.all( - 16.0, - ), - child: SizedBox( - width: 16, - height: 16, - child: - CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon( - Icons.share, - color: isCurrentRecording - ? Colors.grey - .withValues( - alpha: 30, - ) - : Colors.blue, - ), - onPressed: isCurrentRecording - ? null - : () => _shareFolder( - folder, - ), - ), - IconButton( - icon: Icon( - Icons.delete, - color: isCurrentRecording - ? Colors.grey - .withValues( - alpha: 30, - ) - : Colors.red, - ), - onPressed: isCurrentRecording - ? null - : () => - _confirmAndDeleteRecording( - folder, - ), - ), - ], - ), - onTap: () { - setState(() { - if (isExpanded) { - _expandedFolders - .remove(folder.path); - } else if (!isCurrentRecording) { - _expandedFolders.add(folder.path); - } - }); - }, - ), - // Show files when expanded - if (isExpanded) - ...files.map((file) { - String fileName = - file.path.split("/").last; - String fileSize = _formatFileSize(file); - - return ListTile( - contentPadding: EdgeInsets.only( - left: 72, - right: 16, - ), - leading: Icon( - fileName.endsWith('.csv') - ? Icons.table_chart - : Icons.insert_drive_file, - size: 20, - ), - title: Text( - fileName, - style: TextStyle(fontSize: 14), - ), - subtitle: Text( - fileSize, - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - trailing: IconButton( - icon: Icon( - Icons.share, - color: Colors.blue, - size: 20, - ), - onPressed: () => _shareFile(file), - ), - onTap: () async { - final result = await OpenFile.open( - file.path, - type: - 'text/comma-separated-values', - ); - if (result.type != - ResultType.done) { - await _showErrorDialog( - 'Could not open file: ${result.message}', - ); - } - }, - ); - }), - ], - ); - }, - ), - ), - ], + child: RefreshIndicator( + onRefresh: _listRecordings, + child: ListView( + controller: _recordingsScrollController, + primary: false, + physics: const AlwaysScrollableScrollPhysics(), + padding: SensorPageSpacing.pageListPadding, + children: [ + if (_recordings.isEmpty) + _buildEmptyRecordingsState(context), + if (_recordings.isNotEmpty) + ..._recordings.asMap().entries.map((entry) { + final folder = entry.value as Directory; + return _buildRecordingCard( + context, + folder, + isRecording: isRecording, + index: entry.key, + ); + }), + ], + ), ), ), ], @@ -677,7 +865,11 @@ class _LocalRecorderViewState extends State { /* ──────────────────────────────────────────────────────────── */ Future _pickDirectory() async { - if (!Platform.isIOS && !kIsWeb) { + if (kIsWeb) { + return null; + } + + if (Platform.isAndroid) { final recordingName = 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; Directory? appDir = await getExternalStorageDirectory(); diff --git a/open_wearable/lib/widgets/sensors/sensor_page.dart b/open_wearable/lib/widgets/sensors/sensor_page.dart index 76eb1d07..cc8e9f7b 100644 --- a/open_wearable/lib/widgets/sensors/sensor_page.dart +++ b/open_wearable/lib/widgets/sensors/sensor_page.dart @@ -1,89 +1,212 @@ 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'; +import 'package:open_wearable/view_models/sensor_data_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/common/no_devices_prompt.dart'; +import 'package:open_wearable/widgets/recording_activity_indicator.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_view.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_view.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_values_page.dart'; import 'package:provider/provider.dart'; -import '../../view_models/sensor_recorder_provider.dart'; +class SensorPageController { + _SensorPageState? _state; + int? _pendingTabIndex; + void _attach(_SensorPageState state) { + _state = state; + if (_pendingTabIndex != null) { + state.openTab(_pendingTabIndex!); + _pendingTabIndex = null; + } + } + + void _detach(_SensorPageState state) { + if (_state == state) { + _state = null; + } + } + + void openTab(int tabIndex) { + final attachedState = _state; + if (attachedState == null) { + _pendingTabIndex = tabIndex; + return; + } + attachedState.openTab(tabIndex); + } +} -class SensorPage extends StatelessWidget { - const SensorPage({super.key}); +class SensorPage extends StatefulWidget { + final SensorPageController? controller; + + const SensorPage({ + super.key, + this.controller, + }); @override - Widget build(BuildContext context) { - return DefaultTabController( - length: 3, - child: PlatformScaffold( - body: NestedScrollView( - headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { - return [ - SliverAppBar( - title: PlatformText("Sensors"), - actions: [ - PlatformIconButton( - icon: Icon(context.platformIcons.bluetooth), - onPressed: () { - context.push('/connect-devices'); - }, - ), - ], - pinned: true, - floating: true, - snap: true, - forceElevated: innerBoxIsScrolled, - bottom: TabBar( - tabs: [ - const Tab(text: 'Configuration'), - const Tab(text: 'Charts'), - Tab( - child: Row( - children: [ - const _RecordingIndicator(), - PlatformText('Recorder'), - ], - ), - ), - ], - ), - ), - ]; - }, - body: TabBarView( - children: [ - Builder( - builder: (tabCtx) => SensorConfigurationView( - onSetConfigPressed: () { - DefaultTabController.of(tabCtx).animateTo(1); - }, - ), - ), + State createState() => _SensorPageState(); +} - SensorValuesPage(), +class _SensorPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + final Map<(Wearable, Sensor), SensorDataProvider> _sensorDataProviders = {}; + WearablesProvider? _wearablesProvider; - LocalRecorderView(), - ], - ), - ), - ), - ); + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + widget.controller?._attach(this); } -} -class _RecordingIndicator extends StatelessWidget { - const _RecordingIndicator(); + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final wearablesProvider = context.read(); + if (_wearablesProvider == wearablesProvider) { + return; + } + _wearablesProvider?.removeListener(_syncSensorDataProviders); + _wearablesProvider = wearablesProvider; + _wearablesProvider?.addListener(_syncSensorDataProviders); + _syncSensorDataProviders(); + } + + @override + void didUpdateWidget(covariant SensorPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller?._detach(this); + widget.controller?._attach(this); + } + } + + @override + void dispose() { + _wearablesProvider?.removeListener(_syncSensorDataProviders); + for (final provider in _sensorDataProviders.values) { + provider.dispose(); + } + _sensorDataProviders.clear(); + widget.controller?._detach(this); + _tabController.dispose(); + super.dispose(); + } + + void openTab(int tabIndex) { + final safeIndex = tabIndex.clamp(0, _tabController.length - 1).toInt(); + if (_tabController.index == safeIndex) return; + _tabController.animateTo(safeIndex); + } @override Widget build(BuildContext context) { - return Consumer( - builder: (context, recorderProvider, child) { - return Icon( - recorderProvider.isRecording ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, - color: recorderProvider.isRecording ? Colors.red : Colors.grey, + return Consumer( + builder: (context, wearablesProvider, child) { + final hasConnectedDevices = wearablesProvider.wearables.isNotEmpty; + final noDevicesPrompt = Center( + child: NoDevicesPrompt( + onScanPressed: () => context.push('/connect-devices'), + ), + ); + + return PlatformScaffold( + body: NestedScrollView( + headerSliverBuilder: + (BuildContext context, bool innerBoxIsScrolled) { + return [ + SliverAppBar( + title: PlatformText("Sensors"), + actions: [ + const AppBarRecordingIndicator(), + PlatformIconButton( + icon: Icon(context.platformIcons.bluetooth), + onPressed: () { + context.push('/connect-devices'); + }, + ), + ], + pinned: true, + floating: false, + snap: false, + forceElevated: innerBoxIsScrolled, + bottom: TabBar( + controller: _tabController, + tabs: [ + const Tab(text: 'Configure'), + const Tab(text: 'Live Data'), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const RecordingActivityIndicator(size: 14), + const SizedBox(width: 4), + PlatformText('Recorder'), + ], + ), + ), + ], + ), + ), + ]; + }, + body: TabBarView( + controller: _tabController, + children: [ + hasConnectedDevices + ? SensorConfigurationView( + onSetConfigPressed: () { + _tabController.animateTo(1); + }, + ) + : noDevicesPrompt, + hasConnectedDevices + ? SensorValuesPage( + sharedProviders: _sensorDataProviders, + ) + : noDevicesPrompt, + const LocalRecorderView(), + ], + ), + ), ); }, ); } + + void _syncSensorDataProviders() { + final wearables = _wearablesProvider?.wearables ?? const []; + for (final wearable in wearables) { + if (!wearable.hasCapability()) { + continue; + } + for (final sensor + in wearable.requireCapability().sensors) { + _sensorDataProviders.putIfAbsent( + (wearable, sensor), + () => SensorDataProvider(sensor: sensor), + ); + } + } + + _sensorDataProviders.removeWhere((key, provider) { + final keepProvider = wearables.any( + (wearable) => + wearable.hasCapability() && + wearable == key.$1 && + wearable.requireCapability().sensors.contains( + key.$2, + ), + ); + if (!keepProvider) { + provider.dispose(); + } + return !keepProvider; + }); + } } diff --git a/open_wearable/lib/widgets/sensors/sensor_page_spacing.dart b/open_wearable/lib/widgets/sensors/sensor_page_spacing.dart new file mode 100644 index 00000000..1d438cf4 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/sensor_page_spacing.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +/// Shared spacing scale for the Sensors page tabs. +class SensorPageSpacing { + static const double sectionGap = 8; + static const double gridGap = 10; + + static const EdgeInsets pagePadding = EdgeInsets.all(10); + static EdgeInsets pagePaddingWithBottomInset(BuildContext context) { + final bottomInset = MediaQuery.paddingOf(context).bottom; + return EdgeInsets.fromLTRB(10, 10, 10, 10 + bottomInset); + } + + static const EdgeInsets pageHeaderPadding = + EdgeInsets.fromLTRB(10, 10, 10, 0); + + static const EdgeInsets pageListPadding = + EdgeInsets.fromLTRB(10, sectionGap, 10, 10); +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart.dart index 6beda2b1..58d286d3 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_chart.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart.dart @@ -7,14 +7,16 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:provider/provider.dart'; -import '../../../models/logger.dart'; - class SensorChart extends StatefulWidget { final bool allowToggleAxes; + final bool liveUpdatesEnabled; + final VoidCallback? onDisabledTap; const SensorChart({ super.key, this.allowToggleAxes = true, + this.liveUpdatesEnabled = true, + this.onDisabledTap, }); @override @@ -22,17 +24,34 @@ class SensorChart extends StatefulWidget { } class _SensorChartState extends State { + static const List _fallbackColors = [ + Color(0xFF4A90E2), + Color(0xFFE76F51), + Color(0xFF2A9D8F), + Color(0xFFB565D9), + Color(0xFFF4A261), + Color(0xFF3D5A80), + Color(0xFFD62828), + ]; + late Map _axisEnabled; + late String _sensorIdentity; @override void initState() { super.initState(); final sensor = context.read().sensor; - _axisEnabled = { for (var axis in sensor.axisNames) axis: true }; + _initializeAxisState(sensor); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final sensor = context.read().sensor; + _syncAxisState(sensor); } void _toggleAxis(String axisName, bool value) { - logger.d('Toggling axis $axisName to $value'); setState(() { _axisEnabled[axisName] = value; }); @@ -40,122 +59,516 @@ class _SensorChartState extends State { @override Widget build(BuildContext context) { - Sensor sensor = context.watch().sensor; - final enabledAxes = sensor.axisNames - .where((axis) => _axisEnabled[axis] ?? false) - .toList(); + final dataProvider = widget.liveUpdatesEnabled + ? context.watch() + : context.read(); + final sensor = dataProvider.sensor; + _syncAxisState(sensor); + final sensorValues = widget.liveUpdatesEnabled + ? dataProvider.sensorValues + : Queue(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final compactMode = !widget.allowToggleAxes; + final referenceTimestamp = dataProvider.displayTimestamp; + final axisData = _buildAxisData( sensor, - context.watch().sensorValues, + sensorValues, + windowSeconds: dataProvider.timeWindow.toDouble(), + referenceTimestamp: referenceTimestamp, ); - - return Column( - children: [ - if (widget.allowToggleAxes) - Wrap( - spacing: 8, - children: sensor.axisNames.map((axisName) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: _axisEnabled[axisName], - checkColor: Colors.white, - activeColor: _axisColor(axisName), - onChanged: (value) => - _toggleAxis(axisName, value ?? false), + final enabledSeries = <_AxisSeries>[ + for (int i = 0; i < sensor.axisNames.length; i++) + if (_axisEnabled[sensor.axisNames[i]] ?? false) + _AxisSeries( + spots: axisData[sensor.axisNames[i]] ?? const [], + color: _axisColor( + axisIndex: i, + axisName: sensor.axisNames[i], + colorScheme: colorScheme, + ), + ), + ]; + + final windowSeconds = dataProvider.timeWindow.toDouble(); + const maxX = 0.0; + final minX = -windowSeconds; + final yAxisBounds = _computeYAxisBounds(enabledSeries); + + final axisChipTextStyle = theme.textTheme.labelMedium; + const disabledChipLabelColor = Color(0xFF8A8A8A); + const disabledChipBackgroundColor = Color(0xFFECECEC); + const disabledChipBorderColor = Color(0xFFD7D7D7); + const disabledChipDotColor = Color(0xFFB3B3B3); + + final leftUnit = sensor.axisUnits.isNotEmpty ? sensor.axisUnits.first : ''; + + final chartData = LineChartData( + minX: minX, + maxX: maxX, + minY: yAxisBounds.min, + maxY: yAxisBounds.max, + lineTouchData: LineTouchData( + enabled: widget.liveUpdatesEnabled && !compactMode, + handleBuiltInTouches: widget.liveUpdatesEnabled && !compactMode, + ), + gridData: FlGridData( + show: true, + drawVerticalLine: true, + getDrawingHorizontalLine: (_) => FlLine( + color: colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + getDrawingVerticalLine: (_) => FlLine( + color: colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + axisNameWidget: leftUnit.isEmpty + ? null + : PlatformText( + leftUnit, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - PlatformText(axisName), - ], + ), + axisNameSize: leftUnit.isEmpty ? 0 : (compactMode ? 16 : 22), + sideTitles: SideTitles( + showTitles: true, + reservedSize: compactMode ? 34 : 46, + minIncluded: false, + maxIncluded: false, + getTitlesWidget: (value, meta) { + final isBoundaryTick = (value - yAxisBounds.min).abs() < 1e-6 || + (value - yAxisBounds.max).abs() < 1e-6; + if (isBoundaryTick) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + meta: meta, + space: 6, + child: SizedBox( + width: compactMode ? 30 : 40, + child: Text( + _formatYAxisTick(value), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.right, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), ); - }).toList(), + }, ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + axisNameSize: 0, + sideTitles: SideTitles( + showTitles: true, + reservedSize: compactMode ? 20 : 24, + interval: 1, + minIncluded: true, + maxIncluded: true, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + _formatXAxisTick(value), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border( + left: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.28), + ), + bottom: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.28), + ), + ), + ), + lineBarsData: enabledSeries + .map( + (series) => LineChartBarData( + spots: series.spots, + isCurved: false, + barWidth: 2.2, + color: series.color, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + ) + .toList(growable: false), + ); + + final enabledAxes = + sensor.axisNames.where((axis) => _axisEnabled[axis] ?? false).toList(); + + return Column( + children: [ Expanded( - child: LineChart( - LineChartData( - lineTouchData: LineTouchData(enabled: true), - gridData: FlGridData(show: true), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - axisNameWidget: PlatformText(sensor.axisUnits.first), - sideTitles: SideTitles( - showTitles: true, - reservedSize: 45, + child: Padding( + padding: EdgeInsets.fromLTRB( + compactMode ? 2 : 6, + compactMode ? 2 : 4, + 2, + 0, + ), + child: Stack( + alignment: Alignment.center, + children: [ + Opacity( + opacity: widget.liveUpdatesEnabled ? 1 : 0.5, + child: LineChart( + chartData, + duration: const Duration(milliseconds: 0), ), ), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: false, + if (!widget.liveUpdatesEnabled) + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: widget.onDisabledTap, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.35), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Text( + widget.onDisabledTap == null + ? 'Live graphs disabled' + : 'Live graphs disabled. Tap to open settings.', + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ), + ), + ), ), - ), - topTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: false, + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: max(0.0, constraints.maxWidth - 70), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: + sensor.axisNames.asMap().entries.map((entry) { + final axisIndex = entry.key; + final axisName = entry.value; + final axisColor = _axisColor( + axisIndex: axisIndex, + axisName: axisName, + colorScheme: colorScheme, + ); + final selected = _axisEnabled[axisName] ?? false; + final chipLabelColor = selected + ? axisColor.withValues(alpha: 0.95) + : disabledChipLabelColor; + final chipBackgroundColor = selected + ? axisColor.withValues(alpha: 0.18) + : disabledChipBackgroundColor; + final chipBorderColor = selected + ? axisColor.withValues(alpha: 0.28) + : disabledChipBorderColor; + final chipDotColor = axisColor; + final disabledDotColor = disabledChipDotColor; + + return Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + label: Text( + axisName, + style: axisChipTextStyle?.copyWith( + color: chipLabelColor, + fontWeight: FontWeight.w700, + fontSize: compactMode ? 10.5 : 11.5, + ), + ), + avatar: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: selected + ? chipDotColor + : disabledDotColor, + shape: BoxShape.circle, + ), + ), + selected: selected, + onSelected: widget.liveUpdatesEnabled + ? (value) => _toggleAxis(axisName, value) + : null, + showCheckmark: false, + visualDensity: compactMode + ? const VisualDensity( + horizontal: -3, + vertical: -3, + ) + : const VisualDensity( + horizontal: -2, + vertical: -2, + ), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + labelPadding: + const EdgeInsets.symmetric(horizontal: 4), + padding: + const EdgeInsets.symmetric(horizontal: 4), + selectedColor: chipBackgroundColor, + backgroundColor: chipBackgroundColor, + side: BorderSide( + color: chipBorderColor, + ), + ), + ); + }).toList(growable: false), + ), + ), + ), ), ), - bottomTitles: AxisTitles( - axisNameWidget: PlatformText('Time (s)'), - axisNameSize: 30, - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, + const SizedBox(width: 8), + Text( + 'Time (s)', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, ), ), - ), - borderData: FlBorderData(show: false), - lineBarsData: enabledAxes.map((axisName) { - return LineChartBarData( - spots: axisData[axisName] ?? [], - isCurved: false, - barWidth: 2, - color: _axisColor(axisName), - isStrokeCapRound: true, - dotData: FlDotData(show: false), - ); - }).toList(), + ], ), - duration: const Duration(milliseconds: 0), ), ), + if (enabledAxes.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Enable at least one axis to display data.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), ], ); } - Map> _buildAxisData(Sensor sensor, Queue buffer) { - if (buffer.isEmpty) return { for (var axis in sensor.axisNames) axis: [] }; - + double _toRelativeSeconds( + Sensor sensor, + int timestamp, { + required int referenceTimestamp, + }) { final scale = pow(10, -sensor.timestampExponent).toDouble(); + return (timestamp - referenceTimestamp).toDouble() / scale; + } + + Map> _buildAxisData( + Sensor sensor, + Queue buffer, { + required double windowSeconds, + required int referenceTimestamp, + }) { + final data = >{ + for (var axis in sensor.axisNames) axis: [], + }; + if (buffer.isEmpty) return data; + + for (final sensorValue in buffer) { + final x = _toRelativeSeconds( + sensor, + sensorValue.timestamp, + referenceTimestamp: referenceTimestamp, + ).clamp(-windowSeconds, 0.0); + if (sensorValue is SensorDoubleValue) { + for (int i = 0; i < sensor.axisCount; i++) { + data[sensor.axisNames[i]]!.add(FlSpot(x, sensorValue.values[i])); + } + } else { + final values = (sensorValue as SensorIntValue).values; + for (int i = 0; i < sensor.axisCount; i++) { + data[sensor.axisNames[i]]!.add(FlSpot(x, values[i].toDouble())); + } + } + } + + return data; + } + + _YAxisBounds _computeYAxisBounds(List<_AxisSeries> seriesList) { + var minY = double.infinity; + var maxY = double.negativeInfinity; + + for (final series in seriesList) { + for (final spot in series.spots) { + minY = min(minY, spot.y); + maxY = max(maxY, spot.y); + } + } + + if (!minY.isFinite || !maxY.isFinite) { + return const _YAxisBounds(min: -1, max: 1); + } + + final range = maxY - minY; + if (range.abs() < 1e-9) { + final pad = max(minY.abs() * 0.05, 1e-3); + return _YAxisBounds( + min: minY - pad, + max: maxY + pad, + ); + } + + final pad = max(range * 0.1, 1e-6); + return _YAxisBounds( + min: minY - pad, + max: maxY + pad, + ); + } + + void _initializeAxisState(Sensor sensor) { + _sensorIdentity = _sensorKey(sensor); + _axisEnabled = {for (final axis in sensor.axisNames) axis: true}; + } + + void _syncAxisState(Sensor sensor) { + final sensorIdentity = _sensorKey(sensor); + if (sensorIdentity != _sensorIdentity) { + _initializeAxisState(sensor); + return; + } + + final hasSameAxes = _axisEnabled.length == sensor.axisNames.length && + sensor.axisNames.every((axis) => _axisEnabled.containsKey(axis)); + if (hasSameAxes) { + return; + } - return { - for (int i = 0; i < sensor.axisCount; i++) - sensor.axisNames[i]: buffer.map((v) { - final x = v.timestamp.toDouble() / scale; - final y = v is SensorDoubleValue - ? v.values[i] - : (v as SensorIntValue).values[i].toDouble(); - return FlSpot(x, y); - }).toList(), + _axisEnabled = { + for (final axis in sensor.axisNames) axis: _axisEnabled[axis] ?? true, }; } - Color _axisColor(String axisName) { + String _sensorKey(Sensor sensor) => + '${sensor.runtimeType}|${sensor.sensorName}|${sensor.axisNames.join(',')}|${sensor.axisUnits.join(',')}'; + + String _formatXAxisTick(double value) { + final rounded = value.roundToDouble(); + if ((value - rounded).abs() < 0.05) { + return rounded.toInt().toString(); + } + return value.toStringAsFixed(1); + } + + String _formatYAxisTick(double value) { + final abs = value.abs(); + String output; + + if (abs >= 100000 || (abs > 0 && abs < 0.001)) { + output = value.toStringAsExponential(1); + } else if (abs >= 1000) { + output = value.toStringAsFixed(0); + } else if (abs >= 100) { + output = value.toStringAsFixed(1); + } else if (abs >= 1) { + output = value.toStringAsFixed(2); + } else { + output = value.toStringAsFixed(3); + } + + return _trimTrailingZeros(output); + } + + String _trimTrailingZeros(String value) { + if (value.contains('e') || value.contains('E')) return value; + var result = value; + if (result.contains('.')) { + result = result.replaceFirst(RegExp(r'0+$'), ''); + result = result.replaceFirst(RegExp(r'\.$'), ''); + } + return result; + } + + Color _axisColor({ + required int axisIndex, + required String axisName, + required ColorScheme colorScheme, + }) { final name = axisName.toLowerCase(); + if (name == 'x') return const Color(0xFF4A90E2); + if (name == 'y') return const Color(0xFFE76F51); + if (name == 'z') return const Color(0xFF2A9D8F); if (name == 'r' || name == 'red') return Colors.red; if (name == 'g' || name == 'green') return Colors.green; if (name == 'b' || name == 'blue') return Colors.blue; + if (name.contains('temp')) return const Color(0xFFFB8500); + if (name.contains('pressure')) return const Color(0xFF6C63FF); - // Fallback for unrecognized names (e.g., axis4, temp, etc.) - final fallbackColors = [ - Colors.teal, - Colors.amber, - Colors.indigo, - Colors.lime, - Colors.brown, - Colors.deepOrange, - Colors.pink, - ]; - final index = context.read().sensor.axisNames.indexOf(axisName); - return fallbackColors[index % fallbackColors.length]; + if (axisIndex == 0) return colorScheme.primary; + return _fallbackColors[axisIndex % _fallbackColors.length]; } } + +class _AxisSeries { + final List spots; + final Color color; + + const _AxisSeries({ + required this.spots, + required this.color, + }); +} + +class _YAxisBounds { + final double min; + final double max; + + const _YAxisBounds({ + required this.min, + required this.max, + }); +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart index 85f8e111..156b534e 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart @@ -1,7 +1,11 @@ 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'; +import 'package:open_wearable/models/app_shutdown_settings.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; +import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_chart.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_detail.dart'; import 'package:provider/provider.dart'; @@ -10,21 +14,30 @@ class SensorValueCard extends StatelessWidget { final Sensor sensor; final Wearable wearable; - const SensorValueCard({super.key, required this.sensor, required this.wearable}); + const SensorValueCard({ + super.key, + required this.sensor, + required this.wearable, + }); @override Widget build(BuildContext context) { return GestureDetector( onTap: () { - final provider = context.read(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => ChangeNotifierProvider.value( - value: provider, - child: SensorValueDetail(sensor: sensor, wearable: wearable), - ), + if (AppShutdownSettings.disableLiveDataGraphs) { + context.push('/settings/general'); + return; + } + + final provider = context.read(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChangeNotifierProvider.value( + value: provider, + child: SensorValueDetail(sensor: sensor, wearable: wearable), ), - ); + ), + ); }, child: Card( child: Padding( @@ -33,16 +46,84 @@ class SensorValueCard extends StatelessWidget { children: [ Row( children: [ - PlatformText(sensor.sensorName, style: Theme.of(context).textTheme.bodyLarge), - Spacer(), - PlatformText(wearable.name, style: Theme.of(context).textTheme.bodyMedium), + Expanded( + child: PlatformText( + sensor.sensorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + PlatformText( + formatWearableDisplayName(wearable.name), + style: Theme.of(context).textTheme.bodyMedium, + ), + if (wearable.hasCapability()) + Padding( + padding: const EdgeInsets.only(left: 8), + child: StereoPositionBadge( + device: wearable.requireCapability(), + ), + ), + ], + ), ], ), Padding( padding: const EdgeInsets.only(top: 10.0), - child: SizedBox( - height: 200, - child: SensorChart(allowToggleAxes: false,), + child: ValueListenableBuilder( + valueListenable: + AppShutdownSettings.disableLiveDataGraphsListenable, + builder: (context, disableLiveDataGraphs, _) { + return ValueListenableBuilder( + valueListenable: AppShutdownSettings + .hideLiveDataGraphsWithoutDataListenable, + builder: (context, hideGraphsWithoutData, __) { + final shouldHideWithoutData = + hideGraphsWithoutData && !disableLiveDataGraphs; + if (!shouldHideWithoutData) { + return SizedBox( + height: 200, + child: SensorChart( + allowToggleAxes: false, + liveUpdatesEnabled: !disableLiveDataGraphs, + onDisabledTap: disableLiveDataGraphs + ? () => context.push('/settings/general') + : null, + ), + ); + } + + return Consumer( + builder: (context, dataProvider, ___) { + final hideNoDataGraph = + dataProvider.sensorValues.isEmpty; + return SizedBox( + height: 200, + child: hideNoDataGraph + ? _GraphsDisabledPlaceholder( + icon: Icons.sensors_off_outlined, + title: 'No live data yet', + subtitle: + 'Graph hidden when no data is received. Tap to open General settings', + onTap: () => + context.push('/settings/general'), + ) + : const SensorChart( + allowToggleAxes: false, + ), + ); + }, + ); + }, + ); + }, ), ), ], @@ -52,3 +133,63 @@ class SensorValueCard extends StatelessWidget { ); } } + +class _GraphsDisabledPlaceholder extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _GraphsDisabledPlaceholder({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 8), + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart b/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart index 1b8a0429..397300fb 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart @@ -1,27 +1,117 @@ 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'; +import 'package:open_wearable/models/app_shutdown_settings.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_chart.dart'; +import 'package:provider/provider.dart'; class SensorValueDetail extends StatelessWidget { final Sensor sensor; final Wearable wearable; - const SensorValueDetail({super.key, required this.sensor, required this.wearable}); + const SensorValueDetail({ + super.key, + required this.sensor, + required this.wearable, + }); @override Widget build(BuildContext context) { return PlatformScaffold( appBar: PlatformAppBar( - title: PlatformText(sensor.sensorName, style: Theme.of(context).textTheme.titleMedium), + title: PlatformText( + sensor.sensorName, + style: Theme.of(context).textTheme.titleMedium, + ), ), - body: Padding( - padding: EdgeInsets.all(10), + body: SafeArea( + top: false, + bottom: true, + minimum: const EdgeInsets.all(10), child: Column( children: [ - PlatformText(wearable.name, style: Theme.of(context).textTheme.bodyMedium), + PlatformText( + formatWearableDisplayName(wearable.name), + style: Theme.of(context).textTheme.bodyMedium, + ), Expanded( - child: SensorChart(allowToggleAxes: true), + child: ValueListenableBuilder( + valueListenable: + AppShutdownSettings.disableLiveDataGraphsListenable, + builder: (context, disableLiveDataGraphs, _) { + return ValueListenableBuilder( + valueListenable: AppShutdownSettings + .hideLiveDataGraphsWithoutDataListenable, + builder: (context, hideGraphsWithoutData, __) { + final shouldHideWithoutData = + hideGraphsWithoutData && !disableLiveDataGraphs; + if (!shouldHideWithoutData) { + return SensorChart( + allowToggleAxes: true, + liveUpdatesEnabled: !disableLiveDataGraphs, + onDisabledTap: disableLiveDataGraphs + ? () => context.push('/settings/general') + : null, + ); + } + + return Consumer( + builder: (context, dataProvider, ___) { + if (dataProvider.sensorValues.isNotEmpty) { + return const SensorChart( + allowToggleAxes: true, + ); + } + + return Center( + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => context.push('/settings/general'), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Live data graph is hidden while this sensor has no data.', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .bodyMedium, + ), + const SizedBox(height: 6), + Text( + 'Tap to open General settings', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + }, + ), ), ], ), diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 97b6101f..b3483772 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,99 +1,434 @@ 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/app_shutdown_settings.dart'; +import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; import 'package:provider/provider.dart'; -class SensorValuesPage extends StatelessWidget { - final Map<(Wearable, Sensor), SensorDataProvider> _sensorDataProvider = {}; +class SensorValuesPage extends StatefulWidget { + final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; - SensorValuesPage({super.key}); + const SensorValuesPage({ + super.key, + this.sharedProviders, + }); + + @override + State createState() => _SensorValuesPageState(); +} + +class _SensorValuesPageState extends State + with AutomaticKeepAliveClientMixin { + final Map<(Wearable, Sensor), SensorDataProvider> _ownedProviders = {}; + + Map<(Wearable, Sensor), SensorDataProvider> get _sensorDataProvider => + widget.sharedProviders ?? _ownedProviders; + + bool get _ownsProviders => widget.sharedProviders == null; + + @override + bool get wantKeepAlive => true; + + @override + void dispose() { + if (_ownsProviders) { + for (final provider in _ownedProviders.values) { + provider.dispose(); + } + _ownedProviders.clear(); + } + super.dispose(); + } @override Widget build(BuildContext context) { - return Consumer( - builder: (context, wearablesProvider, child) { - List charts = []; - for (var wearable in wearablesProvider.wearables) { - if (wearable.hasCapability()) { - for (Sensor sensor in wearable.requireCapability().sensors) { - if (!_sensorDataProvider.containsKey((wearable, sensor))) { - _sensorDataProvider[(wearable, sensor)] = SensorDataProvider(sensor: sensor); - } - charts.add( - ChangeNotifierProvider.value( - value: _sensorDataProvider[(wearable, sensor)], - child: SensorValueCard(sensor: sensor, wearable: wearable,), - ), - ); - } - } - } + super.build(context); + return ValueListenableBuilder( + valueListenable: AppShutdownSettings.disableLiveDataGraphsListenable, + builder: (context, disableLiveDataGraphs, _) { + return ValueListenableBuilder( + valueListenable: + AppShutdownSettings.hideLiveDataGraphsWithoutDataListenable, + builder: (context, hideCardsWithoutLiveData, __) { + final shouldHideCardsWithoutLiveData = + hideCardsWithoutLiveData && !disableLiveDataGraphs; + return Consumer( + builder: (context, wearablesProvider, child) { + return FutureBuilder>( + future: buildWearableDisplayGroups( + wearablesProvider.wearables, + shouldCombinePair: (left, right) => + wearablesProvider.isStereoPairCombined( + first: left, + second: right, + ), + ), + builder: (context, snapshot) { + final groups = orderWearableGroupsByNameAndSide( + snapshot.data ?? + wearablesProvider.wearables + .map( + (wearable) => WearableDisplayGroup.single( + wearable: wearable, + ), + ) + .toList(), + ); + final orderedWearables = + _orderedWearablesFromGroups(groups); + _ensureProviders(orderedWearables); + _cleanupProviders(orderedWearables); - _sensorDataProvider.removeWhere((key, _) => - !wearablesProvider.wearables.any((device) => device.hasCapability() - && device == key.$1 - && device.requireCapability().sensors.contains(key.$2),), - ); + Widget buildContent() { + final hasAnySensors = _hasAnySensors(orderedWearables); + final charts = _buildCharts( + orderedWearables, + hideCardsWithoutLiveData: + shouldHideCardsWithoutLiveData, + ); + + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 600) { + return _buildSmallScreenLayout( + context, + charts, + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: + shouldHideCardsWithoutLiveData, + ); + } else { + return _buildLargeScreenLayout( + context, + charts, + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: + shouldHideCardsWithoutLiveData, + ); + } + }, + ); + } + + if (disableLiveDataGraphs) { + return buildContent(); + } + + final sensorDataListenable = + Listenable.merge(_providersFor(orderedWearables)); - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 600) { - return _buildSmallScreenLayout(context, charts); - } else { - return _buildLargeScreenLayout(context, charts); - } + return AnimatedBuilder( + animation: sensorDataListenable, + builder: (context, ___) => buildContent(), + ); + }, + ); + }, + ); }, ); }, ); } - Widget _buildSmallScreenLayout(BuildContext context, List charts) { - return Padding( - padding: EdgeInsets.all(10), - child: charts.isEmpty - ? Center( - child: PlatformText("No sensors connected", style: Theme.of(context).textTheme.titleLarge), - ) - : ListView( - children: charts, + void _ensureProviders(List orderedWearables) { + for (final wearable in orderedWearables) { + if (!wearable.hasCapability()) { + continue; + } + for (final sensor + in wearable.requireCapability().sensors) { + _sensorDataProvider.putIfAbsent( + (wearable, sensor), + () => SensorDataProvider(sensor: sensor), + ); + } + } + } + + Iterable _providersFor( + List orderedWearables, + ) sync* { + for (final wearable in orderedWearables) { + if (!wearable.hasCapability()) { + continue; + } + for (final sensor + in wearable.requireCapability().sensors) { + final provider = _sensorDataProvider[(wearable, sensor)]; + if (provider != null) { + yield provider; + } + } + } + } + + bool _hasAnySensors(List orderedWearables) { + return orderedWearables.any( + (wearable) => + wearable.hasCapability() && + wearable.requireCapability().sensors.isNotEmpty, + ); + } + + List _buildCharts( + List orderedWearables, { + required bool hideCardsWithoutLiveData, + }) { + final charts = []; + for (final wearable in orderedWearables) { + if (!wearable.hasCapability()) { + continue; + } + for (final sensor + in wearable.requireCapability().sensors) { + final provider = _sensorDataProvider[(wearable, sensor)]; + if (provider == null) { + continue; + } + if (hideCardsWithoutLiveData && provider.sensorValues.isEmpty) { + continue; + } + final chartIdentity = _sensorChartIdentity( + wearable: wearable, + sensor: sensor, + ); + charts.add( + ChangeNotifierProvider.value( + key: ValueKey(chartIdentity), + value: provider, + child: SensorValueCard( + sensor: sensor, + wearable: wearable, + ), + ), + ); + } + } + return charts; + } + + String _sensorChartIdentity({ + required Wearable wearable, + required Sensor sensor, + }) { + final axisNames = sensor.axisNames.join(','); + final axisUnits = sensor.axisUnits.join(','); + return '${wearable.deviceId}|${sensor.runtimeType}|${sensor.sensorName}|$axisNames|$axisUnits'; + } + + void _cleanupProviders(List orderedWearables) { + if (!_ownsProviders) { + return; + } + _sensorDataProvider.removeWhere((key, provider) { + final keepProvider = orderedWearables.any( + (device) => + device.hasCapability() && + device == key.$1 && + device.requireCapability().sensors.contains(key.$2), + ); + if (!keepProvider) { + provider.dispose(); + } + return !keepProvider; + }); + } + + List _orderedWearablesFromGroups( + List groups, + ) { + final ordered = []; + for (final group in groups) { + final left = group.leftDevice; + final right = group.rightDevice; + if (left != null) { + ordered.add(left); + } + if (right != null && right.deviceId != left?.deviceId) { + ordered.add(right); + } + if (left == null && right == null) { + ordered.addAll(group.members); + } + } + return ordered; + } + + Widget _buildSmallScreenLayout( + BuildContext context, + List charts, { + required bool hasAnySensors, + required bool hideCardsWithoutLiveData, + }) { + if (charts.isEmpty) { + final emptyState = _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ); + return Padding( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: _buildEmptyStateCard(context, emptyState), + ), ), + ); + } + + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: charts, ); } - Widget _buildLargeScreenLayout(BuildContext context, List charts) { + Widget _buildLargeScreenLayout( + BuildContext context, + List charts, { + required bool hasAnySensors, + required bool hideCardsWithoutLiveData, + }) { + final emptyState = _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ); + return GridView.builder( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 500, childAspectRatio: 1.5, - crossAxisSpacing: 10, - mainAxisSpacing: 10, + crossAxisSpacing: SensorPageSpacing.gridGap, + mainAxisSpacing: SensorPageSpacing.gridGap, ), - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), itemCount: charts.isEmpty ? 1 : charts.length, itemBuilder: (context, index) { if (charts.isEmpty) { - return Card( - shape: RoundedRectangleBorder( - side: BorderSide( - color: Colors.grey, - width: 1, - style: BorderStyle.solid, - strokeAlign: -1, - ), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: PlatformText("No sensors available", style: Theme.of(context).textTheme.titleLarge), - ), - ); + return _buildEmptyStateCard(context, emptyState); } return charts[index]; }, ); } + + _SensorValuesEmptyState _resolveEmptyState({ + required bool hasAnySensors, + required bool hideCardsWithoutLiveData, + }) { + if (hasAnySensors && hideCardsWithoutLiveData) { + return const _SensorValuesEmptyState( + icon: Icons.sensors_outlined, + title: 'Waiting for live sensor data', + subtitle: + 'Graphs will appear once your sensors stream their first samples.', + removeCardBackground: true, + ); + } + + return const _SensorValuesEmptyState( + icon: Icons.sensors_off_outlined, + title: 'No sensors connected', + subtitle: 'Connect a wearable to start viewing live sensor values.', + ); + } + + Widget _buildEmptyStateCard( + BuildContext context, + _SensorValuesEmptyState emptyState, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final removeCardBackground = emptyState.removeCardBackground; + + return Card( + color: removeCardBackground ? Colors.transparent : null, + clipBehavior: Clip.antiAlias, + elevation: removeCardBackground ? 0 : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: removeCardBackground + ? BorderSide.none + : BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + shadowColor: removeCardBackground ? Colors.transparent : null, + surfaceTintColor: removeCardBackground ? Colors.transparent : null, + child: Ink( + decoration: removeCardBackground + ? null + : BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primaryContainer.withValues(alpha: 0.28), + colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + ], + ), + ), + child: Padding( + padding: const EdgeInsets.all(22), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.primary.withValues(alpha: 0.12), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.28), + ), + ), + child: Icon( + emptyState.icon, + size: 28, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 14), + PlatformText( + emptyState.title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + PlatformText( + emptyState.subtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.3, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SensorValuesEmptyState { + final IconData icon; + final String title; + final String subtitle; + final bool removeCardBackground; + + const _SensorValuesEmptyState({ + required this.icon, + required this.title, + required this.subtitle, + this.removeCardBackground = false, + }); } diff --git a/open_wearable/lib/widgets/settings/connectors_page.dart b/open_wearable/lib/widgets/settings/connectors_page.dart new file mode 100644 index 00000000..ef80c825 --- /dev/null +++ b/open_wearable/lib/widgets/settings/connectors_page.dart @@ -0,0 +1,611 @@ +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/connector_settings.dart'; +import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class ConnectorsPage extends StatefulWidget { + const ConnectorsPage({super.key}); + + @override + State createState() => _ConnectorsPageState(); +} + +class _ConnectorsPageState extends State { + static final Uri _udpBridgeGuideUri = Uri.parse( + 'https://github.com/OpenEarable/open_earable_flutter/blob/main/tools/README.md#quick-setup-from-openwearables-app', + ); + + late final TextEditingController _hostController; + late final TextEditingController _portController; + late final TextEditingController _streamPrefixController; + + bool _enabled = false; + bool _isLoading = true; + bool _isSaving = false; + String? _validationMessage; + + final bool _isUdpBridgeSupported = UdpBridgeForwarder.instance.isSupported; + + bool get _controlsEnabled => !_isSaving && _isUdpBridgeSupported; + + @override + void initState() { + super.initState(); + _hostController = TextEditingController(); + _portController = TextEditingController( + text: defaultUdpBridgePort.toString(), + ); + _streamPrefixController = TextEditingController( + text: defaultUdpBridgeStreamPrefix, + ); + _loadSettings(); + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _streamPrefixController.dispose(); + super.dispose(); + } + + Future _loadSettings() async { + try { + final settings = await ConnectorSettings.loadUdpBridgeSettings(); + if (!mounted) { + return; + } + + setState(() { + _enabled = settings.enabled; + _hostController.text = settings.host; + _portController.text = settings.port.toString(); + _streamPrefixController.text = settings.streamPrefix; + _validationMessage = null; + _isLoading = false; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _validationMessage = 'Could not load connector settings.'; + _isLoading = false; + }); + AppToast.show( + context, + message: 'Failed to load Network Relay settings.', + type: AppToastType.error, + icon: Icons.error_outline_rounded, + ); + } + } + + Future _saveSettings() async { + if (_isSaving) { + return; + } + + final host = _hostController.text.trim(); + final parsedPort = int.tryParse(_portController.text.trim()); + final streamPrefix = _streamPrefixController.text.trim().isEmpty + ? defaultUdpBridgeStreamPrefix + : _streamPrefixController.text.trim(); + + if (_enabled && host.isEmpty) { + setState(() { + _validationMessage = + 'Bridge host is required when Network Relay is enabled.'; + }); + return; + } + if (parsedPort == null || parsedPort <= 0 || parsedPort > 65535) { + setState(() { + _validationMessage = 'Port must be a number between 1 and 65535.'; + }); + return; + } + + setState(() { + _isSaving = true; + _validationMessage = null; + }); + + try { + final saved = await ConnectorSettings.saveUdpBridgeSettings( + UdpBridgeConnectorSettings( + enabled: _enabled, + host: host, + port: parsedPort, + streamPrefix: streamPrefix, + ), + ); + + if (!mounted) { + return; + } + + setState(() { + _enabled = saved.enabled; + _hostController.text = saved.host; + _portController.text = saved.port.toString(); + _streamPrefixController.text = saved.streamPrefix; + }); + + AppToast.show( + context, + message: 'Network Relay settings saved.', + type: AppToastType.success, + icon: Icons.check_circle_outline_rounded, + ); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _validationMessage = + 'Could not save Network Relay settings. Please try again.'; + }); + AppToast.show( + context, + message: 'Failed to save Network Relay settings.', + type: AppToastType.error, + icon: Icons.error_outline_rounded, + ); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _setEnabled(bool value) async { + if (_isSaving || !_isUdpBridgeSupported) { + return; + } + final previousEnabled = _enabled; + + final host = _hostController.text.trim(); + final parsedPort = int.tryParse(_portController.text.trim()); + final streamPrefix = _streamPrefixController.text.trim().isEmpty + ? defaultUdpBridgeStreamPrefix + : _streamPrefixController.text.trim(); + + if (value && host.isEmpty) { + setState(() { + _validationMessage = + 'Bridge host is required when Network Relay is enabled.'; + }); + return; + } + if (parsedPort == null || parsedPort <= 0 || parsedPort > 65535) { + setState(() { + _validationMessage = 'Port must be a number between 1 and 65535.'; + }); + return; + } + + setState(() { + _enabled = value; + _isSaving = true; + _validationMessage = null; + }); + + try { + final saved = await ConnectorSettings.saveUdpBridgeSettings( + UdpBridgeConnectorSettings( + enabled: value, + host: host, + port: parsedPort, + streamPrefix: streamPrefix, + ), + ); + if (!mounted) { + return; + } + setState(() { + _enabled = saved.enabled; + _hostController.text = saved.host; + _portController.text = saved.port.toString(); + _streamPrefixController.text = saved.streamPrefix; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _enabled = previousEnabled; + _validationMessage = + 'Could not update connector status. Please try again.'; + }); + AppToast.show( + context, + message: 'Failed to update Network Relay status.', + type: AppToastType.error, + icon: Icons.error_outline_rounded, + ); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _openExternalUrl({ + required Uri uri, + required String label, + }) async { + final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (opened || !mounted) { + return; + } + + AppToast.show( + context, + message: 'Could not open $label.', + type: AppToastType.error, + icon: Icons.link_off_rounded, + ); + } + + void _handleDraftChanged([String? _]) { + setState(() { + _validationMessage = null; + }); + } + + bool _hasPendingUdpBridgeChanges(UdpBridgeConnectorSettings appliedSettings) { + final host = _hostController.text.trim(); + final portText = _portController.text.trim(); + final parsedPort = int.tryParse(portText); + final hasPortChanged = parsedPort == null || + parsedPort <= 0 || + parsedPort > 65535 || + parsedPort != appliedSettings.port; + final streamPrefix = _streamPrefixController.text.trim().isEmpty + ? defaultUdpBridgeStreamPrefix + : _streamPrefixController.text.trim(); + + return _enabled != appliedSettings.enabled || + host != appliedSettings.host || + hasPortChanged || + streamPrefix != appliedSettings.streamPrefix; + } + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('Connectors'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: EdgeInsets.fromLTRB( + 16, + 12, + 16, + 20 + MediaQuery.paddingOf(context).bottom, + ), + children: [ + Text( + 'Available connectors', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'Forward sensor data from this app to other platforms, such as your computer', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + _buildUdpBridgeConnectorCard(context), + const SizedBox(height: 8), + Align( + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_awesome_rounded, + size: 14, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + 'More connectors coming soon', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildUdpBridgeConnectorCard(BuildContext context) { + const udpGreen = Color(0xFF2E7D32); + final colorScheme = Theme.of(context).colorScheme; + + return ValueListenableBuilder( + valueListenable: ConnectorSettings.udpBridgeSettingsListenable, + builder: (context, appliedSettings, _) { + return ValueListenableBuilder( + valueListenable: ConnectorSettings.udpBridgeConnectionStateListenable, + builder: (context, connectionState, __) { + final isAppliedUdpBridgeActive = + appliedSettings.enabled && appliedSettings.isConfigured; + final hasPendingChanges = + _hasPendingUdpBridgeChanges(appliedSettings); + final hasConnectionProblem = isAppliedUdpBridgeActive && + connectionState == SensorForwarderConnectionState.unreachable; + final udpIconColor = hasConnectionProblem + ? colorScheme.error + : isAppliedUdpBridgeActive + ? udpGreen + : colorScheme.primary; + final udpIconBackground = udpIconColor.withValues(alpha: 0.12); + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: udpIconBackground, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Icon( + hasConnectionProblem + ? Icons.cloud_off_rounded + : Icons.cloud_done_rounded, + color: udpIconColor, + size: 18, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Network Relay', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Forward sensor data from this app to your computer', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Switch.adaptive( + value: _enabled, + onChanged: !_controlsEnabled ? null : _setEnabled, + ), + ], + ), + if (hasConnectionProblem) ...[ + const SizedBox(height: 8), + _buildUdpBridgeConnectionStatus( + context, + settings: appliedSettings, + hasConnectionProblem: hasConnectionProblem, + ), + ], + const SizedBox(height: 6), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _openExternalUrl( + uri: _udpBridgeGuideUri, + label: 'Network Relay setup guide', + ), + icon: const Icon(Icons.open_in_new_rounded, size: 18), + label: + const Text('Open Network Relay Setup Instructions'), + ), + ), + if (!_isUdpBridgeSupported) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.errorContainer + .withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'Network Relay forwarding is not supported on this platform', + style: + Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onErrorContainer, + ), + ), + ), + ], + const SizedBox(height: 12), + Text( + 'Bridge settings', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _hostController, + enabled: _controlsEnabled, + onChanged: _handleDraftChanged, + decoration: const InputDecoration( + labelText: 'Bridge Host / IP', + hintText: '192.168.1.42', + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: _portController, + enabled: _controlsEnabled, + onChanged: _handleDraftChanged, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Relay Port', + hintText: '16571', + suffixIcon: IconButton( + tooltip: 'Reset to default', + onPressed: !_controlsEnabled + ? null + : () { + _portController.text = + defaultUdpBridgePort.toString(); + _handleDraftChanged(); + }, + icon: const Icon(Icons.restart_alt_rounded), + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: _streamPrefixController, + enabled: _controlsEnabled, + onChanged: _handleDraftChanged, + decoration: const InputDecoration( + labelText: 'Source Device Name', + hintText: defaultUdpBridgeStreamPrefix, + ), + ), + ), + ], + ), + if (_validationMessage != null) ...[ + const SizedBox(height: 8), + Text( + _validationMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ], + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: PlatformElevatedButton( + onPressed: !_controlsEnabled || !hasPendingChanges + ? null + : _saveSettings, + child: Text(_isSaving ? 'Saving...' : 'Save & Apply'), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + Widget _buildUdpBridgeConnectionStatus( + BuildContext context, { + required UdpBridgeConnectorSettings settings, + required bool hasConnectionProblem, + }) { + const udpGreen = Color(0xFF2E7D32); + final colorScheme = Theme.of(context).colorScheme; + final endpoint = '${settings.host}:${settings.port}'; + final foreground = hasConnectionProblem ? colorScheme.error : udpGreen; + final background = foreground.withValues(alpha: 0.12); + final border = foreground.withValues(alpha: 0.34); + final title = hasConnectionProblem + ? 'Network Relay unreachable' + : 'Network Relay active'; + final detail = hasConnectionProblem + ? 'Could not reach $endpoint. Check host, port, and network' + : 'Connected to $endpoint'; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + hasConnectionProblem + ? Icons.cloud_off_rounded + : Icons.cloud_done_rounded, + size: 17, + color: foreground, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 1), + Text( + detail, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/settings/general_settings_page.dart b/open_wearable/lib/widgets/settings/general_settings_page.dart new file mode 100644 index 00000000..6cf230af --- /dev/null +++ b/open_wearable/lib/widgets/settings/general_settings_page.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/models/auto_connect_preferences.dart'; +import 'package:open_wearable/models/app_shutdown_settings.dart'; +import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; + +class GeneralSettingsPage extends StatefulWidget { + const GeneralSettingsPage({super.key}); + + @override + State createState() => _GeneralSettingsPageState(); +} + +class _GeneralSettingsPageState extends State { + bool _isSaving = false; + + Future _setShutOffSensorsOnClose(bool enabled) async { + if (_isSaving) { + return; + } + + setState(() { + _isSaving = true; + }); + + try { + await AppShutdownSettings.saveShutOffAllSensorsOnAppClose(enabled); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _setDisableLiveDataGraphs(bool enabled) async { + if (_isSaving) { + return; + } + + setState(() { + _isSaving = true; + }); + + try { + await AppShutdownSettings.saveDisableLiveDataGraphs(enabled); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _setHideLiveDataGraphsWithoutData(bool enabled) async { + if (_isSaving) { + return; + } + + setState(() { + _isSaving = true; + }); + + try { + await AppShutdownSettings.saveHideLiveDataGraphsWithoutData(enabled); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + Future _setAutoConnectEnabled(bool enabled) async { + if (_isSaving) { + return; + } + + setState(() { + _isSaving = true; + }); + + try { + await AutoConnectPreferences.saveAutoConnectEnabled(enabled); + } finally { + if (mounted) { + setState(() { + _isSaving = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('General settings'), + ), + body: ValueListenableBuilder( + valueListenable: + AppShutdownSettings.shutOffAllSensorsOnAppCloseListenable, + builder: (context, shutOffOnCloseEnabled, _) { + return ValueListenableBuilder( + valueListenable: + AppShutdownSettings.disableLiveDataGraphsListenable, + builder: (context, disableLiveGraphsEnabled, __) { + return ValueListenableBuilder( + valueListenable: + AppShutdownSettings.hideLiveDataGraphsWithoutDataListenable, + builder: (context, hideLiveGraphsWithoutDataEnabled, ___) { + return ValueListenableBuilder( + valueListenable: + AutoConnectPreferences.autoConnectEnabledListenable, + builder: (context, autoConnectEnabled, ____) { + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset( + context, + ), + children: [ + _buildSectionHeader( + context, + title: 'Connectivity', + description: + 'Manage how devices reconnect in the background', + ), + _buildSettingGroup( + [ + SwitchListTile.adaptive( + value: autoConnectEnabled, + onChanged: + _isSaving ? null : _setAutoConnectEnabled, + secondary: const Icon( + Icons.bluetooth_searching_rounded, + size: 18, + ), + title: const Text( + 'Enable Bluetooth auto-connect', + ), + subtitle: const Text( + 'Automatically reconnect remembered devices in the background', + ), + ), + ], + ), + _buildSectionHeader( + context, + title: 'App lifecycle', + description: + 'Control what happens to sensors when the app goes to the background', + ), + _buildSettingGroup( + [ + SwitchListTile.adaptive( + value: shutOffOnCloseEnabled, + onChanged: _isSaving + ? null + : _setShutOffSensorsOnClose, + secondary: const Icon( + Icons.power_settings_new_rounded, + size: 18, + ), + title: const Text( + 'Disable all sensors on app close', + ), + subtitle: const Text( + 'Turns configurable sensors off after 10s in background when possible', + ), + ), + ], + ), + _buildSectionHeader( + context, + title: 'Live data', + description: + 'Adjust graph visibility and update behavior in Sensors › Live Data', + ), + _buildSettingGroup( + [ + SwitchListTile.adaptive( + value: disableLiveGraphsEnabled, + onChanged: _isSaving + ? null + : _setDisableLiveDataGraphs, + secondary: const Icon( + Icons.area_chart_rounded, + size: 18, + ), + title: const Text('Disable live data graphs'), + subtitle: const Text( + 'Stop live chart updates in the Sensors › Live Data views', + ), + ), + SwitchListTile.adaptive( + value: hideLiveGraphsWithoutDataEnabled, + onChanged: _isSaving + ? null + : _setHideLiveDataGraphsWithoutData, + secondary: const Icon( + Icons.sensors_off_rounded, + size: 18, + ), + title: const Text( + 'Hide live data graphs without data', + ), + subtitle: const Text( + 'Hides live data graphs in Sensors › Live Data until samples arrive', + ), + ), + ], + ), + ], + ); + }, + ); + }, + ); + }, + ); + }, + ), + ); + } + + Widget _buildSectionHeader( + BuildContext context, { + required String title, + required String description, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 2), + Text( + description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildSettingGroup(List tiles) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < tiles.length; index++) ...[ + tiles[index], + if (index < tiles.length - 1) const Divider(height: 1), + ], + ], + ), + ); + } +} diff --git a/open_wearable/macos/Podfile.lock b/open_wearable/macos/Podfile.lock index 1abbe1f3..0fb17023 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -7,10 +7,7 @@ PODS: - FlutterMacOS - ZIPFoundation (= 0.9.19) - FlutterMacOS (1.0.0) - - open_file_mac (0.0.1): - - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter + - open_file_mac (1.0.3): - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS @@ -30,7 +27,6 @@ DEPENDENCIES: - flutter_archive (from `Flutter/ephemeral/.symlinks/plugins/flutter_archive/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - open_file_mac (from `Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`) @@ -51,8 +47,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral open_file_mac: :path: Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: @@ -67,8 +61,7 @@ SPEC CHECKSUMS: file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 flutter_archive: 07888d9aeb79da005e0ad8b9d347d17cdea07f68 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - open_file_mac: 01874b6d6a2c1485ac9b126d7105b99102dea2cf - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb universal_ble: ff19787898040d721109c6324472e5dd4bc86adc diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 034ce0e3..1010832c 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -444,18 +444,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" mcumgr_flutter: dependency: "direct main" description: @@ -507,11 +507,10 @@ packages: open_earable_flutter: dependency: "direct main" description: - name: open_earable_flutter - sha256: "23b784abdb9aa2a67afd6bcf22778cc9e3d124eba5a4d02f49443581fa3f8958" - url: "https://pub.dev" - source: hosted - version: "2.3.1" + path: "../../open_earable_flutter" + relative: true + source: path + version: "2.3.2" open_file: dependency: "direct main" description: @@ -865,10 +864,10 @@ packages: dependency: transitive description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.7" tuple: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 6b9c9fa2..033a92f4 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -35,7 +35,8 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 open_file: ^3.3.2 - open_earable_flutter: ^2.3.1 + open_earable_flutter: + path: ../../open_earable_flutter flutter_platform_widgets: ^9.0.0 provider: ^6.1.2 logger: ^2.5.0 @@ -82,6 +83,9 @@ flutter: assets: - lib/apps/posture_tracker/assets/ - lib/apps/heart_tracker/assets/ + - lib/apps/fever_thermometer/assets/ + - lib/apps/self_test/assets/ + - android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png # An image asset can refer to one or more resolution-specific "variants", seeq # https://flutter.dev/to/resolution-aware-images diff --git a/open_wearable/test/apps/widgets/app_compatibility_test.dart b/open_wearable/test/apps/widgets/app_compatibility_test.dart new file mode 100644 index 00000000..4c729438 --- /dev/null +++ b/open_wearable/test/apps/widgets/app_compatibility_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/apps/widgets/app_compatibility.dart'; + +void main() { + group('wearableNameStartsWithPrefix', () { + test('matches OpenRing prefixes for raw bcl names', () { + expect(wearableNameStartsWithPrefix('bcl-1234', 'OpenRing'), isTrue); + expect(wearableNameStartsWithPrefix('BCL_9876', 'openring'), isTrue); + }); + + test('still matches raw names directly', () { + expect( + wearableNameStartsWithPrefix('OpenEarable-2-L', 'OpenEarable'), + isTrue, + ); + }); + }); + + test('wearableIsCompatibleWithApp accepts OpenRing with bcl name', () { + expect( + wearableIsCompatibleWithApp( + wearableName: 'bcl-0001', + supportedDevicePrefixes: const ['OpenRing'], + ), + isTrue, + ); + }); + + test('hasConnectedWearableForPrefix supports OpenRing prefixes', () { + expect( + hasConnectedWearableForPrefix( + devicePrefix: 'OpenRing', + connectedWearableNames: const ['bcl-0012'], + ), + isTrue, + ); + }); +} diff --git a/open_wearable/test/models/app_shutdown_settings_test.dart b/open_wearable/test/models/app_shutdown_settings_test.dart new file mode 100644 index 00000000..a30891da --- /dev/null +++ b/open_wearable/test/models/app_shutdown_settings_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/app_shutdown_settings.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AppShutdownSettings', () { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await AppShutdownSettings.initialize(); + }); + + test('defaults to disabled sensor shutdown on app close', () { + expect(AppShutdownSettings.shutOffAllSensorsOnAppClose, isFalse); + expect(AppShutdownSettings.disableLiveDataGraphs, isFalse); + expect(AppShutdownSettings.hideLiveDataGraphsWithoutData, isFalse); + }); + + test('persists and reloads the shutdown preference', () async { + await AppShutdownSettings.saveShutOffAllSensorsOnAppClose(true); + + expect(AppShutdownSettings.shutOffAllSensorsOnAppClose, isTrue); + + final reloaded = + await AppShutdownSettings.loadShutOffAllSensorsOnAppClose(); + expect(reloaded, isTrue); + + await AppShutdownSettings.saveShutOffAllSensorsOnAppClose(false); + expect(AppShutdownSettings.shutOffAllSensorsOnAppClose, isFalse); + }); + + test('persists and reloads live data graph preference', () async { + await AppShutdownSettings.saveDisableLiveDataGraphs(true); + + expect(AppShutdownSettings.disableLiveDataGraphs, isTrue); + + final reloaded = await AppShutdownSettings.loadDisableLiveDataGraphs(); + expect(reloaded, isTrue); + + await AppShutdownSettings.saveDisableLiveDataGraphs(false); + expect(AppShutdownSettings.disableLiveDataGraphs, isFalse); + }); + + test('persists and reloads hide-no-data graph preference', () async { + await AppShutdownSettings.saveHideLiveDataGraphsWithoutData(true); + + expect(AppShutdownSettings.hideLiveDataGraphsWithoutData, isTrue); + + final reloaded = + await AppShutdownSettings.loadHideLiveDataGraphsWithoutData(); + expect(reloaded, isTrue); + + await AppShutdownSettings.saveHideLiveDataGraphsWithoutData(false); + expect(AppShutdownSettings.hideLiveDataGraphsWithoutData, isFalse); + }); + }); +} diff --git a/open_wearable/test/models/auto_connect_preferences_test.dart b/open_wearable/test/models/auto_connect_preferences_test.dart new file mode 100644 index 00000000..703f36c8 --- /dev/null +++ b/open_wearable/test/models/auto_connect_preferences_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/auto_connect_preferences.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AutoConnectPreferences', () { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await AutoConnectPreferences.loadAutoConnectEnabled(); + }); + + test('rememberDeviceName stores normalized names and keeps duplicates', + () async { + SharedPreferences.setMockInitialValues({ + AutoConnectPreferences.connectedDeviceNamesKey: [ + 'OpenEarable 2', + ' ', + 'OpenEarable 2', + ], + }); + + final prefs = await SharedPreferences.getInstance(); + + expect( + AutoConnectPreferences.readRememberedDeviceNames(prefs), + ['OpenEarable 2', 'OpenEarable 2'], + ); + + await AutoConnectPreferences.rememberDeviceName( + prefs, + ' OpenEarable 3 ', + ); + await AutoConnectPreferences.rememberDeviceName( + prefs, + 'OpenEarable 2', + ); + + expect( + prefs.getStringList(AutoConnectPreferences.connectedDeviceNamesKey), + [ + 'OpenEarable 2', + 'OpenEarable 2', + 'OpenEarable 3', + 'OpenEarable 2', + ], + ); + }); + + test('forgetDeviceName removes one matching remembered name per call', + () async { + SharedPreferences.setMockInitialValues({ + AutoConnectPreferences.connectedDeviceNamesKey: [ + 'OpenEarable 2', + 'OpenEarable 3', + 'OpenEarable 3', + ], + }); + + final prefs = await SharedPreferences.getInstance(); + + await AutoConnectPreferences.forgetDeviceName(prefs, ' OpenEarable 3 '); + await AutoConnectPreferences.forgetDeviceName(prefs, 'Unknown'); + + expect( + prefs.getStringList(AutoConnectPreferences.connectedDeviceNamesKey), + ['OpenEarable 2', 'OpenEarable 3'], + ); + }); + + test('countRememberedDeviceName returns normalized occurrence counts', + () async { + SharedPreferences.setMockInitialValues({ + AutoConnectPreferences.connectedDeviceNamesKey: [ + 'OpenEarable 2', + ' OpenEarable 2 ', + 'OpenEarable 3', + ], + }); + + final prefs = await SharedPreferences.getInstance(); + + expect( + AutoConnectPreferences.countRememberedDeviceName( + prefs, + 'OpenEarable 2', + ), + 2, + ); + expect( + AutoConnectPreferences.countRememberedDeviceName( + prefs, + ' OpenEarable 3 ', + ), + 1, + ); + expect( + AutoConnectPreferences.countRememberedDeviceName(prefs, 'Unknown'), + 0, + ); + }); + + test('changes stream emits for remember and forget updates', () async { + final prefs = await SharedPreferences.getInstance(); + + final rememberChange = AutoConnectPreferences.changes.first; + await AutoConnectPreferences.rememberDeviceName(prefs, 'OpenEarable 9'); + await expectLater(rememberChange, completes); + + final forgetChange = AutoConnectPreferences.changes.first; + await AutoConnectPreferences.forgetDeviceName(prefs, 'OpenEarable 9'); + await expectLater(forgetChange, completes); + }); + + test('auto-connect enabled defaults to true when no value is stored', + () async { + final loaded = await AutoConnectPreferences.loadAutoConnectEnabled(); + + expect(loaded, isTrue); + expect(AutoConnectPreferences.autoConnectEnabled, isTrue); + }); + + test('saveAutoConnectEnabled persists value and emits changes', () async { + final changed = AutoConnectPreferences.changes.first; + final saved = await AutoConnectPreferences.saveAutoConnectEnabled(false); + final prefs = await SharedPreferences.getInstance(); + + expect(saved, isFalse); + expect( + prefs.getBool(AutoConnectPreferences.autoConnectEnabledKey), + false, + ); + expect(AutoConnectPreferences.autoConnectEnabled, isFalse); + await expectLater(changed, completes); + }); + }); +} diff --git a/open_wearable/test/models/sensor_configuration_storage_test.dart b/open_wearable/test/models/sensor_configuration_storage_test.dart new file mode 100644 index 00000000..67b1cf5c --- /dev/null +++ b/open_wearable/test/models/sensor_configuration_storage_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/view_models/sensor_configuration_storage.dart'; + +void main() { + group('SensorConfigurationStorage scope helpers', () { + test('normalizes device names and firmware versions for scope keys', () { + expect( + SensorConfigurationStorage.normalizeDeviceNameForScope( + ' OpenRing Pro (Left) ', + ), + 'openring_pro__left_', + ); + expect( + SensorConfigurationStorage.normalizeFirmwareVersionForScope( + ' V1.2.3-beta ', + ), + '1_2_3-beta', + ); + }); + + test('builds name and firmware scopes', () { + final nameScope = + SensorConfigurationStorage.deviceNameScope('OpenRing 2'); + final firmwareScope = SensorConfigurationStorage.deviceNameFirmwareScope( + deviceName: 'OpenRing 2', + firmwareVersion: 'v1.0.0', + ); + + expect(nameScope, 'name_openring_2'); + expect(firmwareScope, 'name_openring_2__fw_1_0_0'); + }); + + test('returns null firmware scope when firmware version is missing', () { + expect( + SensorConfigurationStorage.deviceNameFirmwareScope( + deviceName: 'OpenRing 2', + firmwareVersion: null, + ), + isNull, + ); + expect( + SensorConfigurationStorage.deviceNameFirmwareScope( + deviceName: 'OpenRing 2', + firmwareVersion: ' ', + ), + isNull, + ); + }); + }); + + group('DeviceProfileScopeMatch', () { + test('matches firmware-scoped keys when firmware is available', () { + final match = DeviceProfileScopeMatch.forDevice( + deviceName: 'OpenRing 2', + firmwareVersion: 'v1.0.0', + ); + final firmwareKey = SensorConfigurationStorage.buildScopedKey( + scope: match.saveScope, + name: 'Default', + ); + final nameOnlyKey = SensorConfigurationStorage.buildScopedKey( + scope: SensorConfigurationStorage.deviceNameScope('OpenRing 2'), + name: 'Default', + ); + + expect(match.saveScope, 'name_openring_2__fw_1_0_0'); + expect(match.matchesScopedKey(firmwareKey), isTrue); + expect(match.matchesScopedKey(nameOnlyKey), isFalse); + }); + + test('matches name-scoped keys when firmware is unavailable', () { + final match = DeviceProfileScopeMatch.forDevice( + deviceName: 'OpenRing 2', + firmwareVersion: null, + ); + final nameOnlyKey = SensorConfigurationStorage.buildScopedKey( + scope: match.saveScope, + name: 'Default', + ); + final otherNameKey = SensorConfigurationStorage.buildScopedKey( + scope: SensorConfigurationStorage.deviceNameScope('OpenRing X'), + name: 'Default', + ); + + expect(match.saveScope, 'name_openring_2'); + expect(match.matchesScopedKey(nameOnlyKey), isTrue); + expect(match.matchesScopedKey(otherNameKey), isFalse); + }); + + test('does not match legacy id-scoped keys', () { + final match = DeviceProfileScopeMatch.forDevice( + deviceName: 'OpenRing 2', + firmwareVersion: '1.0.0', + ); + final legacyScopedKey = SensorConfigurationStorage.buildScopedKey( + scope: 'device_1234', + name: 'OldProfile', + ); + + expect(match.matchesScopedKey(legacyScopedKey), isFalse); + expect(match.allowsKey(legacyScopedKey), isFalse); + }); + + test('allows legacy unscoped keys but rejects wrong scoped keys', () { + final match = DeviceProfileScopeMatch.forDevice( + deviceName: 'OpenRing 2', + firmwareVersion: '1.0.0', + ); + final wrongScopedKey = SensorConfigurationStorage.buildScopedKey( + scope: SensorConfigurationStorage.deviceNameFirmwareScope( + deviceName: 'OpenRing X', + firmwareVersion: '1.0.0', + )!, + name: 'Profile', + ); + + expect(match.allowsKey('legacy_shared_profile'), isTrue); + expect(match.allowsKey(wrongScopedKey), isFalse); + }); + }); +} diff --git a/open_wearable/test/widget_test.dart b/open_wearable/test/widget_test.dart index f012e6aa..629b2769 100644 --- a/open_wearable/test/widget_test.dart +++ b/open_wearable/test/widget_test.dart @@ -1,30 +1,34 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read PlatformText, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:open_wearable/main.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/home_page.dart'; +import 'package:provider/provider.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); + testWidgets('Home shell shows top-level navigation', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => WearablesProvider()), + ChangeNotifierProvider(create: (_) => SensorRecorderProvider()), + ChangeNotifierProvider( + create: (_) => FirmwareUpdateRequestProvider(), + ), + ], + child: const MaterialApp( + home: HomePage(), + ), + ), + ); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('Overview'), findsWidgets); + expect(find.text('Devices'), findsWidgets); + expect(find.text('Sensors'), findsWidgets); + expect(find.text('Apps'), findsWidgets); + expect(find.text('Settings'), findsWidgets); }); }