Files
chanora/apps/chanora_flutter/lib/widgets/voice_settings.dart
T
EdisonJwa 6a41a0b4db fix(ui,voice): five Voice Bar / settings UX bugs
1. Hard-mute now informs the server (setInputMuted) in addition to
   clamping the local TransmitGate. Without the server-side flag,
   other clients keep seeing us un-muted; without the local clamp
   a beat of in-flight audio leaks through. Drive both together so
   the mic icon and the actual silence land at the same time.

2. Split the badge's Configure affordance from the Voice Bar's
   'Voice settings' gear. The gear opens the mode + release-tail
   dialog (onConfigure); the badge's configure opens the bind-key
   capture flow directly (new onBindKey). Previously both routed
   to the settings dialog, so 'Voice settings' and the badge's
   'Configure' were the same screen — useless duplication.

3. Bind-key label is now PTT-only. The mode-badge row no longer
   prints 'PTT: Space' when Continuous / Voice Activity is
   selected. A new PTT-only secondary line carries the bound key
   plus the release-tail value together, hidden entirely for
   non-PTT modes.

4. Release-tail row is now PTT-only in BOTH the Voice Bar and the
   Voice settings dialog. The dialog previously kept the slider
   visible across all modes; switching to Continuous left the
   user staring at a control that did nothing.

5. PTT capability badge is now PTT-only. In Continuous and Voice
   Activity modes there is no key binding to surface a capability
   for, so the 'L0Focused (focused)' line + its info sheet and
   the Configure button disappear from the Voice Bar when the
   user isn't in PTT mode.

All five fixes are pure UI; no Rust changes needed. flutter analyze
remains clean (6 pre-existing Radio.groupValue deprecation infos).
2026-05-15 23:34:53 +08:00

175 lines
5.6 KiB
Dart

// Voice settings dialog (SDD-097). Surfaces a TransmitMode radio
// group, a bind-key button, and a release-tail slider.
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
/// Result returned by [`VoiceSettingsDialog`]. `null` indicates a
/// cancelled dialog.
class VoiceSettingsResult {
/// Construct a result snapshot.
const VoiceSettingsResult({
required this.mode,
required this.releaseTailMs,
required this.bindKeyRequested,
});
/// Selected transmit mode.
final rust.BridgeTransmitMode mode;
/// Chosen release-tail in milliseconds (0..=500, step 25).
final int releaseTailMs;
/// True when the user tapped the "bind key" button. The caller
/// is expected to open the focus-scoped capture dialog
/// afterwards.
final bool bindKeyRequested;
}
/// Voice settings dialog widget.
class VoiceSettingsDialog extends StatefulWidget {
/// Construct a dialog seeded with the current settings.
const VoiceSettingsDialog({
super.key,
required this.initialMode,
required this.initialReleaseTailMs,
});
/// Currently active transmit mode.
final rust.BridgeTransmitMode initialMode;
/// Currently configured release tail in milliseconds.
final int initialReleaseTailMs;
@override
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
}
class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
late rust.BridgeTransmitMode _mode;
late double _releaseTail;
@override
void initState() {
super.initState();
_mode = widget.initialMode;
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
return AlertDialog(
title: Text(l10n.voiceSettingsTitle),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.voiceModeLabel,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
RadioListTile<rust.BridgeTransmitMode>(
dense: true,
value: rust.BridgeTransmitMode.ptt,
groupValue: _mode,
title: Text(l10n.voiceModePtt),
onChanged: (v) => setState(() => _mode = v!),
),
RadioListTile<rust.BridgeTransmitMode>(
dense: true,
value: rust.BridgeTransmitMode.continuous,
groupValue: _mode,
title: Text(l10n.voiceModeContinuous),
onChanged: (v) => setState(() => _mode = v!),
),
RadioListTile<rust.BridgeTransmitMode>(
dense: true,
value: rust.BridgeTransmitMode.voiceActivity,
groupValue: _mode,
title: Text(l10n.voiceModeVoiceActivity),
secondary: Text(
l10n.voiceModeComingSoon,
style: theme.textTheme.bodySmall,
),
// VoiceActivity is reserved per DEC-030 — keep the
// tile visible but disabled per SDD-095.
onChanged: null,
),
const Divider(),
// Bind-key + release-tail are PTT-only concepts. Hide
// them entirely when the user has switched to a
// non-PTT mode so the dialog stays focused on what's
// actually configurable for that mode.
if (_mode == rust.BridgeTransmitMode.ptt) ...[
OutlinedButton.icon(
icon: const Icon(Icons.keyboard),
label: Text(l10n.voiceBindKeyAction),
onPressed: () {
Navigator.of(context).pop(
VoiceSettingsResult(
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: true,
),
);
},
),
const SizedBox(height: 8),
Text(
l10n.voiceReleaseTailLabel,
style: theme.textTheme.titleSmall,
),
Row(
children: [
Expanded(
child: Slider(
value: _releaseTail,
min: 0,
max: 500,
divisions: 20, // step 25 ms
label:
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
onChanged: (v) => setState(() => _releaseTail = v),
),
),
SizedBox(
width: 64,
child: Text(
'${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.end,
),
),
],
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(
VoiceSettingsResult(
mode: _mode,
releaseTailMs: _releaseTail.round(),
bindKeyRequested: false,
),
),
child: Text(l10n.pttConfigureSaveAction),
),
],
);
}
}