diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index a2278d3..e93d863 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -1126,14 +1126,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { Future _toggleOutputMute() async { final next = !_outputMuted; + // Optimistic: flip the UI immediately. + setState(() { + _outputMuted = next; + }); try { await rust.setOutputMuted(muted: next); - if (!mounted) return; - setState(() { - _outputMuted = next; - }); } catch (e) { if (!mounted) return; + // Roll back on failure. + setState(() { + _outputMuted = !next; + }); _showUiError('output mute', e); } } @@ -1260,6 +1264,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { return; } final next = !_hardMute; + // Optimistic: flip the UI immediately so the icon responds + // before the two bridge calls round-trip through FFI. + setState(() { + _inputMuted = next; + _hardMute = next; + _hardMuteByPermission = false; + }); try { // Hard-mute is two coordinated effects: // * setHardMute — local TransmitGate clamp; we stop sending @@ -1272,14 +1283,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { // through. Drive them together. await rust.setHardMute(muted: next); await rust.setInputMuted(muted: next); - if (!mounted) return; - setState(() { - _inputMuted = next; - _hardMute = next; - _hardMuteByPermission = false; - }); } catch (e) { if (!mounted) return; + // Roll back on failure. + setState(() { + _inputMuted = !next; + _hardMute = !next; + }); _showUiError('hard mute', e); } } @@ -2145,26 +2155,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final theme = Theme.of(context); final headerActions = [ - if (_serverReachable && _inChannel) ...[ - IconButton( - tooltip: _hardMuteByTalkPower - ? 'Insufficient talk power to speak in this channel' - : l10n.voiceHardMuteLabel, - icon: Icon(_hardMute ? Icons.mic_off : Icons.mic), - isSelected: _hardMute, - selectedIcon: const Icon(Icons.mic_off), - color: _hardMute ? theme.colorScheme.error : null, - onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute, - ), - IconButton( - tooltip: l10n.voiceOutputMuteLabel, - icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset), - isSelected: _outputMuted, - selectedIcon: const Icon(Icons.headset_off), - color: _outputMuted ? theme.colorScheme.error : null, - onPressed: _toggleOutputMute, - ), - ], IconButton( tooltip: l10n.aboutAction, icon: const Icon(Icons.info_outline), @@ -2449,7 +2439,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { Expanded(child: snapshotView), const SizedBox(height: 8), permissionBanner, - VoiceStatusChip( + CompactVoiceBar( + inChannel: _inChannel, transmitMode: _transmitMode, releaseTailMs: _releaseTailMs, pttBoundKeyLabel: _pttBoundKeyLabel, @@ -2457,19 +2448,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { isTouchOnly: isTouchOnlyPttHost, inputMuted: _hardMute, outputMuted: _outputMuted, + onToggleInputMute: _onToggleHardMute, + onToggleOutputMute: _toggleOutputMute, + onOpenDetails: () => _onOpenVoiceDetailsSheet(), + onPttHeldChanged: _onOnscreenPttHeldChanged, talkPower: ownClientState?.talkPower, neededTalkPower: ownClientState?.neededTalkPower, talkPowerGranted: ownClientState?.talkPowerGranted, - onTap: () => _onOpenVoiceDetailsSheet(), + hardMuteByTalkPower: _hardMuteByTalkPower, ), - if (_inChannel && - _transmitMode == rust.BridgeTransmitMode.ptt) ...[ - const SizedBox(height: 8), - VoicePttButton( - active: _audioStats?.pttActive ?? false, - onHeldChanged: _onOnscreenPttHeldChanged, - ), - ], ], ); }, diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 601c46e..e8ab5b2 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -1,11 +1,15 @@ -// 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). +// Compact voice UI for narrow / mobile layouts. // -// 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. +// Two-zone voice bar pinned to the bottom: +// • Control row: status text + mute + deafen + settings chevron +// • PTT row: full-width hold-to-talk (PTT mode only) +// Both share a single container whose background colour reflects +// the current voice state (normal / muted / talk-power-blocked). +// +// Gesture isolation: the control row uses tap-only InkWell / +// IconButton; the PTT row uses a raw Listener for pointer-down / +// pointer-up. Because each row is a disjoint hit-test region, a +// finger holding PTT cannot accidentally toggle mute or deafen. import 'dart:async' show Timer, unawaited; import 'dart:io' show Platform; @@ -539,6 +543,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge), const SizedBox(height: 12), + // ── Primary section (always visible) ──────────────────── + // 1) Audio output route picker tile (mobile only). if (showRoutePicker) ...[ const AudioOutputTile(), @@ -574,7 +580,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity), ), - // 3) Release-tail slider (PTT only). + // Release-tail slider (PTT only). if (isPtt) ...[ const SizedBox(height: 8), Row( @@ -624,7 +630,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 12), - // 4) Level meter + live TX/RX stats. + // Level meter + live TX/RX stats. VoiceLevelMeter(active: levelActive), const SizedBox(height: 6), _StatsRow( @@ -648,183 +654,227 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { ), ], - // Audio processing. - const SizedBox(height: 12), - Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), - Text( - 'Audio processing', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 4), - // Android HW/SW selector. - if (Platform.isAndroid) ...[ - const VoiceSubHeader('Processing backend'), - SegmentedButton( - style: voiceSegmentedButtonStyle(theme), - segments: androidProcessingSegments, - selected: {_audioProcessing.preferHardware}, - onSelectionChanged: (s) { - setState(() => _audioProcessing.preferHardware = s.first); - _notifyAudioConfig(); - }, - ), - const SizedBox(height: 4), - Text( - _audioProcessing.preferHardware - ? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.' - : 'Software mode applies the full WebRTC APM stage set.', - style: theme.textTheme.bodySmall?.copyWith( + // ── Collapsible: Audio processing ─────────────────────── + ExpansionTile( + initiallyExpanded: false, + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.symmetric(horizontal: 0), + title: Text( + 'Audio processing', + style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), - ], + children: [ + _buildAudioProcessingSection(theme), + ], + ), - if (_isIos) ...[ - Text( - 'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are ' - 'hidden here; only settings that still affect the shipping ' - 'iOS path are shown.', - style: theme.textTheme.bodySmall?.copyWith( + // ── Collapsible: PTT capability (PTT mode only) ───────── + if (isPtt) + ExpansionTile( + initiallyExpanded: false, + tilePadding: const EdgeInsets.symmetric(horizontal: 0), + shape: const Border(), + collapsedShape: const Border(), + title: Text( + 'PTT capability', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + children: [ + PttCapabilityBadge( + level: widget.pttLevel, + backendId: widget.pttBackendId, + boundInputClass: widget.pttBoundInputClass, + ), + const SizedBox(height: 8), + ], + ), + + // ── Collapsible: Debug ────────────────────────────────── + ExpansionTile( + initiallyExpanded: false, + shape: const Border(), + collapsedShape: const Border(), + tilePadding: const EdgeInsets.symmetric(horizontal: 0), + title: Text( + 'Debug', + style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), - const SizedBox(height: 4), - ], - if (!_isIos && - (!Platform.isAndroid || - androidShowsNsControl(_audioProcessing))) - AudioProcessingToggleRow( - dense: true, - label: 'Noise suppression', - subtitle: 'Wiener filter', - value: _audioProcessing.nsEnabled, - onChanged: (v) { - setState(() => _audioProcessing.nsEnabled = v); - _notifyAudioConfig(); - }, - ), - if (!_isIos && - (!Platform.isAndroid || - androidShowsAecControl(_audioProcessing))) - AudioProcessingToggleRow( - dense: true, - label: 'Echo cancellation', - subtitle: Platform.isAndroid - ? (_audioProcessing.preferHardware - ? 'Prefers device/OS effect; falls back to WebRTC AEC3' - : 'WebRTC AEC3 · adaptive filter') - : (_isMacOS - ? 'Managed by platform VPIO' - : 'WebRTC AEC3 · adaptive filter'), - value: _isMacOS ? true : _audioProcessing.aecEnabled, - onChanged: _isMacOS - ? null - : (v) { - setState(() => _audioProcessing.aecEnabled = v); - _notifyAudioConfig(); - }, - ), - if (!_isIos && - (!Platform.isAndroid || - androidShowsAgcControl(_audioProcessing))) - AudioProcessingToggleRow( - dense: true, - label: 'Auto gain control', - subtitle: 'AGC2 · -18 dBFS target', - value: _audioProcessing.agcEnabled, - onChanged: (v) { - setState(() => _audioProcessing.agcEnabled = v); - _notifyAudioConfig(); - }, - ), - if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing)) - AudioProcessingToggleRow( - dense: true, - label: 'High-pass filter', - subtitle: '80 Hz · DC removal', - value: _audioProcessing.hpfEnabled, - onChanged: (v) { - setState(() => _audioProcessing.hpfEnabled = v); - _notifyAudioConfig(); - }, - ), - if (!_isIos && - (!Platform.isAndroid || - androidShowsLimiterControl(_audioProcessing))) - AudioProcessingToggleRow( - dense: true, - label: 'Peak limiter', - subtitle: '-1 dBFS soft-knee · 2 ms look-ahead', - value: _audioProcessing.limiterEnabled, - onChanged: (v) { - setState(() => _audioProcessing.limiterEnabled = v); - _notifyAudioConfig(); - }, - ), - - // VAD backend. - const SizedBox(height: 8), - Text( - 'Voice activity detection (VAD)', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + children: [ + _buildDebugSection(theme), + ], ), - const SizedBox(height: 2), - SegmentedButton( - style: voiceSegmentedButtonStyle(theme), - segments: _isDesktopSileroVadHost - ? desktopVadBackendSegments - : vadBackendSegments, - selected: {_audioProcessing.vadBackend}, - onSelectionChanged: (s) { - setState(() => _audioProcessing.vadBackend = s.first); - _notifyAudioConfig(); - }, - ), - - // Debug. - const SizedBox(height: 8), - Text( - 'Debug', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - AudioProcessingToggleRow( - dense: true, - label: 'WAV dump', - subtitle: 'Record raw/processed mic to temp dir', - value: _audioProcessing.debugWavDump, - onChanged: (v) { - setState(() => _audioProcessing.debugWavDump = v); - _notifyAudioConfig(); - }, - ), - - // 6) 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, - ), - ], ], ), ), ); } + + // ── Audio processing section (inside ExpansionTile) ───────────────── + + Widget _buildAudioProcessingSection(ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Android HW/SW selector. + if (Platform.isAndroid) ...[ + const VoiceSubHeader('Processing backend'), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: androidProcessingSegments, + selected: {_audioProcessing.preferHardware}, + onSelectionChanged: (s) { + setState(() => _audioProcessing.preferHardware = s.first); + _notifyAudioConfig(); + }, + ), + const SizedBox(height: 4), + Text( + _audioProcessing.preferHardware + ? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.' + : 'Software mode applies the full WebRTC APM stage set.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + + if (_isIos) ...[ + Text( + 'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are ' + 'hidden here; only settings that still affect the shipping ' + 'iOS path are shown.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + ], + if (!_isIos && + (!Platform.isAndroid || + androidShowsNsControl(_audioProcessing))) + AudioProcessingToggleRow( + dense: true, + label: 'Noise suppression', + subtitle: 'Wiener filter', + value: _audioProcessing.nsEnabled, + onChanged: (v) { + setState(() => _audioProcessing.nsEnabled = v); + _notifyAudioConfig(); + }, + ), + if (!_isIos && + (!Platform.isAndroid || + androidShowsAecControl(_audioProcessing))) + AudioProcessingToggleRow( + dense: true, + label: 'Echo cancellation', + subtitle: Platform.isAndroid + ? (_audioProcessing.preferHardware + ? 'Prefers device/OS effect; falls back to WebRTC AEC3' + : 'WebRTC AEC3 · adaptive filter') + : (_isMacOS + ? 'Managed by platform VPIO' + : 'WebRTC AEC3 · adaptive filter'), + value: _isMacOS ? true : _audioProcessing.aecEnabled, + onChanged: _isMacOS + ? null + : (v) { + setState(() => _audioProcessing.aecEnabled = v); + _notifyAudioConfig(); + }, + ), + if (!_isIos && + (!Platform.isAndroid || + androidShowsAgcControl(_audioProcessing))) + AudioProcessingToggleRow( + dense: true, + label: 'Auto gain control', + subtitle: 'AGC2 · -18 dBFS target', + value: _audioProcessing.agcEnabled, + onChanged: (v) { + setState(() => _audioProcessing.agcEnabled = v); + _notifyAudioConfig(); + }, + ), + if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing)) + AudioProcessingToggleRow( + dense: true, + label: 'High-pass filter', + subtitle: '80 Hz · DC removal', + value: _audioProcessing.hpfEnabled, + onChanged: (v) { + setState(() => _audioProcessing.hpfEnabled = v); + _notifyAudioConfig(); + }, + ), + if (!_isIos && + (!Platform.isAndroid || + androidShowsLimiterControl(_audioProcessing))) + AudioProcessingToggleRow( + dense: true, + label: 'Peak limiter', + subtitle: '-1 dBFS soft-knee · 2 ms look-ahead', + value: _audioProcessing.limiterEnabled, + onChanged: (v) { + setState(() => _audioProcessing.limiterEnabled = v); + _notifyAudioConfig(); + }, + ), + + // VAD backend. + const SizedBox(height: 8), + Text( + 'Voice activity detection (VAD)', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + SegmentedButton( + style: voiceSegmentedButtonStyle(theme), + segments: _isDesktopSileroVadHost + ? desktopVadBackendSegments + : vadBackendSegments, + selected: {_audioProcessing.vadBackend}, + onSelectionChanged: (s) { + setState(() => _audioProcessing.vadBackend = s.first); + _notifyAudioConfig(); + }, + ), + const SizedBox(height: 8), + ], + ); + } + + // ── Debug section (inside ExpansionTile) ───────────────────────────── + + Widget _buildDebugSection(ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AudioProcessingToggleRow( + dense: true, + label: 'WAV dump', + subtitle: 'Record raw/processed mic to temp dir', + value: _audioProcessing.debugWavDump, + onChanged: (v) { + setState(() => _audioProcessing.debugWavDump = v); + _notifyAudioConfig(); + }, + ), + const SizedBox(height: 8), + ], + ); + } } // ── Live TX/RX stats row ────────────────────────────────────────────────── @@ -948,3 +998,424 @@ class _ModeRow extends StatelessWidget { ); } } + +// ── Unified mobile voice bar ────────────────────────────────────────────── + +/// A unified bottom-anchored voice bar for compact / mobile layouts. +/// +/// Combines the former [VoiceStatusChip] and [VoicePttButton] into one +/// visual zone with two rows: +/// +/// ┌──────────────────────────────────────────────┐ +/// │ 🟢 PTT · Connected [🔇] [🎧] [▲] │ ← control row (tap) +/// ├──────────────────────────────────────────────┤ +/// │ ════ hold to talk ════ │ ← PTT row (hold) +/// └──────────────────────────────────────────────┘ +/// +/// The PTT row is shown only when [transmitMode] is PTT; for continuous +/// or voice-activity modes the bar shrinks to the control row alone. +/// +/// State colour is applied to the entire container: +/// - normal: `surfaceContainerHigh` +/// - muted: `errorContainer` (35 % alpha) +/// - talk-power-block: amber (18 % alpha) +class CompactVoiceBar extends StatelessWidget { + /// Construct a compact voice bar. + const CompactVoiceBar({ + super.key, + required this.inChannel, + required this.transmitMode, + required this.releaseTailMs, + required this.pttBoundKeyLabel, + required this.audioStats, + required this.isTouchOnly, + required this.inputMuted, + required this.outputMuted, + required this.onToggleInputMute, + required this.onToggleOutputMute, + required this.onOpenDetails, + required this.onPttHeldChanged, + this.talkPower, + this.neededTalkPower, + this.talkPowerGranted, + this.hardMuteByTalkPower = false, + }); + + /// True when the client is inside a channel (gates PTT row visibility). + final bool inChannel; + + /// 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; + + /// True when local mic is muted. + final bool inputMuted; + + /// True when local speaker is muted. + final bool outputMuted; + + /// Toggle hard-mute on / off. + final VoidCallback onToggleInputMute; + + /// Toggle output mute on / off. + final VoidCallback onToggleOutputMute; + + /// Open the voice details modal sheet. + final VoidCallback onOpenDetails; + + /// Called with `true` on finger-down, `false` on finger-up / cancel. + final ValueChanged onPttHeldChanged; + + /// Own client's talk power. + final int? talkPower; + + /// Talk power required to speak in current channel. + final int? neededTalkPower; + + /// True when server granted talk power. + final bool? talkPowerGranted; + + /// True when talk power prevents speaking. + final bool hardMuteByTalkPower; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppL10n.of(context); + final isPtt = transmitMode == rust.BridgeTransmitMode.ptt; + final pttActive = audioStats?.pttActive ?? false; + + final summary = voiceStatusSummary( + l10n: l10n, + transmitMode: transmitMode, + releaseTailMs: releaseTailMs, + pttBoundKeyLabel: pttBoundKeyLabel, + isTouchOnly: isTouchOnly, + inputMuted: inputMuted, + outputMuted: outputMuted, + pttActive: pttActive, + talkPower: talkPower, + neededTalkPower: neededTalkPower, + talkPowerGranted: talkPowerGranted, + ); + + // Container colour based on voice state. + final containerColor = summary.talkPowerBlocked + ? Colors.amber.withValues(alpha: 0.18) + : summary.muted + ? theme.colorScheme.errorContainer.withValues(alpha: 0.35) + : theme.colorScheme.surfaceContainerHigh; + + final borderColor = summary.talkPowerBlocked + ? Colors.amber.shade700 + : summary.muted + ? theme.colorScheme.error + : theme.colorScheme.outlineVariant; + + final borderWidth = 1.0; + + return Semantics( + label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}', + child: Material( + type: MaterialType.transparency, + child: Container( + decoration: BoxDecoration( + color: containerColor, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: borderColor, width: borderWidth), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Control row ────────────────────────────────────── + _ControlRow( + summary: summary, + inputMuted: inputMuted, + outputMuted: outputMuted, + hardMuteByTalkPower: hardMuteByTalkPower, + onToggleInputMute: onToggleInputMute, + onToggleOutputMute: onToggleOutputMute, + onOpenDetails: onOpenDetails, + ), + // ── PTT row (in-channel + PTT mode only) ────────────── + if (isPtt && inChannel) ...[ + Divider( + height: 1, + thickness: 0.5, + color: borderColor, + indent: 12, + endIndent: 12, + ), + _PttRow( + active: pttActive, + enabled: !hardMuteByTalkPower, + onHeldChanged: onPttHeldChanged, + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +// ── Control row (tap-only zone) ─────────────────────────────────────────── + +class _ControlRow extends StatelessWidget { + const _ControlRow({ + required this.summary, + required this.inputMuted, + required this.outputMuted, + required this.hardMuteByTalkPower, + required this.onToggleInputMute, + required this.onToggleOutputMute, + required this.onOpenDetails, + }); + + final VoiceStatusSummary summary; + final bool inputMuted; + final bool outputMuted; + final bool hardMuteByTalkPower; + final VoidCallback onToggleInputMute; + final VoidCallback onToggleOutputMute; + final VoidCallback onOpenDetails; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + // Status dot. + Icon( + summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, + size: 10, + color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline, + ), + const SizedBox(width: 8), + + // Status text (tappable → open details). + Expanded( + child: Semantics( + button: true, + label: summary.line1, + child: InkWell( + onTap: onOpenDetails, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + summary.line1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + Text( + summary.line2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + + const SizedBox(width: 4), + + // Mute button. + _ToggleButton( + icon: inputMuted ? Icons.mic_off : Icons.mic, + isActive: inputMuted, + tooltip: 'Mute mic', + onPressed: hardMuteByTalkPower ? null : onToggleInputMute, + ), + + // Deafen button. + _ToggleButton( + icon: outputMuted ? Icons.headset_off : Icons.headset, + isActive: outputMuted, + tooltip: 'Deafen', + onPressed: onToggleOutputMute, + ), + + // Settings / expand chevron. + IconButton( + icon: Icon(Icons.expand_less, size: 20, color: theme.colorScheme.onSurfaceVariant), + tooltip: 'Voice settings', + onPressed: onOpenDetails, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(minWidth: 40, minHeight: 40), + padding: EdgeInsets.zero, + ), + ], + ), + ); + } +} + +// ── Toggle button (mute / deafen) ───────────────────────────────────────── + +class _ToggleButton extends StatelessWidget { + const _ToggleButton({ + required this.icon, + required this.isActive, + required this.tooltip, + required this.onPressed, + }); + + final IconData icon; + final bool isActive; + final String tooltip; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return IconButton( + icon: Icon(icon, size: 22), + tooltip: tooltip, + color: isActive ? theme.colorScheme.error : null, + onPressed: onPressed, + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints(minWidth: 44, minHeight: 44), + padding: EdgeInsets.zero, + ); + } +} + +// ── PTT row (hold-only zone) ────────────────────────────────────────────── + +class _PttRow extends StatefulWidget { + const _PttRow({ + required this.active, + required this.enabled, + required this.onHeldChanged, + }); + + /// True while the engine reports the gate open. + final bool active; + + /// Whether the PTT button can be engaged. + final bool enabled; + + /// Called with `true` on pointer-down, `false` on pointer-up / cancel. + final ValueChanged onHeldChanged; + + @override + State<_PttRow> createState() => _PttRowState(); +} + +class _PttRowState extends State<_PttRow> { + int? _activePointer; + bool _held = false; + + @override + void initState() { + super.initState(); + prepareVoiceHaptics(); + } + + void _begin(PointerDownEvent event) { + if (!widget.enabled || _activePointer != null) return; + _activePointer = event.pointer; + _held = true; + widget.onHeldChanged(true); + playVoicePttHaptic(true); + setState(() {}); + } + + void _end(int pointer) { + if (_activePointer != pointer) return; + _activePointer = null; + _held = false; + widget.onHeldChanged(false); + playVoicePttHaptic(false); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppL10n.of(context); + final activeNow = _held || widget.active; + + return Semantics( + button: true, + liveRegion: true, + label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk, + hint: l10n.pttHoldToTalkSemanticsHint, + child: Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: _begin, + onPointerUp: (e) => _end(e.pointer), + onPointerCancel: (e) => _end(e.pointer), + child: ExcludeSemantics( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: activeNow + ? theme.colorScheme.primary + : Colors.transparent, + ), + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + activeNow ? Icons.mic : Icons.mic_none_outlined, + size: 22, + color: activeNow + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + color: activeNow + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +}