Files
chanora/apps/chanora_flutter/lib/widgets/voice_compact.dart
T
EdisonJwa 3b08ea9d32 feat(ui,mobile): Plan E voice UI -- status chip + bottom-anchored PTT + AppBar mutes
Restructure the narrow / mobile body layout around three principles
(Hoober thumb-zone research validated, Material 3 components):

  1. Channel tree gets ~85% of the screen height.
  2. Live voice state is always visible in a 2-line status chip
     above the PTT button.
  3. PTT button is wide, bottom-anchored (thumb-natural lower zone).
  4. Frequent toggles (mic mute, headset mute, voice settings) live
     in the AppBar so they don't compete with the channel tree.
  5. Non-essential live readouts (level meter, TX/RX, capability
     badge) live in a modal sheet opened by tapping the status chip
     -- progressive disclosure.

apps/chanora_flutter/lib/widgets/voice_compact.dart  (new file)
  * VoiceStatusChip: 2-line live readout. Line 1 = mode + bind hint;
    Line 2 = release-tail + 'Mic on/off'. Tap opens
    showVoiceDetailsSheet.
  * VoicePttButton: 56 dp wide bottom-anchored Push to Talk button.
    Same touch-and-hold gestures as the previous _PttHoldButton.
  * showVoiceDetailsSheet: modal bottom sheet with mode recap +
    bind/tail hint + level meter + TX/RX counts + PTT capability
    badge (desktop-only).

apps/chanora_flutter/lib/main.dart
  * AppBar gains mic-mute, headset-mute, voice-settings icons when
    in voice channel AND MediaQuery width < 840 dp (mobile only).
    Wide mode keeps these controls inside the existing VoiceBar
    widget unchanged.
  * AppBar title becomes a Row of 'app name + channel chip' when
    in voice channel.
  * Narrow-mode body restructured: Expanded(channelTree) +
    VoiceStatusChip + VoicePttButton (latter only when in voice
    channel AND PTT mode). The old narrow-mode VoiceBar is gone;
    wide-mode VoiceBar is unchanged.
  * New _onOpenVoiceDetailsSheet handler bridges the chip-tap to
    showVoiceDetailsSheet.
  * New top-level helper _isTouchOnlyPttHost mirrors the helpers
    in widgets/voice_bar.dart and widgets/voice_settings.dart so
    the AppBar + narrow-mode chip can branch consistently.

apps/chanora_flutter/lib/l10n/app_en.arb
apps/chanora_flutter/lib/l10n/app_zh.arb
apps/chanora_flutter/lib/l10n/generated/*  (regenerated)
  * New string voicePttHoldHint = 'Hold the button' / '按住按钮'.
    Surfaced in line 1 of VoiceStatusChip on touch-only hosts and
    in the modal sheet's PTT line where the desktop equivalent
    would name a bound key.

Wide-mode (>= 840 dp) layout intentionally unchanged so the
signed-off rc.8 desktop verification still applies.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Local cargo + flutter analyze pass; Mac was offline at commit
time so iOS device build verification is pending the next sync.
2026-05-16 16:57:08 +08:00

420 lines
15 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).
//
// Wide-mode (>= 840 dp) keeps the existing [VoiceBar] widget; this
// file is only invoked from `main.dart` when the body is narrow.
//
// Layout in narrow mode (in voice channel, PTT mode):
//
// [ AppBar with #channel chip + 🎤 mic 🎧 headset ⚙ settings ... ]
// [ ============= channel tree (Expanded) ============== ]
// [ chip: 'PTT · Hold the button' ↑ ]
// [ '200 ms tail · Mic on' ]
// [ ┌──────────────────────────────────────────────────┐]
// [ │ 🎤 PUSH TO TALK │] PTT button
// [ └──────────────────────────────────────────────────┘]
//
// Tapping the chip opens a [showModalBottomSheet] that surfaces the
// mode radio, release-tail slider, level meter, stats, and the
// (currently rare) capability badge. The mute buttons live in the
// AppBar so they remain visible without expanding the sheet.
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 [_VoiceDetailsSheet] modal.
///
/// Line 1: mode + bound key (or "Hold the button" on mobile)
/// Line 2: release tail + mic state
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 (PTT / Continuous / VoiceActivity).
final rust.BridgeTransmitMode transmitMode;
/// Release-tail in milliseconds.
final int releaseTailMs;
/// Bound key label (empty on touch-only hosts where no hardware
/// key is bound — the chip's line 1 then says "Hold the button").
final String pttBoundKeyLabel;
/// Current audio stats; null while audio engine not running.
final rust.BridgeAudioStats? audioStats;
/// True on iOS / iPadOS / Android. Used so the chip's line 1
/// can say "Hold the button" rather than naming a hardware key.
final bool isTouchOnly;
/// Open the [_VoiceDetailsSheet] 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) {
// Touch-only hosts have no bound key; describe the
// on-screen button instead.
line1 = '$modeLabel · ${l10n.voicePttHoldHint}';
} else {
// Desktop: name the bound key.
line1 = '$modeLabel · ${pttBoundKeyLabel.isEmpty ? "—" : 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 · $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: [
// Mic state dot — solid + primary while transmitting,
// outlined while idle.
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. Touch-and-hold drives
/// the engine via `onHeldChanged`; the bridge's release-tail timer
/// handles the trailing tail (SDD-096) so the user sees the same
/// behaviour as a hardware-key host.
///
/// Visually: filled primary-container chip at rest, filled primary
/// (with a soft outer glow) while held. 56 dp tall by spec, matching
/// the Material 3 extended-FAB height; horizontal margin is the
/// caller's responsibility so the button matches sibling content.
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 (mirrors the level
/// meter's active flag). Drives the held-style visuals so the
/// user gets feedback that holding actually engaged the mic.
final bool active;
/// Called with `true` on finger-down, `false` on finger-up or
/// gesture cancel. Map to `setPtt(active: held)` on the caller.
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 details modal sheet. Returns when the user
/// dismisses (taps outside / swipes down / hits the close button).
///
/// Surfaces the non-essential controls + live readouts that don't
/// fit in the AppBar or the status chip:
/// * Mic level meter
/// * TX / RX frame counts + mic state line
/// * PTT capability badge (only on desktop; touch-only hosts hide
/// this because the capability story is always "L0 Focused via
/// on-screen button" and the on-screen button is itself the
/// evidence)
///
/// Mode + release-tail + bind-key are intentionally NOT duplicated
/// here — those still live in `VoiceSettingsDialog` reachable from
/// the AppBar's settings icon, so there's exactly one configuration
/// surface.
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,
}) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: 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})',
};
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.voiceSettingsTitle,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 16),
// Mode + (PTT-only) bind / release-tail recap line.
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} · ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}'
: '${l10n.voiceModePtt}: '
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
' · ${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
const SizedBox(height: 16),
// 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,
),
// PTT capability badge — desktop-only (the touch-only
// story is "L0 Focused via on-screen button" which is
// already visually obvious from the PTT button).
if (isPtt && !isTouchOnly) ...[
const SizedBox(height: 12),
PttCapabilityBadge(
level: pttLevel,
backendId: pttBackendId,
boundInputClass: pttBoundInputClass,
),
],
],
),
),
);
},
);
}
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),
),
),
),
);
}
}