feat(voice): unified mobile voice bar with gesture-isolated PTT row

Replace separate VoiceStatusChip + VoicePttButton with a single
CompactVoiceBar widget that combines both into a two-row layout:

- Control row (tap): status text, mute, deafen, settings chevron
- PTT row (hold): full-width hold-to-talk, shown only in PTT mode

Gesture isolation prevents mis-touch between rows: the control row
uses tap-only InkWell/IconButton while the PTT row uses a raw
Listener for pointer-down/up events.

Key changes:
- Add CompactVoiceBar widget with state-colored container (normal,
  muted, talk-power-blocked)
- Remove mute/deafen IconButtons from AppBar headerActions
- Restructure voice details sheet into primary section + collapsible
  ExpansionTiles (audio processing, PTT capability, debug)
- Optimistic state updates for mute/deafen to eliminate tap delay
- Instant PTT visual feedback (no AnimatedContainer fade)
- Constant geometry across all states (no layout shift on toggle)
This commit is contained in:
Edison Jwa
2026-06-04 22:46:19 +09:00
parent 8cb5a7a258
commit e0060f3c19
2 changed files with 669 additions and 211 deletions
+27 -40
View File
@@ -1126,14 +1126,18 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _toggleOutputMute() async {
final next = !_outputMuted;
// Optimistic: flip the UI immediately.
setState(() {
_outputMuted = next;
});
try {
await rust.setOutputMuted(muted: next);
if (!mounted) return;
setState(() {
_outputMuted = next;
});
} catch (e) {
if (!mounted) return;
// Roll back on failure.
setState(() {
_outputMuted = !next;
});
_showUiError('output mute', e);
}
}
@@ -1260,6 +1264,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
return;
}
final next = !_hardMute;
// Optimistic: flip the UI immediately so the icon responds
// before the two bridge calls round-trip through FFI.
setState(() {
_inputMuted = next;
_hardMute = next;
_hardMuteByPermission = false;
});
try {
// Hard-mute is two coordinated effects:
// * setHardMute — local TransmitGate clamp; we stop sending
@@ -1272,14 +1283,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
// through. Drive them together.
await rust.setHardMute(muted: next);
await rust.setInputMuted(muted: next);
if (!mounted) return;
setState(() {
_inputMuted = next;
_hardMute = next;
_hardMuteByPermission = false;
});
} catch (e) {
if (!mounted) return;
// Roll back on failure.
setState(() {
_inputMuted = !next;
_hardMute = !next;
});
_showUiError('hard mute', e);
}
}
@@ -2145,26 +2155,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final theme = Theme.of(context);
final headerActions = [
if (_serverReachable && _inChannel) ...[
IconButton(
tooltip: _hardMuteByTalkPower
? 'Insufficient talk power to speak in this channel'
: l10n.voiceHardMuteLabel,
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
isSelected: _hardMute,
selectedIcon: const Icon(Icons.mic_off),
color: _hardMute ? theme.colorScheme.error : null,
onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute,
),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
isSelected: _outputMuted,
selectedIcon: const Icon(Icons.headset_off),
color: _outputMuted ? theme.colorScheme.error : null,
onPressed: _toggleOutputMute,
),
],
IconButton(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
@@ -2449,7 +2439,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Expanded(child: snapshotView),
const SizedBox(height: 8),
permissionBanner,
VoiceStatusChip(
CompactVoiceBar(
inChannel: _inChannel,
transmitMode: _transmitMode,
releaseTailMs: _releaseTailMs,
pttBoundKeyLabel: _pttBoundKeyLabel,
@@ -2457,19 +2448,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
onToggleInputMute: _onToggleHardMute,
onToggleOutputMute: _toggleOutputMute,
onOpenDetails: () => _onOpenVoiceDetailsSheet(),
onPttHeldChanged: _onOnscreenPttHeldChanged,
talkPower: ownClientState?.talkPower,
neededTalkPower: ownClientState?.neededTalkPower,
talkPowerGranted: ownClientState?.talkPowerGranted,
onTap: () => _onOpenVoiceDetailsSheet(),
hardMuteByTalkPower: _hardMuteByTalkPower,
),
if (_inChannel &&
_transmitMode == rust.BridgeTransmitMode.ptt) ...[
const SizedBox(height: 8),
VoicePttButton(
active: _audioStats?.pttActive ?? false,
onHeldChanged: _onOnscreenPttHeldChanged,
),
],
],
);
},