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:
@@ -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.",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"voiceOutputMuteLabel": "扬声器静音",
|
||||
"voiceSettingsTitle": "语音设置",
|
||||
"voiceBindKeyAction": "绑定 PTT 按键",
|
||||
"voicePttHoldHint": "按住按钮",
|
||||
"voiceMicOn": "开启",
|
||||
"voiceMicOff": "关闭",
|
||||
"channelJoinFailedPermission": "权限不足,无法加入此频道。",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -260,6 +260,9 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get voiceBindKeyAction => '绑定 PTT 按键';
|
||||
|
||||
@override
|
||||
String get voicePttHoldHint => '按住按钮';
|
||||
|
||||
@override
|
||||
String get voiceMicOn => '开启';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user