// Compact voice UI for narrow / mobile layouts (Plan E hybrid: // AppBar mutes + status chip with 2-line live readout + wide bottom- // anchored PTT button + modal sheet for non-essential controls). // // rc.8 follow-up: the AppBar gear icon was removed; the modal sheet // is now the **single** voice-controls surface on mobile. Mode + // release-tail are surfaced inline (radio buttons + slider) inside // the modal. Audio output route picker is driven by `audio_session` // (Ryan Heise, 865k downloads): we enumerate AVAudioSession's // available inputs + current route ourselves and render a Discord/ // WhatsApp-style 'Choose audio' bottom-sheet. Switching is via // AVAudioSession.setPreferredInput(port) + overrideOutputAudioPort // (.speaker | .none). We previously tried `audio_router 1.1.1` // whose iOS path is AVRoutePickerView (the AirPlay button) \u2014 // wrong UI: that only lists AirPlay output destinations, not the // speaker/receiver/Bluetooth choices we want. import 'dart:async' show StreamSubscription; import 'dart:io' show Platform; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../l10n/generated/app_localizations.dart'; import '../main.dart' show PttCapabilityBadge; import '../src/rust/api.dart' as rust; /// Two-line status chip that summarises the current voice state. /// Tap to open the voice details modal. class VoiceStatusChip extends StatelessWidget { /// Construct a status chip. const VoiceStatusChip({ super.key, required this.transmitMode, required this.releaseTailMs, required this.pttBoundKeyLabel, required this.audioStats, required this.isTouchOnly, required this.onTap, }); /// Current transmit mode. final rust.BridgeTransmitMode transmitMode; /// Release-tail in milliseconds. final int releaseTailMs; /// Bound key label (empty on touch-only hosts). final String pttBoundKeyLabel; /// Current audio stats; null while audio engine not running. final rust.BridgeAudioStats? audioStats; /// True on iOS / iPadOS / Android. final bool isTouchOnly; /// Open the voice details modal. final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final stats = audioStats; final micOn = stats?.pttActive ?? false; final modeLabel = switch (transmitMode) { rust.BridgeTransmitMode.ptt => l10n.voiceModePtt, rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous, rust.BridgeTransmitMode.voiceActivity => '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})', }; String line1; if (transmitMode == rust.BridgeTransmitMode.ptt) { if (isTouchOnly) { line1 = '$modeLabel \u00b7 ${l10n.voicePttHoldHint}'; } else { line1 = '$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'; } } else { line1 = modeLabel; } final tailText = transmitMode == rust.BridgeTransmitMode.ptt ? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}' : null; final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff; final line2 = tailText == null ? micText : '$tailText \u00b7 $micText'; return Material( type: MaterialType.transparency, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.outlineVariant, width: 0.5, ), ), child: Row( children: [ Icon( micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, size: 12, color: micOn ? theme.colorScheme.primary : theme.colorScheme.outline, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( line1, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), ), Text( line2, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), const SizedBox(width: 8), Icon( Icons.expand_less, size: 18, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ), ); } } /// Wide, bottom-anchored push-to-talk button. class VoicePttButton extends StatefulWidget { /// Construct a PTT button. const VoicePttButton({ super.key, required this.active, required this.onHeldChanged, }); /// True while the engine reports the gate open. final bool active; /// Called with `true` on finger-down, `false` on finger-up or cancel. final ValueChanged onHeldChanged; @override State createState() => _VoicePttButtonState(); } class _VoicePttButtonState extends State { bool _pressed = false; void _setHeld(bool held) { if (_pressed == held) return; setState(() => _pressed = held); widget.onHeldChanged(held); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final activeNow = _pressed || widget.active; return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => _setHeld(true), onTapUp: (_) => _setHeld(false), onTapCancel: () => _setHeld(false), onPanDown: (_) => _setHeld(true), onPanEnd: (_) => _setHeld(false), onPanCancel: () => _setHeld(false), child: AnimatedContainer( duration: const Duration(milliseconds: 80), height: 56, decoration: BoxDecoration( color: activeNow ? theme.colorScheme.primary : theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16), boxShadow: activeNow ? [ BoxShadow( color: theme.colorScheme.primary.withAlpha(100), blurRadius: 16, spreadRadius: 2, offset: const Offset(0, 2), ), ] : null, ), child: Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( activeNow ? Icons.mic : Icons.mic_none, color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, size: 28, ), const SizedBox(width: 12), Text( activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, letterSpacing: 0.4, color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, ), ), ], ), ), ), ); } } /// Show the voice controls modal sheet — the single voice-controls /// surface on mobile. Tiles: /// 1. Audio output route picker (iOS native AVRoutePickerView / /// Android Material 3 list). Mobile only. /// 2. Mode radio buttons (PTT / Continuous; VoiceActivity disabled /// coming-soon). /// 3. Release-tail slider (PTT-only). /// 4. Mic level meter. /// 5. TX / RX frame counts. /// 6. PTT capability badge. /// /// Mode + release-tail are inlined directly here instead of being /// hidden behind an "Adjust" button → nested dialog. Single-screen /// control panel, zero navigation depth. `onModeChanged` and /// `onReleaseTailChanged` are debounced by the caller so users can /// drag the slider freely. Future showVoiceDetailsSheet( BuildContext context, { required rust.BridgeAudioStats? audioStats, required rust.BridgeTransmitMode transmitMode, required int releaseTailMs, required String pttBoundKeyLabel, required String pttLevel, required String pttBackendId, required String pttBoundInputClass, required bool isTouchOnly, required ValueChanged onModeChanged, required ValueChanged onReleaseTailChanged, }) async { await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, builder: (ctx) { return _VoiceSheetBody( audioStats: audioStats, initialMode: transmitMode, initialReleaseTailMs: releaseTailMs, pttBoundKeyLabel: pttBoundKeyLabel, pttLevel: pttLevel, pttBackendId: pttBackendId, pttBoundInputClass: pttBoundInputClass, isTouchOnly: isTouchOnly, onModeChanged: onModeChanged, onReleaseTailChanged: onReleaseTailChanged, ); }, ); } class _VoiceSheetBody extends StatefulWidget { const _VoiceSheetBody({ required this.audioStats, required this.initialMode, required this.initialReleaseTailMs, required this.pttBoundKeyLabel, required this.pttLevel, required this.pttBackendId, required this.pttBoundInputClass, required this.isTouchOnly, required this.onModeChanged, required this.onReleaseTailChanged, }); final rust.BridgeAudioStats? audioStats; final rust.BridgeTransmitMode initialMode; final int initialReleaseTailMs; final String pttBoundKeyLabel; final String pttLevel; final String pttBackendId; final String pttBoundInputClass; final bool isTouchOnly; final ValueChanged onModeChanged; final ValueChanged onReleaseTailChanged; @override State<_VoiceSheetBody> createState() => _VoiceSheetBodyState(); } class _VoiceSheetBodyState extends State<_VoiceSheetBody> { late rust.BridgeTransmitMode _mode = widget.initialMode; late int _tail = widget.initialReleaseTailMs; void _setMode(rust.BridgeTransmitMode m) { if (m == rust.BridgeTransmitMode.voiceActivity) { // Coming-soon \u2014 disabled in UI; defensive guard. return; } setState(() => _mode = m); widget.onModeChanged(m); } void _setTail(double v) { final ms = v.round(); setState(() => _tail = ms); widget.onReleaseTailChanged(ms); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final stats = widget.audioStats; final levelActive = stats?.pttActive ?? false; final isPtt = _mode == rust.BridgeTransmitMode.ptt; // Route picker only meaningful on iOS + Android where the OS // owns audio routing. Desktop hosts skip the tile entirely. final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid); return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge), const SizedBox(height: 12), // 1) Audio output route picker tile (mobile only). if (showRoutePicker) ...[ const _AudioOutputTile(), const SizedBox(height: 8), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), ], // 2) Mode \u2014 inline radio rows. Text( l10n.voiceModeLabel, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), _ModeRow( label: l10n.voiceModePtt, icon: Icons.radio_button_checked, selected: _mode == rust.BridgeTransmitMode.ptt, onTap: () => _setMode(rust.BridgeTransmitMode.ptt), ), _ModeRow( label: l10n.voiceModeContinuous, icon: Icons.podcasts, selected: _mode == rust.BridgeTransmitMode.continuous, onTap: () => _setMode(rust.BridgeTransmitMode.continuous), ), _ModeRow( label: '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})', icon: Icons.graphic_eq, selected: false, onTap: null, ), // 3) Release-tail slider (PTT only). if (isPtt) ...[ const SizedBox(height: 8), Row( children: [ Text( l10n.voiceReleaseTailLabel, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), Text( '$_tail${l10n.voiceReleaseTailHint}', style: theme.textTheme.bodyMedium?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), Slider( value: _tail.toDouble().clamp(0, 500), min: 0, max: 500, divisions: 20, label: '$_tail ms', onChanged: _setTail, ), // Desktop-only: surface the bound key so the user // sees what hardware key is wired. On mobile this // row is suppressed (there is no hardware key; the // PTT button is the input). if (!widget.isTouchOnly && widget.pttBoundKeyLabel.isNotEmpty) ...[ Padding( padding: const EdgeInsets.only(left: 4), child: Text( '${l10n.voiceBoundKeyLabel}: ${widget.pttBoundKeyLabel}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ], const SizedBox(height: 16), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 12), // 4) Level meter. _LevelMeter(active: levelActive), const SizedBox(height: 6), if (stats != null) Text( l10n.audioStatsLine( stats.framesSent, stats.framesReceived, stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff, ), style: theme.textTheme.bodySmall, ), // 5) PTT capability badge. On iOS this must remain // visible even though the resolved level is always // `L0Focused`, because the P0 acceptance flow requires // honest capability advertising with an explanation of // the sandbox limitation. if (isPtt) ...[ const SizedBox(height: 12), PttCapabilityBadge( level: widget.pttLevel, backendId: widget.pttBackendId, boundInputClass: widget.pttBoundInputClass, ), ], ], ), ), ); } } class _ModeRow extends StatelessWidget { const _ModeRow({ required this.label, required this.icon, required this.selected, required this.onTap, }); final String label; final IconData icon; final bool selected; final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final disabled = onTap == null; final color = disabled ? theme.colorScheme.onSurfaceVariant.withAlpha(120) : selected ? theme.colorScheme.primary : theme.colorScheme.onSurface; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), child: Row( children: [ Icon( selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, color: color, ), const SizedBox(width: 12), Icon(icon, size: 18, color: color), const SizedBox(width: 8), Expanded( child: Text( label, style: theme.textTheme.bodyLarge?.copyWith(color: color), ), ), ], ), ), ); } } /// Tile that displays the current audio output route + opens the /// native picker on tap. Subscribes to `currentDeviceStream` so the /// row auto-updates when the user plugs in headphones, connects /// AirPods, etc. /// Tile that displays the **active** audio output port (Speaker / /// iPhone receiver / AirPods / wired headset / etc.) and opens a /// 'Choose audio' bottom sheet on tap. Backed by `audio_session`: /// /// * `AVAudioSession.currentRoute.outputs` for the active label. /// * `AVAudioSession.availableInputs` for the picker list of /// selectable inputs (Built-in mic, BT HFP, wired headset, USB). /// * `routeChangeStream` for live updates. /// * `setPreferredInput(port)` for input selection (also moves /// the matching output for HFP/headset/wired). /// * `overrideOutputAudioPort(.speaker | .none)` for the /// speakerphone <-> earpiece toggle. /// /// This is the same primitive used by Discord / WhatsApp / FaceTime /// for their VoIP audio chooser. It is NOT the AirPlay picker /// (`AVRoutePickerView`), which is a different UI for streaming /// audio to other devices. class _AudioOutputTile extends StatefulWidget { const _AudioOutputTile(); @override State<_AudioOutputTile> createState() => _AudioOutputTileState(); } class _AudioOutputTileState extends State<_AudioOutputTile> { AVAudioSessionPortDescription? _activeOutput; StreamSubscription? _routeSub; @override void initState() { super.initState(); _refresh(); // Live updates when the user plugs / unplugs / connects a // headset / BT device while the modal sheet is open. _routeSub = AVAudioSession().routeChangeStream.listen((_) { if (!mounted) return; _refresh(); }); } @override void dispose() { _routeSub?.cancel(); _routeSub = null; super.dispose(); } Future _refresh() async { try { final route = await AVAudioSession().currentRoute; if (!mounted) return; setState(() { // The 'output' port we want to display is whichever output // the system has currently routed to. There is normally one. _activeOutput = route.outputs.isEmpty ? null : route.outputs.first; }); } catch (_) { // Suppress \u2014 AVAudioSession may transiently throw on first // call before the session is active. } } static String _portLabel( AVAudioSessionPort? type, String fallback, AppL10n l10n, ) { switch (type) { case AVAudioSessionPort.builtInSpeaker: return l10n.audioRouteSpeaker; case AVAudioSessionPort.builtInReceiver: return l10n.audioRouteReceiver; case AVAudioSessionPort.bluetoothHfp: case AVAudioSessionPort.bluetoothA2dp: case AVAudioSessionPort.bluetoothLe: return fallback.isEmpty ? l10n.audioRouteBluetooth : fallback; case AVAudioSessionPort.headphones: case AVAudioSessionPort.headsetMic: return fallback.isEmpty ? l10n.audioRouteWiredHeadset : fallback; case AVAudioSessionPort.carAudio: return l10n.audioRouteCarAudio; case AVAudioSessionPort.airPlay: return l10n.audioRouteAirplay; case AVAudioSessionPort.builtInMic: // Built-in mic is implied 'iPhone' \u2014 only seen if we somehow // end up with an input listed as an output. return fallback.isEmpty ? l10n.audioRouteReceiver : fallback; case null: default: return fallback.isEmpty ? l10n.audioRouteUnknown : fallback; } } static IconData _portIcon(AVAudioSessionPort? type) { switch (type) { case AVAudioSessionPort.builtInSpeaker: return Icons.volume_up; case AVAudioSessionPort.builtInReceiver: return Icons.phone_in_talk; case AVAudioSessionPort.bluetoothHfp: case AVAudioSessionPort.bluetoothA2dp: case AVAudioSessionPort.bluetoothLe: return Icons.bluetooth_audio; case AVAudioSessionPort.headphones: case AVAudioSessionPort.headsetMic: return Icons.headset; case AVAudioSessionPort.carAudio: return Icons.directions_car; case AVAudioSessionPort.airPlay: return Icons.airplay; default: return Icons.speaker; } } Future _openPicker() async { await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, builder: (ctx) { return const _AudioOutputPickerSheet(); }, ); // Refresh after the picker closes (user may have changed route). await _refresh(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final port = _activeOutput; final label = _portLabel(port?.portType, port?.portName ?? '', l10n); return InkWell( onTap: _openPicker, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), child: Row( children: [ Icon(_portIcon(port?.portType), color: theme.colorScheme.primary), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.audioOutputLabel, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), Text( label, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, ), ), ], ), ), Icon( Icons.chevron_right, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ); } } /// 'Choose audio' bottom sheet that lists every selectable route /// (Speaker, iPhone receiver, every connected BT / wired / USB /// input). Tap to switch; Speaker / Receiver use /// `overrideOutputAudioPort(.speaker | .none)`, other ports use /// `setPreferredInput(port)` which also moves the paired output /// (e.g. AirPods). class _AudioOutputPickerSheet extends StatefulWidget { const _AudioOutputPickerSheet(); @override State<_AudioOutputPickerSheet> createState() => _AudioOutputPickerSheetState(); } class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { Set _availableInputs = const {}; AVAudioSessionRouteDescription? _route; StreamSubscription? _routeSub; bool _loading = true; @override void initState() { super.initState(); _refresh(); _routeSub = AVAudioSession().routeChangeStream.listen((_) { if (!mounted) return; _refresh(); }); } @override void dispose() { _routeSub?.cancel(); _routeSub = null; super.dispose(); } Future _refresh() async { try { final session = AVAudioSession(); final inputs = await session.availableInputs; final route = await session.currentRoute; if (!mounted) return; setState(() { _availableInputs = inputs; _route = route; _loading = false; }); } catch (_) { if (!mounted) return; setState(() => _loading = false); } } Future _selectSpeaker() async { try { // Apple-documented quirk: in .voiceChat mode, calling // setPreferredInput(builtInMic) AFTER overrideOutputAudioPort(.speaker) // causes iOS to recalculate the route. Built-in mic naturally // pairs with the receiver (not the speaker), so the system // SILENTLY REVERTS the speaker override and routes audio // back through the earpiece. Net: the await chain returns // successfully ('no exception'), but the user hears no // change. // // Fix: do NOT call setPreferredInput when forcing speaker. // The speaker override is sufficient on its own \u2014 input // remains on whatever the system was already using (built-in // mic by default, or BT/wired if connected and selected // elsewhere). For consistency, only switch input when the // user explicitly picks a non-speaker input row. await AVAudioSession().overrideOutputAudioPort( AVAudioSessionPortOverride.speaker, ); debugPrint('chanora: audio output -> speakerphone (override applied)'); } catch (e, st) { debugPrint('chanora: _selectSpeaker FAILED: $e\n$st'); } if (!mounted) return; Navigator.of(context).pop(); } Future _selectReceiver() async { try { // Same quirk in reverse: removing the speaker override // (.none) is enough to restore the .voiceChat default route, // which is the built-in receiver. Calling setPreferredInput // explicitly here is redundant and risks the same recalc // race that broke _selectSpeaker before. await AVAudioSession().overrideOutputAudioPort( AVAudioSessionPortOverride.none, ); debugPrint('chanora: audio output -> receiver (override cleared)'); } catch (e, st) { debugPrint('chanora: _selectReceiver FAILED: $e\n$st'); } if (!mounted) return; Navigator.of(context).pop(); } Future _selectInput(AVAudioSessionPortDescription port) async { try { // Drop any speakerphone override so the route follows the // selected input. BT / wired headset / USB inputs pair their // OWN output (the user hears audio through the same device // they speak into), so .none + setPreferredInput is the // correct combo here. await AVAudioSession().overrideOutputAudioPort( AVAudioSessionPortOverride.none, ); await AVAudioSession().setPreferredInput(port); debugPrint( 'chanora: audio output -> ${port.portName} (${port.portType})', ); } catch (e, st) { debugPrint('chanora: _selectInput FAILED: $e\n$st'); } if (!mounted) return; Navigator.of(context).pop(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); if (_loading) { return const SafeArea( child: Padding( padding: EdgeInsets.all(40), child: Center(child: CircularProgressIndicator()), ), ); } final currentOutputType = _route?.outputs.isNotEmpty == true ? _route!.outputs.first.portType : null; final currentInputUid = _route?.inputs.isNotEmpty == true ? _route!.inputs.first.uid : null; // Whether the active route is the speakerphone override (built-in // speaker is the output but the actual session category isn't // playback \u2014 it's playAndRecord + override). final isSpeaker = currentOutputType == AVAudioSessionPort.builtInSpeaker; final isReceiver = currentOutputType == AVAudioSessionPort.builtInReceiver; // Non-built-in inputs (BT / wired / USB / car audio) for the // device-specific rows. Built-in mic is rendered as 'iPhone // (receiver)' above; we filter it out here. final externalInputs = _availableInputs .where((p) => p.portType != AVAudioSessionPort.builtInMic) .toList(); return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), const SizedBox(height: 16), _PickerRow( icon: Icons.volume_up, label: l10n.audioRouteSpeaker, selected: isSpeaker, onTap: _selectSpeaker, ), _PickerRow( icon: Icons.phone_in_talk, label: l10n.audioRouteReceiver, selected: isReceiver, onTap: _selectReceiver, ), for (final port in externalInputs) _PickerRow( icon: _AudioOutputTileState._portIcon(port.portType), label: _AudioOutputTileState._portLabel( port.portType, port.portName, l10n, ), selected: !isSpeaker && port.uid == currentInputUid, onTap: () => _selectInput(port), ), ], ), ), ); } } class _PickerRow extends StatelessWidget { const _PickerRow({ required this.icon, required this.label, required this.selected, required this.onTap, }); final IconData icon; final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = selected ? theme.colorScheme.primary : theme.colorScheme.onSurface; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), child: Row( children: [ Icon(icon, color: color), const SizedBox(width: 16), Expanded( child: Text( label, style: theme.textTheme.bodyLarge?.copyWith(color: color), ), ), if (selected) Icon(Icons.check, color: theme.colorScheme.primary), ], ), ), ); } } class _LevelMeter extends StatelessWidget { const _LevelMeter({required this.active}); final bool active; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Container( height: 8, decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: FractionallySizedBox( alignment: AlignmentDirectional.centerStart, widthFactor: active ? 0.75 : 0.05, child: Container( decoration: BoxDecoration( color: active ? theme.colorScheme.primary : theme.colorScheme.outlineVariant, borderRadius: BorderRadius.circular(4), ), ), ), ); } }