diff --git a/open_wearable/lib/models/audio_input_source.dart b/open_wearable/lib/models/audio_input_source.dart new file mode 100644 index 00000000..c35fef1c --- /dev/null +++ b/open_wearable/lib/models/audio_input_source.dart @@ -0,0 +1,84 @@ +/// Describes an audio input that can be used for local audio recordings. +/// +/// The app owns this model instead of exposing the recorder plugin's device +/// type through the UI. That keeps persisted selections and widgets isolated +/// from platform plugin implementation details. +class AudioInputSource { + /// Stable identifier used by the platform recorder to select this source. + final String id; + + /// Human-readable label surfaced by the platform. + final String label; + + /// Coarse category used for icons and explanatory UI. + final AudioInputSourceKind kind; + + /// Whether this option delegates source selection to the operating system. + final bool isSystemDefault; + + const AudioInputSource({ + required this.id, + required this.label, + required this.kind, + this.isSystemDefault = false, + }); + + /// The synthetic source that lets the OS pick its current default input. + static const systemDefault = AudioInputSource( + id: '__system_default_audio_input__', + label: 'System Default', + kind: AudioInputSourceKind.systemDefault, + isSystemDefault: true, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AudioInputSource && + other.id == id && + other.label == label && + other.kind == kind && + other.isSystemDefault == isSystemDefault; + } + + @override + int get hashCode => Object.hash(id, label, kind, isSystemDefault); +} + +/// Coarse audio input categories used to keep UI copy and icons consistent. +enum AudioInputSourceKind { + systemDefault, + builtIn, + bluetooth, + wearable, + external, + unknown, +} + +/// Classifies a platform microphone label for display purposes. +AudioInputSourceKind classifyAudioInputSourceLabel(String label) { + final normalized = label.toLowerCase(); + if (normalized.contains('openearable') || + normalized.contains('open earable') || + normalized.contains('wearable')) { + return AudioInputSourceKind.wearable; + } + if (normalized.contains('bluetooth') || + normalized.contains('ble') || + normalized.contains('headset') || + normalized.contains('airpods')) { + return AudioInputSourceKind.bluetooth; + } + if (normalized.contains('built-in') || + normalized.contains('builtin') || + RegExp(r'\bphone\b').hasMatch(normalized) || + normalized.contains('internal')) { + return AudioInputSourceKind.builtIn; + } + if (normalized.contains('usb') || + normalized.contains('external') || + normalized.contains('line in')) { + return AudioInputSourceKind.external; + } + return AudioInputSourceKind.unknown; +} diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart index bf573ea3..346c7f27 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart @@ -6,6 +6,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; +import '../models/audio_input_source.dart'; import '../models/logger.dart'; import '../models/sensor_streams.dart'; @@ -35,73 +36,239 @@ class SensorRecorderProvider with ChangeNotifier { String? _currentDirectory; DateTime? _recordingStart; final AudioRecorder _audioRecorder = AudioRecorder(); + static const Duration _audioInputRefreshInterval = Duration(seconds: 3); bool _isAudioRecording = false; String? _currentAudioPath; StreamSubscription? _amplitudeSub; + Timer? _audioInputRefreshTimer; + List _availableInputDevices = const []; + List _audioInputSources = const [ + AudioInputSource.systemDefault, + ]; + AudioInputSource? _selectedAudioInputSource; + AudioInputSource? _appliedAudioInputSource; bool get isRecording => _isRecording; bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; + List get audioInputSources => + List.unmodifiable(_audioInputSources); + AudioInputSource? get selectedAudioInputSource => _selectedAudioInputSource; + AudioInputSource? get appliedAudioInputSource => _appliedAudioInputSource; + bool get isAudioInputEnabled => + _appliedAudioInputSource != null || + _isStreamingActive || + _isAudioRecording; + bool get isAudioMonitoringActive => _isStreamingActive; + bool get isAudioInputSelectionPending => !_sameAudioInputSource( + _selectedAudioInputSource, + _appliedAudioInputSource, + ); final List _waveformData = []; final int _waveformRevision = 0; int get waveformRevision => _waveformRevision; List get waveformData => List.unmodifiable(_waveformData); - InputDevice? _selectedBLEDevice; - - bool _isBLEMicrophoneStreamingEnabled = false; - bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; - // Path for temporary streaming file String? _streamingPath; bool _isStreamingActive = false; - Future _selectBLEDevice() async { + /// Starts periodic microphone discovery while microphone settings UI exists. + void startAudioInputSourceRefresh() { + if (_audioInputRefreshTimer != null) { + return; + } + unawaited(refreshAudioInputSources()); + _audioInputRefreshTimer = Timer.periodic( + _audioInputRefreshInterval, + (_) => unawaited(refreshAudioInputSources()), + ); + } + + /// Stops periodic microphone discovery when no UI needs it. + void stopAudioInputSourceRefresh() { + _audioInputRefreshTimer?.cancel(); + _audioInputRefreshTimer = null; + } + + /// Refreshes the platform microphone list used by the virtual microphone row. + Future refreshAudioInputSources() async { try { final devices = await _audioRecorder.listInputDevices(); - - try { - _selectedBLEDevice = devices.firstWhere( - (device) => - device.label.toLowerCase().contains('bluetooth') || - device.label.toLowerCase().contains('ble') || - device.label.toLowerCase().contains('headset') || - device.label.toLowerCase().contains('openearable'), - ); - logger.i("Selected audio input device: ${_selectedBLEDevice!.label}"); - } catch (e) { - _selectedBLEDevice = null; - logger.w("No BLE headset found"); + final uniqueDevices = []; + final seenDeviceIds = {}; + for (final device in devices) { + if (seenDeviceIds.add(device.id)) { + uniqueDevices.add(device); + } + } + final nextSources = [ + AudioInputSource.systemDefault, + ...uniqueDevices.map( + (device) => AudioInputSource( + id: device.id, + label: device.label, + kind: classifyAudioInputSourceLabel(device.label), + ), + ), + ]; + if (!_sameInputDevices(_availableInputDevices, uniqueDevices) || + !_sameAudioInputSources(_audioInputSources, nextSources)) { + _availableInputDevices = uniqueDevices; + _audioInputSources = nextSources; + notifyListeners(); } } catch (e) { - logger.e("Error selecting BLE device: $e"); - _selectedBLEDevice = null; + logger.e("Error listing audio input devices: $e"); } } - Future startBLEMicrophoneStream() async { - if (!kIsWeb && !Platform.isAndroid) { - logger.w("BLE microphone streaming only supported on Android"); + /// Selects the app-local microphone source used by local recordings. + /// + /// Passing `null` turns audio capture off while leaving wearable sensor + /// configuration untouched. + Future selectAudioInputSource(AudioInputSource? source) async { + if (_isAudioRecording) { + logger.w("Cannot change audio input while recording is active"); + return; + } + + if (_sameAudioInputSource(_selectedAudioInputSource, source)) { + return; + } + + _selectedAudioInputSource = source; + notifyListeners(); + } + + /// Enables or disables audio capture without changing the remembered source. + Future setAudioInputEnabled(bool enabled) async { + if (enabled) { + _selectedAudioInputSource ??= AudioInputSource.systemDefault; + await refreshAudioInputSources(); + } else { + await selectAudioInputSource(null); + } + notifyListeners(); + } + + InputDevice? _inputDeviceForSource(AudioInputSource source) { + if (source.isSystemDefault) { + return null; + } + for (final device in _availableInputDevices) { + if (device.id == source.id) { + return device; + } + } + return null; + } + + bool _sameAudioInputSource( + AudioInputSource? left, + AudioInputSource? right, + ) { + if (left == null || right == null) { + return left == null && right == null; + } + return left.id == right.id; + } + + bool _sameInputDevices(List left, List right) { + if (left.length != right.length) { + return false; + } + for (var i = 0; i < left.length; i++) { + if (left[i].id != right[i].id || left[i].label != right[i].label) { + return false; + } + } + return true; + } + + bool _sameAudioInputSources( + List left, + List right, + ) { + if (left.length != right.length) { + return false; + } + for (var i = 0; i < left.length; i++) { + if (left[i] != right[i]) { + return false; + } + } + return true; + } + + Future startAudioMonitoring() async { + return _startAudioMonitoring(); + } + + Future stopAudioMonitoring() async { + await _stopAudioMonitoring(); + } + + /// Applies the pending microphone selection to the live monitoring stream. + /// + /// Selecting a source in the sensor configuration tab only changes local + /// pending state. Calling this method mirrors the wearable profile apply + /// flow by starting or stopping the actual microphone stream. + Future applySelectedAudioInputSource() async { + if (_isAudioRecording) { + logger.w("Cannot apply audio input while recording is active"); + return false; + } + if (!isAudioInputSelectionPending) { return false; } + final selectedSource = _selectedAudioInputSource; + if (selectedSource == null) { + await stopAudioMonitoring(); + _appliedAudioInputSource = null; + _waveformData.clear(); + notifyListeners(); + return true; + } + + if (_isStreamingActive) { + await stopAudioMonitoring(); + } + _appliedAudioInputSource = selectedSource; + final started = await startAudioMonitoring(); + if (started) { + notifyListeners(); + } else { + _appliedAudioInputSource = null; + notifyListeners(); + } + return started; + } + + Future _startAudioMonitoring() async { if (_isStreamingActive) { - logger.i("BLE microphone streaming already active"); + logger.i("Audio input monitoring already active"); return true; } try { + final source = _appliedAudioInputSource; + if (source == null) { + logger.w("No audio input selected for monitoring"); + return false; + } if (!await _audioRecorder.hasPermission()) { - logger.w("No microphone permission for streaming"); + logger.w("No microphone permission for monitoring"); return false; } - await _selectBLEDevice(); - - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, cannot start streaming"); + await refreshAudioInputSources(); + final selectedDevice = _inputDeviceForSource(source); + if (!source.isSystemDefault && selectedDevice == null) { + logger.w("Selected audio input is unavailable: ${source.label}"); return false; } @@ -120,12 +287,11 @@ class SensorRecorderProvider with ChangeNotifier { sampleRate: 48000, bitRate: 768000, numChannels: 1, - device: _selectedBLEDevice, + device: selectedDevice, ); await _audioRecorder.start(config, path: _streamingPath!); _isStreamingActive = true; - _isBLEMicrophoneStreamingEnabled = true; // Set up amplitude monitoring for waveform display _amplitudeSub?.cancel(); @@ -143,21 +309,20 @@ class SensorRecorderProvider with ChangeNotifier { }); logger.i( - "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}", + "Audio monitoring started with input: ${source.label}", ); notifyListeners(); return true; } catch (e) { - logger.e("Failed to start BLE microphone streaming: $e"); + logger.e("Failed to start audio monitoring: $e"); _isStreamingActive = false; - _isBLEMicrophoneStreamingEnabled = false; _streamingPath = null; notifyListeners(); return false; } } - Future stopBLEMicrophoneStream() async { + Future _stopAudioMonitoring() async { if (!_isStreamingActive) { return; } @@ -167,7 +332,6 @@ class SensorRecorderProvider with ChangeNotifier { _amplitudeSub?.cancel(); _amplitudeSub = null; _isStreamingActive = false; - _isBLEMicrophoneStreamingEnabled = false; _waveformData.clear(); // Clean up temporary streaming file @@ -183,10 +347,10 @@ class SensorRecorderProvider with ChangeNotifier { _streamingPath = null; } - logger.i("BLE microphone streaming stopped"); + logger.i("Audio monitoring stopped"); notifyListeners(); } catch (e) { - logger.e("Error stopping BLE microphone streaming: $e"); + logger.e("Error stopping audio monitoring: $e"); } } @@ -215,20 +379,14 @@ class SensorRecorderProvider with ChangeNotifier { rethrow; } - await _startAudioRecording( - dirname, - ); + await _startAudioRecording(dirname); notifyListeners(); } Future _startAudioRecording(String recordingFolderPath) async { - if (!kIsWeb && !Platform.isAndroid) return; - - // Only start recording if BLE microphone streaming is enabled - if (!_isBLEMicrophoneStreamingEnabled) { - logger - .w("BLE microphone streaming not enabled, skipping audio recording"); + final source = _appliedAudioInputSource; + if (source == null) { return; } @@ -259,10 +417,12 @@ class SensorRecorderProvider with ChangeNotifier { return; } - await _selectBLEDevice(); - - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, skipping audio recording"); + await refreshAudioInputSources(); + final selectedDevice = _inputDeviceForSource(source); + if (!source.isSystemDefault && selectedDevice == null) { + logger.w( + "Selected audio input is unavailable, skipping audio recording: ${source.label}", + ); return; } @@ -280,7 +440,7 @@ class SensorRecorderProvider with ChangeNotifier { sampleRate: 48000, // Set to 48kHz for BLE audio quality bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps numChannels: 1, - device: _selectedBLEDevice, + device: selectedDevice, ); await _audioRecorder.start(config, path: audioPath); @@ -288,7 +448,7 @@ class SensorRecorderProvider with ChangeNotifier { _isAudioRecording = true; logger.i( - "Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}", + "Audio recording started: $_currentAudioPath with input: ${source.label}", ); _amplitudeSub = _audioRecorder @@ -328,11 +488,16 @@ class SensorRecorderProvider with ChangeNotifier { logger.e("Error stopping audio recording: $e"); } - // Restart streaming if it was enabled before recording - if (!turnOffMic && - _isBLEMicrophoneStreamingEnabled && - !_isStreamingActive) { - unawaited(startBLEMicrophoneStream()); + if (turnOffMic) { + unawaited(() async { + await selectAudioInputSource(null); + await stopAudioMonitoring(); + _appliedAudioInputSource = null; + _waveformData.clear(); + notifyListeners(); + }()); + } else if (_selectedAudioInputSource != null) { + unawaited(applySelectedAudioInputSource()); } notifyListeners(); @@ -572,8 +737,8 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { _disposed = true; - // Stop streaming - stopBLEMicrophoneStream(); + stopAudioInputSourceRefresh(); + stopAudioMonitoring(); // Stop recording _audioRecorder.stop().then((_) { diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart index 40bc27ad..52807438 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart @@ -6,6 +6,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage_web.dart'; +import '../models/audio_input_source.dart'; import '../models/logger.dart'; import '../models/sensor_streams.dart'; @@ -22,26 +23,55 @@ class SensorRecorderProvider with ChangeNotifier { bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; + List get audioInputSources => const []; + AudioInputSource? get selectedAudioInputSource => null; + AudioInputSource? get appliedAudioInputSource => null; + bool get isAudioInputEnabled => false; + bool get isAudioMonitoringActive => false; + bool get isAudioInputSelectionPending => false; final List _waveformData = []; final int _waveformRevision = 0; int get waveformRevision => _waveformRevision; List get waveformData => List.unmodifiable(_waveformData); - bool _isBLEMicrophoneStreamingEnabled = false; - bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; - - Future startBLEMicrophoneStream() async { - logger.w('BLE microphone streaming is not supported on web.'); - return false; + Future refreshAudioInputSources() async { + logger.w('Audio input selection is not supported on web yet.'); } - Future stopBLEMicrophoneStream() async { - _isBLEMicrophoneStreamingEnabled = false; + void startAudioInputSourceRefresh() {} + + void stopAudioInputSourceRefresh() {} + + Future selectAudioInputSource(AudioInputSource? source) async { + if (source != null) { + logger.w('Audio input recording is not supported on web yet.'); + } _waveformData.clear(); notifyListeners(); } + Future setAudioInputEnabled(bool enabled) async { + if (enabled) { + logger.w('Audio input recording is not supported on web yet.'); + } + await selectAudioInputSource(null); + } + + Future startAudioMonitoring() async { + logger.w('Audio input monitoring is not supported on web yet.'); + return false; + } + + Future stopAudioMonitoring() async { + await selectAudioInputSource(null); + } + + Future applySelectedAudioInputSource() async { + logger.w('Audio input monitoring is not supported on web yet.'); + return false; + } + Future startRecording(String dirname) async { if (_isRecording) { return; @@ -107,10 +137,6 @@ class SensorRecorderProvider with ChangeNotifier { _currentDirectory = null; - if (!turnOffMic && _isBLEMicrophoneStreamingEnabled) { - unawaited(startBLEMicrophoneStream()); - } - notifyListeners(); } diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart deleted file mode 100644 index 13dfb3e1..00000000 --- a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:provider/provider.dart'; -import '../../../view_models/sensor_recorder_provider_facade.dart'; - -class BLEMicrophoneStreamingRow extends StatelessWidget { - const BLEMicrophoneStreamingRow({super.key}); - - @override - Widget build(BuildContext context) { - if (kIsWeb || !Platform.isAndroid) { - return const SizedBox.shrink(); - } - - return Consumer( - builder: (context, recorderProvider, child) { - final isStreamingEnabled = - recorderProvider.isBLEMicrophoneStreamingEnabled; - - return PlatformListTile( - title: PlatformText('BLE Microphone Streaming'), - subtitle: PlatformText( - isStreamingEnabled - ? 'Microphone stream is active' - : 'Enable to start microphone streaming', - ), - trailing: PlatformSwitch( - value: isStreamingEnabled, - onChanged: (value) async { - if (value) { - final success = - await recorderProvider.startBLEMicrophoneStream(); - if (!success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: PlatformText( - 'Failed to start BLE microphone streaming. ' - 'Make sure a BLE headset is connected and microphone permission is granted.', - ), - backgroundColor: Colors.red, - ), - ); - } - } else { - await recorderProvider.stopBLEMicrophoneStream(); - } - }, - ), - ); - }, - ); - } -} diff --git a/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart b/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart new file mode 100644 index 00000000..7b73d5df --- /dev/null +++ b/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart @@ -0,0 +1,452 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/models/audio_input_source.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; +import 'package:provider/provider.dart'; + +/// Displays app-local microphone recording as a virtual sensor configuration. +/// +/// The card intentionally mirrors wearable sensor configuration rows while +/// writing only to [SensorRecorderProvider]. It does not apply settings to a +/// physical wearable. +class MicrophoneConfigurationCard extends StatefulWidget { + const MicrophoneConfigurationCard({super.key}); + + @override + State createState() => + _MicrophoneConfigurationCardState(); +} + +class _MicrophoneConfigurationCardState + extends State with WidgetsBindingObserver { + bool _refreshStarted = false; + SensorRecorderProvider? _recorderProvider; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final provider = context.read(); + if (!identical(_recorderProvider, provider)) { + _recorderProvider?.stopAudioInputSourceRefresh(); + _recorderProvider = provider; + _refreshStarted = false; + } + if (_refreshStarted) { + return; + } + _refreshStarted = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _recorderProvider?.startAudioInputSourceRefresh(); + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _recorderProvider?.refreshAudioInputSources(); + } + } + + @override + void dispose() { + _recorderProvider?.stopAudioInputSourceRefresh(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, recorderProvider, _) { + final selectedSource = recorderProvider.selectedAudioInputSource; + final isPending = recorderProvider.isAudioInputSelectionPending; + final isApplied = !isPending && selectedSource != null; + final isOn = selectedSource != null || isPending; + final colorScheme = Theme.of(context).colorScheme; + const sensorOnGreen = Color(0xFF2E7D32); + final accentColor = isPending + ? colorScheme.primary + : (isApplied ? sensorOnGreen : colorScheme.outline); + + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + Expanded( + child: PlatformText( + 'Microphone', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + tooltip: 'Refresh audio inputs', + onPressed: () => + recorderProvider.refreshAudioInputSources(), + icon: const Icon(Icons.refresh_rounded, size: 20), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 2, 12, 2), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => + _openMicrophoneSheet(context, recorderProvider), + child: Padding( + padding: const EdgeInsets.fromLTRB(2, 8, 2, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isOn ? 3 : 2, + height: 26, + decoration: BoxDecoration( + color: accentColor.withValues( + alpha: isOn ? 0.7 : 0.6, + ), + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(width: 6), + Icon( + _iconForSource(selectedSource), + size: 14, + color: isOn ? accentColor : colorScheme.outline, + ), + const SizedBox(width: 7), + Expanded( + child: Text( + 'Audio Input', + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: isOn ? accentColor : null, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + _SourcePill( + label: selectedSource?.label ?? 'Off', + foreground: isOn + ? accentColor + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 2), + Icon( + Icons.chevron_right_rounded, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 6), + ], + ), + ); + }, + ); + } + + void _openMicrophoneSheet( + BuildContext context, + SensorRecorderProvider recorderProvider, + ) { + showPlatformModalSheet( + context: context, + builder: (modalContext) { + return ChangeNotifierProvider.value( + value: recorderProvider, + child: SafeArea( + child: SizedBox( + height: MediaQuery.of(modalContext).size.height * 0.52, + child: Material( + color: Theme.of(modalContext).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: [ + Text( + 'Microphone', + style: Theme.of(modalContext) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Select the audio source recorded with local sessions.', + style: Theme.of(modalContext) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(modalContext) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(modalContext).pop(), + icon: const Icon(Icons.close_rounded, size: 20), + ), + ], + ), + ), + const Expanded(child: _MicrophoneConfigurationDetail()), + ], + ), + ), + ), + ), + ); + }, + ); + } + + IconData _iconForSource(AudioInputSource? source) { + if (source == null) { + return Icons.mic_off_rounded; + } + return switch (source.kind) { + AudioInputSourceKind.systemDefault => Icons.settings_voice_rounded, + AudioInputSourceKind.builtIn => Icons.phone_android_rounded, + AudioInputSourceKind.bluetooth => Icons.bluetooth_audio_rounded, + AudioInputSourceKind.wearable => Icons.hearing_rounded, + AudioInputSourceKind.external => Icons.cable_rounded, + AudioInputSourceKind.unknown => Icons.mic_rounded, + }; + } +} + +class _MicrophoneConfigurationDetail extends StatelessWidget { + const _MicrophoneConfigurationDetail(); + + static const String _offSelectionKey = '__audio_input_off__'; + + @override + Widget build(BuildContext context) { + final recorderProvider = context.watch(); + final sources = recorderProvider.audioInputSources; + final selected = _resolveSelectedSource( + sources, + recorderProvider.selectedAudioInputSource, + ); + final colorScheme = Theme.of(context).colorScheme; + final dropdownValues = [ + ...sources, + if (selected != null && !sources.contains(selected)) selected, + ]; + final selectedKey = selected?.id ?? _offSelectionKey; + + return ListView( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + children: [ + Text( + 'Audio Source', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + recorderProvider.isRecording + ? 'Audio source changes are locked while recording.' + : 'Choose a source, then apply profiles to start monitoring.', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: selectedKey, + isExpanded: true, + decoration: InputDecoration( + isDense: true, + filled: false, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.primary.withValues(alpha: 0.6), + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 7, + ), + ), + items: [ + const DropdownMenuItem( + value: _offSelectionKey, + child: Text('Off'), + ), + ...dropdownValues.map( + (source) => DropdownMenuItem( + value: source.id, + child: Text(source.label), + ), + ), + ], + onChanged: recorderProvider.isRecording + ? null + : (key) async { + if (key == null || key == _offSelectionKey) { + await recorderProvider.selectAudioInputSource(null); + return; + } + await recorderProvider.selectAudioInputSource( + dropdownValues.firstWhere((source) => source.id == key), + ); + }, + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: recorderProvider.refreshAudioInputSources, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Refresh inputs'), + ), + ), + ], + ); + } + + AudioInputSource? _resolveSelectedSource( + List sources, + AudioInputSource? selected, + ) { + if (selected == null) { + return null; + } + for (final source in sources) { + if (source.id == selected.id) { + return source; + } + } + return selected; + } +} + +class _SourcePill extends StatelessWidget { + final String label; + final Color foreground; + + const _SourcePill({ + required this.label, + required this.foreground, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + height: 22, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: foreground.withValues(alpha: 0.42), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150, minWidth: 38), + child: Text( + label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} + +class _PendingStatePill extends StatelessWidget { + final Color accentColor; + + const _PendingStatePill({required this.accentColor}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 22, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: accentColor.withValues(alpha: 0.42), + ), + ), + child: Text( + 'Pending', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index 0c60af2a..fdc2a3ba 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -8,6 +8,7 @@ import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:open_wearable/widgets/sensors/configuration/microphone_configuration_card.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_device_row.dart'; import 'package:provider/provider.dart'; @@ -35,11 +36,18 @@ class SensorConfigurationView extends StatelessWidget { WearablesProvider wearablesProvider, ) { if (wearablesProvider.wearables.isEmpty) { - return Center( - child: PlatformText( - "No devices connected", - style: Theme.of(context).textTheme.titleLarge, - ), + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + const MicrophoneConfigurationCard(), + const SizedBox(height: 12), + _buildApplyConfigButton( + context, + targets: const <_ConfigApplyTarget>[], + ), + const SizedBox(height: 12), + const Center(child: Text('No devices connected')), + ], ); } @@ -67,6 +75,7 @@ class SensorConfigurationView extends StatelessWidget { wearablesProvider: wearablesProvider, ); final sections = [ + const MicrophoneConfigurationCard(), ...groups.map( (group) => _buildGroupConfigurationRow( group: group, @@ -254,31 +263,10 @@ class SensorConfigurationView extends StatelessWidget { BuildContext context, { required List<_ConfigApplyTarget> targets, }) async { - if (targets.isEmpty) { - await showPlatformDialog( - context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: PlatformText('No configurable devices'), - content: PlatformText( - 'Connect a wearable with configurable sensors to apply settings.', - ), - actions: [ - PlatformDialogAction( - child: PlatformText('OK'), - onPressed: () => Navigator.of(dialogContext).pop(), - ), - ], - ), - ); - return; - } - + final recorderProvider = context.read(); + final audioApplied = await recorderProvider.applySelectedAudioInputSource(); int actionableCount = 0; - final recorderProvider = - Provider.of(context, listen: false); - bool shouldEnableMicrophoneStreaming = false; - for (final target in targets) { final primaryEntriesToApply = _entriesToApplyForProvider(target.provider); final mirroredEntriesToApply = _entriesToApplyForMirroredTarget(target); @@ -291,13 +279,6 @@ class SensorConfigurationView extends StatelessWidget { for (final entry in primaryEntriesToApply) { final SensorConfiguration config = entry.$1; final SensorConfigurationValue value = entry.$2; - if (config.name.toLowerCase().contains('microphone')) { - final options = - target.provider.getSelectedConfigurationOptions(config); - if (options.any((opt) => opt is StreamSensorConfigOption)) { - shouldEnableMicrophoneStreaming = true; - } - } // Always push the selected canonical value to the primary device on // apply. This also heals primary-side drift/unknown states. config.setConfiguration(value); @@ -309,14 +290,6 @@ class SensorConfigurationView extends StatelessWidget { config.setConfiguration(value); } - if (shouldEnableMicrophoneStreaming && - !recorderProvider.isBLEMicrophoneStreamingEnabled) { - await recorderProvider.startBLEMicrophoneStream(); - } else if (!shouldEnableMicrophoneStreaming && - recorderProvider.isBLEMicrophoneStreamingEnabled) { - await recorderProvider.stopBLEMicrophoneStream(); - } - logger.d( "Applied ${primaryEntriesToApply.length} primary and ${mirroredEntriesToApply.length} mirrored sensor settings for ${target.primaryDevice.name}", ); @@ -326,7 +299,7 @@ class SensorConfigurationView extends StatelessWidget { return; } - if (actionableCount == 0) { + if (actionableCount == 0 && !audioApplied) { AppToast.show( context, message: 'No pending sensor settings to apply.', @@ -338,7 +311,10 @@ class SensorConfigurationView extends StatelessWidget { AppToast.show( context, - message: 'Sensor settings applied.', + message: actionConfigMessage( + appliedSensorSettings: actionableCount, + appliedAudioInput: audioApplied, + ), type: AppToastType.success, icon: Icons.check_circle_outline_rounded, ); @@ -346,6 +322,16 @@ class SensorConfigurationView extends StatelessWidget { (onSetConfigPressed ?? () {})(); } + String actionConfigMessage({ + required int appliedSensorSettings, + required bool appliedAudioInput, + }) { + if (appliedSensorSettings == 0 && appliedAudioInput) { + return 'Microphone setting applied.'; + } + return 'Sensor settings applied.'; + } + Widget _buildThroughputWarningBanner(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart index 56f403de..1b8134bf 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -61,7 +61,7 @@ class _RecordingControls extends State { final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); await Future.wait(futures); - await recorder.stopBLEMicrophoneStream(); + await recorder.selectAudioInputSource(null); } await widget.updateRecordingsList(); } catch (e) { diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 6db4854c..c1ff1afd 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,6 +1,3 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; @@ -34,42 +31,9 @@ class _SensorValuesPageState extends State bool get _ownsProviders => widget.sharedProviders == null; - String? _errorMessage; - - bool _isInitializing = true; - @override bool get wantKeepAlive => true; - @override - void initState() { - super.initState(); - if (!kIsWeb && Platform.isAndroid) { - _checkStreamingStatus(); - } - } - - void _checkStreamingStatus() { - final recorderProvider = - Provider.of(context, listen: false); - if (!recorderProvider.isBLEMicrophoneStreamingEnabled) { - if (mounted) { - setState(() { - _isInitializing = false; - _errorMessage = - 'BLE microphone streaming not enabled. Enable it in sensor configuration.'; - }); - } - } else { - if (mounted) { - setState(() { - _isInitializing = false; - _errorMessage = null; - }); - } - } - } - @override void dispose() { if (_ownsProviders) { @@ -302,20 +266,9 @@ class _SensorValuesPageState extends State } Widget _buildAudioUI(SensorRecorderProvider recorderProvider) { - // If initializing, show a loading card - if (!kIsWeb && _isInitializing && Platform.isAndroid) { - return Card( - child: Container( - height: 100, - alignment: Alignment.center, - child: const CircularProgressIndicator(), - ), - ); - } - return Column( children: [ - if (recorderProvider.isBLEMicrophoneStreamingEnabled) + if (recorderProvider.isAudioInputEnabled) Card( child: Padding( padding: const EdgeInsets.all(16), @@ -330,9 +283,13 @@ class _SensorValuesPageState extends State size: 16, ), const SizedBox(width: 8), - Text( - 'AUDIO WAVEFORM ${recorderProvider.isRecording ? "(RECORDING)" : ""}', - style: Theme.of(context).textTheme.labelLarge, + Expanded( + child: Text( + 'AUDIO WAVEFORM ${recorderProvider.isRecording ? "(RECORDING)" : ""}', + style: Theme.of(context).textTheme.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ], ), @@ -347,24 +304,6 @@ class _SensorValuesPageState extends State ], ), ), - ) - else if (_errorMessage != null) - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.red), - const SizedBox(width: 12), - Expanded( - child: PlatformText( - _errorMessage!, - style: const TextStyle(color: Colors.red), - ), - ), - ], - ), - ), ), const SizedBox(height: 10), ], diff --git a/open_wearable/test/models/audio_input_source_test.dart b/open_wearable/test/models/audio_input_source_test.dart new file mode 100644 index 00000000..91bbabaf --- /dev/null +++ b/open_wearable/test/models/audio_input_source_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/audio_input_source.dart'; + +void main() { + group('AudioInputSource', () { + test('classifies common microphone labels for display', () { + expect( + classifyAudioInputSourceLabel('OpenEarable Ring Microphone'), + AudioInputSourceKind.wearable, + ); + expect( + classifyAudioInputSourceLabel('Bluetooth Headset'), + AudioInputSourceKind.bluetooth, + ); + expect( + classifyAudioInputSourceLabel('Built-in Phone Microphone'), + AudioInputSourceKind.builtIn, + ); + expect( + classifyAudioInputSourceLabel('USB Audio Interface'), + AudioInputSourceKind.external, + ); + expect( + classifyAudioInputSourceLabel('Studio Microphone'), + AudioInputSourceKind.unknown, + ); + }); + + test('represents the system default as an app-owned synthetic source', () { + expect(AudioInputSource.systemDefault.isSystemDefault, isTrue); + expect( + AudioInputSource.systemDefault.kind, + AudioInputSourceKind.systemDefault, + ); + }); + }); +}