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 in6a41a0b. Also removed the dead legacy widget class (lines 1001-1181) — it had no callers since the VoiceBar refactor inba444d9but 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).
265 lines
9.0 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|