Files
chanora/apps/chanora_flutter/lib/widgets/voice_compact.dart
T
EdisonJwa b8e9c8549e feat(ui,ios,android): audio output route picker + collapse voice controls into single modal
Two user-reported issues addressed:

  1. 'on supported devices such as iphone or android user should be
     able to select audio device such as speaker or airpods or phone'
  2. 'the bottom folder is duplicated with the app bar settings'

Issue 1 \u2014 audio output route picker:

Added the audio_router 1.1.1 plugin (MIT, supports iOS + Android)
which renders the platform-native picker:

  * iOS: Apple AVRoutePickerView system sheet \u2014 the same UI as
    Control Center's audio chooser. Lists Speaker / iPhone receiver /
    AirPods / connected Bluetooth devices / AirPlay / CarPlay.
    System manages the device list; we don't have to track route
    changes manually.

  * Android (post-rc.8 when we wire the platform): Material Design 3
    dialog backed by AudioManager.setCommunicationDevice() with
    SCO Bluetooth + USB headsets filtered for VoIP.

The picker prerequisite documented by the plugin (audio session
must be playAndRecord/voiceChat before the picker fires) is already
satisfied by our AppDelegate.swift configuration from commit 0466000.

The new _AudioOutputTile widget in voice_compact.dart subscribes to
AudioRouter.currentDeviceStream so the row label + icon auto-update
when the user plugs in headphones, connects AirPods, etc. \u2014 no
manual KVO observation needed.

Tile is mobile-only (Platform.isIOS || Platform.isAndroid). Desktop
hosts continue to use the system mixer; the tile is hidden.

Plugin caveat: the published enum AudioSourceType has no .usb
variant (despite README mentioning USB support). We map only the
seven real enum cases: builtinSpeaker / builtinReceiver / bluetooth /
wiredHeadset / carAudio / airplay / unknown.

Issue 2 \u2014 collapse voice controls into the single modal sheet:

The AppBar gear icon (Icons.tune) that opened VoiceSettingsDialog
was removed. It duplicated the configuration entry point that the
status chip \u2192 modal-sheet path already provides, and the user found
that duplication confusing on a phone-narrow screen where AppBar
real estate is precious.

The voice modal sheet (showVoiceDetailsSheet) is now the **single**
voice-controls surface on mobile, with layout (top to bottom):

  1. Audio output route picker tile (iOS / Android only) \u2014 new.
  2. Mode + bind / release-tail recap (display only).
  3. 'Adjust mode & release tail' OutlinedButton that closes the
     sheet and opens the same VoiceSettingsDialog the gear icon
     used to open. One config form, not two.
  4. Mic level meter.
  5. TX / RX frame counts + mic state.
  6. PTT capability badge (desktop only).

Sheet title renamed from 'Voice settings' (which collided with the
gear-icon tooltip) to 'Voice'. New l10n keys: voiceSheetTitle,
voiceAdjustSettings, audioOutputLabel, audioRoute{Speaker,Receiver,
Bluetooth,WiredHeadset,CarAudio,Airplay,Unknown}. en + zh translated.

Build: flutter build ios --release --no-codesign clean, 50.5 s,
Runner.app 30.2 MB (+200 KB from audio_router). flutter analyze
clean (6 pre-existing Radio.groupValue deprecation infos in
voice_settings.dart, unchanged).
2026-05-16 18:18:43 +08:00

532 lines
17 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: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 + bind / tail recap (display).
/// 3. "Adjust mode & release tail" button → [VoiceSettingsDialog].
/// 4. Mic level meter.
/// 5. TX / RX frame counts.
/// 6. PTT capability badge (desktop only).
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 VoidCallback onAdjustVoiceSettings,
}) async {
await showModalBottomSheet<void>(
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;
final modeLabel = switch (transmitMode) {
rust.BridgeTransmitMode.ptt => l10n.voiceModePtt,
rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous,
rust.BridgeTransmitMode.voiceActivity =>
'${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
};
// 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: 16),
// 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).
Row(
children: [
Icon(
transmitMode == rust.BridgeTransmitMode.ptt
? Icons.radio_button_checked
: Icons.podcasts,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
modeLabel,
style: theme.textTheme.bodyMedium,
),
),
],
),
if (isPtt) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 26),
child: Text(
isTouchOnly
? '${l10n.voicePttHoldHint} \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}'
: '${l10n.voiceModePtt}: '
'${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'
' \u00b7 ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
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,
),
],
],
),
),
);
},
);
}
/// 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();
_router.currentDeviceStream.listen((dev) {
if (!mounted) return;
setState(() => _device = dev);
});
}
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),
),
),
),
);
}
}