feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import '../src/rust/api.dart' as rust;
/// Loads available input/output audio devices.
typedef AudioDeviceListLoader = Future<rust.BridgeAudioDeviceList> Function();
/// Persists a selected audio device name.
typedef AudioDeviceSetter = Future<void> Function({String? name});
/// Which desktop audio device group this tile manages.
enum AudioDeviceKind {
/// Capture device.
input,
/// Playback device.
output,
}
/// Desktop audio device selector tile.
class AudioDeviceListTile extends StatefulWidget {
/// Construct an audio device list tile.
const AudioDeviceListTile({
super.key,
required this.label,
required this.kind,
AudioDeviceListLoader? loadDevices,
AudioDeviceSetter? setInputDevice,
AudioDeviceSetter? setOutputDevice,
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
setInputDevice = setInputDevice ?? rust.setInputDevice,
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
/// Tile title.
final String label;
/// Device group managed by this tile.
final AudioDeviceKind kind;
/// Loads available devices.
final AudioDeviceListLoader loadDevices;
/// Selects an input device.
final AudioDeviceSetter setInputDevice;
/// Selects an output device.
final AudioDeviceSetter setOutputDevice;
@override
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
}
class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
List<rust.BridgeAudioDevice> _devices = [];
bool _loaded = false;
@override
void initState() {
super.initState();
_loadDevices();
}
Future<void> _loadDevices() async {
final list = await widget.loadDevices();
if (!mounted) return;
setState(() {
_devices = switch (widget.kind) {
AudioDeviceKind.input => list.inputDevices,
AudioDeviceKind.output => list.outputDevices,
};
_loaded = true;
});
}
Future<void> _selectDevice(rust.BridgeAudioDevice device) async {
switch (widget.kind) {
case AudioDeviceKind.input:
await widget.setInputDevice(name: device.name);
case AudioDeviceKind.output:
await widget.setOutputDevice(name: device.name);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${widget.label} set to ${device.name}'),
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
if (!_loaded) {
return ListTile(
title: Text(widget.label),
subtitle: const Text('Loading...'),
trailing: const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (_devices.isEmpty) {
return ListTile(
title: Text(widget.label),
subtitle: const Text('System default'),
leading: const Icon(Icons.check_circle_outline, size: 18),
);
}
return ExpansionTile(
title: Text(widget.label),
subtitle: Text('${_devices.length} available'),
leading: const Icon(Icons.headphones, size: 18),
children: [
for (final device in _devices)
ListTile(
dense: true,
title: Text(device.name, style: const TextStyle(fontSize: 13)),
trailing: device.isDefault
? const Icon(Icons.check, size: 16, color: Colors.green)
: null,
onTap: device.isDefault ? null : () => _selectDevice(device),
),
],
);
}
}
@@ -0,0 +1,567 @@
import 'dart:async' show StreamSubscription, unawaited;
import 'dart:io' show Platform;
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:haptic_kit/haptic_kit.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/android_audio_output_devices.dart';
/// Tile that displays the active audio output route and opens the
/// native picker on tap.
class AudioOutputTile extends StatefulWidget {
/// Construct an audio output tile.
const AudioOutputTile({super.key});
@override
State<AudioOutputTile> createState() => AudioOutputTileState();
}
/// State for [AudioOutputTile].
class AudioOutputTileState extends State<AudioOutputTile> {
static const _androidOutputChannel = MethodChannel('app.audio_output');
static const _androidOutputEvents = EventChannel('app.audio_output/events');
AVAudioSessionPortDescription? _activeOutput;
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
StreamSubscription<dynamic>? _androidOutputSub;
List<AndroidAudioOutputDevice> _androidDevices = const [];
bool _androidLoading = false;
@override
void initState() {
super.initState();
if (!kIsWeb && Platform.isAndroid) {
_refreshAndroidDevices();
_androidOutputSub = _androidOutputEvents.receiveBroadcastStream().listen((
_,
) {
if (!mounted) return;
_refreshAndroidDevices();
});
return;
}
_refresh();
_routeSub = AVAudioSession().routeChangeStream.listen((_) {
if (!mounted) return;
_refresh();
});
}
@override
void dispose() {
_routeSub?.cancel();
_routeSub = null;
_androidOutputSub?.cancel();
_androidOutputSub = null;
super.dispose();
}
Future<void> _refreshAndroidDevices() async {
if (_androidLoading) return;
setState(() => _androidLoading = true);
try {
final raw =
await _androidOutputChannel.invokeListMethod<dynamic>(
'getOutputDevices',
) ??
const [];
final devices = parseAndroidAudioOutputDevices(raw);
if (!mounted) return;
setState(() {
_androidDevices = devices;
_androidLoading = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _androidLoading = false);
}
}
Future<void> _refresh() async {
try {
final route = await AVAudioSession().currentRoute;
if (!mounted) return;
setState(() {
_activeOutput = route.outputs.firstOrNull;
});
} catch (_) {
// AVAudioSession may transiently throw before the session is active.
}
}
static String portLabel(
AVAudioSessionPort? type,
String fallback,
AppL10n l10n,
) {
switch (type) {
case AVAudioSessionPort.builtInSpeaker:
return l10n.audioRouteSpeaker;
case AVAudioSessionPort.builtInReceiver:
return l10n.audioRouteReceiver;
case AVAudioSessionPort.bluetoothHfp:
case AVAudioSessionPort.bluetoothA2dp:
case AVAudioSessionPort.bluetoothLe:
return fallback.isEmpty ? l10n.audioRouteBluetooth : fallback;
case AVAudioSessionPort.headphones:
case AVAudioSessionPort.headsetMic:
return fallback.isEmpty ? l10n.audioRouteWiredHeadset : fallback;
case AVAudioSessionPort.carAudio:
return l10n.audioRouteCarAudio;
case AVAudioSessionPort.airPlay:
return l10n.audioRouteAirplay;
case AVAudioSessionPort.builtInMic:
return fallback.isEmpty ? l10n.audioRouteReceiver : fallback;
case null:
default:
return fallback.isEmpty ? l10n.audioRouteUnknown : fallback;
}
}
static IconData portIcon(AVAudioSessionPort? type) {
switch (type) {
case AVAudioSessionPort.builtInSpeaker:
return Icons.volume_up;
case AVAudioSessionPort.builtInReceiver:
return Icons.phone_in_talk;
case AVAudioSessionPort.bluetoothHfp:
case AVAudioSessionPort.bluetoothA2dp:
case AVAudioSessionPort.bluetoothLe:
return Icons.bluetooth_audio;
case AVAudioSessionPort.headphones:
case AVAudioSessionPort.headsetMic:
return Icons.headset;
case AVAudioSessionPort.carAudio:
return Icons.directions_car;
case AVAudioSessionPort.airPlay:
return Icons.airplay;
default:
return Icons.speaker;
}
}
Future<void> _openPicker() async {
if (!kIsWeb && Platform.isAndroid) {
final selectedDeviceId = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) => _AndroidAudioOutputPickerSheet(
devices: _androidDevices,
loading: _androidLoading,
onRefresh: _refreshAndroidDevices,
),
);
if (selectedDeviceId == null) return;
try {
if (selectedDeviceId == 'auto') {
await _androidOutputChannel.invokeMethod<void>(
'clearCommunicationDevice',
);
} else {
final changed = await _androidOutputChannel.invokeMethod<bool>(
'setCommunicationDevice',
{'deviceId': selectedDeviceId},
);
if (changed != true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).audioRouteCannotSelect),
),
);
}
}
await _refreshAndroidDevices();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppL10n.of(context).audioRouteChangeFailed)),
);
}
return;
}
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) => const _AudioOutputPickerSheet(),
);
await _refresh();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
if (!kIsWeb && Platform.isAndroid) {
final selected = selectedAndroidAudioOutputDevice(_androidDevices);
final label = selected == null
? l10n.audioRouteSystemDefault
: _androidDeviceLabel(selected.type, selected.name, l10n);
return _AudioRouteRow(
icon: _androidDeviceIcon(selected?.type),
label: _androidLoading ? '${l10n.audioRouteUnknown}' : label,
onTap: _openPicker,
);
}
final port = _activeOutput;
final label = portLabel(port?.portType, port?.portName ?? '', l10n);
return _AudioRouteRow(
icon: portIcon(port?.portType),
label: label,
onTap: _openPicker,
);
}
static String _androidDeviceLabel(
String type,
String fallback,
AppL10n l10n,
) => switch (type) {
'speaker' => l10n.audioRouteSpeaker,
'earpiece' => l10n.audioRouteEarpiece,
'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset,
'bluetoothA2dp' ||
'bluetoothSco' ||
'bluetoothLe' => l10n.audioRouteBluetooth,
'usbHeadset' => fallback.isEmpty ? l10n.audioRouteUsbHeadset : fallback,
'hdmi' => l10n.audioRouteCarAudio,
_ => fallback.isEmpty ? l10n.audioRouteOtherDevice : fallback,
};
static IconData _androidDeviceIcon(String? type) => switch (type) {
'speaker' => Icons.volume_up,
'earpiece' => Icons.phone_in_talk,
'wiredHeadset' || 'wiredHeadphones' || 'usbHeadset' => Icons.headset,
'bluetoothA2dp' || 'bluetoothSco' || 'bluetoothLe' => Icons.bluetooth_audio,
'hdmi' => Icons.tv,
_ => Icons.speaker,
};
}
class _AudioRouteRow extends StatelessWidget {
const _AudioRouteRow({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(
children: [
Icon(icon, color: theme.colorScheme.primary),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.audioOutputLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
class _AndroidAudioOutputPickerSheet extends StatelessWidget {
const _AndroidAudioOutputPickerSheet({
required this.devices,
required this.loading,
required this.onRefresh,
});
final List<AndroidAudioOutputDevice> devices;
final bool loading;
final Future<void> Function() onRefresh;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
_PickerRow(
icon: Icons.speaker,
label: l10n.audioRouteSystemDefault,
selected: !devices.any((d) => d.isSelected),
onTap: () => Navigator.of(context).pop('auto'),
),
if (loading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (devices.isEmpty)
TextButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: Text(l10n.audioRouteRefreshDevices),
)
else
for (final device in devices)
_PickerRow(
icon: AudioOutputTileState._androidDeviceIcon(device.type),
label: AudioOutputTileState._androidDeviceLabel(
device.type,
device.name,
l10n,
),
selected: device.isSelected,
onTap: device.isAvailableForCommunication
? () => Navigator.of(context).pop(device.id)
: null,
),
],
),
),
);
}
}
class _AudioOutputPickerSheet extends StatefulWidget {
const _AudioOutputPickerSheet();
@override
State<_AudioOutputPickerSheet> createState() =>
_AudioOutputPickerSheetState();
}
class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
Set<AVAudioSessionPortDescription> _availableInputs = const {};
AVAudioSessionRouteDescription? _route;
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
bool _loading = true;
@override
void initState() {
super.initState();
_refresh();
_routeSub = AVAudioSession().routeChangeStream.listen((_) {
if (!mounted) return;
_refresh();
});
}
@override
void dispose() {
_routeSub?.cancel();
_routeSub = null;
super.dispose();
}
Future<void> _refresh() async {
try {
final session = AVAudioSession();
final inputs = await session.availableInputs;
final route = await session.currentRoute;
if (!mounted) return;
setState(() {
_availableInputs = inputs;
_route = route;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _loading = false);
}
}
Future<void> _selectSpeaker() async {
try {
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.speaker,
);
unawaited(Haptics.selection().catchError((_) {}));
debugPrint('chanora: audio output -> speakerphone (override applied)');
} catch (e, st) {
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _selectReceiver() async {
try {
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
unawaited(Haptics.selection().catchError((_) {}));
debugPrint('chanora: audio output -> receiver (override cleared)');
} catch (e, st) {
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _selectInput(AVAudioSessionPortDescription port) async {
try {
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
await AVAudioSession().setPreferredInput(port);
unawaited(Haptics.selection().catchError((_) {}));
debugPrint('chanora: audio output -> ${port.portType}');
} catch (e, st) {
debugPrint('chanora: _selectInput FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
if (_loading) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(40),
child: Center(child: CircularProgressIndicator()),
),
);
}
final currentOutputType = _route?.outputs.firstOrNull?.portType;
final currentInputUid = _route?.inputs.firstOrNull?.uid;
final isSpeaker = currentOutputType == AVAudioSessionPort.builtInSpeaker;
final isReceiver = currentOutputType == AVAudioSessionPort.builtInReceiver;
final externalInputs = _availableInputs
.where((p) => p.portType != AVAudioSessionPort.builtInMic)
.toList();
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
_PickerRow(
icon: Icons.volume_up,
label: l10n.audioRouteSpeaker,
selected: isSpeaker,
onTap: _selectSpeaker,
),
_PickerRow(
icon: Icons.phone_in_talk,
label: l10n.audioRouteReceiver,
selected: isReceiver,
onTap: _selectReceiver,
),
for (final port in externalInputs)
_PickerRow(
icon: AudioOutputTileState.portIcon(port.portType),
label: AudioOutputTileState.portLabel(
port.portType,
port.portName,
l10n,
),
selected: !isSpeaker && port.uid == currentInputUid,
onTap: () => _selectInput(port),
),
],
),
),
);
}
}
class _PickerRow extends StatelessWidget {
const _PickerRow({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final enabled = onTap != null;
final color = selected
? theme.colorScheme.primary
: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withAlpha(130);
return Semantics(
button: true,
selected: selected,
enabled: enabled,
label: label,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4),
child: Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(color: color),
),
),
if (selected)
Icon(Icons.check, color: theme.colorScheme.primary),
],
),
),
),
),
);
}
}
@@ -0,0 +1,147 @@
import 'dart:io' show Platform;
import '../src/rust/api.dart' as rust;
/// Fallback audio-processing config used before the bridge can report one.
const defaultAudioProcessingConfig = rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.unknown,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: rust.BridgeVadBackend.sileroOnnx,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.platform,
agc: rust.BridgeEffectOwner.platform,
hpfEnabled: true,
limiterEnabled: true,
vadHangoverMs: 500,
vadPreRollMs: 160,
vadMinTxMs: 200,
debugWavDumpEnabled: false,
);
/// Mutable UI state for audio-processing controls.
class AudioProcessingConfigState {
/// Build control state from a bridge config.
AudioProcessingConfigState.fromConfig(rust.BridgeAudioProcessingConfig config)
: nsEnabled = config.ns != rust.BridgeEffectOwner.off,
aecEnabled = config.aec != rust.BridgeEffectOwner.off,
agcEnabled = config.agc != rust.BridgeEffectOwner.off,
hpfEnabled = config.hpfEnabled,
limiterEnabled = config.limiterEnabled,
debugWavDump = config.debugWavDumpEnabled,
preferHardware = _usesPlatformEffects(config),
vadBackend = normalizedVadBackend(config.vadBackend),
iosMode = config.iosMode;
/// Noise suppression toggle.
bool nsEnabled;
/// Echo cancellation toggle.
bool aecEnabled;
/// Automatic gain-control toggle.
bool agcEnabled;
/// High-pass filter toggle.
bool hpfEnabled;
/// Limiter toggle.
bool limiterEnabled;
/// Debug WAV dump toggle.
bool debugWavDump;
/// Android hardware effects preference.
bool preferHardware;
/// Selected VAD backend.
rust.BridgeVadBackend vadBackend;
/// Selected iOS processing mode.
rust.BridgeIosVoiceProcessingMode iosMode;
/// Build the bridge config represented by this UI state.
rust.BridgeAudioProcessingConfig buildConfig({
required rust.BridgeAudioProcessingConfig base,
bool? isAndroid,
}) {
final android = isAndroid ?? Platform.isAndroid;
final vad = normalizedVadBackend(vadBackend);
if (android) {
final owner = preferHardware
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: iosMode,
processingBackend: preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: vad,
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
final isSonora =
iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
final aecOwner = isSonora
? (aecEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform;
final nsOwner = isSonora
? (nsEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (agcEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: iosMode,
processingBackend: isSonora
? rust.BridgeAudioBackend.webrtcApm
: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vad,
aec: aecOwner,
ns: nsOwner,
agc: agcOwner,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
}
/// Never leave the UI on the hidden disabled backend.
rust.BridgeVadBackend normalizedVadBackend(rust.BridgeVadBackend backend) {
return backend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: backend;
}
bool _usesPlatformEffects(rust.BridgeAudioProcessingConfig config) {
return config.aec == rust.BridgeEffectOwner.platform ||
config.ns == rust.BridgeEffectOwner.platform ||
config.agc == rust.BridgeEffectOwner.platform;
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/link_trust_service.dart';
import '../services/ts3_server_link.dart';
final _tagRe = RegExp(
r'\[(\/?(?:b|i|u|s'
@@ -13,12 +14,14 @@ final _tagRe = RegExp(
r'))\]',
caseSensitive: false,
);
final _colorRe = RegExp(r'color=([#\w]+)');
final _sizeRe = RegExp(r'size=(\d+)');
final _urlRe = RegExp(r'url=(.+)');
final _urlRe = RegExp(r'url=(.+)', caseSensitive: false);
final _imgRe = RegExp(r'img=(.+)');
final _urlAutoRe = RegExp(r'(?:\[url\])?(https?://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false);
final _urlAutoRe = RegExp(
r'(?:\[url\])?((?:https?|ts3server)://[^\s\[\]]+)(?:\[/url\])?',
caseSensitive: false,
);
final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false);
const _linkStyle = TextStyle(color: Colors.blue);
int? _findCloseUrl(String src, int from) {
final m = _closeUrlRe.matchAsPrefix(src, from);
@@ -42,10 +45,16 @@ Color? _parseColor(String hex) {
}
class BbCodeText extends StatelessWidget {
const BbCodeText(this.text, {super.key, required this.linkTrust});
const BbCodeText(
this.text, {
super.key,
required this.linkTrust,
this.onTs3ServerLink,
});
final String text;
final LinkTrustService linkTrust;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
Widget build(BuildContext context) {
@@ -63,20 +72,17 @@ class BbCodeText extends StatelessWidget {
parts.add(TextSpan(text: src.substring(last, m.start)));
}
final url = m.group(1)!;
parts.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
child: Text(
url,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
parts.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
onTs3ServerLink: onTs3ServerLink,
child: Text(url, style: _linkStyle),
),
),
));
);
last = m.end;
}
if (last < src.length) {
@@ -118,19 +124,21 @@ class BbCodeText extends StatelessWidget {
}
}
spans.add(TextSpan(
text: t,
style: TextStyle(
fontWeight: bold ? FontWeight.bold : null,
fontStyle: italic ? FontStyle.italic : null,
decoration: TextDecoration.combine([
if (underline) TextDecoration.underline,
if (strikethrough) TextDecoration.lineThrough,
]),
color: color,
fontSize: size,
spans.add(
TextSpan(
text: t,
style: TextStyle(
fontWeight: bold ? FontWeight.bold : null,
fontStyle: italic ? FontStyle.italic : null,
decoration: TextDecoration.combine([
if (underline) TextDecoration.underline,
if (strikethrough) TextDecoration.lineThrough,
]),
color: color,
fontSize: size,
),
),
));
);
}
final buf = StringBuffer();
@@ -150,7 +158,8 @@ class BbCodeText extends StatelessWidget {
}
flush(buf);
final raw = m.group(1)!.toLowerCase();
final rawTag = m.group(1)!;
final raw = rawTag.toLowerCase();
i = m.end;
if (raw.startsWith('/')) {
@@ -184,7 +193,7 @@ class BbCodeText extends StatelessWidget {
if (raw.startsWith('color=') || raw.startsWith('size=')) {
tags.add(raw);
} else if (raw.startsWith('url=')) {
final url = _urlRe.firstMatch(raw)?.group(1) ?? '';
final url = _urlRe.firstMatch(rawTag)?.group(1) ?? '';
final closeIdx = _findCloseUrl(src, i);
String inner;
if (closeIdx != null) {
@@ -193,53 +202,49 @@ class BbCodeText extends StatelessWidget {
} else {
inner = url;
}
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url.isNotEmpty ? url : inner,
linkTrust: linkTrust,
child: Text(
inner,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url.isNotEmpty ? url : inner,
linkTrust: linkTrust,
onTs3ServerLink: onTs3ServerLink,
child: Text(inner, style: _linkStyle),
),
),
));
);
} else if (raw.startsWith('img=')) {
final src2 = _imgRe.firstMatch(raw)?.group(1) ?? '';
if (src2.isNotEmpty) {
spans.add(WidgetSpan(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
Uri.tryParse(src2)?.toString() ?? src2,
fit: BoxFit.scaleDown,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
spans.add(
WidgetSpan(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
Uri.tryParse(src2)?.toString() ?? src2,
fit: BoxFit.scaleDown,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
),
));
);
}
} else if (raw == 'url') {
final closeIdx = _findCloseUrl(src, i);
if (closeIdx != null) {
final url = src.substring(i, closeIdx).trim();
i = closeIdx + 6;
spans.add(WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
child: Text(
url,
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
onTs3ServerLink: onTs3ServerLink,
child: Text(url, style: _linkStyle),
),
),
));
);
}
}
break;
@@ -258,11 +263,13 @@ class _LinkTap extends StatefulWidget {
required this.url,
required this.child,
required this.linkTrust,
this.onTs3ServerLink,
});
final String url;
final Widget child;
final LinkTrustService linkTrust;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
State<_LinkTap> createState() => _LinkTapState();
@@ -284,6 +291,12 @@ class _LinkTapState extends State<_LinkTap> {
void _onChanged() => mounted ? setState(() {}) : null;
Future<void> _open() async {
final ts3Link = parseTs3ServerLink(widget.url);
if (ts3Link != null) {
await widget.onTs3ServerLink?.call(ts3Link);
return;
}
final uri = Uri.tryParse(widget.url);
if (uri == null) return;
final host = uri.host;
@@ -305,9 +318,6 @@ class _LinkTapState extends State<_LinkTap> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _open,
child: widget.child,
);
return GestureDetector(onTap: _open, child: widget.child);
}
}
+321 -236
View File
@@ -280,13 +280,20 @@ class ChatPage extends StatefulWidget {
}
class _ChatPageState extends State<ChatPage> {
rust.BridgeMessageTarget? _detailTarget;
late rust.BridgeMessageTarget _selectedTarget;
String _selectedClientName = '';
final Set<BigInt> _closedPrivateChats = {};
@override
void initState() {
super.initState();
_detailTarget = widget.initialTarget;
_selectedTarget =
widget.initialTarget ??
resolveInitialChatTarget(
messages: widget.messages,
currentVoiceChannelId: _currentChannelId,
) ??
const rust.BridgeMessageTarget.server();
_selectedClientName = widget.initialClientName;
}
@@ -298,31 +305,102 @@ class _ChatPageState extends State<ChatPage> {
Widget build(BuildContext context) {
final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(widget.snapshot, currentChannelId);
if (_detailTarget != null) {
return _ChatDetailView(
target: _detailTarget!,
clientName: _selectedClientName,
snapshot: widget.snapshot,
messages: widget.messages,
currentChannelId: currentChannelId,
channelName: channelName,
onBack: () => setState(() => _detailTarget = null),
onTs3ServerLink: widget.onTs3ServerLink,
return Scaffold(
appBar: AppBar(
title: Text('Chat — ${widget.snapshot.serverName}'),
actions: [
IconButton(
tooltip: 'Close chat',
icon: const Icon(Icons.close),
onPressed: _selectedPrivateClientId == null
? null
: _closeSelectedPrivateChat,
),
],
),
body: Row(
children: [
_ChatSidebar(
selectedTarget: _selectedTarget,
privateChats: _privateChats,
onSelect: _selectTarget,
onNewPrivateChat: () => _pickClient((id, name) {
_closedPrivateChats.remove(id);
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
}),
),
const VerticalDivider(width: 1),
Expanded(
child: _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: widget.snapshot,
messages: widget.messages,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: widget.onTs3ServerLink,
),
),
],
),
);
}
BigInt? get _selectedPrivateClientId {
final target = _selectedTarget;
return target is rust.BridgeMessageTarget_Client ? target.field0 : null;
}
List<_PrivateChatItem> get _privateChats {
final chats = <BigInt, _PrivateChatItem>{};
for (final message in widget.messages) {
final target = message.target;
if (target is! rust.BridgeMessageTarget_Client) continue;
final id = target.field0;
if (_closedPrivateChats.contains(id)) continue;
final existing = chats[id];
final name = existing?.name.isNotEmpty == true
? existing!.name
: _privateChatName(id, message.senderName);
chats[id] = _PrivateChatItem(id: id, name: name);
}
final selectedId = _selectedPrivateClientId;
if (selectedId != null && !_closedPrivateChats.contains(selectedId)) {
chats.putIfAbsent(
selectedId,
() => _PrivateChatItem(
id: selectedId,
name: _selectedClientName.isNotEmpty ? _selectedClientName : 'Direct',
),
);
}
return _ChatHub(
snapshot: widget.snapshot,
messages: widget.messages,
currentChannelId: currentChannelId,
channelName: channelName,
onOpen: (t, {String name = ''}) {
setState(() {
_detailTarget = t;
_selectedClientName = name;
});
},
onPickClient: (fn) => _pickClient(fn),
);
return chats.values.toList()..sort((a, b) => a.name.compareTo(b.name));
}
String _privateChatName(BigInt id, String fallback) {
for (final client in widget.snapshot.clients) {
if (client.id == id && client.name.isNotEmpty) return client.name;
}
return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct';
}
void _selectTarget(rust.BridgeMessageTarget target, {String name = ''}) {
setState(() {
_selectedTarget = target;
_selectedClientName = name;
});
}
void _closeSelectedPrivateChat() {
final id = _selectedPrivateClientId;
if (id == null) return;
setState(() {
_closedPrivateChats.add(id);
_selectedTarget = _currentChannelId != null
? const rust.BridgeMessageTarget.channel()
: const rust.BridgeMessageTarget.server();
_selectedClientName = '';
});
}
void _pickClient(void Function(BigInt id, String name) cb) {
@@ -342,6 +420,134 @@ class _ChatPageState extends State<ChatPage> {
}
}
class _PrivateChatItem {
const _PrivateChatItem({required this.id, required this.name});
final BigInt id;
final String name;
}
class _ChatSidebar extends StatelessWidget {
const _ChatSidebar({
required this.selectedTarget,
required this.privateChats,
required this.onSelect,
required this.onNewPrivateChat,
});
final rust.BridgeMessageTarget selectedTarget;
final List<_PrivateChatItem> privateChats;
final void Function(rust.BridgeMessageTarget target, {String name}) onSelect;
final VoidCallback onNewPrivateChat;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 148,
child: Column(
children: [
_ChatSidebarItem(
icon: Icons.dns_outlined,
label: 'Server',
selected: selectedTarget is rust.BridgeMessageTarget_Server,
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
),
_ChatSidebarItem(
icon: Icons.tag,
label: 'Channel',
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: privateChats.length,
itemBuilder: (context, index) {
final chat = privateChats[index];
final selected = switch (selectedTarget) {
rust.BridgeMessageTarget_Client(:final field0) =>
field0 == chat.id,
_ => false,
};
return _ChatSidebarItem(
icon: Icons.person_outline,
label: chat.name.isNotEmpty ? chat.name : 'Direct',
selected: selected,
onTap: () => onSelect(
rust.BridgeMessageTarget.client(chat.id),
name: chat.name,
),
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(8),
child: IconButton.filledTonal(
tooltip: 'New private chat',
icon: const Icon(Icons.add),
onPressed: onNewPrivateChat,
),
),
],
),
);
}
}
class _ChatSidebarItem extends StatelessWidget {
const _ChatSidebarItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = selected
? theme.colorScheme.primaryContainer
: Colors.transparent;
final fg = selected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface;
return Material(
color: bg,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 44,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
Icon(icon, size: 18, color: fg),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: fg),
),
),
],
),
),
),
),
);
}
}
class _ClientPickerDialog extends StatefulWidget {
const _ClientPickerDialog({
required this.channels,
@@ -461,124 +667,6 @@ class _ChannelGroup extends StatelessWidget {
}
}
class _ChatHub extends StatelessWidget {
const _ChatHub({
required this.snapshot,
required this.messages,
required this.currentChannelId,
required this.channelName,
required this.onOpen,
required this.onPickClient,
});
final rust.BridgeSnapshot snapshot;
final List<ChatEntry> messages;
final BigInt? currentChannelId;
final String channelName;
final void Function(rust.BridgeMessageTarget t, {String name}) onOpen;
final void Function(void Function(BigInt id, String name) cb) onPickClient;
Iterable<ChatEntry> _of(rust.BridgeMessageTarget t) =>
messages.where((m) => m.target == t);
@override
Widget build(BuildContext context) {
final channelMsgs = _of(const rust.BridgeMessageTarget.channel());
final privateMsgs = messages.where((m) => m.isPrivate);
final serverMsgs = _of(const rust.BridgeMessageTarget.server());
return Scaffold(
appBar: AppBar(title: Text('Chat & Activity — ${snapshot.serverName}')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_HubCard(
icon: Icons.tag,
title: channelName.isNotEmpty
? '# $channelName'
: 'Current Channel',
subtitle: channelMsgs.isNotEmpty
? '${channelMsgs.length} message${channelMsgs.length == 1 ? '' : 's'}'
: 'No messages yet',
detail: currentChannelId != null
? 'You can send messages here'
: 'Join a channel to send messages',
onTap: () => onOpen(const rust.BridgeMessageTarget.channel()),
),
const SizedBox(height: 12),
_HubCard(
icon: Icons.person_outline,
title: 'Direct Messages',
subtitle: privateMsgs.isNotEmpty
? '${privateMsgs.length} message${privateMsgs.length == 1 ? '' : 's'}'
: 'No private messages',
detail: 'Select a user to start a private chat.',
onTap: () => onPickClient(
(id, name) =>
onOpen(rust.BridgeMessageTarget.client(id), name: name),
),
),
const SizedBox(height: 12),
_HubCard(
icon: Icons.list_alt_outlined,
title: 'Server Activity',
subtitle: serverMsgs.isNotEmpty
? '${serverMsgs.length} message${serverMsgs.length == 1 ? '' : 's'}'
: 'No server activity',
detail: serverMsgs.isNotEmpty
? 'Server messages and events appear here.'
: 'Server-wide messages will appear here.',
onTap: () => onOpen(const rust.BridgeMessageTarget.server()),
),
],
),
);
}
}
class _HubCard extends StatelessWidget {
const _HubCard({
required this.icon,
required this.title,
required this.subtitle,
required this.detail,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final String detail;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: Icon(icon, color: theme.colorScheme.primary),
title: Text(title, style: theme.textTheme.titleSmall),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(subtitle, style: theme.textTheme.bodyMedium),
Text(
detail,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _ChatDetailView extends StatefulWidget {
const _ChatDetailView({
required this.target,
@@ -587,7 +675,6 @@ class _ChatDetailView extends StatefulWidget {
required this.messages,
required this.currentChannelId,
required this.channelName,
required this.onBack,
this.onTs3ServerLink,
});
@@ -597,7 +684,6 @@ class _ChatDetailView extends StatefulWidget {
final List<ChatEntry> messages;
final BigInt? currentChannelId;
final String channelName;
final VoidCallback onBack;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
@@ -682,109 +768,108 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
clientName: widget.clientName,
);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
return Column(
children: [
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Text(_title, style: theme.textTheme.titleMedium),
),
title: Text(_title),
actions: [
TextButton(onPressed: widget.onBack, child: const Text('Activity')),
],
),
body: Column(
children: [
Expanded(
child: msgs.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.forum_outlined,
size: 48,
Expanded(
child: msgs.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.forum_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
chatEmptyTitle(widget.target),
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
chatEmptyBody(
widget.target,
channelName: widget.channelName,
),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
chatEmptyTitle(widget.target),
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
chatEmptyBody(
widget.target,
channelName: widget.channelName,
),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
)
: ListView.builder(
controller: _scrollCtl,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: msgs.length,
itemBuilder: (_, i) => _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
textAlign: TextAlign.center,
),
],
),
),
),
if (_blockedReason != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: theme.colorScheme.surfaceContainerHighest,
child: Text(
_blockedReason!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
)
: ListView.builder(
controller: _scrollCtl,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: msgs.length,
itemBuilder: (_, i) => _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
),
),
textAlign: TextAlign.center,
),
if (_blockedReason != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: theme.colorScheme.surfaceContainerHighest,
child: Text(
_blockedReason!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (_canSend && _blockedReason == null)
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textCtl,
decoration: InputDecoration(
hintText: placeholder,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
if (_canSend && _blockedReason == null)
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textCtl,
decoration: InputDecoration(
hintText: placeholder,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _send,
tooltip: 'Send',
),
],
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _send,
tooltip: 'Send',
),
],
),
],
),
),
],
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
/// Server connection form.
class ConnectForm extends StatefulWidget {
/// Construct a connect form.
const ConnectForm({
super.key,
required this.hostCtl,
required this.nickCtl,
required this.passwordCtl,
required this.onConnect,
required this.onAddBookmark,
});
/// Server host controller.
final TextEditingController hostCtl;
/// Nickname controller.
final TextEditingController nickCtl;
/// Server password controller.
final TextEditingController passwordCtl;
/// Called when the user submits a connection.
final VoidCallback onConnect;
/// Called when the user saves the current form as a bookmark.
final VoidCallback onAddBookmark;
@override
State<ConnectForm> createState() => _ConnectFormState();
}
class _ConnectFormState extends State<ConnectForm> {
final FocusNode _hostFocus = FocusNode();
final FocusNode _nickFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
void _onTapOutside(PointerDownEvent _) {
FocusManager.instance.primaryFocus?.unfocus();
}
@override
void dispose() {
_hostFocus.dispose();
_nickFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: widget.hostCtl,
focusNode: _hostFocus,
onTapOutside: _onTapOutside,
keyboardType: TextInputType.url,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'\s')),
TextInputFormatter.withFunction(
(oldValue, newValue) => newValue.copyWith(
text: newValue.text.toLowerCase(),
selection: newValue.selection,
),
),
],
decoration: InputDecoration(
labelText: l10n.fieldServerHost,
hintText: 'host[:port]',
prefixIcon: const Icon(Icons.dns_outlined),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: widget.nickCtl,
focusNode: _nickFocus,
onTapOutside: _onTapOutside,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldNickname,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: widget.passwordCtl,
focusNode: _passwordFocus,
onTapOutside: _onTapOutside,
obscureText: true,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldServerPassword,
helperText: l10n.fieldServerPasswordHelp,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: Text(l10n.connectAction),
onPressed: widget.onConnect,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
icon: const Icon(Icons.bookmark_add_outlined),
label: Text(l10n.bookmarkAddAction),
onPressed: widget.onAddBookmark,
),
],
),
],
);
}
}
/// Saved bookmark list.
class BookmarkList extends StatelessWidget {
/// Construct a bookmark list.
const BookmarkList({
super.key,
required this.bookmarks,
required this.onConnect,
required this.onDelete,
});
/// Saved bookmarks.
final List<rust.BridgeBookmark> bookmarks;
/// Connect to a bookmark.
final ValueChanged<rust.BridgeBookmark> onConnect;
/// Delete a bookmark.
final ValueChanged<rust.BridgeBookmark> onDelete;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
if (bookmarks.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
for (final b in bookmarks)
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
title: Text(b.displayName),
subtitle: Text('${b.host}${b.nickname}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.login),
tooltip: l10n.connectAction,
onPressed: () => onConnect(b),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: l10n.bookmarkDeleteAction,
onPressed: () => onDelete(b),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
/// stored by the PTT binding flow.
String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
if (k == LogicalKeyboardKey.space) return 'Space';
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
return 'Enter';
}
if (k == LogicalKeyboardKey.tab) return 'Tab';
if (k == LogicalKeyboardKey.escape) return 'Escape';
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
if (k == LogicalKeyboardKey.delete) return 'Delete';
if (k == LogicalKeyboardKey.insert) return 'Insert';
if (k == LogicalKeyboardKey.home) return 'Home';
if (k == LogicalKeyboardKey.end) return 'End';
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
if (k == LogicalKeyboardKey.shift ||
k == LogicalKeyboardKey.shiftLeft ||
k == LogicalKeyboardKey.shiftRight ||
k == LogicalKeyboardKey.control ||
k == LogicalKeyboardKey.controlLeft ||
k == LogicalKeyboardKey.controlRight ||
k == LogicalKeyboardKey.alt ||
k == LogicalKeyboardKey.altLeft ||
k == LogicalKeyboardKey.altRight ||
k == LogicalKeyboardKey.meta ||
k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight ||
k == LogicalKeyboardKey.capsLock ||
k == LogicalKeyboardKey.numLock ||
k == LogicalKeyboardKey.scrollLock) {
return null;
}
final fallback = k.keyLabel.trim();
if (fallback.isEmpty) return null;
return fallback;
}
/// Result of a successful PTT binding capture.
class CapturedBinding {
const CapturedBinding({required this.inputClass, required this.platformKey});
/// Coarse input class, safe to persist and display.
final rust.BridgePttInputClass inputClass;
/// Opaque platform-neutral key label.
final String platformKey;
}
/// 'Save bookmark' name-entry dialog.
class BookmarkNameDialog extends StatefulWidget {
/// Construct a bookmark-name dialog.
const BookmarkNameDialog({super.key, required this.initialName});
/// Initial display name.
final String initialName;
@override
State<BookmarkNameDialog> createState() => _BookmarkNameDialogState();
}
class _BookmarkNameDialogState extends State<BookmarkNameDialog> {
late final TextEditingController _ctl = TextEditingController(
text: widget.initialName,
);
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return AlertDialog(
title: Text(l10n.bookmarkAddTitle),
content: TextField(
controller: _ctl,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctl.text),
child: Text(l10n.bookmarkAddAction),
),
],
);
}
}
/// Channel-password dialog.
class ChannelPasswordDialog extends StatefulWidget {
/// Construct a channel-password dialog.
const ChannelPasswordDialog({super.key});
@override
State<ChannelPasswordDialog> createState() => _ChannelPasswordDialogState();
}
class _ChannelPasswordDialogState extends State<ChannelPasswordDialog> {
final TextEditingController _ctl = TextEditingController();
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return AlertDialog(
title: Text(l10n.channelPasswordTitle),
content: TextField(
controller: _ctl,
obscureText: true,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldPassword),
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctl.text),
child: Text(l10n.connectAction),
),
],
);
}
}
/// Focus-scoped dialog that captures the next key press or mouse
/// side-button click.
class PttBindingCaptureDialog extends StatefulWidget {
/// Construct a PTT binding capture dialog.
const PttBindingCaptureDialog({super.key});
@override
State<PttBindingCaptureDialog> createState() =>
_PttBindingCaptureDialogState();
}
class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
final FocusNode _focusNode = FocusNode();
String? _captured;
rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final label = pttDisplayLabelForKey(event.logicalKey);
if (label == null) return KeyEventResult.ignored;
setState(() {
_captured = label;
_capturedClass = rust.BridgePttInputClass.keyboard;
});
return KeyEventResult.handled;
}
void _captureMouseSideButton(int button) {
setState(() {
_captured = 'mouse-side-button:$button';
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
return AlertDialog(
title: Text(l10n.pttConfigureTitle),
content: SizedBox(
width: 360,
child: Focus(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
autofocus: true,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
const int back = 0x08;
const int forward = 0x10;
if (e.buttons == back || e.buttons == forward) {
_captureMouseSideButton(e.buttons);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttConfigurePrompt,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
_captured == null
? l10n.pttConfigureWaiting
: '${l10n.pttConfigureCaptured}: $_captured',
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
),
),
),
const SizedBox(height: 12),
Text(
l10n.pttConfigurePrivacyNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: _captured == null
? null
: () => Navigator.of(context).pop(
CapturedBinding(
inputClass: _capturedClass,
platformKey: _captured!,
),
),
child: Text(l10n.pttConfigureSaveAction),
),
],
);
}
}
@@ -0,0 +1,665 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
import 'talk_power_warning.dart';
/// Connected-server snapshot with welcome text, channels, and clients.
class SnapshotView extends StatefulWidget {
/// Construct a snapshot view.
const SnapshotView({
super.key,
required this.snapshot,
required this.audioStats,
required this.currentVoiceChannelId,
required this.pendingVoiceChannelId,
required this.localInputMuted,
required this.localOutputMuted,
required this.hasJoinPending,
required this.canJoinVoiceChannel,
required this.onJoinChannel,
required this.onJoinChannelWithPassword,
this.onTs3ServerLink,
});
/// Current bridge snapshot.
final rust.BridgeSnapshot snapshot;
/// Latest audio stats, used for local speaking state.
final rust.BridgeAudioStats? audioStats;
/// Current voice channel id.
final BigInt? currentVoiceChannelId;
/// Pending join target, if any.
final BigInt? pendingVoiceChannelId;
/// Local input mute state.
final bool localInputMuted;
/// Local output mute state.
final bool localOutputMuted;
/// True while a channel join is in flight.
final bool hasJoinPending;
/// True when the local client may join voice channels.
final bool canJoinVoiceChannel;
/// Join an unlocked channel.
final ValueChanged<rust.BridgeChannel> onJoinChannel;
/// Join a password-protected channel.
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
/// Handle TeamSpeak server links embedded in server-provided text.
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
State<SnapshotView> createState() => _SnapshotViewState();
}
class _SnapshotViewState extends State<SnapshotView> {
static const _indentPerLevel = 12.0;
static const _expandColumnWidth = 28.0;
static const _channelIconColumnWidth = 24.0;
static const _channelTextGap = 8.0;
static const _userRowStartIndent = 32.0;
final _scrollController = ScrollController();
final Map<BigInt, bool> _channelExpandedById = {};
bool _welcomeExpanded = true;
double _welcomeHeight = 0;
final _welcomeKey = GlobalKey();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _welcomeKey.currentContext;
if (ctx != null) {
final box = ctx.findRenderObject() as RenderBox?;
if (box != null && mounted) {
setState(() => _welcomeHeight = box.size.height);
}
}
});
}
void _onScroll() {
if (_welcomeExpanded && _scrollController.offset > _welcomeHeight) {
setState(() => _welcomeExpanded = false);
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final tree = _buildChannelTree(widget.snapshot.channels);
final clientsByChannel = <BigInt, List<rust.BridgeClient>>{};
for (final c in widget.snapshot.clients) {
if (!c.isServerQuery) {
clientsByChannel.putIfAbsent(c.channel, () => []).add(c);
}
}
return ListView(
controller: _scrollController,
children: [
Text(
l10n.countChannelsAndClients(
widget.snapshot.channels.length,
widget.snapshot.clients.length,
),
style: theme.textTheme.bodyMedium,
),
if (widget.snapshot.welcomeMessage.isNotEmpty) ...[
const SizedBox(height: 8),
_WelcomeMessageTile(
key: _welcomeKey,
welcomeMessage: widget.snapshot.welcomeMessage,
expanded: _welcomeExpanded,
onToggle: () =>
setState(() => _welcomeExpanded = !_welcomeExpanded),
onTs3ServerLink: widget.onTs3ServerLink,
),
],
const Divider(height: 24),
for (final node in tree.roots)
..._channelTreeRows(theme, node, clientsByChannel, 0),
],
);
}
List<Widget> _channelTreeRows(
ThemeData theme,
_ChannelTreeNode node,
Map<BigInt, List<rust.BridgeClient>> clientsByChannel,
int depth,
) {
final channel = node.channel;
final clients = clientsByChannel[channel.id] ?? const <rust.BridgeClient>[];
final hasVisibleChildren = clients.isNotEmpty || node.children.isNotEmpty;
final expanded = _isChannelExpanded(channel.id);
final channelIndent = (depth.clamp(0, 8)) * _indentPerLevel;
return [
_channelTile(
theme,
channel,
channelIndent: channelIndent,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onToggleExpanded: hasVisibleChildren
? () => _toggleChannelExpanded(channel.id)
: null,
),
if (expanded) ...[
for (final client in clients) _clientTile(theme, client, channelIndent),
for (final child in node.children)
..._channelTreeRows(theme, child, clientsByChannel, depth + 1),
],
];
}
Widget _channelTile(
ThemeData theme,
rust.BridgeChannel channel, {
required double channelIndent,
required bool hasVisibleChildren,
required bool expanded,
required VoidCallback? onToggleExpanded,
}) {
final spacer = parseSpacerChannelName(channel.name);
final onTap =
widget.hasJoinPending ||
!widget.canJoinVoiceChannel ||
channel.id == widget.currentVoiceChannelId
? null
: () => channel.hasPassword
? widget.onJoinChannelWithPassword(channel)
: widget.onJoinChannel(channel);
if (spacer.isSpacer) {
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
const SizedBox(width: _channelTextGap),
Expanded(child: _SpacerChannelContent(spacer: spacer)),
],
),
),
);
}
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
SizedBox(
width: _channelIconColumnWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: _channelTextGap),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
);
}
Widget _clientTile(
ThemeData theme,
rust.BridgeClient client,
double channelIndent,
) {
final status = _clientVoiceStatusIcon(theme, client);
final nameStyle = client.isServerQuery
? TextStyle(color: theme.colorScheme.onSurfaceVariant)
: status.isSpeaking
? TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
)
: null;
final decoration = status.isSpeaking
? BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.55),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.14),
blurRadius: 8,
spreadRadius: 1,
),
],
)
: null;
return Padding(
padding: EdgeInsets.only(
left: channelIndent + _userRowStartIndent,
right: 8,
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: decoration,
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: status.icon,
title: Text(client.name, style: nameStyle),
),
),
);
}
Widget _expandButton(
ThemeData theme, {
required bool hasVisibleChildren,
required bool expanded,
required VoidCallback? onPressed,
}) {
if (!hasVisibleChildren) {
return const SizedBox(
width: _expandColumnWidth,
height: _expandColumnWidth,
);
}
return Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: SizedBox(
width: _expandColumnWidth,
height: _expandColumnWidth,
child: Icon(
expanded ? Icons.expand_more : Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
bool _isChannelExpanded(BigInt channelId) {
return _channelExpandedById[channelId] ?? true;
}
void _toggleChannelExpanded(BigInt channelId) {
setState(() {
_channelExpandedById[channelId] = !_isChannelExpanded(channelId);
});
}
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
ThemeData theme,
rust.BridgeClient client,
) {
final isSelf = client.id == widget.snapshot.ownClientId;
final inCurrentChannel = client.channel == widget.currentVoiceChannelId;
final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted;
final inputMuted = isSelf ? widget.localInputMuted : client.inputMuted;
final neededTalkPower = snapshotNeededTalkPower(
widget.snapshot,
client.channel,
);
final talkPowerBlocked =
isSelf &&
inCurrentChannel &&
isTalkPowerBlocked(
talkPower: client.talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: client.talkPowerGranted,
);
final rawSpeaking = isSelf
? (widget.audioStats?.pttActive ?? false)
: client.isSpeaking;
final transmitAllowed =
!outputMuted &&
!inputMuted &&
(!isSelf || (inCurrentChannel && !talkPowerBlocked));
final speaking = rawSpeaking && transmitAllowed;
final IconData icon;
final Color color;
final String tooltip;
if (outputMuted) {
icon = Icons.volume_off;
color = theme.colorScheme.error;
tooltip = 'Speaker muted';
} else if (inputMuted) {
icon = Icons.mic_off;
color = theme.colorScheme.error;
tooltip = 'Microphone muted';
} else if (talkPowerBlocked) {
icon = Icons.volume_off;
color = theme.colorScheme.error;
tooltip =
'Insufficient talk power (${client.talkPower} < $neededTalkPower)';
} else if (speaking) {
icon = isSelf ? Icons.mic : Icons.volume_up;
color = theme.colorScheme.primary;
tooltip = 'Speaking';
} else if (inCurrentChannel) {
icon = isSelf ? Icons.mic_none : Icons.volume_up_outlined;
color = theme.colorScheme.onSurfaceVariant;
tooltip = 'Not speaking';
} else {
icon = Icons.person_outline;
color = theme.colorScheme.onSurfaceVariant;
tooltip = 'Outside current channel';
}
return (
icon: Tooltip(
message: tooltip,
child: Icon(icon, color: color),
),
isSpeaking: speaking,
);
}
}
class _SpacerChannelContent extends StatelessWidget {
const _SpacerChannelContent({required this.spacer});
final SpacerChannelNameParseResult spacer;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.onSurfaceVariant;
if (spacer.isBlankSpacer) {
return const SizedBox(height: 20);
}
if (spacer.specialType != null) {
return SizedBox(
height: 22,
child: CustomPaint(
painter: _SpacerLinePainter(
color: color.withValues(alpha: 0.72),
type: spacer.specialType!,
),
),
);
}
if (spacer.isRepeating) {
return LayoutBuilder(
builder: (context, constraints) {
final pattern = spacer.text.isEmpty ? ' ' : spacer.text;
final estimatedColumns = (constraints.maxWidth / 8).ceil().clamp(
1,
256,
);
return Text(
channelSpacerLabel(
formatSpacerChannelName(
SpacerChannelNameFormatOptions(
alignment: spacer.alignment,
isRepeating: true,
uniqueSuffix: spacer.uniqueSuffix,
text: pattern,
),
),
repeatColumns: estimatedColumns,
),
maxLines: 1,
overflow: TextOverflow.clip,
softWrap: false,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
);
},
);
}
return Text(
spacer.text,
textAlign: switch (spacer.alignment) {
SpacerAlignment.left => TextAlign.left,
SpacerAlignment.right => TextAlign.right,
SpacerAlignment.center => TextAlign.center,
null => TextAlign.center,
},
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
);
}
}
class _SpacerLinePainter extends CustomPainter {
const _SpacerLinePainter({required this.color, required this.type});
final Color color;
final SpacerSpecialType type;
@override
void paint(Canvas canvas, Size size) {
final y = size.height / 2;
final paint = Paint()
..color = color
..strokeCap = StrokeCap.square
..strokeWidth = 1.4;
switch (type) {
case SpacerSpecialType.solidLine:
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
case SpacerSpecialType.dashLine:
_drawPattern(canvas, size.width, y, paint, const [8, 5]);
case SpacerSpecialType.dotLine:
final dotPaint = Paint()..color = color;
for (var x = 1.5; x < size.width; x += 7) {
canvas.drawCircle(Offset(x, y), 1.5, dotPaint);
}
case SpacerSpecialType.dashDotLine:
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4]);
case SpacerSpecialType.dashDotDotLine:
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4, 2, 4]);
}
}
void _drawPattern(
Canvas canvas,
double width,
double y,
Paint paint,
List<double> pattern,
) {
var x = 0.0;
var index = 0;
while (x < width) {
final length = pattern[index % pattern.length];
if (index.isEven) {
final end = x + length > width ? width : x + length;
canvas.drawLine(Offset(x, y), Offset(end, y), paint);
}
x += length;
index += 1;
}
}
@override
bool shouldRepaint(covariant _SpacerLinePainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.type != type;
}
}
class _ChannelTree {
const _ChannelTree({required this.roots});
final List<_ChannelTreeNode> roots;
}
class _ChannelTreeNode {
_ChannelTreeNode(this.channel);
final rust.BridgeChannel channel;
final List<_ChannelTreeNode> children = [];
}
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
final byParent = <BigInt, List<rust.BridgeChannel>>{};
final knownIds = {for (final channel in channels) channel.id};
for (final channel in channels) {
final parent = knownIds.contains(channel.parent)
? channel.parent
: BigInt.zero;
byParent.putIfAbsent(parent, () => []).add(channel);
}
_ChannelTreeNode buildNode(rust.BridgeChannel channel) {
final node = _ChannelTreeNode(channel);
for (final child in byParent[channel.id] ?? const <rust.BridgeChannel>[]) {
node.children.add(buildNode(child));
}
return node;
}
return _ChannelTree(
roots: [
for (final channel
in byParent[BigInt.zero] ?? const <rust.BridgeChannel>[])
buildNode(channel),
],
);
}
class _WelcomeMessageTile extends StatelessWidget {
const _WelcomeMessageTile({
super.key,
required this.welcomeMessage,
required this.expanded,
required this.onToggle,
this.onTs3ServerLink,
});
final String welcomeMessage;
final bool expanded;
final VoidCallback onToggle;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
HapticFeedback.selectionClick();
onToggle();
},
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Icon(
expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Server welcome message',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
AnimatedCrossFade(
firstChild: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: BbCodeText(
welcomeMessage,
linkTrust: LinkTrustService.instance,
onTs3ServerLink: onTs3ServerLink,
),
),
secondChild: const SizedBox(width: double.infinity),
crossFadeState: expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
],
),
);
}
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
bool isTalkPowerBlocked({
required int? talkPower,
required int? neededTalkPower,
required bool? talkPowerGranted,
}) {
return talkPower != null &&
neededTalkPower != null &&
talkPower < neededTalkPower &&
talkPowerGranted != true;
}
class TalkPowerWarning extends StatelessWidget {
const TalkPowerWarning({
super.key,
required this.talkPower,
required this.neededTalkPower,
required this.talkPowerGranted,
});
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
@override
Widget build(BuildContext context) {
if (!isTalkPowerBlocked(
talkPower: talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: talkPowerGranted,
)) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber, size: 18, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Insufficient talk power ($talkPower < $neededTalkPower)',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
);
}
}
+163 -354
View File
@@ -3,14 +3,14 @@
// `BridgeEvent::VoiceState` stream the bridge publishes from the
// core's transmit-mode selector + release-tail timer.
import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import 'package:haptic_kit/haptic_kit.dart';
import '../l10n/generated/app_localizations.dart';
import 'ptt_capability_badge.dart';
import 'voice_compact.dart';
import 'voice_level_meter.dart';
import 'voice_platform.dart';
import 'voice_status_summary.dart';
import '../src/rust/api.dart' as rust;
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
@@ -30,27 +30,16 @@ class VoiceBar extends StatelessWidget {
required this.pttBackendId,
required this.pttBoundInputClass,
required this.pttBoundKeyLabel,
required this.onToggleMute,
required this.onToggleOutputMute,
required this.onConfigure,
required this.onPttHeldChanged,
this.talkPowerBlocked = false,
});
/// 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;
/// Speaker (output) mute state. Mirrors the server-broadcast
/// `ClientOutputMuted` flag plus the engine's local output
/// silencer — toggling this hushes incoming voice immediately
/// AND tells the server so other clients see the headphone-off
/// icon next to our name.
final bool outputMuted;
final bool talkPowerBlocked;
/// Configured release-tail in milliseconds (0..=500). Surfaced as
/// a hint underneath the mode badge.
@@ -77,12 +66,6 @@ class VoiceBar extends StatelessWidget {
/// Platform-neutral key label captured by the binding dialog.
final String pttBoundKeyLabel;
/// Toggle the hard-mute clamp.
final VoidCallback onToggleMute;
/// Toggle speaker (output) mute.
final VoidCallback onToggleOutputMute;
/// 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
@@ -98,17 +81,6 @@ class VoiceBar extends StatelessWidget {
/// timer handles the trailing tail (SDD-096).
final ValueChanged<bool> onPttHeldChanged;
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;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
@@ -116,344 +88,181 @@ class VoiceBar extends StatelessWidget {
final stats = audioStats;
final levelActive = stats?.pttActive ?? false;
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: transmitMode,
releaseTailMs: releaseTailMs,
pttBoundKeyLabel: pttBoundKeyLabel,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: hardMute,
outputMuted: outputMuted,
pttActive: stats?.pttActive ?? false,
talkPower: talkPowerBlocked ? 0 : null,
neededTalkPower: talkPowerBlocked ? 1 : null,
talkPowerGranted: false,
);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Row 1: channel pill + mute toggle. The pill is
// wrapped in `Flexible` so very long channel names
// truncate with an ellipsis instead of overflowing the
// Voice Bar's column width (320 dp in the wide layout)
// and pushing the mute icons under the adjacent channel
// tree.
Row(
children: [
if (inChannel && channelName.isNotEmpty) ...[
Flexible(
flex: 100,
fit: FlexFit.loose,
child: 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),
Flexible(
child: Text(
channelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
],
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: Container(
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: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Row 1: 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(
summary.modeLabel,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 16),
label: Text(l10n.voiceSettingsTitle),
onPressed: onConfigure,
),
],
const SizedBox(width: 8),
const Spacer(),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
isSelected: outputMuted,
selectedIcon: const Icon(Icons.headset_off),
onPressed: onToggleOutputMute,
),
IconButton(
tooltip: l10n.voiceHardMuteLabel,
icon: Icon(hardMute ? Icons.mic_off : Icons.mic),
isSelected: hardMute,
selectedIcon: const Icon(Icons.mic_off),
onPressed: onToggleMute,
),
// Status row: talk power / mic / speaker state.
if (inChannel) ...[
const SizedBox(height: 2),
Text(
summary.talkPowerBlocked
? 'Insufficient talk power'
: summary.statusText,
style: theme.textTheme.bodySmall?.copyWith(
color: summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
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(
// Row 3: PTT-only secondary content.
//
// On hardware-keyboard hosts (Windows / macOS / Linux /
// Web) this is a one-line bound-key + release-tail hint
// sitting right under the mode badge.
//
// On touch-only hosts (iOS / iPadOS / Android) the
// on-screen Push to Talk button is rendered AT THE
// BOTTOM of the Voice Bar (see below) so it sits
// closest to the user's thumb when the Voice Bar is
// pinned to the bottom of a narrow-layout screen. The
// release-tail value is folded into the small print
// under the button rather than shown here.
if (isPtt && !isTouchOnlyPttHost)
Padding(
padding: const EdgeInsets.only(left: 22, top: 2),
child: Text(
_modeLabel(l10n),
style: theme.textTheme.bodyMedium?.copyWith(
'${l10n.voiceModePtt}: '
'${pttBoundKeyLabel.isEmpty ? "" : pttBoundKeyLabel}'
' · '
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.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 content.
//
// On hardware-keyboard hosts (Windows / macOS / Linux /
// Web) this is a one-line bound-key + release-tail hint
// sitting right under the mode badge.
//
// On touch-only hosts (iOS / iPadOS / Android) the
// on-screen Push to Talk button is rendered AT THE
// BOTTOM of the Voice Bar (see below) so it sits
// closest to the user's thumb when the Voice Bar is
// pinned to the bottom of a narrow-layout screen. The
// release-tail value is folded into the small print
// under the button rather than shown here.
if (isPtt && !isTouchOnlyPttHost)
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 ? l10n.voiceMicOn : l10n.voiceMicOff,
),
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`).
//
// Still shown on touch-only mobile hosts because iOS P0
// acceptance requires an explicit `L0Focused` badge and
// explanation that global hotkeys are not available in
// the iOS sandbox.
if (isPtt)
PttCapabilityBadge(
level: pttLevel,
backendId: pttBackendId,
boundInputClass: pttBoundInputClass,
),
// On touch-only mobile hosts the Push to Talk button is
// the LAST element of the Voice Bar so it lands closest
// to the user's thumb when the Voice Bar is pinned to
// the bottom of a narrow-layout screen. The release-
// tail value sits above the button so the user sees
// how long their voice continues after they let go.
if (isPtt && isTouchOnlyPttHost) ...[
const SizedBox(height: 6),
// Row 4: level meter
VoiceLevelMeter(active: levelActive),
const SizedBox(height: 4),
Center(
child: Text(
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
if (stats != null)
Text(
l10n.audioStatsLine(
stats.framesSent,
stats.framesReceived,
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
),
style: theme.textTheme.bodySmall,
),
),
const SizedBox(height: 8),
_PttHoldButton(
active: levelActive,
onHeldChanged: onPttHeldChanged,
),
],
// Leave-voice button intentionally absent: TeamSpeak's
// model is "user is always in some channel", not
// Discord's join/leave-voice. To stop being heard /
// hearing others, mute mic and/or speaker via the
// icons at the top of the bar. To physically move,
// tap a different channel in the tree below.
],
),
),
);
}
}
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),
),
),
),
);
}
}
/// On-screen push-to-talk button for touch-only mobile platforms
/// (iOS / iPadOS / Android). Hardware-keyboard hosts hide this in
/// favour of a bound key.
///
/// Behaviour:
/// * `onPanDown` (finger touches the button) → fires
/// `onHeldChanged(true)`. The Rust release-tail timer treats
/// this as `key_down`.
/// * `onPanEnd` / `onPanCancel` (finger lifts or drags off) →
/// fires `onHeldChanged(false)` → `key_up` → tail expires →
/// mic closes.
///
/// Using `GestureDetector` rather than `Listener` because we want
/// gesture-arena semantics: if the user starts dragging the
/// channel-tree underneath, the PTT should release. `onPanCancel`
/// fires in that case.
///
/// The button visually mirrors the `_LevelMeter` state via the
/// `active` flag so the user gets feedback that holding actually
/// engaged the mic.
class _PttHoldButton extends StatefulWidget {
const _PttHoldButton({required this.active, required this.onHeldChanged});
final bool active;
final ValueChanged<bool> onHeldChanged;
@override
State<_PttHoldButton> createState() => _PttHoldButtonState();
}
class _PttHoldButtonState extends State<_PttHoldButton> {
bool _pressed = false;
@override
void initState() {
super.initState();
unawaited(Haptics.prepare().catchError((_) => false));
}
void _setHeld(bool held) {
if (_pressed == held) return;
setState(() => _pressed = held);
widget.onHeldChanged(held);
_playPressHaptic(held);
}
void _playPressHaptic(bool held) {
final haptic = held
? Haptics.impact(HapticImpactStyle.medium)
: Haptics.selection();
unawaited(haptic.catchError((_) {}));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeNow = _pressed || widget.active;
final l10n = AppL10n.of(context);
return Semantics(
button: true,
liveRegion: true,
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
hint: l10n.pttHoldToTalkSemanticsHint,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _setHeld(true),
onTapUp: (_) => _setHeld(false),
onTapCancel: () => _setHeld(false),
onPanDown: (_) => _setHeld(true),
onPanEnd: (_) => _setHeld(false),
onPanCancel: () => _setHeld(false),
child: ExcludeSemantics(
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
height: 64,
decoration: BoxDecoration(
color: activeNow
? theme.colorScheme.primary
: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
boxShadow: activeNow
? [
BoxShadow(
color: theme.colorScheme.primary.withAlpha(100),
blurRadius: 12,
offset: const Offset(0, 2),
),
]
: null,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
activeNow ? Icons.mic : Icons.mic_none,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
size: 24,
),
const SizedBox(width: 10),
Text(
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
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`).
//
// Still shown on touch-only mobile hosts because iOS P0
// acceptance requires an explicit `L0Focused` badge and
// explanation that global hotkeys are not available in
// the iOS sandbox.
if (isPtt)
PttCapabilityBadge(
level: pttLevel,
backendId: pttBackendId,
boundInputClass: pttBoundInputClass,
),
// On touch-only mobile hosts the Push to Talk button is
// the LAST element of the Voice Bar so it lands closest
// to the user's thumb when the Voice Bar is pinned to
// the bottom of a narrow-layout screen. The release-
// tail value sits above the button so the user sees
// how long their voice continues after they let go.
if (isPtt && isTouchOnlyPttHost) ...[
const SizedBox(height: 4),
Center(
child: Text(
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
const SizedBox(height: 8),
VoicePttButton(
active: levelActive,
onHeldChanged: onPttHeldChanged,
height: 64,
borderRadius: 12,
iconSize: 24,
iconGap: 10,
blurRadius: 12,
spreadRadius: 0,
listenForPan: true,
labelLetterSpacing: null,
),
],
// Leave-voice button intentionally absent: TeamSpeak's
// model is "user is always in some channel", not
// Discord's join/leave-voice. To stop being heard /
// hearing others, mute mic and/or speaker via the
// icons at the top of the bar. To physically move,
// tap a different channel in the tree below.
],
),
),
),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
import 'dart:async' show unawaited;
import 'package:haptic_kit/haptic_kit.dart';
/// Prepare mobile haptics without surfacing platform failures.
void prepareVoiceHaptics() {
unawaited(Haptics.prepare().catchError((_) => false));
}
/// Play the standard touch PTT haptic without surfacing platform failures.
void playVoicePttHaptic(bool held) {
final haptic = held
? Haptics.impact(HapticImpactStyle.medium)
: Haptics.selection();
unawaited(haptic.catchError((_) {}));
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
/// Shared compact level meter used by voice surfaces.
class VoiceLevelMeter extends StatelessWidget {
const VoiceLevelMeter({super.key, 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),
),
),
),
);
}
}
@@ -7,15 +7,19 @@
// - VAD backend
// - iOS voice processing mode
// ignore_for_file: deprecated_member_use
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import 'audio_device_list_tile.dart';
import 'audio_output_tile.dart';
import 'audio_processing_config_state.dart';
import 'ptt_capability_badge.dart';
import 'talk_power_warning.dart';
import 'voice_platform.dart';
import 'voice_settings_controls.dart';
import '../src/rust/api.dart' as rust;
bool get _isAndroid {
@@ -50,11 +54,23 @@ class VoiceSettingsDialog extends StatefulWidget {
required this.initialMode,
required this.initialReleaseTailMs,
required this.initialAudioConfig,
this.pttLevel = '',
this.pttBackendId = '',
this.pttBoundInputClass = '',
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
});
final rust.BridgeTransmitMode initialMode;
final int initialReleaseTailMs;
final rust.BridgeAudioProcessingConfig initialAudioConfig;
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
@override
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
@@ -64,16 +80,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
late rust.BridgeTransmitMode _mode;
late double _releaseTail;
// Audio processing state — mirrors BridgeAudioProcessingConfig fields.
late bool _nsEnabled;
late bool _aecEnabled;
late bool _agcEnabled;
late bool _hpfEnabled;
late bool _limiterEnabled;
late rust.BridgeVadBackend _vadBackend;
late rust.BridgeIosVoiceProcessingMode _iosMode;
late bool _debugWavDump;
late bool _preferHardware; // Android only: try JNI hardware effects
late final AudioProcessingConfigState _audioProcessing;
@override
void initState() {
@@ -81,93 +88,15 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
_mode = widget.initialMode;
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
final c = widget.initialAudioConfig;
_nsEnabled = c.ns != rust.BridgeEffectOwner.off;
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
_hpfEnabled = c.hpfEnabled;
_limiterEnabled = c.limiterEnabled;
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: c.vadBackend;
_iosMode = c.iosMode;
_debugWavDump = c.debugWavDumpEnabled;
_preferHardware = c.aec == rust.BridgeEffectOwner.platform
|| c.ns == rust.BridgeEffectOwner.platform
|| c.agc == rust.BridgeEffectOwner.platform;
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
}
rust.BridgeAudioProcessingConfig _buildConfig() {
final c = widget.initialAudioConfig;
final isSonora =
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
if (_isAndroid) {
final owner = _preferHardware
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: c.route,
iosMode: _iosMode,
processingBackend: _preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: _vadBackend,
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: _hpfEnabled,
limiterEnabled: _limiterEnabled,
vadHangoverMs: c.vadHangoverMs,
vadPreRollMs: c.vadPreRollMs,
vadMinTxMs: c.vadMinTxMs,
debugWavDumpEnabled: _debugWavDump,
);
}
// iOS / macOS: VPIO vs Sonora paths.
// In VPIO mode, enabled effects are platform-owned. The experimental raw
// path uses WebRTC APM ownership so config validation stays honest.
final aecOwner = isSonora
? (_aecEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
final nsOwner = isSonora
? (_nsEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (_agcEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: _vadBackend;
return rust.BridgeAudioProcessingConfig(
route: c.route,
iosMode: _iosMode,
processingBackend: isSonora
? rust.BridgeAudioBackend.webrtcApm
: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vadBackend,
aec: aecOwner,
ns: nsOwner,
agc: agcOwner,
hpfEnabled: _hpfEnabled,
limiterEnabled: _limiterEnabled,
vadHangoverMs: c.vadHangoverMs,
vadPreRollMs: c.vadPreRollMs,
vadMinTxMs: c.vadMinTxMs,
debugWavDumpEnabled: _debugWavDump,
return _audioProcessing.buildConfig(
base: widget.initialAudioConfig,
isAndroid: _isAndroid,
);
}
@@ -176,7 +105,8 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final platformVpio =
_iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
_audioProcessing.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
return AlertDialog(
title: Text(l10n.voiceSettingsTitle),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
@@ -188,24 +118,12 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Transmit mode ──────────────────────────────────────
_sectionHeader(theme, l10n.voiceModeLabel),
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.ptt,
groupValue: _mode,
title: Text(l10n.voiceModePtt),
onSelected: (v) => _mode = v,
),
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.continuous,
groupValue: _mode,
title: Text(l10n.voiceModeContinuous),
onSelected: (v) => _mode = v,
),
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.voiceActivity,
groupValue: _mode,
title: Text(l10n.voiceModeVoiceActivity),
onSelected: (v) => _mode = v,
VoiceSectionHeader(l10n.voiceModeLabel),
SegmentedButton<rust.BridgeTransmitMode>(
style: voiceSegmentedButtonStyle(theme),
segments: transmitModeSegments,
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
// ── PTT options ────────────────────────────────────────
@@ -257,133 +175,148 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
// ── Audio processing ───────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Audio processing'),
const VoiceSectionHeader('Audio processing'),
// iOS mode selector (iOS only)
if (_isIos) ...[
_subHeader(theme, 'Processing backend'),
_radioTile<rust.BridgeIosVoiceProcessingMode>(
value:
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
groupValue: _iosMode,
title: const Text('Platform (VPIO)'),
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
onSelected: (v) => _iosMode = v,
),
_radioTile<rust.BridgeIosVoiceProcessingMode>(
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
groupValue: _iosMode,
title: const Text('Sonora (experimental)'),
subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
onSelected: (v) => _iosMode = v,
const VoiceSubHeader('Processing backend'),
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
style: voiceSegmentedButtonStyle(theme),
segments: iosProcessingSegments,
selected: {_audioProcessing.iosMode},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.iosMode = s.first),
),
const SizedBox(height: 4),
],
// Android HW/SW selector
if (_isAndroid) ...[
_subHeader(theme, 'Processing backend'),
_radioTile<bool>(
value: true,
groupValue: _preferHardware,
title: const Text('Platform (auto)'),
subtitle: _tileSubtitle(
'Try hardware JNI effects · software fallback',
),
onSelected: (v) => setState(() => _preferHardware = v),
),
_radioTile<bool>(
value: false,
groupValue: _preferHardware,
title: const Text('WebRTC APM'),
subtitle: _tileSubtitle(
'Software AEC3 · NS · AGC2',
),
onSelected: (v) => setState(() => _preferHardware = v),
const VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.preferHardware = s.first),
),
const SizedBox(height: 4),
],
// DSP toggles
_subHeader(theme, 'DSP stages'),
_switchTile(
title: 'Noise suppression (NS)',
const VoiceSubHeader('DSP stages'),
AudioProcessingToggleRow(
label: 'Noise suppression (NS)',
subtitle: 'Wiener filter · stationary noise',
value: _nsEnabled,
onSelected: (v) => _nsEnabled = v,
value: _audioProcessing.nsEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.nsEnabled = v),
),
_switchTile(
title: 'Echo cancellation (AEC3)',
AudioProcessingToggleRow(
label: 'Echo cancellation (AEC3)',
subtitle: _isAndroid
? 'WebRTC AEC3 · adaptive filter'
: platformVpio
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
value: _aecEnabled,
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
value: _audioProcessing.aecEnabled,
// AEC is always on in VPIO mode — disable the toggle.
onSelected: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null,
onChanged: (_isAndroid || !platformVpio)
? (v) => setState(() => _audioProcessing.aecEnabled = v)
: null,
),
_switchTile(
title: 'Auto gain control (AGC2)',
AudioProcessingToggleRow(
label: 'Auto gain control (AGC2)',
subtitle: 'RNN VAD-gated · 18 dBFS target',
value: _agcEnabled,
onSelected: (v) => _agcEnabled = v,
value: _audioProcessing.agcEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.agcEnabled = v),
),
_switchTile(
title: 'High-pass filter (HPF)',
AudioProcessingToggleRow(
label: 'High-pass filter (HPF)',
subtitle: '80 Hz Butterworth · DC removal',
value: _hpfEnabled,
onSelected: (v) => _hpfEnabled = v,
value: _audioProcessing.hpfEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.hpfEnabled = v),
),
_switchTile(
title: 'Peak limiter',
AudioProcessingToggleRow(
label: 'Peak limiter',
subtitle: '1 dBFS soft-knee · 2 ms look-ahead',
value: _limiterEnabled,
onSelected: (v) => _limiterEnabled = v,
value: _audioProcessing.limiterEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.limiterEnabled = v),
),
if (isTalkPowerBlocked(
talkPower: widget.talkPower,
neededTalkPower: widget.neededTalkPower,
talkPowerGranted: widget.talkPowerGranted,
)) ...[
const SizedBox(height: 8),
TalkPowerWarning(
talkPower: widget.talkPower,
neededTalkPower: widget.neededTalkPower,
talkPowerGranted: widget.talkPowerGranted,
),
],
// ── VAD ────────────────────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Voice activity detection (VAD)'),
const VoiceSectionHeader('Voice activity detection (VAD)'),
_subHeader(theme, 'Backend'),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.webrtcVad,
groupValue: _vadBackend,
title: const Text('WebRTC VAD'),
subtitle: _tileSubtitle(
'Fast · energy-based · always available',
),
onSelected: (v) => _vadBackend = v,
),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.sileroOnnx,
groupValue: _vadBackend,
title: const Text('Silero v6 (ONNX)'),
subtitle: _tileSubtitle(
'Neural · 32 ms frames · requires model file',
),
onSelected: (v) => _vadBackend = v,
),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.tenVad,
groupValue: _vadBackend,
title: const Text('TEN VAD'),
subtitle: _tileSubtitle(
'Neural · 16 kHz · native runtime optional',
),
onSelected: (v) => _vadBackend = v,
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.vadBackend = s.first),
),
const SizedBox(height: 8),
// ── PTT capability badge ────────────────────────────────
if (_mode == rust.BridgeTransmitMode.ptt &&
widget.pttLevel.isNotEmpty) ...[
const Divider(height: 24),
const VoiceSectionHeader('PTT capability'),
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
],
// ── Audio output route picker (mobile only) ─────────────
if (_isAndroid || _isIos) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio output'),
const AudioOutputTile(),
],
// ── Audio devices (desktop only, SRS-026) ──────────────
if (!_isAndroid && !_isIos) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio devices'),
const AudioDeviceListTile(
label: 'Input',
kind: AudioDeviceKind.input,
),
const AudioDeviceListTile(
label: 'Output',
kind: AudioDeviceKind.output,
),
const SizedBox(height: 8),
],
// ── Debug ──────────────────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Debug'),
_switchTile(
title: 'WAV dump',
const VoiceSectionHeader('Debug'),
AudioProcessingToggleRow(
label: 'WAV dump',
subtitle: 'Record raw/processed mic to temp dir',
value: _debugWavDump,
onSelected: (v) => _debugWavDump = v,
value: _audioProcessing.debugWavDump,
onChanged: (v) =>
setState(() => _audioProcessing.debugWavDump = v),
),
const SizedBox(height: 8),
],
@@ -409,54 +342,4 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
],
);
}
Widget _radioTile<T>({
required T value,
required T groupValue,
required Widget title,
Widget? subtitle,
required ValueChanged<T> onSelected,
}) => RadioListTile<T>(
dense: true,
value: value,
groupValue: groupValue,
title: title,
subtitle: subtitle,
onChanged: (v) {
if (v == null) return;
setState(() => onSelected(v));
},
);
Widget _switchTile({
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool>? onSelected,
}) => SwitchListTile(
dense: true,
title: Text(title),
subtitle: _tileSubtitle(subtitle),
value: value,
onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
);
Widget _tileSubtitle(String text) =>
Text(text, style: const TextStyle(fontSize: 11));
Widget _sectionHeader(ThemeData theme, String text) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(text, style: theme.textTheme.titleSmall),
);
Widget _subHeader(ThemeData theme, String text) => Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
letterSpacing: 0.5,
),
),
);
}
@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import '../src/rust/api.dart' as rust;
/// Shared compact style for voice settings segmented buttons.
ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) {
return SegmentedButton.styleFrom(
textStyle: theme.textTheme.labelSmall,
visualDensity: VisualDensity.compact,
);
}
/// Transmit mode selector segments.
const transmitModeSegments = [
ButtonSegment(
value: rust.BridgeTransmitMode.ptt,
label: Text('PTT'),
icon: Icon(Icons.radio_button_checked, size: 14),
),
ButtonSegment(
value: rust.BridgeTransmitMode.continuous,
label: Text('Always'),
icon: Icon(Icons.podcasts, size: 14),
),
ButtonSegment(
value: rust.BridgeTransmitMode.voiceActivity,
label: Text('VAD'),
icon: Icon(Icons.graphic_eq, size: 14),
),
];
/// Android hardware/WebRTC selector segments.
const androidProcessingSegments = [
ButtonSegment(
value: true,
label: Text('Hardware'),
icon: Icon(Icons.phone_android, size: 14),
),
ButtonSegment(
value: false,
label: Text('WebRTC'),
icon: Icon(Icons.science_outlined, size: 14),
),
];
/// iOS VPIO/Sonora selector segments.
const iosProcessingSegments = [
ButtonSegment(
value: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
label: Text('VPIO'),
icon: Icon(Icons.phone_iphone, size: 14),
),
ButtonSegment(
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
label: Text('Sonora'),
icon: Icon(Icons.science_outlined, size: 14),
),
];
/// Voice activity detector selector segments.
const vadBackendSegments = [
ButtonSegment(
value: rust.BridgeVadBackend.webrtcVad,
label: Text('WebRTC'),
icon: Icon(Icons.speed, size: 14),
),
ButtonSegment(
value: rust.BridgeVadBackend.sileroOnnx,
label: Text('Silero'),
icon: Icon(Icons.psychology, size: 14),
),
ButtonSegment(
value: rust.BridgeVadBackend.tenVad,
label: Text('TEN'),
icon: Icon(Icons.graphic_eq, size: 14),
),
];
/// Section subheader used by both voice settings surfaces.
class VoiceSubHeader extends StatelessWidget {
/// Construct a voice settings subheader.
const VoiceSubHeader(this.text, {super.key});
/// Header text.
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
letterSpacing: 0.5,
),
),
);
}
}
/// Section header used by the full voice settings dialog.
class VoiceSectionHeader extends StatelessWidget {
/// Construct a voice settings section header.
const VoiceSectionHeader(this.text, {super.key});
/// Header text.
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(text, style: theme.textTheme.titleSmall),
);
}
}
/// Shared audio-processing switch row.
class AudioProcessingToggleRow extends StatelessWidget {
/// Construct an audio-processing switch row.
const AudioProcessingToggleRow({
super.key,
required this.label,
required this.subtitle,
required this.value,
required this.onChanged,
this.dense = false,
});
/// Primary row label.
final String label;
/// Secondary row detail.
final String subtitle;
/// Current switch value.
final bool value;
/// Called when the switch changes. Null disables the row.
final ValueChanged<bool>? onChanged;
/// Use the compact inline row layout used by the mobile sheet.
final bool dense;
@override
Widget build(BuildContext context) {
if (dense) return _buildDense(context);
return SwitchListTile(
dense: true,
title: Text(label),
subtitle: Text(subtitle, style: const TextStyle(fontSize: 11)),
value: value,
onChanged: onChanged,
);
}
Widget _buildDense(BuildContext context) {
final theme = Theme.of(context);
final disabled = onChanged == null;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: disabled
? theme.colorScheme.onSurfaceVariant.withAlpha(120)
: null,
),
),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withAlpha(
disabled ? 80 : 160,
),
),
),
],
),
),
Switch(value: value, onChanged: onChanged),
],
),
);
}
}
@@ -0,0 +1,89 @@
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
import 'talk_power_warning.dart';
class VoiceStatusSummary {
const VoiceStatusSummary({
required this.modeLabel,
required this.line1,
required this.line2,
required this.statusText,
required this.micOn,
required this.talkPowerBlocked,
required this.muted,
});
final String modeLabel;
final String line1;
final String line2;
final String statusText;
final bool micOn;
final bool talkPowerBlocked;
final bool muted;
}
String voiceModeLabel(AppL10n l10n, rust.BridgeTransmitMode transmitMode) {
switch (transmitMode) {
case rust.BridgeTransmitMode.ptt:
return l10n.voiceModePtt;
case rust.BridgeTransmitMode.continuous:
return l10n.voiceModeContinuous;
case rust.BridgeTransmitMode.voiceActivity:
return l10n.voiceModeVoiceActivity;
}
}
VoiceStatusSummary voiceStatusSummary({
required AppL10n l10n,
required rust.BridgeTransmitMode transmitMode,
required int releaseTailMs,
required String pttBoundKeyLabel,
required bool isTouchOnly,
required bool inputMuted,
required bool outputMuted,
required bool pttActive,
int? talkPower,
int? neededTalkPower,
bool? talkPowerGranted,
}) {
final modeLabel = voiceModeLabel(l10n, transmitMode);
final talkPowerBlocked = isTalkPowerBlocked(
talkPower: talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: talkPowerGranted,
);
final micOn = inputMuted || outputMuted || talkPowerBlocked
? false
: switch (transmitMode) {
rust.BridgeTransmitMode.continuous => true,
_ => pttActive,
};
final line1 = transmitMode == rust.BridgeTransmitMode.ptt
? isTouchOnly
? '$modeLabel \u00b7 ${l10n.voicePttHoldHint}'
: '$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'
: modeLabel;
final tailText = transmitMode == rust.BridgeTransmitMode.ptt
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
: null;
final statusText = talkPowerBlocked
? 'Insufficient permission'
: inputMuted
? '${l10n.voiceMicOff} (muted)'
: outputMuted
? 'Speaker muted'
: micOn
? l10n.voiceMicOn
: l10n.voiceMicOff;
return VoiceStatusSummary(
modeLabel: modeLabel,
line1: line1,
line2: tailText == null ? statusText : '$tailText \u00b7 $statusText',
statusText: statusText,
micOn: micOn,
talkPowerBlocked: talkPowerBlocked,
muted: inputMuted || outputMuted,
);
}