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.
This commit is contained in:
EdisonJwa
2026-05-16 16:57:08 +08:00
parent 7c0e5caa92
commit 3b08ea9d32
7 changed files with 606 additions and 13 deletions
+173 -13
View File
@@ -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<void> _onOpenVoiceDetailsSheet() async {
await showVoiceDetailsSheet(
context,
audioStats: _audioStats,
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
pttLevel: _pttLevel,
pttBackendId: _pttBackendId,
pttBoundInputClass: _pttBoundInputClass,
isTouchOnly: _isTouchOnlyPttHost,
);
}
Future<void> _onOpenVoiceSettings() async {
final result = await showDialog<VoiceSettingsResult>(
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,