Files
chanora/apps/chanora_flutter/lib/widgets/voice_bar.dart
T
EdisonJwa 64878e3a7d fix(ui,voice): consolidate to single Voice settings entry point
The capability badge had its own 'Configure' TextButton that opened
the bind-key flow, while the Voice Bar's settings gear also reached
bind-key through the settings dialog. Two paths, same destination —
confusing and pointless duplication that the user flagged.

Resolution: the gear is the only configuration entry point. The
capability badge becomes information-only — it still shows the
detected PTT level + backend and (for L0Focused) the info-icon
explanation sheet, but no Configure button. The badge no longer
takes  or  props. The Voice Bar drops
the  callback added in 6a41a0b.

Also removed the dead  legacy widget class (lines
1001-1181) — it had no callers since the VoiceBar refactor in
ba444d9 but was still cluttering the file and even held a stale
reference to PttCapabilityBadge's old constructor signature.

The bound-key string is no longer duplicated either: the Voice Bar's
PTT-only secondary line ('PTT: Space   ·   Release tail: 200ms')
remains the only place that shows the bound key, since it's also
the only PTT-mode-gated surface.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
2026-05-15 23:52:23 +08:00

265 lines
9.0 KiB
Dart

// Voice bar widget (SDD-097). Replaces the legacy `_AudioControls`
// block that used to live in `main.dart`. Driven by the
// `BridgeEvent::VoiceState` stream the bridge publishes from the
// core's transmit-mode selector + release-tail timer.
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../main.dart' show PttCapabilityBadge;
import '../src/rust/api.dart' as rust;
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
/// toggle, level meter, and a leave-channel affordance.
class VoiceBar extends StatelessWidget {
/// Construct a voice bar.
const VoiceBar({
super.key,
required this.inChannel,
required this.transmitMode,
required this.hardMute,
required this.releaseTailMs,
required this.channelName,
required this.audioStats,
required this.pttLevel,
required this.pttBackendId,
required this.pttBoundInputClass,
required this.pttBoundKeyLabel,
required this.onToggleMute,
required this.onConfigure,
required this.onLeave,
});
/// True when the session is currently joined to a voice channel.
final bool inChannel;
/// Active transmit mode.
final rust.BridgeTransmitMode transmitMode;
/// Hard-mute clamp state.
final bool hardMute;
/// Configured release-tail in milliseconds (0..=500). Surfaced as
/// a hint underneath the mode badge.
final int releaseTailMs;
/// Channel display name to show in the channel-name pill. Empty
/// string suppresses the pill (typically when `!inChannel`).
final String channelName;
/// Audio statistics (TX/RX frames + ptt_active) to drive the
/// level meter. Pass `null` to render an idle meter.
final rust.BridgeAudioStats? audioStats;
/// PTT capability badge inputs — passed through to
/// [`PttCapabilityBadge`].
final String pttLevel;
/// Backend id, e.g. `focused`, `windows-raw-input`.
final String pttBackendId;
/// Coarse bound input class.
final String pttBoundInputClass;
/// Platform-neutral key label captured by the binding dialog.
final String pttBoundKeyLabel;
/// Toggle the hard-mute clamp.
final VoidCallback onToggleMute;
/// Open the voice settings dialog. This is the SINGLE entry point
/// for transmit-mode selection, PTT key binding, and release-tail
/// configuration. The capability badge below is information-only
/// and intentionally does NOT have its own configure affordance.
final VoidCallback onConfigure;
/// Leave the voice channel.
final VoidCallback onLeave;
String _modeLabel(AppL10n l10n) {
switch (transmitMode) {
case rust.BridgeTransmitMode.ptt:
return l10n.voiceModePtt;
case rust.BridgeTransmitMode.continuous:
return l10n.voiceModeContinuous;
case rust.BridgeTransmitMode.voiceActivity:
return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})';
}
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final stats = audioStats;
final levelActive = stats?.pttActive ?? false;
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Row 1: channel pill + mute toggle
Row(
children: [
if (inChannel && channelName.isNotEmpty) ...[
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),
Text(
channelName,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
const Spacer(),
IconButton(
tooltip: l10n.voiceHardMuteLabel,
icon: Icon(hardMute ? Icons.mic_off : Icons.mic),
isSelected: hardMute,
selectedIcon: const Icon(Icons.mic_off),
onPressed: onToggleMute,
),
],
),
const SizedBox(height: 6),
// Row 2: mode badge
Row(
children: [
Icon(
transmitMode == rust.BridgeTransmitMode.ptt
? Icons.radio_button_checked
: Icons.podcasts,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
_modeLabel(l10n),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 16),
label: Text(l10n.voiceSettingsTitle),
onPressed: onConfigure,
),
],
),
// Row 3: PTT-only secondary line — bound key + release
// tail. Hidden entirely for Continuous / Voice Activity
// so the bar stays focused on what's actually in use.
if (isPtt)
Padding(
padding: const EdgeInsets.only(left: 22, top: 2),
child: Text(
'${l10n.voiceModePtt}: '
'${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}'
' · '
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 6),
// Row 4: level meter
_LevelMeter(active: levelActive),
const SizedBox(height: 4),
if (stats != null)
Text(
l10n.audioStatsLine(
stats.framesSent,
stats.framesReceived,
stats.pttActive ? 'on' : 'off',
),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 6),
// PTT capability badge — only relevant when PTT mode is
// active. Hidden for Continuous / Voice Activity since
// there's no key binding to surface a capability for.
// The badge is information-only; the user reaches the
// bind-key flow through the Voice Bar's settings gear
// (single configuration entry point — see the comment
// on `onConfigure`).
if (isPtt)
PttCapabilityBadge(
level: pttLevel,
backendId: pttBackendId,
boundInputClass: pttBoundInputClass,
),
if (inChannel) ...[
const SizedBox(height: 6),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton.icon(
icon: const Icon(Icons.call_end),
label: Text(l10n.voiceLeaveAction),
onPressed: onLeave,
),
),
],
],
),
),
);
}
}
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),
),
),
),
);
}
}