Files
chanora/apps/chanora_flutter/lib/widgets/voice_compact.dart
T
EdisonJwa 7c62d14dd8 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: <current route>           >    (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.
2026-05-16 18:44:48 +08:00

692 lines
22 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 (post-iPhone-test feedback): the AppBar gear icon
// was removed; the modal sheet is now the **single** voice-controls
// surface on mobile. Mode + release-tail are reached via an
// "Adjust mode & release tail" button inside the modal that opens
// the existing [VoiceSettingsDialog]. Audio output route picker is
// new — driven by the `audio_router` plugin, which renders the
// native AVRoutePickerView on iOS and a Material 3 device list on
// Android.
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';
import '../l10n/generated/app_localizations.dart';
import '../main.dart' show PttCapabilityBadge;
import '../src/rust/api.dart' as rust;
/// 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,
});
/// 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;
/// Open the voice details modal.
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final stats = audioStats;
final micOn = stats?.pttActive ?? false;
final modeLabel = switch (transmitMode) {
rust.BridgeTransmitMode.ptt => l10n.voiceModePtt,
rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous,
rust.BridgeTransmitMode.voiceActivity =>
'${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
};
String line1;
if (transmitMode == rust.BridgeTransmitMode.ptt) {
if (isTouchOnly) {
line1 = '$modeLabel \u00b7 ${l10n.voicePttHoldHint}';
} else {
line1 =
'$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}';
}
} else {
line1 = modeLabel;
}
final tailText = transmitMode == rust.BridgeTransmitMode.ptt
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
: null;
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outlineVariant,
width: 0.5,
),
),
child: Row(
children: [
Icon(
micOn
? Icons.fiber_manual_record
: Icons.fiber_manual_record_outlined,
size: 12,
color: micOn
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
line1,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
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,
});
/// 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;
@override
State<VoicePttButton> createState() => _VoicePttButtonState();
}
class _VoicePttButtonState extends State<VoicePttButton> {
bool _pressed = false;
void _setHeld(bool held) {
if (_pressed == held) return;
setState(() => _pressed = held);
widget.onHeldChanged(held);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final activeNow = _pressed || widget.active;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _setHeld(true),
onTapUp: (_) => _setHeld(false),
onTapCancel: () => _setHeld(false),
onPanDown: (_) => _setHeld(true),
onPanEnd: (_) => _setHeld(false),
onPanCancel: () => _setHeld(false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
height: 56,
decoration: BoxDecoration(
color: activeNow
? theme.colorScheme.primary
: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
boxShadow: activeNow
? [
BoxShadow(
color: theme.colorScheme.primary.withAlpha(100),
blurRadius: 16,
spreadRadius: 2,
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: 28,
),
const SizedBox(width: 12),
Text(
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
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; 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<void> showVoiceDetailsSheet(
BuildContext context, {
required rust.BridgeAudioStats? audioStats,
required rust.BridgeTransmitMode transmitMode,
required int releaseTailMs,
required String pttBoundKeyLabel,
required String pttLevel,
required String pttBackendId,
required String pttBoundInputClass,
required bool isTouchOnly,
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
required ValueChanged<int> onReleaseTailChanged,
}) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) {
return _VoiceSheetBody(
audioStats: audioStats,
initialMode: transmitMode,
initialReleaseTailMs: releaseTailMs,
pttBoundKeyLabel: pttBoundKeyLabel,
pttLevel: pttLevel,
pttBackendId: pttBackendId,
pttBoundInputClass: pttBoundInputClass,
isTouchOnly: isTouchOnly,
onModeChanged: onModeChanged,
onReleaseTailChanged: onReleaseTailChanged,
);
},
);
}
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,
});
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<rust.BridgeTransmitMode> onModeChanged;
final ValueChanged<int> onReleaseTailChanged;
@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: 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,
),
// 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, 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: 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.
_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
/// native picker on tap. Subscribes to `currentDeviceStream` so the
/// row auto-updates when the user plugs in headphones, connects
/// AirPods, etc.
class _AudioOutputTile extends StatefulWidget {
const _AudioOutputTile();
@override
State<_AudioOutputTile> createState() => _AudioOutputTileState();
}
class _AudioOutputTileState extends State<_AudioOutputTile> {
final AudioRouter _router = AudioRouter();
AudioDevice? _device;
@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<void> _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:
return l10n.audioRouteSpeaker;
case AudioSourceType.builtinReceiver:
return l10n.audioRouteReceiver;
case AudioSourceType.bluetooth:
return l10n.audioRouteBluetooth;
case AudioSourceType.wiredHeadset:
return l10n.audioRouteWiredHeadset;
case AudioSourceType.carAudio:
return l10n.audioRouteCarAudio;
case AudioSourceType.airplay:
return l10n.audioRouteAirplay;
case AudioSourceType.unknown:
case null:
return l10n.audioRouteUnknown;
}
}
IconData _deviceIcon(AudioSourceType? type) {
switch (type) {
case AudioSourceType.builtinSpeaker:
return Icons.volume_up;
case AudioSourceType.builtinReceiver:
return Icons.phone_in_talk;
case AudioSourceType.bluetooth:
return Icons.bluetooth_audio;
case AudioSourceType.wiredHeadset:
return Icons.headset;
case AudioSourceType.carAudio:
return Icons.directions_car;
case AudioSourceType.airplay:
return Icons.airplay;
case AudioSourceType.unknown:
case null:
return Icons.speaker;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return InkWell(
onTap: () => _router.showAudioRoutePicker(context),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(
children: [
Icon(
_deviceIcon(_device?.type),
color: theme.colorScheme.primary,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.audioOutputLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
_deviceLabel(_device?.type, l10n),
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
class _LevelMeter extends StatelessWidget {
const _LevelMeter({required this.active});
final bool active;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: FractionallySizedBox(
alignment: AlignmentDirectional.centerStart,
widthFactor: active ? 0.75 : 0.05,
child: Container(
decoration: BoxDecoration(
color: active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(4),
),
),
),
);
}
}