diff --git a/open_wearable/lib/apps/seal_check/audio_response_measurement_view.dart b/open_wearable/lib/apps/seal_check/audio_response_measurement_view.dart index 9d390310..11764971 100644 --- a/open_wearable/lib/apps/seal_check/audio_response_measurement_view.dart +++ b/open_wearable/lib/apps/seal_check/audio_response_measurement_view.dart @@ -8,7 +8,8 @@ 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/seal_check/audio_response_measurement_session.dart'; -import 'package:open_wearable/apps/seal_check/seal_check_quality.dart'; +import 'package:open_wearable/apps/seal_check/seal_check_quality.dart' + show sealCheckTargetMagnitudes; // NOTE: We intentionally do NOT support writing files on web here. // If you want web downloads, we can add a proper conditional import helper. @@ -347,72 +348,9 @@ class _SealCheckMeasurementViewState extends State { ? 1.0 : allMags.reduce((a, b) => a + b) / allMags.length; - final leftQuality = - leftPoints.isNotEmpty ? computeSealCheckQuality(leftPoints) : null; - final rightQuality = - rightPoints.isNotEmpty ? computeSealCheckQuality(rightPoints) : null; - return SingleChildScrollView( child: Column( children: [ - // Summary cards - Row( - children: [ - if (leftQuality != null) ...[ - Expanded( - child: Card( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Left Quality', - style: theme.textTheme.labelMedium, - ), - const SizedBox(height: 4), - Text( - '${leftQuality.round()} / 100', - style: theme.textTheme.titleLarge, - ), - ], - ), - ), - ), - ), - if (rightQuality != null) const SizedBox(width: 8), - ], - if (rightQuality != null) - Expanded( - child: Card( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Right Quality', - style: theme.textTheme.labelMedium, - ), - const SizedBox(height: 4), - Text( - '${rightQuality.round()} / 100', - style: theme.textTheme.titleLarge, - ), - ], - ), - ), - ), - ), - ], - ), - const SizedBox(height: 8), SizedBox( height: 320, child: Card( @@ -710,14 +648,11 @@ class _SealCheckMeasurementViewState extends State { // Single side: show table directly without tabs if (tabs.length == 1) { - return SizedBox( - height: 300, - child: _buildRawValuesTable( - theme, - tabs.first.points, - normMag, - label: tabs.first.label, - ), + return _buildRawValuesTable( + theme, + tabs.first.points, + normMag, + label: tabs.first.label, ); } diff --git a/open_wearable/lib/models/app_upgrade_registry.dart b/open_wearable/lib/models/app_upgrade_registry.dart index 3e85fa42..a19c86dd 100644 --- a/open_wearable/lib/models/app_upgrade_registry.dart +++ b/open_wearable/lib/models/app_upgrade_registry.dart @@ -182,12 +182,12 @@ class AppUpgradeRegistry { AppUpgradeHighlight( version: '1.5.0', eyebrow: 'OpenWearables 1.5.0', - title: 'Label recordings and\ntune your earables', + title: 'Labels and more control\nfor your OpenEarables', summary: - 'Mark important moments, check ear-tip seals, and fine-tune microphone gain.', + 'Label recordings, check the seal, tune microphones, and choose compatible devices.', heroDescription: - 'OpenWearables 1.5.0 adds richer tools for experiments and everyday device setup. ' - 'Annotate recordings as they happen, run a guided Seal Check, adjust microphone gain, and find compatible devices more easily.', + 'OpenWearables 1.5.0 adds reusable recording labels, Seal Check, and microphone gain controls. ' + 'Capability-aware device selection helps you start each app with a compatible wearable.', accentColor: Color(0xFF8F6A67), useHeroGradient: false, features: [ @@ -198,22 +198,22 @@ class AppUpgradeRegistry { 'Create reusable label sets and mark events while recording. Label states are saved alongside sensor data with timestamps for easier review and analysis.', ), AppUpgradeFeatureHighlight( - icon: Icons.hearing_rounded, + icon: Icons.graphic_eq_rounded, title: 'Seal Check', description: - 'Run a guided audio-response measurement for one or both earables and get a clear seal-quality result with live progress.', + 'Measure the ear seal and review the audio-response graph directly in the app.', ), AppUpgradeFeatureHighlight( icon: Icons.mic_rounded, title: 'Microphone gain controls', description: - 'Adjust inner and outer microphone gain, link channels, mute microphones, and keep stereo pairs in sync from the device controls.', + 'Adjust inner and outer mic levels, link both channels, mute them, or reset the gain.', ), AppUpgradeFeatureHighlight( - icon: Icons.bluetooth_searching_rounded, + icon: Icons.devices_rounded, title: 'Smarter device selection', description: - 'Filter nearby devices by name and see which connected wearables support an app before starting a workflow.', + 'Find compatible wearables faster with clearer, capability-aware app selection.', ), ], ), diff --git a/open_wearable/lib/view_models/sensor_configuration_provider.dart b/open_wearable/lib/view_models/sensor_configuration_provider.dart index 642b8e56..cdaf3f22 100644 --- a/open_wearable/lib/view_models/sensor_configuration_provider.dart +++ b/open_wearable/lib/view_models/sensor_configuration_provider.dart @@ -55,6 +55,11 @@ class SensorConfigurationProvider with ChangeNotifier { }) : _sensorConfigurationManager = sensorConfigurationManager { _sensorConfigurationSubscription = _sensorConfigurationManager.sensorConfigurationStream.listen((event) { + final hadReceivedConfigurationReport = _hasReceivedConfigurationReport; + final previousReportedConfigurations = + Map.of( + _lastReportedConfigurations, + ); _hasReceivedConfigurationReport = true; _lastReportedConfigurations ..clear() @@ -70,7 +75,11 @@ class SensorConfigurationProvider with ChangeNotifier { ..clear() ..addAll(_lastReportedConfigurations.keys); - var hasStateChange = false; + var hasStateChange = !hadReceivedConfigurationReport || + !_reportedConfigurationsMatch( + previousReportedConfigurations, + _lastReportedConfigurations, + ); for (final e in event.entries) { final sensorConfiguration = e.key; final sensorConfigurationValue = e.value; @@ -242,6 +251,22 @@ class SensorConfigurationProvider with ChangeNotifier { return _normalizeName(current.key) == _normalizeName(expected.key); } + bool _reportedConfigurationsMatch( + Map first, + Map second, + ) { + if (first.length != second.length) { + return false; + } + for (final entry in first.entries) { + final other = second[entry.key]; + if (other == null || !_configurationValuesMatch(entry.value, other)) { + return false; + } + } + return true; + } + Set _optionNameSet(SensorConfigurationValue value) { if (value is! ConfigurableSensorConfigurationValue) { return const {}; 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 e5c2bee2..1daf7aca 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 @@ -243,23 +243,23 @@ class _DeviceDetailPageState extends State { ), ), ), - if (widget.device.hasCapability()) + if (widget.device.hasCapability()) Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: MicrophoneGainControls(device: widget.device), + child: MicrophoneSelectionWidget( + device: widget.device, + applyScope: StereoPairApplyScope.individualOnly, + ), ), ), - if (widget.device.hasCapability()) + if (widget.device.hasCapability()) Card( margin: EdgeInsets.zero, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: MicrophoneSelectionWidget( - device: widget.device, - applyScope: StereoPairApplyScope.individualOnly, - ), + child: MicrophoneGainControls(device: widget.device), ), ), if (widget.device.hasCapability()) diff --git a/open_wearable/lib/widgets/devices/device_detail/microphone_gain_controls.dart b/open_wearable/lib/widgets/devices/device_detail/microphone_gain_controls.dart index 7de3b2f2..8092b074 100644 --- a/open_wearable/lib/widgets/devices/device_detail/microphone_gain_controls.dart +++ b/open_wearable/lib/widgets/devices/device_detail/microphone_gain_controls.dart @@ -4,6 +4,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart'; const double _gainSliderMinDb = -69.0; const double _gainSliderMaxDb = 24.0; const int _gainSliderDivisions = 31; +const Color _appliedConfigurationGreen = Color(0xFF2E7D32); class MicrophoneGainControls extends StatefulWidget { final Wearable? device; @@ -63,96 +64,143 @@ class _MicrophoneGainControlsState extends State { return const SizedBox.shrink(); } - final colorScheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; final disabled = _loading || _writing || _muted; - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(10, 10, 10, 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: _pairedOutOfSync - ? colorScheme.error.withValues(alpha: 0.7) - : colorScheme.outlineVariant.withValues(alpha: 0.45), + final header = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Microphone Gain', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Text( + 'Adjust inner and outer mic levels.', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + ], + ); + + final controls = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_pairedOutOfSync) ...[ + const _OutOfSyncBanner(), + const SizedBox(height: 8), + ], + if (_loading) ...[ + const LinearProgressIndicator(minHeight: 2), + ] else ...[ Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - 'Microphone Gain', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), + _GainLinkControl( + linked: _linked, + onChanged: _loading || _writing ? null : _setLinked, + ), + const SizedBox(width: 2), + Expanded( + child: Column( + children: [ + _GainSlider( + label: 'Outer', + register: _outerRegister, + fallbackRegister: _lastOuterRegister, + enabled: !disabled, + onChanged: (db) => _updateGain(outer: true, db: db), + onChangeEnd: (_) => _writeGain(), + ), + const SizedBox( + key: Key('microphone-gain-row-spacing'), + height: 4, + ), + _GainSlider( + label: 'Inner', + register: _innerRegister, + fallbackRegister: _lastInnerRegister, + enabled: !disabled, + onChanged: (db) => _updateGain(outer: false, db: db), + onChangeEnd: (_) => _writeGain(), + ), + ], + ), ), - if (_pairedOutOfSync) ...[ - const SizedBox(width: 8), - _OutOfSyncIndicator(color: colorScheme.error), - ], - const Spacer(), ], ), - const SizedBox(height: 2), + const SizedBox(height: 12), Row( children: [ - Checkbox.adaptive( - value: _linked, - onChanged: _loading || _writing - ? null - : (value) => _setLinked(value ?? false), - ), Expanded( - child: Text( - 'Link microphones', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + child: OutlinedButton.icon( + onPressed: _loading || _writing ? null : _resetToDefault, + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + ), + icon: const Icon(Icons.restart_alt_rounded, size: 18), + label: const Text('Reset'), ), ), - FilledButton.tonalIcon( - onPressed: _loading || _writing ? null : _toggleMute, - icon: Icon( - _muted ? Icons.volume_up_rounded : Icons.volume_off_rounded, - size: 18, + const SizedBox(width: 8), + Expanded( + child: FilledButton.tonalIcon( + onPressed: _loading || _writing ? null : _toggleMute, + style: FilledButton.styleFrom( + minimumSize: const Size(0, 48), + backgroundColor: _muted + ? colorScheme.errorContainer + : Colors.transparent, + foregroundColor: _muted + ? colorScheme.onErrorContainer + : colorScheme.onSurfaceVariant, + side: BorderSide( + color: _muted + ? Colors.transparent + : colorScheme.outlineVariant, + ), + ), + icon: Icon( + _muted ? Icons.volume_up_rounded : Icons.volume_off_rounded, + size: 18, + ), + label: Text(_muted ? 'Unmute' : 'Mute'), ), - label: Text(_muted ? 'Unmute' : 'Mute'), ), ], ), - if (_loading) ...[ - const SizedBox(height: 8), - const LinearProgressIndicator(minHeight: 2), - ] else ...[ - const SizedBox(height: 8), - _GainSlider( - label: 'Outer', - register: _outerRegister, - fallbackRegister: _lastOuterRegister, - enabled: !disabled, - onChanged: (db) => _updateGain(outer: true, db: db), - onChangeEnd: (_) => _writeGain(), - ), - _GainSlider( - label: 'Inner', - register: _innerRegister, - fallbackRegister: _lastInnerRegister, - enabled: !disabled && !_linked, - onChanged: (db) => _updateGain(outer: false, db: db), - onChangeEnd: (_) => _writeGain(), - ), - ], - if (_error != null) ...[ - const SizedBox(height: 6), - Text( - _error!, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colorScheme.error), + ], + if (_error != null) ...[ + const SizedBox(height: 6), + Text( + _error!, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.error, ), - ], + ), + ], + ], + ); + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + const SizedBox(height: 8), + if (_hasPairedTarget) + Card( + margin: EdgeInsets.zero, + child: Padding( + key: const Key('microphone-gain-controls-padding'), + padding: const EdgeInsets.all(12), + child: controls, + ), + ) + else + controls, ], ), ); @@ -267,6 +315,18 @@ class _MicrophoneGainControlsState extends State { await _writeGain(); } + Future _resetToDefault() async { + setState(() { + _outerRegister = MicrophoneGain.defaultRegister; + _innerRegister = MicrophoneGain.defaultRegister; + _lastOuterRegister = MicrophoneGain.defaultRegister; + _lastInnerRegister = MicrophoneGain.defaultRegister; + _linked = true; + _muted = false; + }); + await _writeGain(); + } + Future _writeGain() async { final manager = _manager; if (manager == null) { @@ -322,40 +382,175 @@ MicrophoneGain _microphoneGainFromRegisters({ ); } -class _OutOfSyncIndicator extends StatelessWidget { - final Color color; +class _OutOfSyncBanner extends StatelessWidget { + const _OutOfSyncBanner(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final errorColor = theme.colorScheme.error; + + return Container( + key: const Key('microphone-gain-mismatch-warning'), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: errorColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: errorColor.withValues(alpha: 0.38)), + ), + child: Row( + children: [ + Icon(Icons.sync_problem_rounded, color: errorColor, size: 17), + const SizedBox(width: 7), + Expanded( + child: Text( + 'Left and right gains differ', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: errorColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} - const _OutOfSyncIndicator({required this.color}); +class _GainLinkControl extends StatelessWidget { + final bool linked; + final ValueChanged? onChanged; + + const _GainLinkControl({ + required this.linked, + required this.onChanged, + }); @override Widget build(BuildContext context) { - return Tooltip( - message: 'Paired devices report different microphone gains', - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - color: color.withValues(alpha: 0.12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.sync_problem_rounded, color: color, size: 14), - const SizedBox(width: 4), - Text( - 'Out of sync', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w700, + final colorScheme = Theme.of(context).colorScheme; + final connectorColor = linked + ? _appliedConfigurationGreen + : colorScheme.onSurfaceVariant.withValues(alpha: 0.65); + final linkedBackground = Color.alphaBlend( + _appliedConfigurationGreen.withValues(alpha: 0.12), + colorScheme.surface, + ); + final tooltip = linked + ? 'Unlink inner and outer microphones' + : 'Link inner and outer microphones'; + + return Semantics( + button: true, + toggled: linked, + label: tooltip, + child: Tooltip( + message: tooltip, + child: SizedBox( + key: const Key('microphone-gain-link-control'), + width: 40, + height: 100, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: CustomPaint( + painter: _GainConnectorPainter( + color: connectorColor, + linked: linked, ), - ), - ], + ), + ), + Material( + key: const Key('microphone-gain-link-button-surface'), + color: linked + ? linkedBackground + : colorScheme.surfaceContainerHighest, + shape: CircleBorder( + side: BorderSide( + color: connectorColor.withValues(alpha: 0.7), + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onChanged == null ? null : () => onChanged!(!linked), + child: SizedBox.square( + dimension: 32, + child: Icon( + linked ? Icons.link_rounded : Icons.link_off_rounded, + size: 18, + color: connectorColor, + ), + ), + ), + ), + ], + ), ), ), ); } } +class _GainConnectorPainter extends CustomPainter { + final Color color; + final bool linked; + + const _GainConnectorPainter({ + required this.color, + required this.linked, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.75 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + final connectorX = size.width * 0.45; + const topY = 24.0; + final bottomY = size.height - 24; + + canvas.drawLine( + Offset(connectorX, topY), + Offset(size.width, topY), + paint, + ); + canvas.drawLine( + Offset(connectorX, bottomY), + Offset(size.width, bottomY), + paint, + ); + + if (linked) { + canvas.drawLine( + Offset(connectorX, topY), + Offset(connectorX, bottomY), + paint, + ); + } else { + canvas.drawLine( + Offset(connectorX, topY), + Offset(connectorX, size.height * 0.38), + paint, + ); + canvas.drawLine( + Offset(connectorX, size.height * 0.62), + Offset(connectorX, bottomY), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _GainConnectorPainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.linked != linked; + } +} + class _GainSlider extends StatelessWidget { final String label; final int register; @@ -381,47 +576,58 @@ class _GainSlider extends StatelessWidget { MicrophoneGain.registerToDb(MicrophoneGain.defaultRegister)!; final sliderValue = db.clamp(_gainSliderMinDb, _gainSliderMaxDb).toDouble(); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + final displayValue = register == MicrophoneGain.muteRegister + ? 'Muted' + : _formatDb(sliderValue); + + return Row( children: [ - Row( - children: [ - Expanded( - child: Text( - label, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + SizedBox( + width: 42, + child: Text( + label, + maxLines: 1, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), + ), + ), + Expanded( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 12, ), ), - Text( - register == MicrophoneGain.muteRegister - ? 'Muted' - : '${_formatDb(sliderValue)} (${_formatRegister(register)})', - style: Theme.of(context).textTheme.labelMedium, + child: Slider.adaptive( + min: _gainSliderMinDb, + max: _gainSliderMaxDb, + divisions: _gainSliderDivisions, + value: sliderValue, + label: _formatDb(sliderValue), + onChanged: enabled ? onChanged : null, + onChangeEnd: enabled ? onChangeEnd : null, ), - ], + ), ), - Slider.adaptive( - min: _gainSliderMinDb, - max: _gainSliderMaxDb, - divisions: _gainSliderDivisions, - value: sliderValue, - label: _formatDb(sliderValue), - onChanged: enabled ? onChanged : null, - onChangeEnd: enabled ? onChangeEnd : null, + SizedBox( + width: 58, + child: Text( + displayValue, + maxLines: 1, + softWrap: false, + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.labelMedium, + ), ), ], ); } +} - String _formatDb(double db) { - final value = db.abs() < 0.001 ? 0.0 : db; - final sign = value > 0 ? '+' : ''; - return '$sign${value.round()} dB'; - } - - String _formatRegister(int register) { - return '0x${register.toRadixString(16).padLeft(2, '0').toUpperCase()}'; - } +String _formatDb(double db) { + final value = db.abs() < 0.001 ? 0.0 : db; + final sign = value > 0 ? '+' : ''; + return '$sign${value.round()} dB'; } diff --git a/open_wearable/lib/widgets/devices/devices_page.dart b/open_wearable/lib/widgets/devices/devices_page.dart index e6c51358..ee67c68e 100644 --- a/open_wearable/lib/widgets/devices/devices_page.dart +++ b/open_wearable/lib/widgets/devices/devices_page.dart @@ -799,16 +799,6 @@ class _PairedDeviceSheet extends StatelessWidget { ), ), ], - if (supportsMicrophoneGain) ...[ - const SizedBox(height: 12), - MicrophoneGainControls( - key: ValueKey( - 'pair_microphone_gain_${leftDevice.deviceId}_${rightDevice.deviceId}', - ), - device: leftDevice, - pairedDevice: rightDevice, - ), - ], if (microphoneSelectionDevice != null) ...[ const SizedBox(height: 12), MicrophoneSelectionWidget( @@ -827,6 +817,16 @@ class _PairedDeviceSheet extends StatelessWidget { ), ), ], + if (supportsMicrophoneGain) ...[ + const SizedBox(height: 12), + MicrophoneGainControls( + key: ValueKey( + 'pair_microphone_gain_${leftDevice.deviceId}_${rightDevice.deviceId}', + ), + device: leftDevice, + pairedDevice: rightDevice, + ), + ], if (powerSavingModeDevice != null) ...[ const SizedBox(height: 12), PowerSavingModeWidget( diff --git a/open_wearable/test/models/app_upgrade_registry_test.dart b/open_wearable/test/models/app_upgrade_registry_test.dart index 61a06bfc..4929401b 100644 --- a/open_wearable/test/models/app_upgrade_registry_test.dart +++ b/open_wearable/test/models/app_upgrade_registry_test.dart @@ -8,9 +8,13 @@ void main() { expect(highlight, isNotNull); expect(highlight?.version, '1.5.0'); + expect( + highlight?.title, + 'Labels and more control\nfor your OpenEarables', + ); expect( highlight?.features.map((feature) => feature.title), - [ + [ 'Recording labels', 'Seal Check', 'Microphone gain controls', diff --git a/open_wearable/test/view_models/sensor_configuration_provider_test.dart b/open_wearable/test/view_models/sensor_configuration_provider_test.dart new file mode 100644 index 00000000..2e343344 --- /dev/null +++ b/open_wearable/test/view_models/sensor_configuration_provider_test.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; + +void main() { + test('notifies when the first hardware report matches selected values', () { + final value = SensorConfigurationValue(key: 'off'); + final configuration = _FakeSensorConfiguration( + name: 'Example sensor', + values: [value], + ); + final manager = _FakeSensorConfigurationManager([configuration]); + final provider = SensorConfigurationProvider( + sensorConfigurationManager: manager, + ); + addTearDown(() async { + provider.dispose(); + await manager.dispose(); + }); + + provider.addSensorConfiguration( + configuration, + value, + markPending: false, + ); + var notificationCount = 0; + provider.addListener(() => notificationCount += 1); + + manager.emit({configuration: value}); + + expect(provider.hasReceivedConfigurationReport, isTrue); + expect(provider.isConfigurationApplied(configuration), isTrue); + expect(notificationCount, 1); + }); +} + +class _FakeSensorConfiguration extends SensorConfiguration { + const _FakeSensorConfiguration({ + required super.name, + required super.values, + }); + + @override + void setConfiguration(SensorConfigurationValue configuration) {} +} + +class _FakeSensorConfigurationManager implements SensorConfigurationManager { + _FakeSensorConfigurationManager(this.sensorConfigurations); + + final StreamController> + _controller = + StreamController>( + sync: true, + ); + + @override + final List sensorConfigurations; + + @override + Stream> + get sensorConfigurationStream => _controller.stream; + + void emit(Map report) { + _controller.add(report); + } + + Future dispose() => _controller.close(); +} diff --git a/open_wearable/test/widgets/devices/device_detail/microphone_gain_controls_test.dart b/open_wearable/test/widgets/devices/device_detail/microphone_gain_controls_test.dart new file mode 100644 index 00000000..5e484241 --- /dev/null +++ b/open_wearable/test/widgets/devices/device_detail/microphone_gain_controls_test.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/widgets/devices/device_detail/microphone_gain_controls.dart'; + +void main() { + testWidgets('reset restores the documented default gain', (tester) async { + final manager = _FakeMicrophoneGainManager( + const MicrophoneGain.muted(), + ); + final wearable = _FakeWearable(manager, deviceId: 'single-device'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MicrophoneGainControls(device: wearable), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('Default:'), findsNothing); + expect(find.text('Adjust inner and outer mic levels.'), findsOneWidget); + expect(find.byType(Card), findsNothing); + expect(find.text('Muted'), findsNWidgets(2)); + final unmuteButtonFinder = find.widgetWithText(FilledButton, 'Unmute'); + final unmuteButton = tester.widget(unmuteButtonFinder); + expect( + unmuteButton.style?.backgroundColor?.resolve({}), + Theme.of(tester.element(unmuteButtonFinder)).colorScheme.errorContainer, + ); + + await tester.tap(find.text('Reset')); + await tester.pumpAndSettle(); + + expect(manager.writes, hasLength(1)); + expect( + manager.writes.single.outerRegister, + MicrophoneGain.defaultRegister, + ); + expect( + manager.writes.single.innerRegister, + MicrophoneGain.defaultRegister, + ); + expect(find.text('+12 dB'), findsNWidgets(2)); + final muteButton = tester.widget( + find.widgetWithText(FilledButton, 'Mute'), + ); + expect(muteButton.style?.backgroundColor?.resolve({}), Colors.transparent); + }); + + testWidgets('stereo layout keeps heading outside the controls card', ( + tester, + ) async { + final left = _FakeWearable( + _FakeMicrophoneGainManager( + const MicrophoneGain.stereo(MicrophoneGain.defaultRegister), + ), + deviceId: 'left-device', + ); + final right = _FakeWearable( + _FakeMicrophoneGainManager( + const MicrophoneGain.stereo(MicrophoneGain.defaultRegister), + ), + deviceId: 'right-device', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MicrophoneGainControls( + device: left, + pairedDevice: right, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final controlsCard = find.byType(Card); + expect(controlsCard, findsOneWidget); + expect( + find.descendant( + of: controlsCard, + matching: find.text('Microphone Gain'), + ), + findsNothing, + ); + for (final label in ['Reset', 'Mute']) { + expect( + find.descendant(of: controlsCard, matching: find.text(label)), + findsOneWidget, + ); + } + expect( + tester + .widget( + find.byKey(const Key('microphone-gain-controls-padding')), + ) + .padding, + const EdgeInsets.all(12), + ); + expect( + find.descendant(of: controlsCard, matching: find.byType(Divider)), + findsNothing, + ); + final linkControl = find.byKey( + const Key('microphone-gain-link-control'), + ); + expect(linkControl, findsOneWidget); + expect(find.byType(Switch), findsNothing); + expect(find.byType(Checkbox), findsNothing); + expect( + tester + .getSize( + find.byKey(const Key('microphone-gain-row-spacing')), + ) + .height, + 4, + ); + + final actionY = [ + tester.getCenter(find.text('Reset')).dy, + tester.getCenter(find.text('Mute')).dy, + ]; + expect(actionY.toSet(), hasLength(1)); + expect( + tester.getCenter(find.text('Reset')).dx, + lessThan(tester.getCenter(find.text('Mute')).dx), + ); + final resetButton = find.widgetWithText(OutlinedButton, 'Reset'); + final muteButton = find.widgetWithText(FilledButton, 'Mute'); + expect( + tester.getSize(resetButton).width, + closeTo(tester.getSize(muteButton).width, 0.1), + ); + expect(tester.getSize(resetButton).height, 48); + expect(tester.getSize(muteButton).height, 48); + + final outerY = tester.getCenter(find.text('Outer')).dy; + final sliderY = tester.getCenter(find.byType(Slider).first).dy; + final valueY = tester.getCenter(find.text('+12 dB').first).dy; + expect(outerY, closeTo(sliderY, 0.1)); + expect(valueY, closeTo(sliderY, 0.1)); + final sliderTheme = tester.widget( + find.ancestor( + of: find.byType(Slider).first, + matching: find.byType(SliderTheme), + ), + ); + expect( + sliderTheme.data.padding, + const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + ); + final connectorCenter = tester.getCenter(linkControl); + expect( + connectorCenter.dx, + lessThan(tester.getCenter(find.text('Outer')).dx), + ); + expect( + connectorCenter.dy, + greaterThan(tester.getCenter(find.text('Outer')).dy), + ); + expect( + connectorCenter.dy, + lessThan(tester.getCenter(find.text('Inner')).dy), + ); + expect( + tester.getCenter(find.text('Reset')).dy, + greaterThan(tester.getCenter(find.byType(Slider).last).dy), + ); + }); + + testWidgets('both sliders stay active while linked and after unlinking', ( + tester, + ) async { + final manager = _FakeMicrophoneGainManager( + const MicrophoneGain.stereo(MicrophoneGain.defaultRegister), + ); + final wearable = _FakeWearable( + manager, + deviceId: 'single-device', + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MicrophoneGainControls(device: wearable), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.link_rounded), findsOneWidget); + expect( + tester.widget(find.byIcon(Icons.link_rounded)).color, + const Color(0xFF2E7D32), + ); + final linkSurfaceFinder = find.byKey( + const Key('microphone-gain-link-button-surface'), + ); + final linkSurface = tester.widget(linkSurfaceFinder); + final colorScheme = Theme.of(tester.element(linkSurfaceFinder)).colorScheme; + expect(linkSurface.color?.a, 1.0); + expect( + linkSurface.color, + Color.alphaBlend( + const Color(0xFF2E7D32).withValues(alpha: 0.12), + colorScheme.surface, + ), + ); + expect( + tester.widget(find.byType(Slider).first).onChanged, + isNotNull, + ); + final linkedInnerSlider = tester.widget(find.byType(Slider).last); + expect(linkedInnerSlider.onChanged, isNotNull); + + linkedInnerSlider.onChanged!(0); + await tester.pump(); + + expect(find.text('0 dB'), findsNWidgets(2)); + + await tester.tap( + find.byKey(const Key('microphone-gain-link-control')), + ); + await tester.pump(); + + expect(find.byIcon(Icons.link_off_rounded), findsOneWidget); + expect( + tester.widget(find.byType(Slider).last).onChanged, + isNotNull, + ); + }); + + testWidgets('out-of-sync warning has no sync action or confirmation', ( + tester, + ) async { + final leftManager = _FakeMicrophoneGainManager( + const MicrophoneGain.stereo(MicrophoneGain.defaultRegister), + ); + final rightManager = _FakeMicrophoneGainManager( + const MicrophoneGain.stereo(MicrophoneGain.zeroDbRegister), + ); + final left = _FakeWearable(leftManager, deviceId: 'left-device'); + final right = _FakeWearable(rightManager, deviceId: 'right-device'); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MicrophoneGainControls( + device: left, + pairedDevice: right, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Left and right gains differ'), findsOneWidget); + expect(find.text('Sync'), findsNothing); + final warningFinder = find.byKey( + const Key('microphone-gain-mismatch-warning'), + ); + final warning = tester.widget(warningFinder); + final warningDecoration = warning.decoration! as BoxDecoration; + final errorColor = + Theme.of(tester.element(warningFinder)).colorScheme.error; + expect(warningDecoration.color, errorColor.withValues(alpha: 0.10)); + expect( + warningDecoration.border, + Border.all(color: errorColor.withValues(alpha: 0.38)), + ); + expect(leftManager.writes, isEmpty); + expect(rightManager.writes, isEmpty); + expect(find.byType(AlertDialog), findsNothing); + expect(find.byType(SnackBar), findsNothing); + }); +} + +class _FakeMicrophoneGainManager implements MicrophoneGainManager { + _FakeMicrophoneGainManager(this.currentGain); + + MicrophoneGain currentGain; + final List writes = []; + + @override + Future getMicrophoneGain() async => currentGain; + + @override + Future setMicrophoneGain(MicrophoneGain gain) async { + currentGain = gain; + writes.add(gain); + } +} + +class _FakeWearable extends Wearable { + _FakeWearable( + MicrophoneGainManager manager, { + required this.deviceId, + }) : super( + name: 'OpenEarable-2-L', + disconnectNotifier: WearableDisconnectNotifier(), + ) { + registerCapability(manager); + } + + @override + final String deviceId; + + @override + Future disconnect() async {} +}