// 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 (post-iPhone-test feedback): the AppBar gear icon // was removed; the modal sheet is now the **single** voice-controls // surface on mobile. Mode + release-tail are reached via an // "Adjust mode & release tail" button inside the modal that opens // the existing [VoiceSettingsDialog]. Audio output route picker is // new — driven by the `audio_router` plugin, which renders the // native AVRoutePickerView on iOS and a Material 3 device list on // Android. import 'dart:io' show Platform; import 'package:audio_router/audio_router.dart'; import 'package:audio_router/audio_router_platform_interface.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 (desktop only). /// /// 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, 1000), min: 0, max: 1000, 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 \u2014 desktop-only. if (isPtt && !widget.isTouchOnly) ...[ 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. class _AudioOutputTile extends StatefulWidget { const _AudioOutputTile(); @override State<_AudioOutputTile> createState() => _AudioOutputTileState(); } class _AudioOutputTileState extends State<_AudioOutputTile> { final AudioRouter _router = AudioRouter(); AudioDevice? _device; @override void initState() { super.initState(); // Query the current route once on mount so the tile renders the // real device (Speaker / Receiver / AirPods / etc.) immediately, // before currentDeviceStream fires its first delta event. Without // this, the tile shows 'Unknown' until the user changes routes. _refreshCurrent(); _router.currentDeviceStream.listen((dev) { if (!mounted) return; setState(() => _device = dev); }); } Future _refreshCurrent() async { try { final dev = await AudioRouterPlatform.instance.getCurrentDevice(); if (!mounted) return; setState(() => _device = dev); } catch (_) { // Ignore \u2014 we'll fall back to the stream. Plugin can throw on // first call before AVAudioSession is fully active. } } String _deviceLabel(AudioSourceType? type, AppL10n l10n) { switch (type) { case AudioSourceType.builtinSpeaker: return l10n.audioRouteSpeaker; case AudioSourceType.builtinReceiver: return l10n.audioRouteReceiver; case AudioSourceType.bluetooth: return l10n.audioRouteBluetooth; case AudioSourceType.wiredHeadset: return l10n.audioRouteWiredHeadset; case AudioSourceType.carAudio: return l10n.audioRouteCarAudio; case AudioSourceType.airplay: return l10n.audioRouteAirplay; case AudioSourceType.unknown: case null: return l10n.audioRouteUnknown; } } IconData _deviceIcon(AudioSourceType? type) { switch (type) { case AudioSourceType.builtinSpeaker: return Icons.volume_up; case AudioSourceType.builtinReceiver: return Icons.phone_in_talk; case AudioSourceType.bluetooth: return Icons.bluetooth_audio; case AudioSourceType.wiredHeadset: return Icons.headset; case AudioSourceType.carAudio: return Icons.directions_car; case AudioSourceType.airplay: return Icons.airplay; case AudioSourceType.unknown: case null: return Icons.speaker; } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); return InkWell( onTap: () => _router.showAudioRoutePicker(context), borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), child: Row( children: [ Icon( _deviceIcon(_device?.type), 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( _deviceLabel(_device?.type, l10n), style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, ), ), ], ), ), Icon( Icons.chevron_right, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ); } } 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), ), ), ), ); } }