diff --git a/open_wearable/lib/models/app_upgrade_registry.dart b/open_wearable/lib/models/app_upgrade_registry.dart index d34a0ff8..d5d04ab8 100644 --- a/open_wearable/lib/models/app_upgrade_registry.dart +++ b/open_wearable/lib/models/app_upgrade_registry.dart @@ -142,6 +142,43 @@ class AppUpgradeRegistry { ), ], ), + AppUpgradeHighlight( + version: '1.4.0', + eyebrow: 'OpenWearables 1.4.0', + title: 'Tune live graphs\nwhile streams are running', + summary: + 'Configure filters and channel visibility directly from live sensor graphs.', + heroDescription: + 'Live data graphs are now easier to inspect while streams are running. Open a channel chip to show or hide that channel, then choose raw, low-pass, high-pass, band-pass, or notch filtering for the preview.', + accentColor: Color(0xFF8F6A67), + useHeroGradient: false, + features: [ + AppUpgradeFeatureHighlight( + icon: Icons.filter_alt_rounded, + title: 'Filtering', + description: + 'Apply raw, low-pass, high-pass, band-pass, or notch filters to the live graph preview for each channel.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.tune_rounded, + title: 'Channel configuration sheets', + description: + 'Tap a graph channel to open a focused configuration sheet with visibility and filter controls.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.visibility_rounded, + title: 'Clearer graph states', + description: + 'Disabled channels are visually distinct, and graph controls better communicate that they can be configured.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.battery_saver_rounded, + title: 'Power saving modes', + description: + 'Choose the idle auto-off behavior for supported OpenEarable devices to help reduce battery drain.', + ), + ], + ), ]; /// Returns the configured highlight for [version], if any. diff --git a/open_wearable/lib/widgets/fota/fota_verification_banner.dart b/open_wearable/lib/widgets/fota/fota_verification_banner.dart index d78f6d10..4e265394 100644 --- a/open_wearable/lib/widgets/fota/fota_verification_banner.dart +++ b/open_wearable/lib/widgets/fota/fota_verification_banner.dart @@ -119,6 +119,7 @@ class _FotaVerificationBannerState extends State { ), ), ), + const SizedBox(height: 8), AppBanner( backgroundColor: warningBackground, foregroundColor: warningForeground, 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 1ed81ef6..dc9425ab 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 @@ -184,6 +184,12 @@ class SensorConfigurationValueRow extends StatelessWidget { ) { showPlatformModalSheet( context: context, + material: MaterialModalSheetData( + isScrollControlled: true, + showDragHandle: true, + isDismissible: true, + enableDrag: true, + ), builder: (modalContext) { return ChangeNotifierProvider.value( value: sensorConfigNotifier, diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart.dart index 6885a31f..7f08a18b 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_chart.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart.dart @@ -1,17 +1,30 @@ import 'dart:collection'; import 'dart:math'; -import 'package:flutter/material.dart'; + import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/view_models/sensor_configuration_provider.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/values/live_data_graph_settings.dart'; import 'package:provider/provider.dart'; +part 'sensor_chart/axis_channel_chip.dart'; +part 'sensor_chart/axis_configuration_sheet.dart'; +part 'sensor_chart/axis_display_filter.dart'; +part 'sensor_chart/axis_filter_engine.dart'; +part 'sensor_chart/axis_filter_config.dart'; + /// Displays a provider-backed live line chart for a wearable sensor. class SensorChart extends StatefulWidget { - /// Whether users can toggle individual sensor axes. - final bool allowToggleAxes; + /// Whether the chart uses the compact embedded-card layout. + final bool compactMode; + + /// Whether users can open the per-axis configuration sheet. + final bool allowAxisConfiguration; /// Shared live graph policy controlling visibility and sample updates. final LiveDataGraphSettings settings; @@ -21,7 +34,8 @@ class SensorChart extends StatefulWidget { const SensorChart({ super.key, - this.allowToggleAxes = true, + this.compactMode = false, + this.allowAxisConfiguration = true, this.settings = LiveDataGraphSettings.enabled, this.onDisabledTap, }); @@ -42,6 +56,8 @@ class _SensorChartState extends State { ]; late Map _axisEnabled; + late Map _axisFilters; + late Map _axisDisplayFilters; late String _sensorIdentity; @override @@ -58,9 +74,17 @@ class _SensorChartState extends State { _syncAxisState(sensor); } - void _toggleAxis(String axisName, bool value) { + void _setAxisVisible(String axisName, bool value) { setState(() { _axisEnabled[axisName] = value; + _axisDisplayFilters.remove(axisName); + }); + } + + void _setAxisFilter(String axisName, _AxisFilterConfig filter) { + setState(() { + _axisFilters[axisName] = filter; + _axisDisplayFilters.remove(axisName); }); } @@ -69,6 +93,34 @@ class _SensorChartState extends State { final dataProvider = widget.settings.liveUpdatesEnabled ? context.watch() : context.read(); + final sensorConfigurationProvider = _sensorConfigurationProviderFor( + context, + dataProvider.wearable, + ); + + if (sensorConfigurationProvider == null) { + return _buildChart( + context, + dataProvider: dataProvider, + sensorConfigurationProvider: null, + ); + } + + return ListenableBuilder( + listenable: sensorConfigurationProvider, + builder: (context, _) => _buildChart( + context, + dataProvider: dataProvider, + sensorConfigurationProvider: sensorConfigurationProvider, + ), + ); + } + + Widget _buildChart( + BuildContext context, { + required SensorDataProvider dataProvider, + required SensorConfigurationProvider? sensorConfigurationProvider, + }) { final sensor = dataProvider.sensor; _syncAxisState(sensor); final sensorValues = widget.settings.liveUpdatesEnabled @@ -76,14 +128,24 @@ class _SensorChartState extends State { : Queue(); final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final compactMode = !widget.allowToggleAxes; + final compactMode = widget.compactMode; final referenceTimestamp = dataProvider.displayTimestamp; + final visibleAxes = { + for (final axis in sensor.axisNames) + if (_axisEnabled[axis] ?? false) axis, + }; + final frequencyBounds = _frequencyBoundsForSensor( + sensor, + sensorConfigurationProvider: sensorConfigurationProvider, + ); final axisData = _buildAxisData( sensor, sensorValues, + visibleAxes: visibleAxes, windowSeconds: dataProvider.timeWindow.toDouble(), referenceTimestamp: referenceTimestamp, + frequencyBounds: frequencyBounds, ); final enabledSeries = <_AxisSeries>[ for (int i = 0; i < sensor.axisNames.length; i++) @@ -103,7 +165,6 @@ class _SensorChartState extends State { final minX = -windowSeconds; final yAxisBounds = _computeYAxisBounds(enabledSeries); - final axisChipTextStyle = theme.textTheme.labelMedium; const disabledChipLabelColor = Color(0xFF8A8A8A); const disabledChipBackgroundColor = Color(0xFFECECEC); const disabledChipBorderColor = Color(0xFFD7D7D7); @@ -227,9 +288,6 @@ class _SensorChartState extends State { .toList(growable: false), ); - final enabledAxes = - sensor.axisNames.where((axis) => _axisEnabled[axis] ?? false).toList(); - return Column( children: [ Expanded( @@ -276,64 +334,41 @@ class _SensorChartState extends State { 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; + final filter = _effectiveAxisFilter( + axisName, + frequencyBounds, + ); 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, - ), + child: _AxisChannelChip( + axisName: axisName, + statusLabel: _axisChipStatusLabel( + filter: filter, ), - avatar: Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: selected - ? chipDotColor - : disabledDotColor, - shape: BoxShape.circle, - ), - ), - selected: selected, - onSelected: widget.settings.liveUpdatesEnabled - ? (value) => _toggleAxis(axisName, value) + dotColor: + selected ? axisColor : disabledChipDotColor, + labelColor: selected + ? axisColor.withValues(alpha: 0.95) + : disabledChipLabelColor, + backgroundColor: selected + ? axisColor.withValues(alpha: 0.18) + : disabledChipBackgroundColor, + borderColor: selected + ? axisColor.withValues(alpha: 0.28) + : disabledChipBorderColor, + dottedBorder: !selected, + compact: compactMode, + onTap: widget.settings.liveUpdatesEnabled && + widget.allowAxisConfiguration + ? () => _openAxisConfigurationSheet( + context: context, + sensor: sensor, + dataProvider: dataProvider, + axisName: axisName, + axisColor: axisColor, + ) : 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), @@ -354,20 +389,329 @@ class _SensorChartState extends State { ), ), ), - 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, - ), - ), - ), ], ); } + void _openAxisConfigurationSheet({ + required BuildContext context, + required Sensor sensor, + required SensorDataProvider dataProvider, + required String axisName, + required Color axisColor, + }) { + final frequencyBounds = _frequencyBoundsForSensor( + sensor, + sensorConfigurationProvider: _sensorConfigurationProviderFor( + context, + dataProvider.wearable, + ), + ); + final existingFilter = + _axisFilters[axisName] ?? const _AxisFilterConfig.raw(); + final clampedFilter = existingFilter.clampedTo(frequencyBounds); + if (existingFilter != clampedFilter) { + setState(() { + _axisFilters[axisName] = clampedFilter; + _axisDisplayFilters.remove(axisName); + }); + } + + showPlatformModalSheet( + context: context, + material: MaterialModalSheetData( + isScrollControlled: true, + showDragHandle: true, + isDismissible: true, + enableDrag: true, + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (sheetContext, setSheetState) { + final visible = _axisEnabled[axisName] ?? true; + final filter = + (_axisFilters[axisName] ?? const _AxisFilterConfig.raw()) + .clampedTo(frequencyBounds); + + void updateVisible(bool value) { + _setAxisVisible(axisName, value); + setSheetState(() {}); + } + + void updateFilter(_AxisFilterConfig value) { + _setAxisFilter(axisName, value.clampedTo(frequencyBounds)); + setSheetState(() {}); + } + + void applyFilterToAll() { + setState(() { + for (final axis in sensor.axisNames) { + _axisFilters[axis] = filter; + _axisDisplayFilters.remove(axis); + } + }); + setSheetState(() {}); + } + + void resetChannel() { + setState(() { + _axisEnabled[axisName] = true; + _axisFilters[axisName] = + const _AxisFilterConfig.raw().clampedTo(frequencyBounds); + _axisDisplayFilters.remove(axisName); + }); + setSheetState(() {}); + } + + final theme = Theme.of(sheetContext); + final colorScheme = theme.colorScheme; + final sensorTitle = _axisConfigurationSensorTitle(sensor); + final mediaQuery = MediaQuery.of(sheetContext); + return SafeArea( + child: AnimatedPadding( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: EdgeInsets.only(bottom: mediaQuery.viewInsets.bottom), + child: SizedBox( + height: mediaQuery.size.height * 0.82, + child: Material( + color: colorScheme.surface, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + if (sensorTitle.isNotEmpty) ...[ + Flexible( + fit: FlexFit.loose, + child: Text( + sensorTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + ], + _AxisHeaderChannelPill( + axisName: axisName, + axisColor: axisColor, + ), + ], + ), + const SizedBox(height: 2), + Text( + 'Configure graph visibility and filtering. Recordings are unaffected.', + style: + theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Close', + onPressed: () => + Navigator.of(sheetContext).pop(), + icon: const Icon(Icons.close_rounded, size: 20), + ), + ], + ), + ), + Expanded( + child: _AxisConfigurationPanel( + key: ValueKey('axis_config_$axisName'), + axisColor: axisColor, + visible: visible, + filter: filter, + frequencyBounds: frequencyBounds, + onVisibleChanged: updateVisible, + onFilterChanged: updateFilter, + onApplyFilterToAll: applyFilterToAll, + onResetChannel: resetChannel, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + } + + String _axisConfigurationSensorTitle(Sensor sensor) => + sensor.sensorName.trim(); + + String _axisChipStatusLabel({required _AxisFilterConfig filter}) { + return _axisFilterShortLabel(filter); + } + + _AxisFilterConfig _effectiveAxisFilter( + String axisName, + _FilterFrequencyBounds frequencyBounds, + ) { + return (_axisFilters[axisName] ?? const _AxisFilterConfig.raw()).clampedTo( + frequencyBounds, + ); + } + + SensorConfigurationProvider? _sensorConfigurationProviderFor( + BuildContext context, + Wearable wearable, + ) { + try { + return context + .read() + .getSensorConfigurationProvider(wearable); + } catch (_) { + return null; + } + } + + _FilterFrequencyBounds _frequencyBoundsForSensor( + Sensor sensor, { + SensorConfigurationProvider? sensorConfigurationProvider, + }) { + final activeStreamingRates = []; + final streamingRates = []; + final fallbackRates = []; + + for (final configuration in sensor.relatedConfigurations) { + final activeValue = _activeFrequencyValueForConfiguration( + configuration, + sensorConfigurationProvider, + ); + if (activeValue != null && + activeValue.frequencyHz > 0 && + _isStreamingFrequencyValue(configuration, activeValue)) { + activeStreamingRates.add(activeValue.frequencyHz); + } + + for (final value in configuration.values) { + if (value is! SensorFrequencyConfigurationValue || + value.frequencyHz <= 0) { + continue; + } + + fallbackRates.add(value.frequencyHz); + + if (_isStreamingFrequencyValue(configuration, value)) { + streamingRates.add(value.frequencyHz); + } + } + } + + final rates = activeStreamingRates.isNotEmpty + ? activeStreamingRates + : (streamingRates.isNotEmpty ? streamingRates : fallbackRates); + if (rates.isEmpty) { + return const _FilterFrequencyBounds.fallback(); + } + + final maxSamplingRateHz = rates.reduce(max); + return _FilterFrequencyBounds( + maxCutoffHz: max( + _FilterFrequencyBounds.defaultMinCutoffHz, + maxSamplingRateHz / 2, + ), + maxSamplingRateHz: maxSamplingRateHz, + ); + } + + SensorFrequencyConfigurationValue? _activeFrequencyValueForConfiguration( + SensorConfiguration configuration, + SensorConfigurationProvider? sensorConfigurationProvider, + ) { + final selectedValue = + sensorConfigurationProvider?.getSelectedConfigurationValue( + configuration, + ); + if (selectedValue is SensorFrequencyConfigurationValue) { + return selectedValue; + } + + final reportedValue = + sensorConfigurationProvider?.getLastReportedConfigurationValue( + configuration, + ); + if (reportedValue is SensorFrequencyConfigurationValue) { + return reportedValue; + } + + final dynamic configurationDynamic = configuration; + try { + final currentValue = configurationDynamic.currentValue; + if (currentValue is SensorFrequencyConfigurationValue) { + return currentValue; + } + } catch (_) { + // Fall back to advertised values when the configuration has no current + // value API. + } + return null; + } + + bool _isStreamingFrequencyValue( + SensorConfiguration configuration, + SensorFrequencyConfigurationValue value, + ) { + final SensorConfigurationValue configurationValue = value; + if (configurationValue is ConfigurableSensorConfigurationValue) { + return configurationValue.options.contains( + const StreamSensorConfigOption(), + ); + } + + return configuration is! ConfigurableSensorConfiguration || + configuration.availableOptions.contains( + const StreamSensorConfigOption(), + ); + } + + String _axisFilterShortLabel(_AxisFilterConfig filter) { + if (!filter.hasActiveFilters) { + return 'Raw'; + } + + final labels = []; + if (filter.highPassEnabled && filter.lowPassEnabled) { + labels.add( + 'BP ${_formatNumber(filter.highPassCutoffHz)}-${_formatNumber(filter.lowPassCutoffHz)}Hz', + ); + } else if (filter.highPassEnabled) { + labels.add('HP ${_formatNumber(filter.highPassCutoffHz)}Hz'); + } else if (filter.lowPassEnabled) { + labels.add('LP ${_formatNumber(filter.lowPassCutoffHz)}Hz'); + } + + if (filter.notchEnabled) { + labels.add( + 'N ${_formatNumber(filter.notchCenterHz)}±${_formatNumber(filter.notchWidthHz / 2)}Hz', + ); + } + + return labels.isEmpty ? 'Raw' : labels.join(' + '); + } + double _toRelativeSeconds( Sensor sensor, int timestamp, { @@ -380,14 +724,30 @@ class _SensorChartState extends State { Map> _buildAxisData( Sensor sensor, Queue buffer, { + required Set visibleAxes, required double windowSeconds, required int referenceTimestamp, + required _FilterFrequencyBounds frequencyBounds, }) { final data = >{ for (var axis in sensor.axisNames) axis: [], }; if (buffer.isEmpty) return data; + final timestampScale = pow(10, -sensor.timestampExponent).toDouble(); + final filteredVisibleAxes = visibleAxes.where((axisName) { + final filter = _effectiveAxisFilter(axisName, frequencyBounds); + return filter.hasActiveFilters; + }).toList(growable: false); + if (filteredVisibleAxes.isNotEmpty && _axisDisplayFilters.isNotEmpty) { + final visibleTimestamps = { + for (final sensorValue in buffer) sensorValue.timestamp, + }; + for (final axisName in filteredVisibleAxes) { + _axisDisplayFilters[axisName]?.retainTimestamps(visibleTimestamps); + } + } + for (final sensorValue in buffer) { final x = _toRelativeSeconds( sensor, @@ -396,12 +756,34 @@ class _SensorChartState extends State { ).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])); + final axisName = sensor.axisNames[i]; + if (!visibleAxes.contains(axisName)) { + continue; + } + final displayValue = _displayValueForAxis( + axisName: axisName, + rawValue: sensorValue.values[i], + timestamp: sensorValue.timestamp, + timestampScale: timestampScale, + frequencyBounds: frequencyBounds, + ); + data[axisName]!.add(FlSpot(x, displayValue)); } } 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())); + final axisName = sensor.axisNames[i]; + if (!visibleAxes.contains(axisName)) { + continue; + } + final displayValue = _displayValueForAxis( + axisName: axisName, + rawValue: values[i].toDouble(), + timestamp: sensorValue.timestamp, + timestampScale: timestampScale, + frequencyBounds: frequencyBounds, + ); + data[axisName]!.add(FlSpot(x, displayValue)); } } } @@ -409,6 +791,45 @@ class _SensorChartState extends State { return data; } + double _displayValueForAxis({ + required String axisName, + required double rawValue, + required int timestamp, + required double timestampScale, + required _FilterFrequencyBounds frequencyBounds, + }) { + final config = _effectiveAxisFilter(axisName, frequencyBounds); + if (!config.hasActiveFilters) { + return rawValue; + } + + return _displayFilterForAxis( + axisName: axisName, + config: config, + timestampScale: timestampScale, + ).apply(rawValue, timestamp); + } + + _AxisDisplayFilterCache _displayFilterForAxis({ + required String axisName, + required _AxisFilterConfig config, + required double timestampScale, + }) { + final existing = _axisDisplayFilters[axisName]; + if (existing != null && + existing.config == config && + existing.timestampScale == timestampScale) { + return existing; + } + + final next = _AxisDisplayFilterCache( + config: config, + timestampScale: timestampScale, + ); + _axisDisplayFilters[axisName] = next; + return next; + } + _YAxisBounds _computeYAxisBounds(List<_AxisSeries> seriesList) { var minY = double.infinity; var maxY = double.negativeInfinity; @@ -443,6 +864,10 @@ class _SensorChartState extends State { void _initializeAxisState(Sensor sensor) { _sensorIdentity = _sensorKey(sensor); _axisEnabled = {for (final axis in sensor.axisNames) axis: true}; + _axisFilters = { + for (final axis in sensor.axisNames) axis: const _AxisFilterConfig.raw(), + }; + _axisDisplayFilters = {}; } void _syncAxisState(Sensor sensor) { @@ -453,7 +878,11 @@ class _SensorChartState extends State { } final hasSameAxes = _axisEnabled.length == sensor.axisNames.length && - sensor.axisNames.every((axis) => _axisEnabled.containsKey(axis)); + _axisFilters.length == sensor.axisNames.length && + sensor.axisNames.every( + (axis) => + _axisEnabled.containsKey(axis) && _axisFilters.containsKey(axis), + ); if (hasSameAxes) { return; } @@ -461,6 +890,12 @@ class _SensorChartState extends State { _axisEnabled = { for (final axis in sensor.axisNames) axis: _axisEnabled[axis] ?? true, }; + _axisFilters = { + for (final axis in sensor.axisNames) + axis: _axisFilters[axis] ?? const _AxisFilterConfig.raw(), + }; + final axisNames = sensor.axisNames.toSet(); + _axisDisplayFilters.removeWhere((axis, _) => !axisNames.contains(axis)); } String _sensorKey(Sensor sensor) => diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_channel_chip.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_channel_chip.dart new file mode 100644 index 00000000..9f98de99 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_channel_chip.dart @@ -0,0 +1,188 @@ +part of '../sensor_chart.dart'; + +class _AxisChannelChip extends StatelessWidget { + final String axisName; + final String statusLabel; + final Color dotColor; + final Color labelColor; + final Color backgroundColor; + final Color borderColor; + final bool dottedBorder; + final bool compact; + final VoidCallback? onTap; + + const _AxisChannelChip({ + required this.axisName, + required this.statusLabel, + required this.dotColor, + required this.labelColor, + required this.backgroundColor, + required this.borderColor, + required this.dottedBorder, + required this.compact, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final height = compact ? 28.0 : 30.0; + final fontSize = compact ? 10.5 : 11.5; + final separatorColor = labelColor.withValues(alpha: 0.28); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: onTap, + child: CustomPaint( + foregroundPainter: dottedBorder + ? _DottedPillBorderPainter(color: borderColor) + : null, + child: Container( + height: height, + padding: const EdgeInsets.fromLTRB(8, 0, 5, 0), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(999), + border: dottedBorder ? null : Border.all(color: borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: dotColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + axisName, + style: theme.textTheme.labelMedium?.copyWith( + color: labelColor, + fontWeight: FontWeight.w800, + fontSize: fontSize, + ), + ), + const SizedBox(width: 5), + Container( + width: 1, + height: compact ? 12 : 14, + color: separatorColor, + ), + const SizedBox(width: 5), + Text( + statusLabel, + style: theme.textTheme.labelMedium?.copyWith( + color: labelColor.withValues(alpha: 0.78), + fontWeight: FontWeight.w600, + fontSize: compact ? 9.8 : 10.8, + ), + ), + if (onTap != null) ...[ + const SizedBox(width: 2), + Icon( + Icons.keyboard_arrow_down_rounded, + size: compact ? 15 : 16, + color: labelColor.withValues(alpha: 0.8), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _AxisHeaderChannelPill extends StatelessWidget { + final String axisName; + final Color axisColor; + + const _AxisHeaderChannelPill({ + required this.axisName, + required this.axisColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minHeight: 26), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: axisColor.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: axisColor.withValues(alpha: 0.32), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: axisColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Flexible( + child: Text( + axisName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: axisColor.withValues(alpha: 0.95), + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ); + } +} + +class _DottedPillBorderPainter extends CustomPainter { + final Color color; + + const _DottedPillBorderPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + const dotRadius = 0.85; + const gap = 3.0; + final rect = Offset.zero & size; + final rrect = RRect.fromRectAndRadius( + rect.deflate(dotRadius), + Radius.circular(size.height / 2), + ); + final path = Path()..addRRect(rrect); + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill + ..isAntiAlias = true; + + for (final metric in path.computeMetrics()) { + for (var distance = 0.0; + distance < metric.length; + distance += (dotRadius * 2) + gap) { + final tangent = metric.getTangentForOffset(distance); + if (tangent != null) { + canvas.drawCircle(tangent.position, dotRadius, paint); + } + } + } + } + + @override + bool shouldRepaint(covariant _DottedPillBorderPainter oldDelegate) { + return oldDelegate.color != color; + } +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_configuration_sheet.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_configuration_sheet.dart new file mode 100644 index 00000000..f2827141 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_configuration_sheet.dart @@ -0,0 +1,708 @@ +part of '../sensor_chart.dart'; + +class _AxisConfigurationPanel extends StatelessWidget { + final Color axisColor; + final bool visible; + final _AxisFilterConfig filter; + final _FilterFrequencyBounds frequencyBounds; + final ValueChanged onVisibleChanged; + final ValueChanged<_AxisFilterConfig> onFilterChanged; + final VoidCallback onApplyFilterToAll; + final VoidCallback onResetChannel; + + const _AxisConfigurationPanel({ + super.key, + required this.axisColor, + required this.visible, + required this.filter, + required this.frequencyBounds, + required this.onVisibleChanged, + required this.onFilterChanged, + required this.onApplyFilterToAll, + required this.onResetChannel, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return ListView( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + children: [ + _ChannelVisibilityTile( + accentColor: axisColor, + visible: visible, + onChanged: onVisibleChanged, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Divider( + height: 1, + thickness: 1, + color: colorScheme.outlineVariant.withValues(alpha: 0.65), + ), + ), + _FilterToggleTile( + accentColor: axisColor, + icon: Icons.trending_down_rounded, + title: 'Low-pass filter', + subtitle: 'Attenuates components above the cutoff.', + value: filter.lowPassEnabled, + onChanged: (value) { + onFilterChanged( + filter.copyWith(lowPassEnabled: value).clampedTo( + frequencyBounds, + ), + ); + }, + child: _SingleCutoffFields( + frequencyHz: filter.lowPassCutoffHz, + order: filter.lowPassOrder, + frequencyBounds: frequencyBounds, + frequencyLabel: 'Cutoff frequency', + frequencyHelperText: 'Low-pass cutoff frequency.', + orderLabel: 'Low-pass order', + extraFrequencyValidator: (value) { + if (filter.highPassEnabled && value <= filter.highPassCutoffHz) { + return 'Must be above high-pass cutoff.'; + } + return null; + }, + onChanged: (frequencyHz, order) { + onFilterChanged( + filter + .copyWith( + lowPassEnabled: true, + lowPassCutoffHz: frequencyHz, + lowPassOrder: order, + ) + .clampedTo(frequencyBounds), + ); + }, + ), + ), + const SizedBox(height: 8), + _FilterToggleTile( + accentColor: axisColor, + icon: Icons.trending_up_rounded, + title: 'High-pass filter', + subtitle: 'Attenuates components below the cutoff.', + value: filter.highPassEnabled, + onChanged: (value) { + onFilterChanged( + filter.copyWith(highPassEnabled: value).clampedTo( + frequencyBounds, + ), + ); + }, + child: _SingleCutoffFields( + frequencyHz: filter.highPassCutoffHz, + order: filter.highPassOrder, + frequencyBounds: frequencyBounds, + frequencyLabel: 'Cutoff frequency', + frequencyHelperText: 'High-pass cutoff frequency.', + orderLabel: 'High-pass order', + extraFrequencyValidator: (value) { + if (filter.lowPassEnabled && value >= filter.lowPassCutoffHz) { + return 'Must be below low-pass cutoff.'; + } + return null; + }, + onChanged: (frequencyHz, order) { + onFilterChanged( + filter + .copyWith( + highPassEnabled: true, + highPassCutoffHz: frequencyHz, + highPassOrder: order, + ) + .clampedTo(frequencyBounds), + ); + }, + ), + ), + const SizedBox(height: 8), + _FilterToggleTile( + accentColor: axisColor, + icon: Icons.horizontal_rule_rounded, + title: 'Notch filter', + subtitle: 'Attenuates one frequency band.', + value: filter.notchEnabled, + onChanged: (value) { + onFilterChanged( + filter.copyWith(notchEnabled: value).clampedTo(frequencyBounds), + ); + }, + child: _NotchFields( + centerHz: filter.notchCenterHz, + widthHz: filter.notchWidthHz, + order: filter.notchOrder, + frequencyBounds: frequencyBounds, + onChanged: (centerHz, widthHz, order) { + onFilterChanged( + filter + .copyWith( + notchEnabled: true, + notchCenterHz: centerHz, + notchWidthHz: widthHz, + notchOrder: order, + ) + .clampedTo(frequencyBounds), + ); + }, + ), + ), + const SizedBox(height: 14), + Row( + children: [ + TextButton.icon( + onPressed: onResetChannel, + icon: const Icon(Icons.restart_alt_rounded, size: 18), + label: const Text('Reset'), + ), + const Spacer(), + TextButton.icon( + onPressed: onApplyFilterToAll, + icon: const Icon(Icons.copy_all_rounded, size: 18), + label: const Text('Apply to all'), + ), + ], + ), + ], + ); + } +} + +class _ChannelVisibilityTile extends StatelessWidget { + final Color accentColor; + final bool visible; + final ValueChanged onChanged; + + const _ChannelVisibilityTile({ + required this.accentColor, + required this.visible, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return _FilterToggleTile( + accentColor: accentColor, + icon: visible ? Icons.visibility_rounded : Icons.visibility_off_rounded, + title: 'Show channel', + subtitle: 'Hide or show this channel in the graph.', + value: visible, + onChanged: onChanged, + ); + } +} + +class _FilterToggleTile extends StatelessWidget { + final Color accentColor; + final IconData icon; + final String title; + final String subtitle; + final bool value; + final ValueChanged onChanged; + final Widget? child; + + const _FilterToggleTile({ + required this.accentColor, + required this.icon, + required this.title, + required this.subtitle, + required this.value, + required this.onChanged, + this.child, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final foreground = value ? accentColor : colorScheme.onSurface; + + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: value ? accentColor.withValues(alpha: 0.06) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: (value ? accentColor : colorScheme.outlineVariant) + .withValues(alpha: value ? 0.35 : 0.25), + ), + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 15, color: foreground), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + 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: value, + activeThumbColor: colorScheme.surface, + activeTrackColor: accentColor, + inactiveThumbColor: colorScheme.surface, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: onChanged, + ), + ], + ), + if (value && child != null) ...[ + const SizedBox(height: 10), + child!, + ], + ], + ), + ); + } +} + +class _SingleCutoffFields extends StatefulWidget { + final double frequencyHz; + final int order; + final _FilterFrequencyBounds frequencyBounds; + final String frequencyLabel; + final String frequencyHelperText; + final String orderLabel; + final String? Function(double value)? extraFrequencyValidator; + final void Function(double frequencyHz, int order) onChanged; + + const _SingleCutoffFields({ + required this.frequencyHz, + required this.order, + required this.frequencyBounds, + required this.frequencyLabel, + required this.frequencyHelperText, + required this.orderLabel, + required this.onChanged, + this.extraFrequencyValidator, + }); + + @override + State<_SingleCutoffFields> createState() => _SingleCutoffFieldsState(); +} + +class _SingleCutoffFieldsState extends State<_SingleCutoffFields> { + final _formKey = GlobalKey(); + late final TextEditingController _frequencyController; + late final TextEditingController _orderController; + + @override + void initState() { + super.initState(); + _frequencyController = TextEditingController( + text: _formatNumber(widget.frequencyHz), + ); + _orderController = TextEditingController(text: widget.order.toString()); + } + + @override + void didUpdateWidget(covariant _SingleCutoffFields oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.frequencyHz != oldWidget.frequencyHz) { + _replaceText(_frequencyController, _formatNumber(widget.frequencyHz)); + } + if (widget.order != oldWidget.order) { + _replaceText(_orderController, widget.order.toString()); + } + } + + @override + void dispose() { + _frequencyController.dispose(); + _orderController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + children: [ + _FrequencyInputField( + controller: _frequencyController, + label: widget.frequencyLabel, + helperText: widget.frequencyHelperText, + validator: _validateFrequency, + onChanged: _commitIfValid, + ), + const SizedBox(height: 10), + _OrderInputField( + controller: _orderController, + label: widget.orderLabel, + validator: _validateOrder, + onChanged: _commitIfValid, + ), + ], + ), + ); + } + + void _commitIfValid() { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + widget.onChanged( + double.parse(_frequencyController.text), + int.parse(_orderController.text), + ); + } + + String? _validateFrequency(String? value) { + final parsed = _parseFrequency(value); + if (parsed == null) { + return 'Enter a number.'; + } + final rangeError = _validateFrequencyRange(parsed, widget.frequencyBounds); + if (rangeError != null) { + return rangeError; + } + return widget.extraFrequencyValidator?.call(parsed); + } + + String? _validateOrder(String? value) => _validateFilterOrder(value); +} + +class _NotchFields extends StatefulWidget { + final double centerHz; + final double widthHz; + final int order; + final _FilterFrequencyBounds frequencyBounds; + final void Function(double centerHz, double widthHz, int order) onChanged; + + const _NotchFields({ + required this.centerHz, + required this.widthHz, + required this.order, + required this.frequencyBounds, + required this.onChanged, + }); + + @override + State<_NotchFields> createState() => _NotchFieldsState(); +} + +class _NotchFieldsState extends State<_NotchFields> { + final _formKey = GlobalKey(); + late final TextEditingController _centerController; + late final TextEditingController _widthController; + late final TextEditingController _orderController; + + @override + void initState() { + super.initState(); + _centerController = TextEditingController( + text: _formatNumber(widget.centerHz), + ); + _widthController = TextEditingController( + text: _formatNumber(widget.widthHz), + ); + _orderController = TextEditingController(text: widget.order.toString()); + } + + @override + void didUpdateWidget(covariant _NotchFields oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.centerHz != oldWidget.centerHz) { + _replaceText(_centerController, _formatNumber(widget.centerHz)); + } + if (widget.widthHz != oldWidget.widthHz) { + _replaceText(_widthController, _formatNumber(widget.widthHz)); + } + if (widget.order != oldWidget.order) { + _replaceText(_orderController, widget.order.toString()); + } + } + + @override + void dispose() { + _centerController.dispose(); + _widthController.dispose(); + _orderController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + children: [ + _FrequencyInputField( + controller: _centerController, + label: 'Center', + helperText: 'Center frequency of the attenuated band.', + validator: _validateCenter, + onChanged: _commitIfValid, + ), + const SizedBox(height: 10), + _FrequencyInputField( + controller: _widthController, + label: 'Width', + helperText: 'Bandwidth around the center frequency.', + validator: _validateWidth, + onChanged: _commitIfValid, + ), + const SizedBox(height: 10), + _OrderInputField( + controller: _orderController, + label: 'Notch order', + helperText: + 'Even notch order; higher values deepen the attenuated band.', + validator: _validateNotchOrder, + onChanged: _commitIfValid, + ), + ], + ), + ); + } + + void _commitIfValid() { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + widget.onChanged( + double.parse(_centerController.text), + double.parse(_widthController.text), + int.parse(_orderController.text), + ); + } + + String? _validateCenter(String? value) { + final parsed = _parseFrequency(value); + if (parsed == null) { + return 'Enter a number.'; + } + final rangeError = _validateFrequencyRange(parsed, widget.frequencyBounds); + if (rangeError != null) { + return rangeError; + } + return _validateNotchEdges(centerHz: parsed); + } + + String? _validateWidth(String? value) { + final parsed = _parseFrequency(value); + if (parsed == null) { + return 'Enter a number.'; + } + if (parsed < widget.frequencyBounds.minCutoffHz) { + return 'Use at least ${_formatNumber(widget.frequencyBounds.minCutoffHz)} Hz.'; + } + if (parsed > widget.frequencyBounds.maxCutoffHz) { + return 'Use up to ${_formatNumber(widget.frequencyBounds.maxCutoffHz)} Hz.'; + } + return _validateNotchEdges(widthHz: parsed); + } + + String? _validateNotchEdges({ + double? centerHz, + double? widthHz, + }) { + final center = centerHz ?? _parseFrequency(_centerController.text); + final width = widthHz ?? _parseFrequency(_widthController.text); + if (center == null || width == null) { + return null; + } + + final lowEdge = center - width / 2; + final highEdge = center + width / 2; + if (lowEdge < widget.frequencyBounds.minCutoffHz || + highEdge > widget.frequencyBounds.maxCutoffHz) { + return 'Keep the band within ${_formatNumber(widget.frequencyBounds.minCutoffHz)}-${_formatNumber(widget.frequencyBounds.maxCutoffHz)} Hz.'; + } + return null; + } +} + +class _FrequencyInputField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String helperText; + final FormFieldValidator validator; + final VoidCallback onChanged; + + const _FrequencyInputField({ + required this.controller, + required this.label, + required this.helperText, + required this.validator, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')), + ], + decoration: _filterInputDecoration( + context, + label: label, + helperText: helperText, + suffixText: 'Hz', + ), + validator: validator, + onChanged: (_) => onChanged(), + ); + } +} + +class _OrderInputField extends StatelessWidget { + final TextEditingController controller; + final String label; + final String helperText; + final FormFieldValidator validator; + final VoidCallback onChanged; + + const _OrderInputField({ + required this.controller, + required this.label, + required this.validator, + required this.onChanged, + this.helperText = + 'Butterworth order; higher values steepen the transition band.', + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: _filterInputDecoration( + context, + label: label, + helperText: helperText, + ), + validator: validator, + onChanged: (_) => onChanged(), + ); + } +} + +InputDecoration _filterInputDecoration( + BuildContext context, { + required String label, + required String helperText, + String? suffixText, +}) { + final colorScheme = Theme.of(context).colorScheme; + return InputDecoration( + labelText: label, + helperText: helperText, + helperMaxLines: 2, + errorMaxLines: 2, + suffixText: suffixText, + 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: 9, + ), + ); +} + +String? _validateFrequencyRange( + double value, + _FilterFrequencyBounds frequencyBounds, +) { + if (value < frequencyBounds.minCutoffHz || + value > frequencyBounds.maxCutoffHz) { + return 'Use ${_formatNumber(frequencyBounds.minCutoffHz)}-${_formatNumber(frequencyBounds.maxCutoffHz)} Hz.'; + } + return null; +} + +String? _validateFilterOrder(String? value) { + final parsed = int.tryParse(value?.trim() ?? ''); + if (parsed == null) { + return 'Enter a whole number.'; + } + if (parsed < _AxisFilterConfig.minOrder || + parsed > _AxisFilterConfig.maxOrder) { + return 'Use ${_AxisFilterConfig.minOrder}-${_AxisFilterConfig.maxOrder}.'; + } + return null; +} + +String? _validateNotchOrder(String? value) { + final parsed = int.tryParse(value?.trim() ?? ''); + if (parsed == null) { + return 'Enter a whole number.'; + } + if (parsed < _AxisFilterConfig.minNotchOrder || + parsed > _AxisFilterConfig.maxNotchOrder || + parsed.isOdd) { + return 'Use 2, 4, 6, or 8.'; + } + return null; +} + +double? _parseFrequency(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + return double.tryParse(trimmed); +} + +void _replaceText(TextEditingController controller, String value) { + if (controller.text == value) { + return; + } + controller.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_display_filter.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_display_filter.dart new file mode 100644 index 00000000..415a2791 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_display_filter.dart @@ -0,0 +1,141 @@ +part of '../sensor_chart.dart'; + +class _AxisDisplayFilterCache { + final _AxisFilterConfig config; + final double timestampScale; + final _AxisDisplayFilter _filter; + final Map _valuesByTimestamp = {}; + int? _lastTimestamp; + + _AxisDisplayFilterCache({ + required this.config, + required this.timestampScale, + }) : _filter = _AxisDisplayFilter( + config: config, + timestampScale: timestampScale, + ); + + double apply(double input, int timestamp) { + final cachedValue = _valuesByTimestamp[timestamp]; + if (cachedValue != null) { + return cachedValue; + } + + final lastTimestamp = _lastTimestamp; + if (lastTimestamp != null && timestamp < lastTimestamp) { + _valuesByTimestamp.clear(); + _filter.reset(); + } + + final filteredValue = _filter.apply(input, timestamp); + _valuesByTimestamp[timestamp] = filteredValue; + _lastTimestamp = timestamp; + return filteredValue; + } + + void retainTimestamps(Set timestamps) { + _valuesByTimestamp.removeWhere((timestamp, _) { + return !timestamps.contains(timestamp); + }); + } +} + +class _AxisDisplayFilter { + final _AxisFilterConfig config; + final double timestampScale; + final List<_IirFilterStage> _highPassStages; + final List<_IirFilterStage> _lowPassStages; + final List<_IirFilterStage> _notchStages; + + int? _previousTimestamp; + + _AxisDisplayFilter({ + required this.config, + required this.timestampScale, + }) : _highPassStages = _buildHighPassStages(config), + _lowPassStages = _buildLowPassStages(config), + _notchStages = _buildNotchStages(config); + + double apply(double input, int timestamp) { + if (!config.hasActiveFilters) { + return input; + } + + final dt = _timeDeltaSeconds(timestamp); + var output = input; + for (final stage in _highPassStages) { + output = stage.apply(output, dt); + } + for (final stage in _lowPassStages) { + output = stage.apply(output, dt); + } + for (final stage in _notchStages) { + output = stage.apply(output, dt); + } + + return output; + } + + void reset() { + _previousTimestamp = null; + for (final stage in _highPassStages) { + stage.reset(); + } + for (final stage in _lowPassStages) { + stage.reset(); + } + for (final stage in _notchStages) { + stage.reset(); + } + } + + double _timeDeltaSeconds(int timestamp) { + final previousTimestamp = _previousTimestamp; + _previousTimestamp = timestamp; + if (previousTimestamp == null || + timestampScale <= 0 || + !timestampScale.isFinite) { + return 0; + } + return (timestamp - previousTimestamp).abs().toDouble() / timestampScale; + } + + static List<_IirFilterStage> _buildHighPassStages( + _AxisFilterConfig config, + ) { + if (!config.highPassEnabled) { + return const []; + } + return _buildButterworthStages( + type: _ButterworthFilterType.highPass, + cutoffHz: config.highPassCutoffHz, + order: config.highPassOrder, + ); + } + + static List<_IirFilterStage> _buildLowPassStages( + _AxisFilterConfig config, + ) { + if (!config.lowPassEnabled) { + return const []; + } + return _buildButterworthStages( + type: _ButterworthFilterType.lowPass, + cutoffHz: config.lowPassCutoffHz, + order: config.lowPassOrder, + ); + } + + static List<_IirFilterStage> _buildNotchStages( + _AxisFilterConfig config, + ) { + if (!config.notchEnabled) { + return const []; + } + return _buildNotchFilterStages( + centerHz: config.notchCenterHz, + widthHz: config.notchWidthHz, + order: config.notchOrder, + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_filter_config.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_filter_config.dart new file mode 100644 index 00000000..1579d181 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_filter_config.dart @@ -0,0 +1,199 @@ +part of '../sensor_chart.dart'; + +class _FilterFrequencyBounds { + static const double defaultMinCutoffHz = 0.01; + static const double fallbackMaxCutoffHz = 500; + + final double minCutoffHz; + final double maxCutoffHz; + final double? maxSamplingRateHz; + + const _FilterFrequencyBounds({ + required this.maxCutoffHz, + this.maxSamplingRateHz, + }) : minCutoffHz = defaultMinCutoffHz; + + const _FilterFrequencyBounds.fallback() + : minCutoffHz = defaultMinCutoffHz, + maxCutoffHz = fallbackMaxCutoffHz, + maxSamplingRateHz = null; +} + +class _AxisFilterConfig { + static const int minOrder = 1; + static const int maxOrder = 8; + static const int minNotchOrder = 2; + static const int maxNotchOrder = 8; + + final bool highPassEnabled; + final bool lowPassEnabled; + final bool notchEnabled; + final double highPassCutoffHz; + final double lowPassCutoffHz; + final double notchCenterHz; + final double notchWidthHz; + final int highPassOrder; + final int lowPassOrder; + final int notchOrder; + + const _AxisFilterConfig({ + required this.highPassEnabled, + required this.lowPassEnabled, + required this.notchEnabled, + required this.highPassCutoffHz, + required this.lowPassCutoffHz, + required this.notchCenterHz, + required this.notchWidthHz, + required this.highPassOrder, + required this.lowPassOrder, + required this.notchOrder, + }); + + const _AxisFilterConfig.raw() + : highPassEnabled = false, + lowPassEnabled = false, + notchEnabled = false, + highPassCutoffHz = 0.5, + lowPassCutoffHz = 20, + notchCenterHz = 50, + notchWidthHz = 2, + highPassOrder = 2, + lowPassOrder = 2, + notchOrder = 2; + + bool get hasActiveFilters { + return highPassEnabled || lowPassEnabled || notchEnabled; + } + + _AxisFilterConfig clampedTo(_FilterFrequencyBounds frequencyBounds) { + var nextHighPassCutoff = _clampFrequency(highPassCutoffHz, frequencyBounds); + var nextLowPassCutoff = _clampFrequency(lowPassCutoffHz, frequencyBounds); + if (highPassEnabled && + lowPassEnabled && + nextHighPassCutoff >= nextLowPassCutoff && + frequencyBounds.minCutoffHz < frequencyBounds.maxCutoffHz) { + nextHighPassCutoff = frequencyBounds.minCutoffHz; + nextLowPassCutoff = frequencyBounds.maxCutoffHz; + } + + var nextNotchWidth = _clampFrequency(notchWidthHz, frequencyBounds); + final maxWidth = + max(frequencyBounds.minCutoffHz, frequencyBounds.maxCutoffHz); + nextNotchWidth = nextNotchWidth.clamp( + frequencyBounds.minCutoffHz, + maxWidth, + ); + + var nextNotchCenter = _clampFrequency(notchCenterHz, frequencyBounds); + final halfWidth = nextNotchWidth / 2; + final centerMin = frequencyBounds.minCutoffHz + halfWidth; + final centerMax = frequencyBounds.maxCutoffHz - halfWidth; + if (centerMin <= centerMax) { + nextNotchCenter = nextNotchCenter.clamp(centerMin, centerMax).toDouble(); + } else { + nextNotchCenter = + (frequencyBounds.minCutoffHz + frequencyBounds.maxCutoffHz) / 2; + nextNotchWidth = max( + frequencyBounds.minCutoffHz, + frequencyBounds.maxCutoffHz - frequencyBounds.minCutoffHz, + ); + } + + return copyWith( + highPassCutoffHz: nextHighPassCutoff, + lowPassCutoffHz: nextLowPassCutoff, + notchCenterHz: nextNotchCenter, + notchWidthHz: nextNotchWidth, + highPassOrder: _clampOrder(highPassOrder), + lowPassOrder: _clampOrder(lowPassOrder), + notchOrder: _clampNotchOrder(notchOrder), + ); + } + + static double _clampFrequency( + double value, + _FilterFrequencyBounds frequencyBounds, + ) { + if (!value.isFinite) { + return frequencyBounds.minCutoffHz; + } + return value + .clamp(frequencyBounds.minCutoffHz, frequencyBounds.maxCutoffHz) + .toDouble(); + } + + static int _clampOrder(int value) { + return value.clamp(minOrder, maxOrder).toInt(); + } + + static int _clampNotchOrder(int value) { + final clamped = value.clamp(minNotchOrder, maxNotchOrder).toInt(); + if (clamped.isEven) { + return clamped; + } + return clamped == maxNotchOrder ? clamped - 1 : clamped + 1; + } + + _AxisFilterConfig copyWith({ + bool? highPassEnabled, + bool? lowPassEnabled, + bool? notchEnabled, + double? highPassCutoffHz, + double? lowPassCutoffHz, + double? notchCenterHz, + double? notchWidthHz, + int? highPassOrder, + int? lowPassOrder, + int? notchOrder, + }) { + return _AxisFilterConfig( + highPassEnabled: highPassEnabled ?? this.highPassEnabled, + lowPassEnabled: lowPassEnabled ?? this.lowPassEnabled, + notchEnabled: notchEnabled ?? this.notchEnabled, + highPassCutoffHz: highPassCutoffHz ?? this.highPassCutoffHz, + lowPassCutoffHz: lowPassCutoffHz ?? this.lowPassCutoffHz, + notchCenterHz: notchCenterHz ?? this.notchCenterHz, + notchWidthHz: notchWidthHz ?? this.notchWidthHz, + highPassOrder: highPassOrder ?? this.highPassOrder, + lowPassOrder: lowPassOrder ?? this.lowPassOrder, + notchOrder: notchOrder ?? this.notchOrder, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _AxisFilterConfig && + other.highPassEnabled == highPassEnabled && + other.lowPassEnabled == lowPassEnabled && + other.notchEnabled == notchEnabled && + other.highPassCutoffHz == highPassCutoffHz && + other.lowPassCutoffHz == lowPassCutoffHz && + other.notchCenterHz == notchCenterHz && + other.notchWidthHz == notchWidthHz && + other.highPassOrder == highPassOrder && + other.lowPassOrder == lowPassOrder && + other.notchOrder == notchOrder; + } + + @override + int get hashCode => Object.hash( + highPassEnabled, + lowPassEnabled, + notchEnabled, + highPassCutoffHz, + lowPassCutoffHz, + notchCenterHz, + notchWidthHz, + highPassOrder, + lowPassOrder, + notchOrder, + ); +} + +String _formatNumber(double value) { + final fixed = value.toStringAsFixed(value < 10 ? 2 : 1); + return fixed + .replaceFirst(RegExp(r'0+$'), '') + .replaceFirst(RegExp(r'\.$'), ''); +} diff --git a/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_filter_engine.dart b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_filter_engine.dart new file mode 100644 index 00000000..fe364c19 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/values/sensor_chart/axis_filter_engine.dart @@ -0,0 +1,367 @@ +part of '../sensor_chart.dart'; + +enum _ButterworthFilterType { lowPass, highPass } + +abstract class _IirFilterStage { + double apply(double input, double dt); + + void reset(); +} + +class _ButterworthFirstOrderStage implements _IirFilterStage { + final _ButterworthFilterType type; + final double cutoffHz; + + double _x1 = 0; + double _y1 = 0; + bool _primed = false; + + _ButterworthFirstOrderStage({ + required this.type, + required this.cutoffHz, + }); + + @override + double apply(double input, double dt) { + if (!_primed || dt <= 0 || !dt.isFinite) { + return _prime(input); + } + + final coefficients = _firstOrderCoefficients( + type: type, + cutoffHz: cutoffHz, + dt: dt, + ); + final output = + coefficients.b0 * input + coefficients.b1 * _x1 - coefficients.a1 * _y1; + _x1 = input; + _y1 = output; + return output; + } + + @override + void reset() { + _x1 = 0; + _y1 = 0; + _primed = false; + } + + double _prime(double input) { + _x1 = input; + _y1 = type == _ButterworthFilterType.lowPass ? input : 0; + _primed = true; + return _y1; + } +} + +class _ButterworthBiquadStage implements _IirFilterStage { + final _ButterworthFilterType type; + final double cutoffHz; + final double q; + + double _x1 = 0; + double _x2 = 0; + double _y1 = 0; + double _y2 = 0; + bool _primed = false; + + _ButterworthBiquadStage({ + required this.type, + required this.cutoffHz, + required this.q, + }); + + @override + double apply(double input, double dt) { + if (!_primed || dt <= 0 || !dt.isFinite) { + return _prime(input); + } + + final coefficients = _biquadCoefficients( + type: type, + cutoffHz: cutoffHz, + q: q, + dt: dt, + ); + final output = coefficients.b0 * input + + coefficients.b1 * _x1 + + coefficients.b2 * _x2 - + coefficients.a1 * _y1 - + coefficients.a2 * _y2; + _x2 = _x1; + _x1 = input; + _y2 = _y1; + _y1 = output; + return output; + } + + @override + void reset() { + _x1 = 0; + _x2 = 0; + _y1 = 0; + _y2 = 0; + _primed = false; + } + + double _prime(double input) { + final output = type == _ButterworthFilterType.lowPass ? input : 0.0; + _x1 = input; + _x2 = input; + _y1 = output; + _y2 = output; + _primed = true; + return output; + } +} + +class _NotchBiquadStage implements _IirFilterStage { + final double centerHz; + final double widthHz; + + double _x1 = 0; + double _x2 = 0; + double _y1 = 0; + double _y2 = 0; + bool _primed = false; + + _NotchBiquadStage({ + required this.centerHz, + required this.widthHz, + }); + + @override + double apply(double input, double dt) { + if (!_primed || dt <= 0 || !dt.isFinite) { + return _prime(input); + } + + final coefficients = _notchCoefficients( + centerHz: centerHz, + widthHz: widthHz, + dt: dt, + ); + final output = coefficients.b0 * input + + coefficients.b1 * _x1 + + coefficients.b2 * _x2 - + coefficients.a1 * _y1 - + coefficients.a2 * _y2; + _x2 = _x1; + _x1 = input; + _y2 = _y1; + _y1 = output; + return output; + } + + @override + void reset() { + _x1 = 0; + _x2 = 0; + _y1 = 0; + _y2 = 0; + _primed = false; + } + + double _prime(double input) { + _x1 = input; + _x2 = input; + _y1 = input; + _y2 = input; + _primed = true; + return input; + } +} + +class _FirstOrderCoefficients { + final double b0; + final double b1; + final double a1; + + const _FirstOrderCoefficients({ + required this.b0, + required this.b1, + required this.a1, + }); +} + +class _BiquadCoefficients { + final double b0; + final double b1; + final double b2; + final double a1; + final double a2; + + const _BiquadCoefficients({ + required this.b0, + required this.b1, + required this.b2, + required this.a1, + required this.a2, + }); +} + +List<_IirFilterStage> _buildButterworthStages({ + required _ButterworthFilterType type, + required double cutoffHz, + required int order, +}) { + final stageOrder = order.clamp( + _AxisFilterConfig.minOrder, + _AxisFilterConfig.maxOrder, + ); + final stages = <_IirFilterStage>[]; + if (stageOrder.isOdd) { + stages.add( + _ButterworthFirstOrderStage( + type: type, + cutoffHz: cutoffHz, + ), + ); + } + + final biquadCount = stageOrder ~/ 2; + for (var i = 0; i < biquadCount; i++) { + stages.add( + _ButterworthBiquadStage( + type: type, + cutoffHz: cutoffHz, + q: _butterworthSectionQ( + sectionIndex: i, + order: stageOrder, + ), + ), + ); + } + + return stages; +} + +List<_IirFilterStage> _buildNotchFilterStages({ + required double centerHz, + required double widthHz, + required int order, +}) { + final stageOrder = _AxisFilterConfig._clampNotchOrder(order); + final stageCount = stageOrder ~/ 2; + return List<_IirFilterStage>.generate( + stageCount, + (_) => _NotchBiquadStage( + centerHz: centerHz, + widthHz: widthHz, + ), + growable: false, + ); +} + +double _butterworthSectionQ({ + required int sectionIndex, + required int order, +}) { + return 1 / (2 * sin((2 * sectionIndex + 1) * pi / (2 * order))); +} + +_FirstOrderCoefficients _firstOrderCoefficients({ + required _ButterworthFilterType type, + required double cutoffHz, + required double dt, +}) { + final normalizedCutoff = _frequencyBelowNyquist( + frequencyHz: cutoffHz, + dt: dt, + ); + final k = tan(pi * normalizedCutoff * dt); + final norm = 1 / (1 + k); + + if (type == _ButterworthFilterType.lowPass) { + return _FirstOrderCoefficients( + b0: k * norm, + b1: k * norm, + a1: (k - 1) * norm, + ); + } + + return _FirstOrderCoefficients( + b0: norm, + b1: -norm, + a1: (k - 1) * norm, + ); +} + +_BiquadCoefficients _biquadCoefficients({ + required _ButterworthFilterType type, + required double cutoffHz, + required double q, + required double dt, +}) { + final normalizedCutoff = _frequencyBelowNyquist( + frequencyHz: cutoffHz, + dt: dt, + ); + final w0 = 2 * pi * normalizedCutoff * dt; + final cosW0 = cos(w0); + final sinW0 = sin(w0); + final alpha = sinW0 / (2 * q); + final a0 = 1 + alpha; + final a1 = -2 * cosW0; + final a2 = 1 - alpha; + + late final double b0; + late final double b1; + late final double b2; + if (type == _ButterworthFilterType.lowPass) { + b0 = (1 - cosW0) / 2; + b1 = 1 - cosW0; + b2 = (1 - cosW0) / 2; + } else { + b0 = (1 + cosW0) / 2; + b1 = -(1 + cosW0); + b2 = (1 + cosW0) / 2; + } + + return _BiquadCoefficients( + b0: b0 / a0, + b1: b1 / a0, + b2: b2 / a0, + a1: a1 / a0, + a2: a2 / a0, + ); +} + +_BiquadCoefficients _notchCoefficients({ + required double centerHz, + required double widthHz, + required double dt, +}) { + final normalizedCenter = _frequencyBelowNyquist( + frequencyHz: centerHz, + dt: dt, + ); + final bandwidthHz = max(widthHz.abs(), 1e-9); + final q = max(normalizedCenter / bandwidthHz, 1e-6); + final w0 = 2 * pi * normalizedCenter * dt; + final cosW0 = cos(w0); + final sinW0 = sin(w0); + final alpha = sinW0 / (2 * q); + final a0 = 1 + alpha; + + return _BiquadCoefficients( + b0: 1 / a0, + b1: (-2 * cosW0) / a0, + b2: 1 / a0, + a1: (-2 * cosW0) / a0, + a2: (1 - alpha) / a0, + ); +} + +double _frequencyBelowNyquist({ + required double frequencyHz, + required double dt, +}) { + if (dt <= 0 || !dt.isFinite || frequencyHz <= 0 || !frequencyHz.isFinite) { + return 1e-9; + } + + final sampleRateHz = 1 / dt; + final maxFrequencyHz = max(1e-9, sampleRateHz * 0.49); + return frequencyHz.clamp(1e-9, maxFrequencyHz).toDouble(); +} 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 c7c91644..36b7afdb 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_value_card.dart @@ -91,7 +91,7 @@ class SensorValueCard extends StatelessWidget { height: 200, child: settings.shouldShowGraph(hasData: hasData) ? SensorChart( - allowToggleAxes: false, + compactMode: true, settings: settings, onDisabledTap: settings.liveUpdatesEnabled ? null 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 26bcf5b8..37e8988f 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_value_detail.dart @@ -50,7 +50,6 @@ class SensorValueDetail extends StatelessWidget { builder: (context, hasData, _) { if (settings.shouldShowGraph(hasData: hasData)) { return SensorChart( - allowToggleAxes: true, settings: settings, onDisabledTap: settings.liveUpdatesEnabled ? null diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index e12df28f..e8b6bdd2 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.3.3+1 +version: 1.4.0+1 environment: sdk: ^3.6.0 diff --git a/open_wearable/test/models/app_upgrade_registry_test.dart b/open_wearable/test/models/app_upgrade_registry_test.dart index 382068ed..fab1e7db 100644 --- a/open_wearable/test/models/app_upgrade_registry_test.dart +++ b/open_wearable/test/models/app_upgrade_registry_test.dart @@ -3,13 +3,13 @@ import 'package:open_wearable/models/app_upgrade_registry.dart'; void main() { group('AppUpgradeRegistry', () { - test('registers version 1.3.0 as the latest upgrade highlight', () { - final highlight = AppUpgradeRegistry.forVersion('1.3.0'); + test('registers version 1.4.0 as the latest upgrade highlight', () { + final highlight = AppUpgradeRegistry.forVersion('1.4.0'); expect(highlight, isNotNull); - expect(highlight?.version, '1.3.0'); - expect(AppUpgradeRegistry.latest?.version, '1.3.0'); - expect(AppUpgradeRegistry.all.first.version, '1.3.0'); + expect(highlight?.version, '1.4.0'); + expect(AppUpgradeRegistry.latest?.version, '1.4.0'); + expect(AppUpgradeRegistry.all.first.version, '1.4.0'); }); }); }