From 3b08ea9d32ac5c592902b55e58f60040e8aad45f Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 16:57:08 +0800 Subject: [PATCH] feat(ui,mobile): Plan E voice UI -- status chip + bottom-anchored PTT + AppBar mutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/chanora_flutter/lib/l10n/app_en.arb | 1 + apps/chanora_flutter/lib/l10n/app_zh.arb | 1 + .../lib/l10n/generated/app_localizations.dart | 6 + .../l10n/generated/app_localizations_en.dart | 3 + .../l10n/generated/app_localizations_zh.dart | 3 + apps/chanora_flutter/lib/main.dart | 186 +++++++- .../lib/widgets/voice_compact.dart | 419 ++++++++++++++++++ 7 files changed, 606 insertions(+), 13 deletions(-) create mode 100644 apps/chanora_flutter/lib/widgets/voice_compact.dart diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 171e7bc..1632d70 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -127,6 +127,7 @@ "voiceOutputMuteLabel": "Mute speakers", "voiceSettingsTitle": "Voice settings", "voiceBindKeyAction": "Bind PTT key", + "voicePttHoldHint": "Hold the button", "voiceMicOn": "on", "voiceMicOff": "off", "channelJoinFailedPermission": "Insufficient permission to join this channel.", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 2724a93..141735d 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -84,6 +84,7 @@ "voiceOutputMuteLabel": "扬声器静音", "voiceSettingsTitle": "语音设置", "voiceBindKeyAction": "绑定 PTT 按键", + "voicePttHoldHint": "按住按钮", "voiceMicOn": "开启", "voiceMicOff": "关闭", "channelJoinFailedPermission": "权限不足,无法加入此频道。", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 7509092..d4e9578 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -547,6 +547,12 @@ abstract class AppL10n { /// **'Bind PTT key'** String get voiceBindKeyAction; + /// No description provided for @voicePttHoldHint. + /// + /// In en, this message translates to: + /// **'Hold the button'** + String get voicePttHoldHint; + /// No description provided for @voiceMicOn. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index c02b9e1..9a4a20e 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -266,6 +266,9 @@ class AppL10nEn extends AppL10n { @override String get voiceBindKeyAction => 'Bind PTT key'; + @override + String get voicePttHoldHint => 'Hold the button'; + @override String get voiceMicOn => 'on'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 0626884..fd9a8af 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -260,6 +260,9 @@ class AppL10nZh extends AppL10n { @override String get voiceBindKeyAction => '绑定 PTT 按键'; + @override + String get voicePttHoldHint => '按住按钮'; + @override String get voiceMicOn => '开启'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index bd60071..2cdb02b 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -11,6 +11,8 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'dart:io' show Platform; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -21,8 +23,18 @@ import 'src/rust/api.dart' as rust; import 'src/rust/lib.dart' as rust_err; import 'src/rust/frb_generated.dart'; import 'widgets/voice_bar.dart'; +import 'widgets/voice_compact.dart'; import 'widgets/voice_settings.dart'; +/// True when the host is a touch-only mobile platform without a +/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart +/// and widgets/voice_settings.dart so the AppBar + narrow-mode +/// layout in main.dart can branch consistently. +bool get _isTouchOnlyPttHost { + if (kIsWeb) return false; + return Platform.isIOS || Platform.isAndroid; +} + /// Public version string shown in the About dialog. Aligned with /// `pubspec.yaml` and the git tag for the MVP release candidate. /// Bumped to rc.8 for the post-rc.7 v1 audio + PTT lifecycle work @@ -490,6 +502,26 @@ class _BetaHomeState extends State<_BetaHome> { } } + /// Narrow-mode voice details modal sheet (Plan E status chip + /// trigger). Surfaces level meter + TX/RX counts + capability + /// badge in a Material 3 bottom sheet. Mode + bind-key + release- + /// tail configuration still live in [VoiceSettingsDialog] reached + /// via the AppBar gear icon — there is exactly one configuration + /// surface. + Future _onOpenVoiceDetailsSheet() async { + await showVoiceDetailsSheet( + context, + audioStats: _audioStats, + transmitMode: _transmitMode, + releaseTailMs: _releaseTailMs, + pttBoundKeyLabel: _pttBoundKeyLabel, + pttLevel: _pttLevel, + pttBackendId: _pttBackendId, + pttBoundInputClass: _pttBoundInputClass, + isTouchOnly: _isTouchOnlyPttHost, + ); + } + Future _onOpenVoiceSettings() async { final result = await showDialog( context: context, @@ -824,8 +856,42 @@ class _BetaHomeState extends State<_BetaHome> { return Scaffold( appBar: AppBar( - title: Text(l10n.appTitle), + title: _AppBarTitle( + phase: _phase, + inChannel: _inChannel, + channelName: _currentVoiceChannelName(), + ), actions: [ + // Narrow-mode AppBar gains mic-mute + headset-mute + voice + // settings icons when the user is in a voice channel, so + // the on-screen body can dedicate ~85% of its height to + // the channel tree + the bottom-anchored PTT button. Wide + // mode keeps these controls inside the VoiceBar widget + // (left column) so the signed-off rc.8 wide-layout + // doesn't shift. + if (_phase == _Phase.connected && + _inChannel && + MediaQuery.of(context).size.width < 840.0) ...[ + IconButton( + tooltip: l10n.voiceHardMuteLabel, + icon: Icon(_hardMute ? Icons.mic_off : Icons.mic), + isSelected: _hardMute, + selectedIcon: const Icon(Icons.mic_off), + onPressed: _onToggleHardMute, + ), + IconButton( + tooltip: l10n.voiceOutputMuteLabel, + icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset), + isSelected: _outputMuted, + selectedIcon: const Icon(Icons.headset_off), + onPressed: _toggleOutputMute, + ), + IconButton( + tooltip: l10n.voiceSettingsTitle, + icon: const Icon(Icons.tune), + onPressed: _onOpenVoiceSettings, + ), + ], IconButton( tooltip: l10n.aboutAction, icon: const Icon(Icons.info_outline), @@ -1015,19 +1081,47 @@ class _BetaHomeState extends State<_BetaHome> { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // In narrow / one-column layout the Voice - // Bar lives at the BOTTOM of the body so the - // PTT button (rendered as the Voice Bar's - // last element on touch-only mobile hosts) - // sits closest to the user's thumb. The - // channel tree fills the remaining space - // above. In wide mode (Row layout above) - // the Voice Bar is the left column with the - // channel tree on the right, so the - // ordering question doesn't apply. + // In narrow / one-column layout the layout + // is: + // + // [ channel tree (Expanded) ] + // [ status chip (2-line live readout) ] + // [ PTT button (mobile-only, + // PTT-mode-only) ] + // + // The chip is a tap target that opens + // `showVoiceDetailsSheet` with the mic + // level meter + TX/RX counts + capability + // badge. Mode + bind key + release-tail + // live in `VoiceSettingsDialog` + // (gear icon in AppBar). + // + // Wide mode (Row branch above) is + // unchanged from rc.8. Expanded(child: snapshotView), - const SizedBox(height: 12), - voiceBar, + const SizedBox(height: 8), + VoiceStatusChip( + transmitMode: _transmitMode, + releaseTailMs: _releaseTailMs, + pttBoundKeyLabel: _pttBoundKeyLabel, + audioStats: _audioStats, + isTouchOnly: _isTouchOnlyPttHost, + onTap: () => _onOpenVoiceDetailsSheet(), + ), + // PTT button only when PTT mode is active + // AND the user is in a voice channel. In + // Continuous mode there is nothing to + // hold; the chip alone surfaces the + // "Mic on / off" state. + if (_inChannel && + _transmitMode == + rust.BridgeTransmitMode.ptt) ...[ + const SizedBox(height: 8), + VoicePttButton( + active: _audioStats?.pttActive ?? false, + onHeldChanged: _onOnscreenPttHeldChanged, + ), + ], ], ); }, @@ -1043,6 +1137,72 @@ class _BetaHomeState extends State<_BetaHome> { } } +/// AppBar title that shows just the app name when idle / connecting, +/// and 'app name · #channel' (channel as an outlined chip-style +/// pill) when the user is in a voice channel. The chip is read- +/// only — tapping does nothing because the source of truth for +/// "current channel" is the channel tree below. Long names ellipsize. +class _AppBarTitle extends StatelessWidget { + const _AppBarTitle({ + required this.phase, + required this.inChannel, + required this.channelName, + }); + + final _Phase phase; + final bool inChannel; + final String channelName; + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + if (phase != _Phase.connected || !inChannel || channelName.isEmpty) { + return Text(l10n.appTitle); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.appTitle), + const SizedBox(width: 12), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.tag, + size: 14, + color: theme.colorScheme.onPrimaryContainer, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + channelName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} + class _ConnectForm extends StatefulWidget { const _ConnectForm({ required this.hostCtl, diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart new file mode 100644 index 0000000..5b5bdb7 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -0,0 +1,419 @@ +// 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 onHeldChanged; + + @override + State createState() => _VoicePttButtonState(); +} + +class _VoicePttButtonState extends State { + 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 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( + 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), + ), + ), + ), + ); + } +}