Files
chanora/apps/chanora_flutter/lib/widgets/voice_compact.dart
T
Edison Jwa b8df25a195 Reduce Linux setup ambiguity and surface desktop input/message failures honestly
Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful.

Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies
Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped
Confidence: medium
Scope-risk: moderate
Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance
Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib
Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
2026-05-25 11:55:10 +09:00

960 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 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,
this.inputMuted = false,
this.outputMuted = 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;
/// 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;
@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(
button: true,
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
hint: l10n.voiceSettingsTitle,
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(12),
child: ExcludeSemantics(
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: 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: 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,
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
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;
@override
void initState() {
super.initState();
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
// Poll audio stats at 250 ms so TX/RX counters and the level meter
// update in real time while the sheet is open, independent of the parent.
_statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async {
try {
final s = await rust.audioStats();
if (!mounted) return;
setState(() {
_stats = s;
_rateTickCount++;
// Compute rates every ~1 s (4 × 250 ms).
if (_rateTickCount >= 4) {
_txRate = s.framesSent - _prevSent;
_rxRate = s.framesReceived - _prevReceived;
_prevSent = s.framesSent;
_prevReceived = s.framesReceived;
_rateTickCount = 0;
}
});
} catch (_) {}
});
}
@override
void dispose() {
_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),
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();
},
),
if (_isDesktopSileroVadHost) ...[
const SizedBox(height: 4),
Text(
'Silero needs ONNX Runtime. WebRTC works without it.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
// 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,
),
),
],
),
),
],
),
),
),
),
);
}
}