// 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: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 + bind / tail recap (display). /// 3. "Adjust mode & release tail" button → [VoiceSettingsDialog]. /// 4. Mic level meter. /// 5. TX / RX frame counts. /// 6. PTT capability badge (desktop only). 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 VoidCallback onAdjustVoiceSettings, }) async { await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, builder: (ctx) { final theme = Theme.of(ctx); final l10n = AppL10n.of(ctx); final stats = audioStats; final levelActive = stats?.pttActive ?? false; final isPtt = transmitMode == rust.BridgeTransmitMode.ptt; final modeLabel = switch (transmitMode) { rust.BridgeTransmitMode.ptt => l10n.voiceModePtt, rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous, rust.BridgeTransmitMode.voiceActivity => '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})', }; // 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: 16), // 1) Audio output route picker tile (mobile only). if (showRoutePicker) ...[ const _AudioOutputTile(), const SizedBox(height: 12), Divider( height: 1, color: theme.colorScheme.outlineVariant, ), const SizedBox(height: 12), ], // 2) Mode + bind / tail recap (display only). Row( children: [ Icon( transmitMode == rust.BridgeTransmitMode.ptt ? Icons.radio_button_checked : Icons.podcasts, size: 18, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 8), Expanded( child: Text( modeLabel, style: theme.textTheme.bodyMedium, ), ), ], ), if (isPtt) ...[ const SizedBox(height: 8), Padding( padding: const EdgeInsets.only(left: 26), child: Text( isTouchOnly ? '${l10n.voicePttHoldHint} \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}' : '${l10n.voiceModePtt}: ' '${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}' ' \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], const SizedBox(height: 12), // 3) Adjust button — opens VoiceSettingsDialog. One // config form for mode + tail (+ bind on desktop), // not two. OutlinedButton.icon( onPressed: () { Navigator.of(ctx).pop(); onAdjustVoiceSettings(); }, icon: const Icon(Icons.tune), label: Text(l10n.voiceAdjustSettings), ), const SizedBox(height: 16), // 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 — desktop-only. if (isPtt && !isTouchOnly) ...[ const SizedBox(height: 12), PttCapabilityBadge( level: pttLevel, backendId: pttBackendId, boundInputClass: pttBoundInputClass, ), ], ], ), ), ); }, ); } /// 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(); _router.currentDeviceStream.listen((dev) { if (!mounted) return; setState(() => _device = dev); }); } 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), ), ), ), ); } }