Compare commits

..
Author SHA1 Message Date
Edison Jwa e0060f3c19 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)
2026-06-04 22:46:19 +09:00
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,
),
],
],
);
},
@@ -1,11 +1,15 @@
// 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).
// Compact voice UI for narrow / mobile layouts.
//
// rc.8 follow-up: the AppBar gear icon was removed; the modal sheet
// is now the **single** voice-controls surface on mobile. Mode +
// release-tail are surfaced inline (radio buttons + slider) inside
// the modal.
// Two-zone voice bar pinned to the bottom:
// • Control row: status text + mute + deafen + settings chevron
// • PTT row: full-width hold-to-talk (PTT mode only)
// Both share a single container whose background colour reflects
// the current voice state (normal / muted / talk-power-blocked).
//
// Gesture isolation: the control row uses tap-only InkWell /
// IconButton; the PTT row uses a raw Listener for pointer-down /
// pointer-up. Because each row is a disjoint hit-test region, a
// finger holding PTT cannot accidentally toggle mute or deafen.
import 'dart:async' show Timer, unawaited;
import 'dart:io' show Platform;
@@ -539,6 +543,8 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge),
const SizedBox(height: 12),
// ── Primary section (always visible) ────────────────────
// 1) Audio output route picker tile (mobile only).
if (showRoutePicker) ...[
const AudioOutputTile(),
@@ -574,7 +580,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity),
),
// 3) Release-tail slider (PTT only).
// Release-tail slider (PTT only).
if (isPtt) ...[
const SizedBox(height: 8),
Row(
@@ -624,7 +630,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
Divider(height: 1, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 12),
// 4) Level meter + live TX/RX stats.
// Level meter + live TX/RX stats.
VoiceLevelMeter(active: levelActive),
const SizedBox(height: 6),
_StatsRow(
@@ -648,183 +654,227 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
),
],
// Audio processing.
const SizedBox(height: 12),
Divider(height: 1, color: theme.colorScheme.outlineVariant),
const SizedBox(height: 8),
Text(
'Audio processing',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
// Android HW/SW selector.
if (Platform.isAndroid) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) {
setState(() => _audioProcessing.preferHardware = s.first);
_notifyAudioConfig();
},
),
const SizedBox(height: 4),
Text(
_audioProcessing.preferHardware
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
: 'Software mode applies the full WebRTC APM stage set.',
style: theme.textTheme.bodySmall?.copyWith(
// ── Collapsible: Audio processing ───────────────────────
ExpansionTile(
initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
title: Text(
'Audio processing',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
children: [
_buildAudioProcessingSection(theme),
],
),
if (_isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
// ── Collapsible: PTT capability (PTT mode only) ─────────
if (isPtt)
ExpansionTile(
initiallyExpanded: false,
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
shape: const Border(),
collapsedShape: const Border(),
title: Text(
'PTT capability',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
children: [
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
const SizedBox(height: 8),
],
),
// ── Collapsible: Debug ──────────────────────────────────
ExpansionTile(
initiallyExpanded: false,
shape: const Border(),
collapsedShape: const Border(),
tilePadding: const EdgeInsets.symmetric(horizontal: 0),
title: Text(
'Debug',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
],
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsNsControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: _audioProcessing.nsEnabled,
onChanged: (v) {
setState(() => _audioProcessing.nsEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAecControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Echo cancellation',
subtitle: Platform.isAndroid
? (_audioProcessing.preferHardware
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
: 'WebRTC AEC3 · adaptive filter')
: (_isMacOS
? 'Managed by platform VPIO'
: 'WebRTC AEC3 · adaptive filter'),
value: _isMacOS ? true : _audioProcessing.aecEnabled,
onChanged: _isMacOS
? null
: (v) {
setState(() => _audioProcessing.aecEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAgcControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Auto gain control',
subtitle: 'AGC2 · -18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) {
setState(() => _audioProcessing.agcEnabled = v);
_notifyAudioConfig();
},
),
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
AudioProcessingToggleRow(
dense: true,
label: 'High-pass filter',
subtitle: '80 Hz · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) {
setState(() => _audioProcessing.hpfEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsLimiterControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) {
setState(() => _audioProcessing.limiterEnabled = v);
_notifyAudioConfig();
},
),
// VAD backend.
const SizedBox(height: 8),
Text(
'Voice activity detection (VAD)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
children: [
_buildDebugSection(theme),
],
),
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(() => _audioProcessing.vadBackend = s.first);
_notifyAudioConfig();
},
),
// Debug.
const SizedBox(height: 8),
Text(
'Debug',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
AudioProcessingToggleRow(
dense: true,
label: 'WAV dump',
subtitle: 'Record raw/processed mic to temp dir',
value: _audioProcessing.debugWavDump,
onChanged: (v) {
setState(() => _audioProcessing.debugWavDump = v);
_notifyAudioConfig();
},
),
// 6) PTT capability badge. On iOS this must remain
// visible even though the resolved level is always
// `L0Focused`, because the P0 acceptance flow requires
// honest capability advertising with an explanation of
// the sandbox limitation.
if (isPtt) ...[
const SizedBox(height: 12),
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
],
],
),
),
);
}
// ── Audio processing section (inside ExpansionTile) ─────────────────
Widget _buildAudioProcessingSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Android HW/SW selector.
if (Platform.isAndroid) ...[
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) {
setState(() => _audioProcessing.preferHardware = s.first);
_notifyAudioConfig();
},
),
const SizedBox(height: 4),
Text(
_audioProcessing.preferHardware
? 'Hardware mode still keeps per-stage WebRTC fallback, so these controls remain effective.'
: 'Software mode applies the full WebRTC APM stage set.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (_isIos) ...[
Text(
'iOS uses Apple VoiceProcessingIO. WebRTC APM controls are '
'hidden here; only settings that still affect the shipping '
'iOS path are shown.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
],
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsNsControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: _audioProcessing.nsEnabled,
onChanged: (v) {
setState(() => _audioProcessing.nsEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAecControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Echo cancellation',
subtitle: Platform.isAndroid
? (_audioProcessing.preferHardware
? 'Prefers device/OS effect; falls back to WebRTC AEC3'
: 'WebRTC AEC3 · adaptive filter')
: (_isMacOS
? 'Managed by platform VPIO'
: 'WebRTC AEC3 · adaptive filter'),
value: _isMacOS ? true : _audioProcessing.aecEnabled,
onChanged: _isMacOS
? null
: (v) {
setState(() => _audioProcessing.aecEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsAgcControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Auto gain control',
subtitle: 'AGC2 · -18 dBFS target',
value: _audioProcessing.agcEnabled,
onChanged: (v) {
setState(() => _audioProcessing.agcEnabled = v);
_notifyAudioConfig();
},
),
if (!Platform.isAndroid || androidShowsHpfControl(_audioProcessing))
AudioProcessingToggleRow(
dense: true,
label: 'High-pass filter',
subtitle: '80 Hz · DC removal',
value: _audioProcessing.hpfEnabled,
onChanged: (v) {
setState(() => _audioProcessing.hpfEnabled = v);
_notifyAudioConfig();
},
),
if (!_isIos &&
(!Platform.isAndroid ||
androidShowsLimiterControl(_audioProcessing)))
AudioProcessingToggleRow(
dense: true,
label: 'Peak limiter',
subtitle: '-1 dBFS soft-knee · 2 ms look-ahead',
value: _audioProcessing.limiterEnabled,
onChanged: (v) {
setState(() => _audioProcessing.limiterEnabled = v);
_notifyAudioConfig();
},
),
// VAD backend.
const SizedBox(height: 8),
Text(
'Voice activity detection (VAD)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(() => _audioProcessing.vadBackend = s.first);
_notifyAudioConfig();
},
),
const SizedBox(height: 8),
],
);
}
// ── Debug section (inside ExpansionTile) ─────────────────────────────
Widget _buildDebugSection(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AudioProcessingToggleRow(
dense: true,
label: 'WAV dump',
subtitle: 'Record raw/processed mic to temp dir',
value: _audioProcessing.debugWavDump,
onChanged: (v) {
setState(() => _audioProcessing.debugWavDump = v);
_notifyAudioConfig();
},
),
const SizedBox(height: 8),
],
);
}
}
// ── Live TX/RX stats row ──────────────────────────────────────────────────
@@ -948,3 +998,424 @@ class _ModeRow extends StatelessWidget {
);
}
}
// ── Unified mobile voice bar ──────────────────────────────────────────────
/// A unified bottom-anchored voice bar for compact / mobile layouts.
///
/// Combines the former [VoiceStatusChip] and [VoicePttButton] into one
/// visual zone with two rows:
///
/// ┌──────────────────────────────────────────────┐
/// │ 🟢 PTT · Connected [🔇] [🎧] [▲] │ ← control row (tap)
/// ├──────────────────────────────────────────────┤
/// │ ════ hold to talk ════ │ ← PTT row (hold)
/// └──────────────────────────────────────────────┘
///
/// The PTT row is shown only when [transmitMode] is PTT; for continuous
/// or voice-activity modes the bar shrinks to the control row alone.
///
/// State colour is applied to the entire container:
/// - normal: `surfaceContainerHigh`
/// - muted: `errorContainer` (35 % alpha)
/// - talk-power-block: amber (18 % alpha)
class CompactVoiceBar extends StatelessWidget {
/// Construct a compact voice bar.
const CompactVoiceBar({
super.key,
required this.inChannel,
required this.transmitMode,
required this.releaseTailMs,
required this.pttBoundKeyLabel,
required this.audioStats,
required this.isTouchOnly,
required this.inputMuted,
required this.outputMuted,
required this.onToggleInputMute,
required this.onToggleOutputMute,
required this.onOpenDetails,
required this.onPttHeldChanged,
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
this.hardMuteByTalkPower = false,
});
/// True when the client is inside a channel (gates PTT row visibility).
final bool inChannel;
/// Current transmit mode.
final rust.BridgeTransmitMode transmitMode;
/// Release-tail in milliseconds.
final int releaseTailMs;
/// Bound key label (empty on touch-only hosts).
final String pttBoundKeyLabel;
/// Current audio stats; null while audio engine not running.
final rust.BridgeAudioStats? audioStats;
/// True on iOS / iPadOS / Android.
final bool isTouchOnly;
/// True when local mic is muted.
final bool inputMuted;
/// True when local speaker is muted.
final bool outputMuted;
/// Toggle hard-mute on / off.
final VoidCallback onToggleInputMute;
/// Toggle output mute on / off.
final VoidCallback onToggleOutputMute;
/// Open the voice details modal sheet.
final VoidCallback onOpenDetails;
/// Called with `true` on finger-down, `false` on finger-up / cancel.
final ValueChanged<bool> onPttHeldChanged;
/// Own client's talk power.
final int? talkPower;
/// Talk power required to speak in current channel.
final int? neededTalkPower;
/// True when server granted talk power.
final bool? talkPowerGranted;
/// True when talk power prevents speaking.
final bool hardMuteByTalkPower;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
final pttActive = audioStats?.pttActive ?? false;
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: transmitMode,
releaseTailMs: releaseTailMs,
pttBoundKeyLabel: pttBoundKeyLabel,
isTouchOnly: isTouchOnly,
inputMuted: inputMuted,
outputMuted: outputMuted,
pttActive: pttActive,
talkPower: talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: talkPowerGranted,
);
// Container colour based on voice state.
final containerColor = summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18)
: summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh;
final borderColor = summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant;
final borderWidth = 1.0;
return Semantics(
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
child: Material(
type: MaterialType.transparency,
child: Container(
decoration: BoxDecoration(
color: containerColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: borderColor, width: borderWidth),
),
clipBehavior: Clip.antiAlias,
child: AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Control row ──────────────────────────────────────
_ControlRow(
summary: summary,
inputMuted: inputMuted,
outputMuted: outputMuted,
hardMuteByTalkPower: hardMuteByTalkPower,
onToggleInputMute: onToggleInputMute,
onToggleOutputMute: onToggleOutputMute,
onOpenDetails: onOpenDetails,
),
// ── PTT row (in-channel + PTT mode only) ──────────────
if (isPtt && inChannel) ...[
Divider(
height: 1,
thickness: 0.5,
color: borderColor,
indent: 12,
endIndent: 12,
),
_PttRow(
active: pttActive,
enabled: !hardMuteByTalkPower,
onHeldChanged: onPttHeldChanged,
),
],
],
),
),
),
),
);
}
}
// ── Control row (tap-only zone) ───────────────────────────────────────────
class _ControlRow extends StatelessWidget {
const _ControlRow({
required this.summary,
required this.inputMuted,
required this.outputMuted,
required this.hardMuteByTalkPower,
required this.onToggleInputMute,
required this.onToggleOutputMute,
required this.onOpenDetails,
});
final VoiceStatusSummary summary;
final bool inputMuted;
final bool outputMuted;
final bool hardMuteByTalkPower;
final VoidCallback onToggleInputMute;
final VoidCallback onToggleOutputMute;
final VoidCallback onOpenDetails;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
// Status dot.
Icon(
summary.micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined,
size: 10,
color: summary.micOn ? theme.colorScheme.primary : theme.colorScheme.outline,
),
const SizedBox(width: 8),
// Status text (tappable → open details).
Expanded(
child: Semantics(
button: true,
label: summary.line1,
child: InkWell(
onTap: onOpenDetails,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
summary.line1,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Text(
summary.line2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
const SizedBox(width: 4),
// Mute button.
_ToggleButton(
icon: inputMuted ? Icons.mic_off : Icons.mic,
isActive: inputMuted,
tooltip: 'Mute mic',
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
),
// Deafen button.
_ToggleButton(
icon: outputMuted ? Icons.headset_off : Icons.headset,
isActive: outputMuted,
tooltip: 'Deafen',
onPressed: onToggleOutputMute,
),
// Settings / expand chevron.
IconButton(
icon: Icon(Icons.expand_less, size: 20, color: theme.colorScheme.onSurfaceVariant),
tooltip: 'Voice settings',
onPressed: onOpenDetails,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
padding: EdgeInsets.zero,
),
],
),
);
}
}
// ── Toggle button (mute / deafen) ─────────────────────────────────────────
class _ToggleButton extends StatelessWidget {
const _ToggleButton({
required this.icon,
required this.isActive,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final bool isActive;
final String tooltip;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton(
icon: Icon(icon, size: 22),
tooltip: tooltip,
color: isActive ? theme.colorScheme.error : null,
onPressed: onPressed,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
padding: EdgeInsets.zero,
);
}
}
// ── PTT row (hold-only zone) ──────────────────────────────────────────────
class _PttRow extends StatefulWidget {
const _PttRow({
required this.active,
required this.enabled,
required this.onHeldChanged,
});
/// True while the engine reports the gate open.
final bool active;
/// Whether the PTT button can be engaged.
final bool enabled;
/// Called with `true` on pointer-down, `false` on pointer-up / cancel.
final ValueChanged<bool> onHeldChanged;
@override
State<_PttRow> createState() => _PttRowState();
}
class _PttRowState extends State<_PttRow> {
int? _activePointer;
bool _held = false;
@override
void initState() {
super.initState();
prepareVoiceHaptics();
}
void _begin(PointerDownEvent event) {
if (!widget.enabled || _activePointer != null) return;
_activePointer = event.pointer;
_held = true;
widget.onHeldChanged(true);
playVoicePttHaptic(true);
setState(() {});
}
void _end(int pointer) {
if (_activePointer != pointer) return;
_activePointer = null;
_held = false;
widget.onHeldChanged(false);
playVoicePttHaptic(false);
setState(() {});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final activeNow = _held || widget.active;
return Semantics(
button: true,
liveRegion: true,
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
hint: l10n.pttHoldToTalkSemanticsHint,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _begin,
onPointerUp: (e) => _end(e.pointer),
onPointerCancel: (e) => _end(e.pointer),
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: activeNow
? theme.colorScheme.primary
: Colors.transparent,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
activeNow ? Icons.mic : Icons.mic_none_outlined,
size: 22,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
),
);
}
}