Files
chanora/apps/chanora_flutter/lib/widgets/voice_compact.dart
T

1022 lines
34 KiB
Dart

// 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<bool> 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<VoicePttButton> createState() => _VoicePttButtonState();
}
class _VoicePttButtonState extends State<VoicePttButton> {
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<void> 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<rust.BridgeTransmitMode> onModeChanged,
required ValueChanged<int> onReleaseTailChanged,
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
int? talkPower,
int? neededTalkPower,
bool? talkPowerGranted,
}) async {
await showModalBottomSheet<void>(
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<rust.BridgeTransmitMode> onModeChanged;
final ValueChanged<int> onReleaseTailChanged;
final ValueChanged<rust.BridgeAudioProcessingConfig> 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<double>? _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),
),
_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<bool>(
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<rust.BridgeVadBackend>(
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,
),
),
],
),
),
],
),
),
),
),
);
}
}