From 7c62d14dd8836ba2dc6f61167a0e79857d8efeed Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 18:44:48 +0800 Subject: [PATCH] feat(ui,ios): inline mode+tail into voice sheet, fix Unknown audio route, tap-outside unfocus Three user-reported issues addressed at once. 1. Audio output displaying as Unknown on iOS The route tile only set _device from currentDeviceStream events, which fire on route *changes*. On first sheet open with no route change yet, _device was null \u2192 _deviceLabel fell through to audioRouteUnknown. Fix: query AudioRouterPlatform.instance.getCurrentDevice() in initState before attaching the stream listener. Plugin returns the current AVAudioSession route synchronously (well, via Future) so the tile renders Speaker / iPhone receiver / AirPods / etc. immediately on first open. Errors swallowed \u2014 the stream remains authoritative for subsequent updates. 2. 'Adjust mode & release tail' too deep (chip \u2192 modal \u2192 button \u2192 dialog) Inlined the mode radio buttons and release-tail slider directly into the voice modal sheet. Dropped the OutlinedButton 'Adjust' trigger and the nested VoiceSettingsDialog dispatch entirely on mobile. Modal sheet is now a single-screen control panel: Title 'Voice' -------- Audio output: > (iOS/Android only) -------- Transmit mode \u25c9 PTT \u25cb Continuous \u25cb Voice activity (Coming soon) (disabled) -------- Release tail 200 ms [\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25cf\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501] (0\u20131000 ms, step 50) Bound key: F (desktop only) -------- Level meter TX/RX frame counts PTT capability badge (desktop only) VoiceSettingsDialog is retained for the wide-mode VoiceBar 'configure' button (desktop entrypoint) and the PTT-bind flow, so desktop UX is unaffected. New widgets: _VoiceSheetBody (StatefulWidget with local _mode + _tail), _ModeRow (RadioListTile-shaped row with optional disabled state for VoiceActivity). New API on showVoiceDetailsSheet: onModeChanged + onReleaseTailChanged callbacks (replace onAdjustVoiceSettings). Wiring in main.dart writes through to rust.setTransmitMode / rust.setReleaseTailMs and mirrors _state. l10n: dropped voiceAdjustSettings (en + zh). Added voiceBoundKeyLabel (en + zh) for the desktop-only bound-key row. 3. iOS first-tap-keyboard regression (flutter/flutter#181474) The 79f8360 _kickFocus workaround (unfocus + microtask refocus on every TextField.onTap) was kept, but extended with a tap-outside- to-unfocus GestureDetector wrapping the connect form Column. This guarantees the FocusNode is in the unfocused state when the next field tap arrives, so the focus transition is always false\u2192true on first tap. GestureDetector(HitTestBehavior.translucent, onTap: unfocus) is the canonical pattern recommended in the flutter/flutter#181474 thread + several older iOS keyboard issues. Translucent behaviour means it catches taps on the column padding / empty regions without swallowing taps on the TextFields themselves (those have onTap: _kickFocus already). flutter analyze: 6 pre-existing Radio.groupValue deprecation infos in voice_settings.dart (unchanged). flutter build ios --release --no-codesign: 27.9 s, Runner.app 30.2 MB (unchanged). Awaiting iPhone retest to confirm all three fixes. --- apps/chanora_flutter/lib/l10n/app_en.arb | 2 +- apps/chanora_flutter/lib/l10n/app_zh.arb | 2 +- apps/chanora_flutter/lib/main.dart | 51 ++- .../lib/widgets/voice_compact.dart | 362 +++++++++++++----- 4 files changed, 305 insertions(+), 112 deletions(-) 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: