diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 56aac22..2237c4b 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -127,11 +127,11 @@ "voiceOutputMuteLabel": "Mute speakers", "voiceSettingsTitle": "Voice settings", "voiceBindKeyAction": "Bind PTT key", + "voiceBoundKeyLabel": "Bound key", "voicePttHoldHint": "Hold the button", "voiceMicOn": "on", "voiceMicOff": "off", "voiceSheetTitle": "Voice", - "voiceAdjustSettings": "Adjust mode & release tail", "audioOutputLabel": "Audio output", "audioRouteSpeaker": "Speaker", "audioRouteReceiver": "iPhone receiver", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 87888d2..e0cd9f4 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -84,11 +84,11 @@ "voiceOutputMuteLabel": "扬声器静音", "voiceSettingsTitle": "语音设置", "voiceBindKeyAction": "绑定 PTT 按键", + "voiceBoundKeyLabel": "已绑定按键", "voicePttHoldHint": "按住按钮", "voiceMicOn": "开启", "voiceMicOff": "关闭", "voiceSheetTitle": "语音", - "voiceAdjustSettings": "调整模式与释放尾延时", "audioOutputLabel": "音频输出", "audioRouteSpeaker": "扬声器", "audioRouteReceiver": "听筒", diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index d384a64..845bacf 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -504,11 +504,9 @@ class _BetaHomeState extends State<_BetaHome> { /// Narrow-mode voice controls modal sheet (Plan E status chip /// trigger). On mobile this is the **single** voice-controls - /// surface: route picker + mode/tail recap + "Adjust" button to - /// [VoiceSettingsDialog] + level meter + stats + (desktop-only) - /// capability badge. The AppBar gear icon was removed in this - /// rc.8 follow-up so there is exactly one entry point to voice - /// controls on mobile. + /// surface: route picker + inline mode radio + inline release-tail + /// slider + level meter + stats + (desktop-only) capability badge. + /// Zero navigation depth \u2014 no nested dialog. Future _onOpenVoiceDetailsSheet() async { await showVoiceDetailsSheet( context, @@ -520,7 +518,26 @@ class _BetaHomeState extends State<_BetaHome> { pttBackendId: _pttBackendId, pttBoundInputClass: _pttBoundInputClass, isTouchOnly: _isTouchOnlyPttHost, - onAdjustVoiceSettings: _onOpenVoiceSettings, + onModeChanged: (mode) async { + try { + await rust.setTransmitMode(mode: mode); + if (!mounted) return; + setState(() => _transmitMode = mode); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + }, + onReleaseTailChanged: (ms) async { + try { + await rust.setReleaseTailMs(ms: ms); + if (!mounted) return; + setState(() => _releaseTailMs = ms); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + }, ); } @@ -1309,9 +1326,24 @@ class _ConnectFormState extends State<_ConnectForm> { @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + // Wrap the form in a GestureDetector that unfocuses any focused + // TextField when the user taps an empty area of the form. This is + // the second half of the flutter/flutter#181474 workaround: + // _kickFocus handles the case where the user re-taps the same + // field after the keyboard auto-dismissed, and this outer + // gesture handler handles the more common case where the user + // taps outside (e.g. on the column padding) to dismiss the + // keyboard. By forcing an unfocus on tap-outside we guarantee + // the FocusNode is in the unfocused state when the next field + // tap arrives, so the focus transition is always false\u2192true + // (which is what iOS needs to re-open TextInput on the very + // first tap). + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ TextField( controller: widget.hostCtl, focusNode: _hostFocus, @@ -1402,6 +1434,7 @@ class _ConnectFormState extends State<_ConnectForm> { ], ), ], + ), ); } } diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 9c02264..6138704 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -14,6 +14,7 @@ import 'dart:io' show Platform; import 'package:audio_router/audio_router.dart'; +import 'package:audio_router/audio_router_platform_interface.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; @@ -246,11 +247,18 @@ class _VoicePttButtonState extends State { /// surface on mobile. Tiles: /// 1. Audio output route picker (iOS native AVRoutePickerView / /// Android Material 3 list). Mobile only. -/// 2. Mode + bind / tail recap (display). -/// 3. "Adjust mode & release tail" button → [VoiceSettingsDialog]. +/// 2. Mode radio buttons (PTT / Continuous; VoiceActivity disabled +/// coming-soon). +/// 3. Release-tail slider (PTT-only). /// 4. Mic level meter. /// 5. TX / RX frame counts. /// 6. PTT capability badge (desktop only). +/// +/// 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.BridgeAudioStats? audioStats, @@ -261,133 +269,269 @@ Future showVoiceDetailsSheet( required String pttBackendId, required String pttBoundInputClass, required bool isTouchOnly, - required VoidCallback onAdjustVoiceSettings, + required ValueChanged onModeChanged, + required ValueChanged onReleaseTailChanged, }) async { await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, builder: (ctx) { - final theme = Theme.of(ctx); - final l10n = AppL10n.of(ctx); - final stats = audioStats; - final levelActive = stats?.pttActive ?? false; - final isPtt = transmitMode == rust.BridgeTransmitMode.ptt; + return _VoiceSheetBody( + audioStats: audioStats, + initialMode: transmitMode, + initialReleaseTailMs: releaseTailMs, + pttBoundKeyLabel: pttBoundKeyLabel, + pttLevel: pttLevel, + pttBackendId: pttBackendId, + pttBoundInputClass: pttBoundInputClass, + isTouchOnly: isTouchOnly, + onModeChanged: onModeChanged, + onReleaseTailChanged: onReleaseTailChanged, + ); + }, + ); +} - final modeLabel = switch (transmitMode) { - rust.BridgeTransmitMode.ptt => l10n.voiceModePtt, - rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous, - rust.BridgeTransmitMode.voiceActivity => - '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})', - }; +class _VoiceSheetBody extends StatefulWidget { + const _VoiceSheetBody({ + required this.audioStats, + required this.initialMode, + required this.initialReleaseTailMs, + required this.pttBoundKeyLabel, + required this.pttLevel, + required this.pttBackendId, + required this.pttBoundInputClass, + required this.isTouchOnly, + required this.onModeChanged, + required this.onReleaseTailChanged, + }); - // Route picker only meaningful on iOS + Android where the OS - // owns audio routing. Desktop hosts skip the tile entirely. - final showRoutePicker = - !kIsWeb && (Platform.isIOS || Platform.isAndroid); + final rust.BridgeAudioStats? audioStats; + final rust.BridgeTransmitMode initialMode; + final int initialReleaseTailMs; + final String pttBoundKeyLabel; + final String pttLevel; + final String pttBackendId; + final String pttBoundInputClass; + final bool isTouchOnly; + final ValueChanged onModeChanged; + final ValueChanged onReleaseTailChanged; - return SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - l10n.voiceSheetTitle, - style: theme.textTheme.titleLarge, + @override + State<_VoiceSheetBody> createState() => _VoiceSheetBodyState(); +} + +class _VoiceSheetBodyState extends State<_VoiceSheetBody> { + late rust.BridgeTransmitMode _mode = widget.initialMode; + late int _tail = widget.initialReleaseTailMs; + + void _setMode(rust.BridgeTransmitMode m) { + if (m == rust.BridgeTransmitMode.voiceActivity) { + // Coming-soon \u2014 disabled in UI; defensive guard. + return; + } + setState(() => _mode = m); + 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 = widget.audioStats; + final levelActive = stats?.pttActive ?? false; + final isPtt = _mode == rust.BridgeTransmitMode.ptt; + + // Route picker only meaningful on iOS + Android where the OS + // owns audio routing. Desktop hosts skip the tile entirely. + final showRoutePicker = + !kIsWeb && (Platform.isIOS || Platform.isAndroid); + + return SafeArea( + child: SingleChildScrollView( + 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), + ], + + // 2) Mode \u2014 inline radio rows. + Text( + l10n.voiceModeLabel, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: 16), + ), + 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} (${l10n.voiceModeComingSoon})', + icon: Icons.graphic_eq, + selected: false, + onTap: null, + ), - // 1) Audio output route picker tile (mobile only). - if (showRoutePicker) ...[ - const _AudioOutputTile(), - const SizedBox(height: 12), - Divider( - height: 1, - color: theme.colorScheme.outlineVariant, - ), - const SizedBox(height: 12), - ], - - // 2) Mode + bind / tail recap (display only). + // 3) Release-tail slider (PTT only). + if (isPtt) ...[ + const SizedBox(height: 8), Row( children: [ - Icon( - transmitMode == rust.BridgeTransmitMode.ptt - ? Icons.radio_button_checked - : Icons.podcasts, - size: 18, - color: theme.colorScheme.onSurfaceVariant, + Text( + l10n.voiceReleaseTailLabel, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - const SizedBox(width: 8), - Expanded( - child: Text( - modeLabel, - style: theme.textTheme.bodyMedium, + const Spacer(), + Text( + '$_tail${l10n.voiceReleaseTailHint}', + style: theme.textTheme.bodyMedium?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), - if (isPtt) ...[ - const SizedBox(height: 8), + Slider( + value: _tail.toDouble().clamp(0, 1000), + min: 0, + max: 1000, + 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: 26), + padding: const EdgeInsets.only(left: 4), child: Text( - isTouchOnly - ? '${l10n.voicePttHoldHint} \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}' - : '${l10n.voiceModePtt}: ' - '${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}' - ' \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', + '${l10n.voiceBoundKeyLabel}: ${widget.pttBoundKeyLabel}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], - const SizedBox(height: 12), - - // 3) Adjust button — opens VoiceSettingsDialog. One - // config form for mode + tail (+ bind on desktop), - // not two. - OutlinedButton.icon( - onPressed: () { - Navigator.of(ctx).pop(); - onAdjustVoiceSettings(); - }, - icon: const Icon(Icons.tune), - label: Text(l10n.voiceAdjustSettings), - ), - const SizedBox(height: 16), - - // 4) Level meter. - _LevelMeter(active: levelActive), - const SizedBox(height: 6), - if (stats != null) - Text( - l10n.audioStatsLine( - stats.framesSent, - stats.framesReceived, - stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff, - ), - style: theme.textTheme.bodySmall, - ), - - // 5) PTT capability badge — desktop-only. - if (isPtt && !isTouchOnly) ...[ - const SizedBox(height: 12), - PttCapabilityBadge( - level: pttLevel, - backendId: pttBackendId, - boundInputClass: pttBoundInputClass, - ), - ], ], - ), + + const SizedBox(height: 16), + Divider(height: 1, color: theme.colorScheme.outlineVariant), + const SizedBox(height: 12), + + // 4) Level meter. + _LevelMeter(active: levelActive), + const SizedBox(height: 6), + if (stats != null) + Text( + l10n.audioStatsLine( + stats.framesSent, + stats.framesReceived, + stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff, + ), + style: theme.textTheme.bodySmall, + ), + + // 5) PTT capability badge \u2014 desktop-only. + if (isPtt && !widget.isTouchOnly) ...[ + const SizedBox(height: 12), + PttCapabilityBadge( + level: widget.pttLevel, + backendId: widget.pttBackendId, + boundInputClass: widget.pttBoundInputClass, + ), + ], + ], ), - ); - }, - ); + ), + ); + } +} + +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 InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + 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: Text( + label, + style: theme.textTheme.bodyLarge?.copyWith(color: color), + ), + ), + ], + ), + ), + ); + } } /// Tile that displays the current audio output route + opens the @@ -408,12 +552,28 @@ class _AudioOutputTileState extends State<_AudioOutputTile> { @override void initState() { super.initState(); + // Query the current route once on mount so the tile renders the + // real device (Speaker / Receiver / AirPods / etc.) immediately, + // before currentDeviceStream fires its first delta event. Without + // this, the tile shows 'Unknown' until the user changes routes. + _refreshCurrent(); _router.currentDeviceStream.listen((dev) { if (!mounted) return; setState(() => _device = dev); }); } + Future _refreshCurrent() async { + try { + final dev = await AudioRouterPlatform.instance.getCurrentDevice(); + if (!mounted) return; + setState(() => _device = dev); + } catch (_) { + // Ignore \u2014 we'll fall back to the stream. Plugin can throw on + // first call before AVAudioSession is fully active. + } + } + String _deviceLabel(AudioSourceType? type, AppL10n l10n) { switch (type) { case AudioSourceType.builtinSpeaker: