// 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. import 'dart:async' show StreamSubscription, Timer, unawaited; import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:haptic_kit/haptic_kit.dart' show Haptics; import '../l10n/generated/app_localizations.dart'; import 'audio_output_tile.dart'; import 'audio_processing_config_state.dart'; import 'ptt_capability_badge.dart'; import 'talk_power_warning.dart'; import 'voice_haptics.dart'; import 'voice_level_meter.dart'; import 'voice_settings_controls.dart'; import 'voice_status_summary.dart'; import '../src/rust/api.dart' as rust; bool get _isIos { if (kIsWeb) return false; return Platform.isIOS; } bool get _isMacOS { if (kIsWeb) return false; return Platform.isMacOS; } bool get _isDesktopSileroVadHost { if (kIsWeb) return false; return Platform.isWindows || Platform.isLinux; } /// 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, required this.onToggleInputMute, required this.onToggleOutputMute, this.inputMuted = false, this.outputMuted = false, this.hardMuteByTalkPower = false, this.talkPower, this.neededTalkPower, this.talkPowerGranted, }); /// 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 (hard mute or permission mute). final bool inputMuted; /// True when local speaker is muted. final bool outputMuted; /// True when the server talk-power gate forces local hard mute. final bool hardMuteByTalkPower; /// 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 regardless of numeric value. final bool? talkPowerGranted; /// Open the voice details modal. final VoidCallback onTap; /// Toggle local input hard mute. final VoidCallback onToggleInputMute; /// Toggle local output mute/deafen. final VoidCallback onToggleOutputMute; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final summary = voiceStatusSummary( l10n: l10n, transmitMode: transmitMode, releaseTailMs: releaseTailMs, pttBoundKeyLabel: pttBoundKeyLabel, isTouchOnly: isTouchOnly, inputMuted: inputMuted, outputMuted: outputMuted, pttActive: audioStats?.pttActive ?? false, talkPower: talkPower, neededTalkPower: neededTalkPower, talkPowerGranted: talkPowerGranted, ); return Semantics( label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}', child: Material( type: MaterialType.transparency, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: summary.talkPowerBlocked ? Colors.amber.withValues(alpha: 0.18) : summary.muted ? theme.colorScheme.errorContainer.withValues(alpha: 0.35) : theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all( color: summary.talkPowerBlocked ? Colors.amber.shade700 : summary.muted ? theme.colorScheme.error : theme.colorScheme.outlineVariant, width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5, ), ), child: Row( children: [ Icon( summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, size: 12, color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline, ), const SizedBox(width: 8), Expanded( child: InkWell( onTap: () { HapticFeedback.lightImpact(); onTap(); }, 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), IconButton( tooltip: hardMuteByTalkPower ? l10n.voiceTalkPowerBlocked : l10n.voiceHardMuteLabel, icon: Icon(inputMuted ? Icons.mic_off : Icons.mic), color: inputMuted ? theme.colorScheme.error : null, onPressed: hardMuteByTalkPower ? null : onToggleInputMute, visualDensity: VisualDensity.compact, constraints: const BoxConstraints.tightFor( width: 40, height: 40, ), padding: EdgeInsets.zero, style: const ButtonStyle( tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), IconButton( tooltip: l10n.voiceOutputMuteLabel, icon: Icon(outputMuted ? Icons.headset_off : Icons.headset), color: outputMuted ? theme.colorScheme.error : null, onPressed: onToggleOutputMute, visualDensity: VisualDensity.compact, constraints: const BoxConstraints.tightFor( width: 40, height: 40, ), padding: EdgeInsets.zero, style: const ButtonStyle( tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), IconButton( tooltip: l10n.voiceSettingsTitle, icon: Icon( Icons.expand_less, size: 18, color: theme.colorScheme.onSurfaceVariant, ), onPressed: onTap, visualDensity: VisualDensity.compact, constraints: const BoxConstraints.tightFor( width: 40, height: 40, ), padding: EdgeInsets.zero, style: const ButtonStyle( tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), ], ), ), ), ); } } /// 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, this.height = 56, this.borderRadius = 16, this.iconSize = 28, this.iconGap = 12, this.blurRadius = 16, this.spreadRadius = 2, this.listenForPan = false, this.labelLetterSpacing = 0.4, }); /// 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; /// Button height. final double height; /// Outer corner radius. final double borderRadius; /// Mic icon size. final double iconSize; /// Space between icon and label. final double iconGap; /// Active-state shadow blur radius. final double blurRadius; /// Active-state shadow spread radius. final double spreadRadius; /// Also react to pan start/end/cancel in addition to tap gestures. final bool listenForPan; /// Optional label letter spacing. final double? labelLetterSpacing; @override State createState() => _VoicePttButtonState(); } class _VoicePttButtonState extends State { bool _pressed = false; @override void initState() { super.initState(); prepareVoiceHaptics(); } void _setHeld(bool held) { if (_pressed == held) return; setState(() => _pressed = held); widget.onHeldChanged(held); playVoicePttHaptic(held); } @override void dispose() { if (_pressed) { _pressed = false; widget.onHeldChanged(false); } super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final activeNow = _pressed || widget.active; return Semantics( button: true, liveRegion: true, label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk, hint: l10n.pttHoldToTalkSemanticsHint, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => _setHeld(true), onTapUp: (_) => _setHeld(false), onTapCancel: () => _setHeld(false), onPanDown: widget.listenForPan ? (_) => _setHeld(true) : null, onPanEnd: widget.listenForPan ? (_) => _setHeld(false) : null, onPanCancel: widget.listenForPan ? () => _setHeld(false) : null, child: ExcludeSemantics( child: AnimatedContainer( duration: const Duration(milliseconds: 80), height: widget.height, decoration: BoxDecoration( color: activeNow ? theme.colorScheme.primary : theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(widget.borderRadius), boxShadow: activeNow ? [ BoxShadow( color: theme.colorScheme.primary.withAlpha(100), blurRadius: widget.blurRadius, spreadRadius: widget.spreadRadius, 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: widget.iconSize, ), SizedBox(width: widget.iconGap), Text( activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, letterSpacing: widget.labelLetterSpacing, 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 / Voice Activity). /// 3. Release-tail slider (PTT-only). /// 4. Mic level meter + frame counts. /// 5. Audio processing (NS · AEC · AGC · HPF · VAD) — all modes. /// 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.BridgeTransmitMode transmitMode, required int releaseTailMs, required String pttBoundKeyLabel, required String pttLevel, required String pttBackendId, required String pttBoundInputClass, required bool isTouchOnly, required rust.BridgeAudioProcessingConfig initialAudioConfig, required ValueChanged onModeChanged, required ValueChanged onReleaseTailChanged, required ValueChanged onAudioConfigChanged, int? talkPower, int? neededTalkPower, bool? talkPowerGranted, }) async { await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, useSafeArea: true, builder: (ctx) { return DraggableScrollableSheet( initialChildSize: 0.6, minChildSize: 0.3, maxChildSize: 0.95, expand: false, snap: true, snapSizes: const [0.3, 0.6, 0.95], builder: (ctx, scrollController) => _VoiceSheetBody( scrollController: scrollController, initialMode: transmitMode, initialReleaseTailMs: releaseTailMs, pttBoundKeyLabel: pttBoundKeyLabel, pttLevel: pttLevel, pttBackendId: pttBackendId, pttBoundInputClass: pttBoundInputClass, isTouchOnly: isTouchOnly, initialAudioConfig: initialAudioConfig, onModeChanged: onModeChanged, onReleaseTailChanged: onReleaseTailChanged, onAudioConfigChanged: onAudioConfigChanged, talkPower: talkPower, neededTalkPower: neededTalkPower, talkPowerGranted: talkPowerGranted, ), ); }, ); } class _VoiceSheetBody extends StatefulWidget { const _VoiceSheetBody({ required this.scrollController, required this.initialMode, required this.initialReleaseTailMs, required this.pttBoundKeyLabel, required this.pttLevel, required this.pttBackendId, required this.pttBoundInputClass, required this.isTouchOnly, required this.initialAudioConfig, required this.onModeChanged, required this.onReleaseTailChanged, required this.onAudioConfigChanged, this.talkPower, this.neededTalkPower, this.talkPowerGranted, }); final ScrollController scrollController; final rust.BridgeTransmitMode initialMode; final int initialReleaseTailMs; final String pttBoundKeyLabel; final String pttLevel; final String pttBackendId; final String pttBoundInputClass; final bool isTouchOnly; final rust.BridgeAudioProcessingConfig initialAudioConfig; final ValueChanged onModeChanged; final ValueChanged onReleaseTailChanged; final ValueChanged onAudioConfigChanged; final int? talkPower; final int? neededTalkPower; final bool? talkPowerGranted; @override State<_VoiceSheetBody> createState() => _VoiceSheetBodyState(); } class _VoiceSheetBodyState extends State<_VoiceSheetBody> { late rust.BridgeTransmitMode _mode = widget.initialMode; late int _tail = widget.initialReleaseTailMs; // Live stats — polled by this widget's own timer so TX/RX update // in real time while the sheet is open, independent of the parent. rust.BridgeAudioStats? _stats; Timer? _statsTimer; // Previous snapshot for computing per-second rates. int _prevSent = 0; int _prevReceived = 0; int _txRate = 0; // frames/s int _rxRate = 0; // frames/s int _rateTickCount = 0; late final AudioProcessingConfigState _audioProcessing; double? _streamLevel; StreamSubscription? _levelSub; @override void initState() { super.initState(); _audioProcessing = AudioProcessingConfigState.fromConfig( widget.initialAudioConfig, ); _levelSub = rust.inputLevelStream().listen((level) { if (mounted) setState(() => _streamLevel = level); }); // Poll audio stats at 250 ms so TX/RX counters update in real time // while the sheet is open. _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { try { final s = await rust.audioStats(); if (!mounted) return; setState(() { _stats = s; _rateTickCount++; if (_rateTickCount >= 4) { _txRate = s.framesSent - _prevSent; _rxRate = s.framesReceived - _prevReceived; _prevSent = s.framesSent; _prevReceived = s.framesReceived; _rateTickCount = 0; } }); } catch (_) {} }); } @override void dispose() { _levelSub?.cancel(); _statsTimer?.cancel(); super.dispose(); } rust.BridgeAudioProcessingConfig _buildConfig() { return _audioProcessing.buildConfig(base: widget.initialAudioConfig); } void _notifyAudioConfig() { widget.onAudioConfigChanged(_buildConfig()); } void _setMode(rust.BridgeTransmitMode m) { if (m == _mode) return; setState(() => _mode = m); unawaited(Haptics.selection().catchError((_) {})); 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 = _stats; // Level meter active = transmitting (any mode). final levelActive = switch (_mode) { rust.BridgeTransmitMode.continuous => true, _ => stats?.pttActive ?? false, }; final isPtt = _mode == rust.BridgeTransmitMode.ptt; // Route picker is mobile-only. iOS uses AVAudioSession below; // Android uses AudioManager through a MethodChannel. final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid); return SafeArea( child: SingleChildScrollView( controller: widget.scrollController, 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), ], // Transmit mode. 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), ), // Voice-activity transmit is only honoured by the engine on // hosts that ship a Chanora-owned VAD pipeline (DEC-030: // Windows + Linux desktop and Android). iOS / macOS rely // on Apple VoiceProcessingIO and have no VAD bridge, so // hiding the row prevents the UI from advertising a // transmit mode the engine cannot honour. if (voiceActivityTransmitAvailable) _ModeRow( label: l10n.voiceModeVoiceActivity, icon: Icons.graphic_eq, selected: _mode == rust.BridgeTransmitMode.voiceActivity, onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity), ), // 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 + live TX/RX stats. VoiceLevelMeter(active: levelActive, level: _streamLevel ?? stats?.inputLevel), const SizedBox(height: 6), _StatsRow( txRate: _txRate, rxRate: _rxRate, totalSent: stats?.framesSent ?? 0, totalReceived: stats?.framesReceived ?? 0, transmitting: levelActive, ), if (isTalkPowerBlocked( talkPower: widget.talkPower, neededTalkPower: widget.neededTalkPower, talkPowerGranted: widget.talkPowerGranted, )) ...[ const SizedBox(height: 8), TalkPowerWarning( talkPower: widget.talkPower, neededTalkPower: widget.neededTalkPower, talkPowerGranted: widget.talkPowerGranted, ), ], // 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( 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(); }, ), // 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, ), ], ], ), ), ); } } // ── Live TX/RX stats row ────────────────────────────────────────────────── class _StatsRow extends StatelessWidget { const _StatsRow({ required this.txRate, required this.rxRate, required this.totalSent, required this.totalReceived, required this.transmitting, }); final int txRate; final int rxRate; final int totalSent; final int totalReceived; final bool transmitting; @override Widget build(BuildContext context) { final theme = Theme.of(context); final txColor = transmitting ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant; return Row( children: [ // TX Icon(Icons.upload, size: 12, color: txColor), const SizedBox(width: 3), Text( 'TX $txRate/s · $totalSent', style: theme.textTheme.bodySmall?.copyWith(color: txColor), ), const SizedBox(width: 12), // RX Icon( Icons.download, size: 12, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 3), Text( 'RX $rxRate/s · $totalReceived', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ); } } // ── Audio processing helper widgets ────────────────────────────────────── // ── Mode row ────────────────────────────────────────────────────────────── 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 Semantics( button: true, selected: selected, enabled: !disabled, label: label, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: ExcludeSemantics( 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: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: theme.textTheme.bodyLarge?.copyWith( color: color, ), ), ], ), ), ], ), ), ), ), ); } }