// 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). // // Wide-mode (>= 840 dp) keeps the existing [VoiceBar] widget; this // file is only invoked from `main.dart` when the body is narrow. // // Layout in narrow mode (in voice channel, PTT mode): // // [ AppBar with #channel chip + ๐ŸŽค mic ๐ŸŽง headset โš™ settings ... ] // [ ============= channel tree (Expanded) ============== ] // [ chip: 'PTT ยท Hold the button' โ†‘ ] // [ '200 ms tail ยท Mic on' ] // [ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”] // [ โ”‚ ๐ŸŽค PUSH TO TALK โ”‚] PTT button // [ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜] // // Tapping the chip opens a [showModalBottomSheet] that surfaces the // mode radio, release-tail slider, level meter, stats, and the // (currently rare) capability badge. The mute buttons live in the // AppBar so they remain visible without expanding the sheet. 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 [_VoiceDetailsSheet] modal. /// /// Line 1: mode + bound key (or "Hold the button" on mobile) /// Line 2: release tail + mic state 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 (PTT / Continuous / VoiceActivity). final rust.BridgeTransmitMode transmitMode; /// Release-tail in milliseconds. final int releaseTailMs; /// Bound key label (empty on touch-only hosts where no hardware /// key is bound โ€” the chip's line 1 then says "Hold the button"). final String pttBoundKeyLabel; /// Current audio stats; null while audio engine not running. final rust.BridgeAudioStats? audioStats; /// True on iOS / iPadOS / Android. Used so the chip's line 1 /// can say "Hold the button" rather than naming a hardware key. final bool isTouchOnly; /// Open the [_VoiceDetailsSheet] 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) { // Touch-only hosts have no bound key; describe the // on-screen button instead. line1 = '$modeLabel ยท ${l10n.voicePttHoldHint}'; } else { // Desktop: name the bound key. line1 = '$modeLabel ยท ${pttBoundKeyLabel.isEmpty ? "โ€”" : 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 ยท $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: [ // Mic state dot โ€” solid + primary while transmitting, // outlined while idle. 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. Touch-and-hold drives /// the engine via `onHeldChanged`; the bridge's release-tail timer /// handles the trailing tail (SDD-096) so the user sees the same /// behaviour as a hardware-key host. /// /// Visually: filled primary-container chip at rest, filled primary /// (with a soft outer glow) while held. 56 dp tall by spec, matching /// the Material 3 extended-FAB height; horizontal margin is the /// caller's responsibility so the button matches sibling content. 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 (mirrors the level /// meter's active flag). Drives the held-style visuals so the /// user gets feedback that holding actually engaged the mic. final bool active; /// Called with `true` on finger-down, `false` on finger-up or /// gesture cancel. Map to `setPtt(active: held)` on the caller. 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 details modal sheet. Returns when the user /// dismisses (taps outside / swipes down / hits the close button). /// /// Surfaces the non-essential controls + live readouts that don't /// fit in the AppBar or the status chip: /// * Mic level meter /// * TX / RX frame counts + mic state line /// * PTT capability badge (only on desktop; touch-only hosts hide /// this because the capability story is always "L0 Focused via /// on-screen button" and the on-screen button is itself the /// evidence) /// /// Mode + release-tail + bind-key are intentionally NOT duplicated /// here โ€” those still live in `VoiceSettingsDialog` reachable from /// the AppBar's settings icon, so there's exactly one configuration /// surface. 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, }) async { await showModalBottomSheet( context: context, showDragHandle: 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})', }; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l10n.voiceSettingsTitle, style: theme.textTheme.titleLarge, ), const SizedBox(height: 16), // Mode + (PTT-only) bind / release-tail recap line. 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} ยท ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}' : '${l10n.voiceModePtt}: ' '${pttBoundKeyLabel.isEmpty ? "โ€”" : pttBoundKeyLabel}' ' ยท ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], const SizedBox(height: 16), // 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, ), // PTT capability badge โ€” desktop-only (the touch-only // story is "L0 Focused via on-screen button" which is // already visually obvious from the PTT button). if (isPtt && !isTouchOnly) ...[ const SizedBox(height: 12), PttCapabilityBadge( level: pttLevel, backendId: pttBackendId, boundInputClass: pttBoundInputClass, ), ], ], ), ), ); }, ); } 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), ), ), ), ); } }