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

* 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)

* fix(voice): preserve current PTT button format

* feat(voice): move mute/deafen controls into VoiceStatusChip

* fix(voice): ensure consistent chip height across mute states

Remove isSelected/selectedIcon from IconButtons inside VoiceStatusChip.
Material 3 toggle IconButtons (_SelectableIconButton) can vary in height
when the selected state changes due to tap target sizing. Use simple
conditional icons instead and set shrinkWrap tap target size with tight
constraints for stable 40x40 buttons regardless of state.

* fix(voice): remove leftover duplicate mute/deafen buttons in VoiceStatusChip

* fix(voice): replace unsafe stereo cast with bytemuck and localise talk-power tooltip

Replace the raw-pointer `&mut [(f32, f32)]` to `&mut [f32]` cast in
the oboe output callback with `bytemuck::cast_slice_mut`, eliminating
the unsafe block and relying on bytemuck compile-time NoUninit
verification instead.

Add voiceTalkPowerBlocked l10n key (en + zh) and replace the only
remaining hard-coded English tooltip in VoiceStatusChip with it.
This commit is contained in:
Edison Jwa
2026-06-05 20:58:16 +09:00
committed by GitHub
parent 82441f3d97
commit 5f1423c349
10 changed files with 225 additions and 122 deletions
@@ -53,8 +53,11 @@ class VoiceStatusChip extends StatelessWidget {
required this.audioStats,
required this.isTouchOnly,
required this.onTap,
required this.onToggleInputMute,
required this.onToggleOutputMute,
this.inputMuted = false,
this.outputMuted = false,
this.hardMuteByTalkPower = false,
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
@@ -81,6 +84,9 @@ class VoiceStatusChip extends StatelessWidget {
/// True when local speaker is muted.
final bool outputMuted;
/// True when the server talk-power gate forces local hard mute.
final bool hardMuteByTalkPower;
/// Own client's talk power.
final int? talkPower;
@@ -93,6 +99,12 @@ class VoiceStatusChip extends StatelessWidget {
/// Open the voice details modal.
final VoidCallback onTap;
/// Toggle local input hard mute.
final VoidCallback onToggleInputMute;
/// Toggle local output mute/deafen.
final VoidCallback onToggleOutputMute;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -113,49 +125,48 @@ class VoiceStatusChip extends StatelessWidget {
);
return Semantics(
button: true,
label: '${l10n.voiceSheetTitle}: ${summary.line1}, ${summary.line2}',
hint: l10n.voiceSettingsTitle,
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(12),
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18)
: summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant,
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18)
: summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant,
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
),
),
child: Row(
children: [
Icon(
summary.micOn
? Icons.fiber_manual_record
: Icons.fiber_manual_record_outlined,
size: 12,
color: summary.micOn
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
child: Row(
children: [
Icon(
summary.micOn
? Icons.fiber_manual_record
: Icons.fiber_manual_record_outlined,
size: 12,
color: summary.micOn
? theme.colorScheme.primary
: theme.colorScheme.outline,
),
const SizedBox(width: 8),
Expanded(
const SizedBox(width: 8),
Expanded(
child: InkWell(
onTap: () {
HapticFeedback.lightImpact();
onTap();
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
@@ -179,15 +190,60 @@ class VoiceStatusChip extends StatelessWidget {
],
),
),
const SizedBox(width: 8),
Icon(
Icons.expand_less,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
),
const SizedBox(width: 4),
IconButton(
tooltip: hardMuteByTalkPower
? l10n.voiceTalkPowerBlocked
: l10n.voiceHardMuteLabel,
icon: Icon(inputMuted ? Icons.mic_off : Icons.mic),
color: inputMuted ? theme.colorScheme.error : null,
onPressed: hardMuteByTalkPower ? null : onToggleInputMute,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
color: outputMuted ? theme.colorScheme.error : null,
onPressed: onToggleOutputMute,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
IconButton(
tooltip: l10n.voiceSettingsTitle,
icon: Icon(
Icons.expand_less,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
onPressed: onTap,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 40,
height: 40,
),
padding: EdgeInsets.zero,
style: const ButtonStyle(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
),
),