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
@@ -69,8 +69,8 @@ android {
// Per-ABI delivery is handled by the AAB bundle splits below
// (SDD-109 item 2) rather than a fat APK.
ndk {
// DEC-032 RESOLVED (2026-05-18): canonical three-ABI set
// restored after the audiopus_sys + cmake-rs + NDK toolchain-file
// DEC-032 RESOLVED (2026-05-18): canonical two-ABI set
// (arm64-v8a + x86_64) restored after the audiopus_sys
// ANDROID_ABI propagation gap was closed by the workspace
// [patch.crates-io] override pinning `cmake` to the fork
// carrying cmake-rs PR #257 (forwards ANDROID_ABI /
@@ -138,9 +138,10 @@ android {
}
// SDD-109 item 2: Android App Bundle (.aab) split configuration.
// Combined with the SDD-073 abiFilters set, this yields three
// native splits (arm64-v8a, armeabi-v7a, x86_64), per-language
// resource delivery, and per-density resource delivery.
// Combined with the SDD-073 abiFilters set, this yields two
// native splits (arm64-v8a, x86_64), per-language resource
// delivery, and per-density resource delivery. Each device
// receives only its matching ABI .so — no cross-ABI bundling.
bundle {
language {
enableSplit = true
@@ -221,7 +222,6 @@ val chanoraAndroidMinSdk: Provider<String> =
// for the per-ABI copy and for the inputs/outputs declaration.
val abiToRustTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86_64" to "x86_64-linux-android",
)
@@ -278,7 +278,7 @@ val checkRustBridgeToolchain = tasks.register("checkRustBridgeToolchain") {
if (missing.isNotEmpty()) {
throw GradleException(
"[SDD-118] Missing Android Rust target(s): ${missing.joinToString(", ")}. " +
"Install: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android"
"Install: rustup target add aarch64-linux-android x86_64-linux-android"
)
}
}
@@ -312,6 +312,15 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
.takeUnless { it.isNullOrBlank() }
?: android.ndkDirectory.absolutePath
val cmakeToolchainFile = "$effectiveNdkPath/build/cmake/android.toolchain.cmake"
val homeDir = System.getenv("HOME") ?: System.getProperty("user.home")
val rustupBinDir = listOf(
"$homeDir/.cargo/bin",
"/opt/homebrew/opt/rustup/bin",
"/usr/local/opt/rustup/bin"
).firstOrNull { File(it).resolve("cargo").exists() }
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
@@ -361,15 +370,18 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
// Any CARGO_TARGET_* env vars set by an outer build wrapper
val cleanEnv = buildMap<String, String> {
// Pass-through from the calling environment, only the safe set.
System.getenv("PATH")?.let { put("PATH", it) }
System.getenv("HOME")?.let { put("HOME", it) }
System.getenv("PATH")?.let { path ->
put("PATH", listOfNotNull(rustupBinDir, path).joinToString(":"))
}
put("HOME", homeDir)
System.getenv("USER")?.let { put("USER", it) }
System.getenv("TMPDIR")?.let { put("TMPDIR", it) }
System.getenv("TEMP")?.let { put("TEMP", it) }
System.getenv("LANG")?.let { put("LANG", it) }
System.getenv("LC_ALL")?.let { put("LC_ALL", it) }
System.getenv("CARGO_HOME")?.let { put("CARGO_HOME", it) }
System.getenv("RUSTUP_HOME")?.let { put("RUSTUP_HOME", it) }
put("CARGO_HOME", cargoHome)
put("RUSTUP_HOME", rustupHome)
put("RUSTUP_TOOLCHAIN", rustupToolchain)
System.getenv("JAVA_HOME")?.let { put("JAVA_HOME", it) }
// Per-task explicit settings (override any pass-through).
// SDD-118 item 5: audiopus_sys needs libopus built from source
@@ -444,7 +456,6 @@ val buildRustBridgeRelease = registerCargoNdkBuildTask("release")
// $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/.
val abiToNdkSysrootTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "arm-linux-androideabi",
"x86_64" to "x86_64-linux-android",
)
fun registerJniLibsCopyTask(profile: String): TaskProvider<Copy> {
+10 -13
View File
@@ -11,8 +11,8 @@
# the app module these are looked up by signature from C/Rust.
# 3. The Android voice foreground service (SDD-107) referenced from
# the manifest by FQN and from JNI via static start/stop helpers.
# 4. The Android audio mode controller (SDD-108) referenced from
# JNI via GlobalRef.
# 4. The Android audio mode controller (SDD-108) audio mode is
# managed in Rust via direct JNI (engine.rs), not a Kotlin class.
# 5. Kotlin metadata required by FRB-generated bindings (SDD-079).
# --- 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105) -----------
@@ -46,21 +46,18 @@
}
# --- 3. AndroidVoiceForegroundService (SDD-107) ------------------------------
# The service is referenced by FQN from AndroidManifest.xml and by JNI from
# the Rust audio engine (start/stop helpers per SDD-107 item 10). Keep the
# class and its public/static members.
-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; }
# Covered by the JNI-surface keep block above.
# --- 4. AndroidAudioModeController (SDD-108) ---------------------------------
# Referenced from Rust via GlobalRef + static method invocation.
# Wave 2B-2 owns the class; keep rule is forward-looking but harmless if
# the class does not yet exist (R8 ignores keep rules for missing classes).
-keep class app.chanora.chanora_flutter.AndroidAudioModeController { *; }
# --- 4. Android Audio Mode Controller (SDD-108) -------------------------
# SDD-108 in-call audio mode (AudioManager.setMode) is implemented in the
# Rust audio engine (crates/chanora_audio/src/engine.rs) via direct JNI,
# not through a Kotlin controller class. No keep rule is needed; the
# engine.rs android_set_audio_mode / android_get_audio_mode helpers
# resolve AudioManager at runtime via ndk_context.
# --- 5. Kotlin metadata + serialisation (SDD-079) ----------------------------
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
-keep class kotlin.Metadata { *; }
# Defer to Flutter's own keep rules for the embedding layer; the Flutter
# Gradle plugin contributes those automatically. If a future SDD revision
# disables R8 entirely, this file can be reduced to a single TODO comment.
# Gradle plugin contributes those automatically.
@@ -5,6 +5,18 @@
protocol layer to dial a TeamSpeak-compatible server over UDP. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- SRS-100: Network diagnostics. Allows the core to query active
network type and state for the diagnostic export. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- SDD-115: Prevent CPU sleep during active voice sessions.
Keeps the audio pipeline running when the screen locks. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- SRS-025: Haptic feedback on PTT key press. Enables
VibrationEffect for transmit-mode tactile confirmation. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- SDD-trace: SDD-106 AndroidPermissionRequester.
Required for the audio engine (chanora_audio) to open the
capture stream for voice transmission. The runtime grant
@@ -56,7 +68,8 @@
<application
android:label="Chanora"
android:name="app.chanora.chanora_flutter.ChanoraApplication"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -324,13 +324,11 @@ class AndroidPermissionRequester {
callback: ((PermissionState) -> Unit)?,
) {
callback?.invoke(state)
// The stateChangeListener is wired by MainActivity to invoke
// METHOD_PERMISSION_STATE_CHANGED on ANDROID_PERMISSIONS, which
// publishes BridgeEvent::PermissionState to the bridge event
// stream. See MainActivity.configureFlutterEngine (SDD-106 §5).
stateChangeListener?.invoke(permission, state)
// TODO(Wave 2B follow-up): wire this into a Flutter MethodChannel
// named MethodChannels.ANDROID_PERMISSIONS, invoking method
// MethodChannels.METHOD_PERMISSION_STATE_CHANGED. The Dart
// handler then publishes the corresponding
// BridgeEvent::PermissionState onto the bridge event stream
// (SDD-106 §5).
}
/**
@@ -71,24 +71,9 @@ class AndroidVoiceForegroundService : Service() {
*/
private const val CHANNEL_ID = "chanora.voice.session"
/**
* Notification channel user-visible name.
*
* TODO(localization): SDD-107 §4 requires this to be sourced
* from a product string resource. Hard-coded here for the
* Wave 2B implementation slice; localised strings land
* alongside the broader Android string-resource pass.
*/
private const val CHANNEL_NAME = "Voice session"
/**
* Notification channel description (product copy only, no
* server-supplied content per SDD-107 §5 privacy note).
*
* TODO(localization): see [CHANNEL_NAME].
*/
private const val CHANNEL_DESCRIPTION =
"Shown while a Chanora voice session is active."
// SDD-107 §4: notification strings are sourced from
// res/values/strings.xml and accessed via the service context
// in instance methods below (ensureChannel, buildNotification).
/**
* Stable notification id ("CHAN") per SDD-107 §5. Must remain
@@ -298,10 +283,10 @@ class AndroidVoiceForegroundService : Service() {
if (existing != null) return
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
getString(R.string.voice_session_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = CHANNEL_DESCRIPTION
description = getString(R.string.voice_session_channel_description)
setShowBadge(false)
}
nm.createNotificationChannel(channel)
@@ -329,14 +314,9 @@ class AndroidVoiceForegroundService : Service() {
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_speakerphone)
// TODO(SDD-107 §5): replace stat_sys_speakerphone with the
// product icon `ic_chanora_voice` once the drawable lands
// in res/drawable. Using a platform-provided icon as the
// interim placeholder keeps the build green without
// touching res/* (out of this slice's file set).
.setContentTitle("Chanora — Voice session active")
.setContentText("Microphone may be in use.")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.voice_session_notification_title))
.setContentText(getString(R.string.voice_session_notification_text))
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_LOW)
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SDD-107 §4: Chinese (Simplified) localized notification strings.
Product-owned copy; no server-supplied content per SDD-107 §5. -->
<string name="voice_session_channel_name">语音会话</string>
<string name="voice_session_channel_description">Chanora 语音会话处于活动状态时显示。</string>
<string name="voice_session_notification_title">Chanora — 语音会话进行中</string>
<string name="voice_session_notification_text">麦克风可能正在使用中。</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SDD-107 §4: Voice session foreground service notification strings.
These are product-owned strings; server-supplied content is never
placed in notification copy per SDD-107 §5 privacy note. -->
<string name="voice_session_channel_name">Voice session</string>
<string name="voice_session_channel_description">Shown while a Chanora voice session is active.</string>
<string name="voice_session_notification_title">Chanora — Voice session active</string>
<string name="voice_session_notification_text">Microphone may be in use.</string>
</resources>
@@ -0,0 +1,185 @@
// SPDX-License-Identifier: Apache-2.0
// Chanora design tokens for connection, voice, and latency states.
// Maps abstract server/voice/network state to Material 3 colour, icon,
// and label affordances.
import 'package:flutter/material.dart';
/// Tokenised visual mapping for a connection state.
class ConnectionTokens {
final Color color;
final Color? background;
final IconData icon;
final String label;
const ConnectionTokens({
required this.color,
required this.icon,
required this.label,
this.background,
});
static ConnectionTokens disconnected(ColorScheme cs) => ConnectionTokens(
color: cs.onSurfaceVariant,
background: cs.surfaceContainerHighest,
icon: Icons.cloud_off,
label: 'Disconnected',
);
static ConnectionTokens connecting(ColorScheme cs) => ConnectionTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.sync,
label: 'Connecting',
);
static ConnectionTokens synchronizing(ColorScheme cs) => ConnectionTokens(
color: cs.secondary,
background: cs.secondaryContainer,
icon: Icons.hourglass_top,
label: 'Synchronizing',
);
static ConnectionTokens connected(ColorScheme cs) => ConnectionTokens(
color: cs.tertiary,
background: cs.tertiaryContainer,
icon: Icons.cloud_done,
label: 'Connected',
);
static ConnectionTokens reconnecting(ColorScheme cs) => ConnectionTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.restart_alt,
label: 'Reconnecting',
);
static ConnectionTokens lost(ColorScheme cs) => ConnectionTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.warning_amber,
label: 'Connection Lost',
);
}
/// Tokenised visual mapping for a voice transmit/mute state.
class VoiceTokens {
final Color color;
final Color? background;
final IconData icon;
const VoiceTokens({
required this.color,
required this.icon,
this.background,
});
static VoiceTokens idle(ColorScheme cs) => VoiceTokens(
color: cs.onSurfaceVariant,
icon: Icons.mic_none,
);
static VoiceTokens pttHeld(ColorScheme cs) => VoiceTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.mic,
);
static VoiceTokens voiceActivity(ColorScheme cs) => VoiceTokens(
color: cs.tertiary,
background: cs.tertiaryContainer,
icon: Icons.mic,
);
static VoiceTokens continuousTx(ColorScheme cs) => VoiceTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.settings_voice,
);
static VoiceTokens muted(ColorScheme cs) => VoiceTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.mic_off,
);
static VoiceTokens outputMuted(ColorScheme cs) => VoiceTokens(
color: cs.error,
icon: Icons.headset_off,
);
static VoiceTokens deafened(ColorScheme cs) => VoiceTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.hearing_disabled,
);
}
/// Tokenised visual mapping for network latency/quality.
class LatencyTokens {
final Color color;
final String label;
const LatencyTokens({required this.color, required this.label});
static LatencyTokens good(ColorScheme cs) => LatencyTokens(
color: cs.tertiary,
label: 'Good',
);
static LatencyTokens warning(ColorScheme cs) => LatencyTokens(
color: cs.error,
label: 'Warning',
);
static LatencyTokens unknown(ColorScheme cs) => LatencyTokens(
color: cs.outlineVariant,
label: '',
);
}
/// Tokens for channel membership status.
class ChannelTokens {
final IconData? icon;
final String? label;
const ChannelTokens({this.icon, this.label});
static ChannelTokens inChannel(ColorScheme cs) => ChannelTokens(
icon: Icons.headset_mic,
label: 'Connected',
);
static ChannelTokens joining(ColorScheme cs) => ChannelTokens(
icon: Icons.login,
label: 'Joining...',
);
static ChannelTokens notInChannel(ColorScheme cs) => ChannelTokens(
icon: null,
label: null,
);
}
/// Tokens for PTT capability display.
class PttTokens {
final String label;
final Color? color;
const PttTokens({required this.label, this.color});
static PttTokens focused(ColorScheme cs) => PttTokens(
label: 'Keyboard PTT',
color: cs.tertiary,
);
static PttTokens platformHook(ColorScheme cs) => PttTokens(
label: 'Global PTT',
color: cs.primary,
);
static PttTokens none(ColorScheme cs) => PttTokens(
label: 'No PTT input',
color: cs.error,
);
}
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: Apache-2.0
// SRS-115: Unsupported/degraded platform behaviours documentation.
// Describes what is available, degraded, or unavailable per platform.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
/// Describes capability tier for a feature on a platform.
enum CapabilityTier { supported, degraded, unavailable }
/// A single platform capability description.
class PlatformCapability {
final String feature;
final String description;
final CapabilityTier tier;
const PlatformCapability({
required this.feature,
required this.description,
required this.tier,
});
}
/// Returns the current platform's capability matrix.
List<PlatformCapability> currentPlatformCapabilities() {
if (kIsWeb) {
return [
PlatformCapability(
feature: 'Audio',
description: 'Web audio is not supported in the beta.',
tier: CapabilityTier.unavailable,
),
];
}
if (Platform.isAndroid) {
return _androidCapabilities();
}
if (Platform.isIOS) {
return _iosCapabilities();
}
if (Platform.isMacOS) {
return _macosCapabilities();
}
if (Platform.isWindows) {
return _windowsCapabilities();
}
if (Platform.isLinux) {
return _linuxCapabilities();
}
return [];
}
List<PlatformCapability> _androidCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'Oboe AAudio backend; Bluetooth SCO supported.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Foreground service',
description: 'Persistent notification during active connection.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on Android.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System-managed; wired/BT/SCO automatic routing.',
tier: CapabilityTier.degraded,
),
const PlatformCapability(
feature: 'Push-to-talk button',
description: 'Media button / volume-key binding supported.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _iosCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'VoiceProcessingIO AudioUnit (default); Sonora raw mode optional.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'Platform AEC/NS/AGC via VPIO; optional Rust AEC3/NS/AGC2 via Sonora.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio route switching',
description: 'Handles speaker/earpiece/Bluetooth route changes and interruptions.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on iOS.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Background audio',
description: 'Supported via AVAudioSession background mode.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _macosCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'cpal device enumeration; VoiceProcessingIO optional.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global key-binding API.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'macOS Keychain.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System audio output route picker.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _windowsCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'WASAPI via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global hotkey binding.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Windows Credential Manager / DPAPI.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Installer',
description: 'MSIX packaging not yet available in beta.',
tier: CapabilityTier.unavailable,
),
];
List<PlatformCapability> _linuxCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'PulseAudio/ALSA via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2; no platform VPIO.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via X11/Wayland global key-binding. ',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Secret Service / libsecret.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Desktop environment',
description: 'DE-specific behaviour: screen locker may suspend audio.',
tier: CapabilityTier.degraded,
),
];
+8 -13
View File
@@ -181,24 +181,19 @@
"audioRouteChangeFailed": "Could not change audio output.",
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
"iosAudioResuming": "Audio resuming",
"settingsAction": "Settings",
"appSettingsTitle": "Settings",
"pokeAlertsTitle": "Poke alerts",
"pokeAlertsDescription": "Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.",
"pokeDialogTitle": "You were poked",
"pokeDialogCloseAction": "Close",
"pokeDialogIncomingNoMessage": "{time} {sender} pokes you",
"@pokeDialogIncomingNoMessage": {
"pokeSnackBarClearAction": "Clear",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
"@pokeSnackBarIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeDialogIncomingWithMessage": "{time} {sender} pokes you with a message",
"@pokeDialogIncomingWithMessage": {
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
"@pokeSnackBarIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
"sender": { "type": "String" },
"message": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
+8 -13
View File
@@ -135,24 +135,19 @@
"audioRouteChangeFailed": "无法切换音频输出。",
"iosAudioInterrupted": "系统已中断音频(电话通话)",
"iosAudioResuming": "音频正在恢复",
"settingsAction": "设置",
"appSettingsTitle": "设置",
"pokeAlertsTitle": "戳一戳提醒",
"pokeAlertsDescription": "收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。",
"pokeDialogTitle": "你被戳了一下",
"pokeDialogCloseAction": "关闭",
"pokeDialogIncomingNoMessage": "{time} {sender} 戳了你一下",
"@pokeDialogIncomingNoMessage": {
"pokeSnackBarClearAction": "清除",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
"@pokeSnackBarIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeDialogIncomingWithMessage": "{time} {sender} 戳了你一下并附带消息",
"@pokeDialogIncomingWithMessage": {
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下{message}",
"@pokeSnackBarIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
"sender": { "type": "String" },
"message": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
@@ -823,53 +823,29 @@ abstract class AppL10n {
/// **'Audio resuming'**
String get iosAudioResuming;
/// No description provided for @settingsAction.
/// No description provided for @pokeSnackBarClearAction.
///
/// In en, this message translates to:
/// **'Settings'**
String get settingsAction;
/// **'Clear'**
String get pokeSnackBarClearAction;
/// No description provided for @appSettingsTitle.
/// No description provided for @pokeSnackBarMoreIndicator.
///
/// In en, this message translates to:
/// **'Settings'**
String get appSettingsTitle;
/// **'...'**
String get pokeSnackBarMoreIndicator;
/// No description provided for @pokeAlertsTitle.
/// No description provided for @pokeSnackBarIncomingNoMessage.
///
/// In en, this message translates to:
/// **'Poke alerts'**
String get pokeAlertsTitle;
/// **'{sender} pokes you'**
String pokeSnackBarIncomingNoMessage(String sender);
/// No description provided for @pokeAlertsDescription.
/// No description provided for @pokeSnackBarIncomingWithMessage.
///
/// In en, this message translates to:
/// **'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.'**
String get pokeAlertsDescription;
/// No description provided for @pokeDialogTitle.
///
/// In en, this message translates to:
/// **'You were poked'**
String get pokeDialogTitle;
/// No description provided for @pokeDialogCloseAction.
///
/// In en, this message translates to:
/// **'Close'**
String get pokeDialogCloseAction;
/// No description provided for @pokeDialogIncomingNoMessage.
///
/// In en, this message translates to:
/// **'{time} {sender} pokes you'**
String pokeDialogIncomingNoMessage(String time, String sender);
/// No description provided for @pokeDialogIncomingWithMessage.
///
/// In en, this message translates to:
/// **'{time} {sender} pokes you with a message'**
String pokeDialogIncomingWithMessage(String time, String sender);
/// **'{sender} pokes you: {message}'**
String pokeSnackBarIncomingWithMessage(String sender, String message);
/// No description provided for @pokeHistorySelfNoMessage.
///
@@ -416,32 +416,19 @@ class AppL10nEn extends AppL10n {
String get iosAudioResuming => 'Audio resuming';
@override
String get settingsAction => 'Settings';
String get pokeSnackBarClearAction => 'Clear';
@override
String get appSettingsTitle => 'Settings';
String get pokeSnackBarMoreIndicator => '...';
@override
String get pokeAlertsTitle => 'Poke alerts';
@override
String get pokeAlertsDescription =>
'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.';
@override
String get pokeDialogTitle => 'You were poked';
@override
String get pokeDialogCloseAction => 'Close';
@override
String pokeDialogIncomingNoMessage(String time, String sender) {
return '$time $sender pokes you';
String pokeSnackBarIncomingNoMessage(String sender) {
return '$sender pokes you';
}
@override
String pokeDialogIncomingWithMessage(String time, String sender) {
return '$time $sender pokes you with a message';
String pokeSnackBarIncomingWithMessage(String sender, String message) {
return '$sender pokes you: $message';
}
@override
@@ -406,31 +406,19 @@ class AppL10nZh extends AppL10n {
String get iosAudioResuming => '音频正在恢复';
@override
String get settingsAction => '设置';
String get pokeSnackBarClearAction => '清除';
@override
String get appSettingsTitle => '设置';
String get pokeSnackBarMoreIndicator => '...';
@override
String get pokeAlertsTitle => '戳一戳提醒';
@override
String get pokeAlertsDescription => '收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。';
@override
String get pokeDialogTitle => '你被戳了一下';
@override
String get pokeDialogCloseAction => '关闭';
@override
String pokeDialogIncomingNoMessage(String time, String sender) {
return '$time $sender 戳了你一下';
String pokeSnackBarIncomingNoMessage(String sender) {
return '$sender 戳了你一下';
}
@override
String pokeDialogIncomingWithMessage(String time, String sender) {
return '$time $sender 戳了你一下并附带消息';
String pokeSnackBarIncomingWithMessage(String sender, String message) {
return '$sender 戳了你一下$message';
}
@override
+170 -199
View File
@@ -145,7 +145,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
ConnectionPhase _phase = ConnectionPhase.idle;
bool get _serverReachable => _phase.isServerReachable;
rust.BridgeSnapshot? _snapshot;
String? _error;
final List<String> _uiDiagnostics = [];
rust.BridgeAudioStats? _audioStats;
Timer? _statsTimer;
int _statsTick = 0;
@@ -195,11 +195,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final List<ChatEntry> _chatMessages = [];
int _chatUnread = 0;
bool _chatOpen = false;
bool _showPokeDialogs = true;
final ValueNotifier<List<_ReceivedPoke>> _pokeDialogPokes = ValueNotifier(
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
bool _pokeDialogShowing = false;
bool _pokeSnackBarVisible = false;
IconData? _audioRoute;
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
@@ -246,25 +245,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (settings.nickname.isNotEmpty) {
_nickCtl.text = settings.nickname;
}
if (mounted) {
setState(() => _showPokeDialogs = settings.showPokeDialogs);
} else {
_showPokeDialogs = settings.showPokeDialogs;
}
} catch (_) {}
}
Future<void> _saveUiSettings({
String? host,
String? nickname,
bool? showPokeDialogs,
}) async {
Future<void> _saveUiSettings({String? host, String? nickname}) async {
try {
await _uiPreferences.saveSettings(
host: host,
nickname: nickname,
showPokeDialogs: showPokeDialogs,
);
await _uiPreferences.saveSettings(host: host, nickname: nickname);
} catch (_) {}
}
@@ -342,12 +328,45 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('clear permission mute', e);
} finally {
_permissionHardMuteClearInFlight = false;
}
}
void _recordUiDiagnostic(String area, Object error) {
final line = '${DateTime.now().toIso8601String()} [$area] $error';
debugPrint('chanora: $line');
_uiDiagnostics.add(line);
if (_uiDiagnostics.length > 100) {
_uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100);
}
}
void _showUiError(String area, Object error) {
if (!mounted) return;
_showUiErrorSnackBar(area: area, error: error);
}
void _showUiErrorSnackBar({
required String area,
required Object error,
String? displayMessage,
}) {
_recordUiDiagnostic(area, error);
final l10n = AppL10n.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(
displayMessage ?? l10n.statusError(error.toString()),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
);
}
bool _handleFocusedPttKey(KeyEvent event) {
final label = pttDisplayLabelForKey(event.logicalKey);
final isBoundKey =
@@ -564,13 +583,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
});
if (isPoke) {
if (_showPokeDialogs) {
_appendPokeDialog(
senderName: senderName,
message: message,
receivedAt: receivedAt,
);
}
_showPokeSnackBar(
senderName: senderName,
message: message,
receivedAt: receivedAt,
);
return;
}
if (!_chatOpen && _chatUnread > 0) {
@@ -656,7 +673,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
_pokeDialogPokes.dispose();
_pokeSnackBarPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
@@ -678,7 +695,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}) async {
setState(() {
_phase = ConnectionPhase.connecting;
_error = null;
_snapshot = null;
_chatMessages.clear();
});
@@ -718,8 +734,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
setState(() {
_phase = ConnectionPhase.idle;
_error = errorStr;
});
_showUiErrorSnackBar(area: 'connect', error: e);
}
}
@@ -786,7 +802,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setPtt(active: active);
} catch (e) {
if (!mounted || !reportError) return;
setState(() => _error = e.toString());
_showUiError('ptt', e);
}
}
@@ -800,7 +816,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('output mute', e);
}
}
@@ -812,7 +828,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (_pendingVoiceChannelId != null) return;
if (ch.id == _currentVoiceChannelId) return;
final l10n = AppL10n.of(context);
final messenger = ScaffoldMessenger.of(context);
String? password;
if (askForPassword) {
password = await _askChannelPassword(l10n);
@@ -872,18 +887,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} catch (e) {
if (!mounted) return;
// Surface as a SnackBar so the user sees it even while
// connected (the persistent _error string lives in the
// pre-connect area and is hidden post-connect). The message
// is selected by TS3 error code per the canonical
// catalogue at https://github.com/ReSpeak/tsdeclarations.
// connected. The message is selected by TS3 error code per
// the canonical catalogue at
// https://github.com/ReSpeak/tsdeclarations.
final message = channelJoinErrorMessage(l10n, e);
setState(() => _pendingVoiceChannelId = null);
messenger.showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
),
_showUiErrorSnackBar(
area: 'join channel',
error: e,
displayMessage: message,
);
}
}
@@ -914,7 +926,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('hard mute', e);
}
}
@@ -938,7 +950,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} catch (e) {
if (held) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('onscreen ptt', e);
}
}
}
@@ -977,7 +989,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _transmitMode = mode);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('transmit mode', e);
}
},
onReleaseTailChanged: (ms) async {
@@ -987,7 +999,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _releaseTailMs = ms);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('release tail', e);
}
},
onAudioConfigChanged: (config) async {
@@ -995,7 +1007,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setAudioProcessingConfig(config: config);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('audio processing config', e);
}
},
);
@@ -1023,7 +1035,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setAudioProcessingConfig(config: result.audioConfig);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('voice settings', e);
}
if (result.bindKeyRequested && mounted) {
await _onConfigurePtt(context);
@@ -1055,7 +1067,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _applySnapshot(snap));
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('refresh snapshot', e);
}
}
@@ -1113,7 +1125,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_phase = phase;
_snapshot = null;
_audioStats = null;
_error = null;
_inputMuted = false;
_outputMuted = false;
_inChannel = false;
@@ -1171,36 +1182,52 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (mounted) setState(() => _chatOpen = false);
}
void _appendPokeDialog({
void _showPokeSnackBar({
required String senderName,
required String message,
required DateTime receivedAt,
}) {
_pokeDialogPokes.value = [
..._pokeDialogPokes.value,
_pokeSnackBarPokes.value = [
..._pokeSnackBarPokes.value,
_ReceivedPoke(
senderName: senderName,
message: message,
receivedAt: receivedAt,
),
];
unawaited(_showPokeDialog());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
}
Future<void> _showPokeDialog() async {
if (_pokeDialogShowing || _pokeDialogPokes.value.isEmpty || !mounted) {
return;
}
_pokeDialogShowing = true;
try {
await showDialog<void>(
context: context,
builder: (ctx) => _PokeDialog(pokes: _pokeDialogPokes),
);
} finally {
_pokeDialogPokes.value = const [];
_pokeDialogShowing = false;
}
void _renderPokeSnackBar() {
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
_pokeSnackBarVisible = true;
final messenger = ScaffoldMessenger.of(context);
final controller = messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(days: 365),
dismissDirection: DismissDirection.none,
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
action: SnackBarAction(
label: AppL10n.of(context).pokeSnackBarClearAction,
onPressed: () {
_pokeSnackBarPokes.value = const [];
_pokeSnackBarVisible = false;
},
),
),
);
controller.closed.then((_) {
if (!mounted) return;
_pokeSnackBarVisible = false;
if (_pokeSnackBarPokes.value.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
});
}
void _showChatMessageSnackBar({
@@ -1208,6 +1235,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
required String message,
required rust.BridgeMessageTarget target,
}) {
if (_pokeSnackBarPokes.value.isNotEmpty) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
@@ -1313,7 +1341,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _onShowDiagnostics(BuildContext context) async {
final l10n = AppL10n.of(context);
final text = rust.exportDiagnostics();
final rustText = rust.exportDiagnostics();
final uiText = _uiDiagnostics.isEmpty
? 'UI diagnostics: none'
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
final text = '$uiText\n\nRust diagnostics:\n$rustText';
if (!mounted) return;
await showDialog<void>(
context: context,
@@ -1370,10 +1402,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
} catch (e) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.statusError(e.toString()))),
);
_showUiErrorSnackBar(area: 'ptt portal binding', error: e);
}
return;
}
@@ -1405,13 +1434,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (!mounted) return;
// Surface the failure as a snackbar so the user sees that
// their binding did not stick.
if (!mounted) return;
// Use the State's context (guaranteed valid because we
// re-checked `mounted` immediately above).
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.statusError(e.toString()))),
);
_showUiErrorSnackBar(area: 'ptt binding', error: e);
}
}
@@ -1514,7 +1537,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('add bookmark', e);
}
}
@@ -1524,7 +1547,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('delete bookmark', e);
}
}
@@ -1573,24 +1596,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"')));
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('teamspeak link bookmark', e);
}
}
Future<void> _onOpenAppSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _AppSettingsPage(
showPokeDialogs: _showPokeDialogs,
onShowPokeDialogsChanged: (value) {
setState(() => _showPokeDialogs = value);
unawaited(_saveUiSettings(showPokeDialogs: value));
},
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
@@ -1617,11 +1626,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
onPressed: _toggleOutputMute,
),
],
IconButton(
tooltip: l10n.settingsAction,
icon: const Icon(Icons.settings_outlined),
onPressed: _onOpenAppSettings,
),
IconButton(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
@@ -1656,7 +1660,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final statusText = connectionStatusText(
phase: _phase,
l10n: l10n,
error: _error,
serverName: _snapshot?.serverName,
lostReason: _lostReason,
reconnectAttempt: _reconnectAttempt,
@@ -1686,11 +1689,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Text(statusText, style: theme.textTheme.titleMedium),
Expanded(
child: Text(
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
if (_serverReachable && _audioRoute != null) ...[
@@ -1928,75 +1936,74 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
}
class _PokeDialog extends StatelessWidget {
const _PokeDialog({required this.pokes});
class _PokeSnackBarContent extends StatelessWidget {
const _PokeSnackBarContent({required this.pokes});
final ValueListenable<List<_ReceivedPoke>> pokes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return AlertDialog(
icon: Icon(
Icons.notifications_active_outlined,
color: theme.colorScheme.primary,
),
title: Text(l10n.pokeDialogTitle),
content: ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360, maxHeight: 360),
child: ListView.separated(
shrinkWrap: true,
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, index) => _PokeDialogEntry(poke: entries[index]),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.pokeDialogCloseAction),
),
],
return ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) {
final l10n = AppL10n.of(context);
final visible = entries.length <= 3
? entries
: entries.sublist(entries.length - 3);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entries.length > 3)
Padding(
padding: const EdgeInsetsDirectional.only(bottom: 2),
child: Text(
l10n.pokeSnackBarMoreIndicator,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final poke in visible) _PokeSnackBarRow(poke: poke),
],
);
},
);
}
}
class _AppSettingsPage extends StatefulWidget {
const _AppSettingsPage({
required this.showPokeDialogs,
required this.onShowPokeDialogsChanged,
});
class _PokeSnackBarRow extends StatelessWidget {
const _PokeSnackBarRow({required this.poke});
final bool showPokeDialogs;
final ValueChanged<bool> onShowPokeDialogsChanged;
@override
State<_AppSettingsPage> createState() => _AppSettingsPageState();
}
class _AppSettingsPageState extends State<_AppSettingsPage> {
late bool _showPokeDialogs = widget.showPokeDialogs;
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.appSettingsTitle)),
body: ListView(
final message = poke.message.trim();
final text = message.isEmpty
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: Text(l10n.pokeAlertsTitle),
subtitle: Text(l10n.pokeAlertsDescription),
value: _showPokeDialogs,
onChanged: (value) {
setState(() => _showPokeDialogs = value);
widget.onShowPokeDialogsChanged(value);
},
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 12),
Text(
pokeSnackBarTimeLabel(poke.receivedAt),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
),
),
],
),
@@ -2004,43 +2011,7 @@ class _AppSettingsPageState extends State<_AppSettingsPage> {
}
}
class _PokeDialogEntry extends StatelessWidget {
const _PokeDialogEntry({required this.poke});
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final message = poke.message.trim();
final hasMessage = message.isNotEmpty;
final time = chatTimeLabel(poke.receivedAt);
final detail = hasMessage
? l10n.pokeDialogIncomingWithMessage(time, poke.senderName)
: l10n.pokeDialogIncomingNoMessage(time, poke.senderName);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
detail,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (hasMessage) ...[const SizedBox(height: 8), Text(message)],
],
),
),
);
}
String pokeSnackBarTimeLabel(DateTime timestamp) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
}
@@ -0,0 +1,40 @@
class AndroidAudioOutputDevice {
const AndroidAudioOutputDevice({
required this.id,
required this.name,
required this.type,
required this.isSelected,
required this.isAvailableForCommunication,
});
final String id;
final String name;
final String type;
final bool isSelected;
final bool isAvailableForCommunication;
factory AndroidAudioOutputDevice.fromMap(Map<dynamic, dynamic> map) {
return AndroidAudioOutputDevice(
id: map['id']?.toString() ?? '',
name: map['name']?.toString() ?? '',
type: map['type']?.toString() ?? 'unknown',
isSelected: map['isSelected'] == true,
isAvailableForCommunication: map['isAvailableForCommunication'] == true,
);
}
}
List<AndroidAudioOutputDevice> parseAndroidAudioOutputDevices(
List<dynamic> raw,
) {
return raw
.whereType<Map<dynamic, dynamic>>()
.map(AndroidAudioOutputDevice.fromMap)
.toList();
}
AndroidAudioOutputDevice? selectedAndroidAudioOutputDevice(
List<AndroidAudioOutputDevice> devices,
) {
return devices.where((device) => device.isSelected).firstOrNull;
}
@@ -0,0 +1,97 @@
import 'dart:io' show File;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import '../src/rust/api.dart' as rust;
const String appSemverBaseline = 'v0.1.0';
const String _sileroVadAsset = 'assets/models/silero_vad.onnx';
const String _tenVadAsset = 'assets/models/ten_vad.onnx';
Future<File> _copyBundledAssetToDocuments({
required String assetPath,
required String fileName,
}) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$fileName');
final data = await rootBundle.load(assetPath);
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
if (await file.exists() && await file.length() == bytes.length) {
return file;
}
await file.writeAsBytes(bytes, flush: true);
return file;
}
Future<void> configureBundledVadModels() async {
final silero = await _copyBundledAssetToDocuments(
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
final ten = await _copyBundledAssetToDocuments(
assetPath: _tenVadAsset,
fileName: 'ten_vad.onnx',
);
await rust.setVadModelPath(path: silero.path);
await rust.setTenVadModelPath(path: ten.path);
}
/// Resolve the human-readable app version displayed in About.
///
/// The semver baseline is kept in code because iOS strips pre-release
/// identifiers from `CFBundleShortVersionString`; `package_info_plus`
/// still provides the platform build counter.
Future<String> resolveAppVersion({
String semverBaseline = appSemverBaseline,
}) async {
try {
final info = await PackageInfo.fromPlatform();
return appVersionFromBuildNumber(
semverBaseline: semverBaseline,
buildNumber: info.buildNumber,
);
} catch (_) {
return semverBaseline;
}
}
String appVersionFromBuildNumber({
required String semverBaseline,
required String buildNumber,
}) {
final build = buildNumber.isEmpty ? '' : '+$buildNumber';
return '$semverBaseline$build';
}
Future<void> wireStorage() async {
try {
final dir = await getApplicationSupportDirectory();
await rust.initStorage(dir: dir.path);
} catch (_) {
// Best-effort; missing storage just means no identity persistence
// and no bookmark list this session.
}
}
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
if (results.isEmpty) return rust.BridgeNetworkState.unknown;
final allNone = results.every((r) => r == ConnectivityResult.none);
if (allNone) return rust.BridgeNetworkState.offline;
return rust.BridgeNetworkState.online;
}
Future<void> wireConnectivity() async {
final connectivity = Connectivity();
try {
final initial = await connectivity.checkConnectivity();
rust.setNetworkState(state: _mapConnectivity(initial));
} catch (_) {}
connectivity.onConnectivityChanged.listen((results) {
rust.setNetworkState(state: _mapConnectivity(results));
});
}
@@ -0,0 +1,107 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import '../src/rust/api.dart' as rust;
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
switch (value) {
case 'Earpiece':
return rust.BridgeAudioRoute.earpiece;
case 'Speaker':
return rust.BridgeAudioRoute.speaker;
case 'WiredHeadset':
return rust.BridgeAudioRoute.wiredHeadset;
case 'BluetoothHfp':
return rust.BridgeAudioRoute.bluetoothHfp;
case 'BluetoothA2dp':
return rust.BridgeAudioRoute.bluetoothA2Dp;
default:
return rust.BridgeAudioRoute.unknown;
}
}
void wireAudioLifecycle() {
wireIosAudioLifecycle();
wireAndroidAudioLifecycle();
}
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
///
/// Swift side (AppDelegate) posts route-change and interruption events through
/// this channel. The handler dispatches them to the FRB bridge functions on
/// the Rust side.
void wireIosAudioLifecycle({
MethodChannel channel = const MethodChannel(iosAudioLifecycleChannelName),
}) {
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleRouteChange':
final routeStr = call.arguments as String? ?? 'Unknown';
final route = parseBridgeAudioRoute(routeStr);
rust.handleRouteChange(route: route);
break;
case 'handleMediaServicesReset':
final routeClass = call.arguments as String? ?? 'Unknown';
rust.handleMediaServicesResetWithRoute(routeClass: routeClass);
break;
case 'handleInterruptionBegan':
rust.handleInterruptionBegan();
break;
case 'handleInterruptionEnded':
final shouldResume = call.arguments as bool? ?? false;
rust.handleInterruptionEnded(shouldResume: shouldResume);
break;
case 'handleWillResignActive':
case 'handleDidEnterBackground':
rust.handleInterruptionBegan();
break;
case 'handleWillEnterForeground':
rust.handleInterruptionEnded(shouldResume: true);
break;
case 'handleWillTerminate':
rust.handleInterruptionBegan();
break;
default:
break;
}
} catch (_) {
// Errors from the Rust side are already logged there; do not propagate
// exceptions to the platform framework.
}
});
}
/// Wire the Android audio lifecycle MethodChannel.
///
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change events
/// through this channel. This mirrors the iOS dispatch path.
void wireAndroidAudioLifecycle({
bool isAndroid = false,
MethodChannel channel = const MethodChannel(androidAudioLifecycleChannelName),
}) {
if (!isAndroid && !Platform.isAndroid) return;
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleRouteChange':
final args = call.arguments;
final routeStr = args is Map
? (args['routeType'] as String? ?? 'Unknown')
: (args as String? ?? 'Unknown');
final route = parseBridgeAudioRoute(routeStr);
rust.handleRouteChange(route: route);
break;
default:
break;
}
} catch (_) {
// Errors from the Rust side are already logged there; do not propagate
// exceptions to the platform framework.
}
});
}
@@ -0,0 +1,39 @@
import '../l10n/generated/app_localizations.dart';
import '../src/rust/lib.dart' as rust_err;
/// Map a `voiceJoin` error to a localized user-facing message.
String channelJoinErrorMessage(AppL10n l10n, Object error) {
if (error is rust_err.BridgeError) {
return error.when(
invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg),
dnsFailed: (host, reason) =>
l10n.channelJoinFailedGeneric('$host: $reason'),
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
alreadyConnected: () =>
l10n.channelJoinFailedGeneric('already connected'),
serverRejected: (code, message) {
switch (code) {
case 0x0001:
return l10n.channelJoinFailedTimeout;
case 0x0a08:
return l10n.channelJoinFailedPermission;
case 0x0302:
return l10n.channelJoinAlreadyIn;
case 0x030d:
return l10n.channelJoinFailedPassword;
case 0x0309:
return l10n.channelJoinFailedFull;
case 0x030a:
return l10n.channelJoinFailedFamilyFull;
case 0x030e:
return l10n.channelJoinFailedPrivate;
default:
return l10n.channelJoinFailedGeneric(message);
}
},
unmapped: (msg) => l10n.channelJoinFailedGeneric(msg),
);
}
return l10n.channelJoinFailedGeneric(error.toString());
}
@@ -0,0 +1,243 @@
/// Alignment requested by a TeamSpeak spacer channel tag.
enum SpacerAlignment {
/// Left-aligned spacer content.
left,
/// Right-aligned spacer content.
right,
/// Center-aligned spacer content.
center,
}
/// Built-in TeamSpeak spacer separator patterns.
enum SpacerSpecialType {
/// `___`
solidLine,
/// `---`
dashLine,
/// `...`
dotLine,
/// `-.-`
dashDotLine,
/// `-..`
dashDotDotLine,
}
/// Parsed form of a TeamSpeak spacer channel name.
class SpacerChannelNameParseResult {
/// Construct a spacer parse result.
const SpacerChannelNameParseResult({
required this.isSpacer,
required this.isValid,
required this.alignment,
required this.isRepeating,
required this.uniqueSuffix,
required this.text,
required this.specialType,
required this.isBlankSpacer,
this.reason,
});
/// True only when the name begins with a valid bracketed spacer tag.
final bool isSpacer;
/// True when the bracketed spacer tag follows the supported syntax.
final bool isValid;
/// Optional alignment flag. Null means the server/client default.
final SpacerAlignment? alignment;
/// True when `*` appears in the tag and the text should repeat.
final bool isRepeating;
/// The exact suffix after `Spacer` and before `]`.
final String uniqueSuffix;
/// The exact text after the closing `]`.
final String text;
/// Built-in separator type for special text values.
final SpacerSpecialType? specialType;
/// True for the known blank-looking right-aligned dot spacer.
final bool isBlankSpacer;
/// Parse error/reason for non-spacer or malformed names.
final String? reason;
}
/// Options for formatting a TeamSpeak spacer channel name.
class SpacerChannelNameFormatOptions {
/// Construct spacer formatting options.
const SpacerChannelNameFormatOptions({
this.alignment,
this.isRepeating = false,
this.uniqueSuffix = '',
this.text = '',
});
/// Optional alignment flag. Null means omit the alignment prefix.
final SpacerAlignment? alignment;
/// Whether to include the repeating `*` tag flag.
final bool isRepeating;
/// Uniqueness suffix to place after `Spacer`.
final String uniqueSuffix;
/// Text to place after the closing `]`.
final String text;
}
const _notSpacer = SpacerChannelNameParseResult(
isSpacer: false,
isValid: false,
alignment: null,
isRepeating: false,
uniqueSuffix: '',
text: '',
specialType: null,
isBlankSpacer: false,
reason: 'not a spacer channel name',
);
/// Return true when [name] begins with a valid bracketed spacer tag.
bool isSpacerChannelName(String name) => parseSpacerChannelName(name).isSpacer;
/// Parse a TeamSpeak spacer channel name.
///
/// Supported tag form is `[?Spacer#]Text`, parsed case-insensitively.
/// `?` may be `l`, `r`, or `c`; `*` may also appear in the tag to
/// mark repeating spacer content. The suffix and text are preserved
/// exactly as written.
SpacerChannelNameParseResult parseSpacerChannelName(String name) {
if (!name.startsWith('[')) return _notSpacer;
final close = name.indexOf(']');
if (close < 0) {
return _invalidSpacerName('missing closing bracket');
}
final tag = name.substring(1, close);
final text = name.substring(close + 1);
final match = RegExp(
r'^([lrc*]*)(spacer)(.*)$',
caseSensitive: false,
).firstMatch(tag);
if (match == null) {
if (tag.toLowerCase().contains('spacer')) {
return _invalidSpacerName('invalid spacer tag');
}
return _notSpacer;
}
final flags = match.group(1) ?? '';
final uniqueSuffix = match.group(3) ?? '';
final alignmentFlags = flags
.toLowerCase()
.split('')
.where((flag) => flag == 'l' || flag == 'r' || flag == 'c')
.toList();
final repeatingFlags = flags.split('').where((flag) => flag == '*').length;
if (alignmentFlags.length > 1) {
return _invalidSpacerName('multiple alignment flags');
}
if (repeatingFlags > 1) {
return _invalidSpacerName('multiple repeating flags');
}
final alignmentFlag = alignmentFlags.isEmpty ? null : alignmentFlags.first;
final alignment = switch (alignmentFlag) {
'l' => SpacerAlignment.left,
'r' => SpacerAlignment.right,
'c' => SpacerAlignment.center,
_ => null,
};
final specialType = _specialTypeForText(text);
return SpacerChannelNameParseResult(
isSpacer: true,
isValid: true,
alignment: alignment,
isRepeating: repeatingFlags == 1,
uniqueSuffix: uniqueSuffix,
text: text,
specialType: specialType,
isBlankSpacer: alignment == SpacerAlignment.right && text == '.',
);
}
/// Format a TeamSpeak spacer channel name deterministically.
///
/// The output uses canonical `Spacer` casing and places `*` before
/// the alignment flag when both are present.
String formatSpacerChannelName(SpacerChannelNameFormatOptions options) {
final repeatFlag = options.isRepeating ? '*' : '';
final alignFlag = switch (options.alignment) {
SpacerAlignment.left => 'l',
SpacerAlignment.right => 'r',
SpacerAlignment.center => 'c',
null => '',
};
return '[$repeatFlag${alignFlag}Spacer${options.uniqueSuffix}]${options.text}';
}
/// Convert a channel name into display text while preserving the
/// underlying channel entity and behavior.
String channelSpacerLabel(String raw, {int repeatColumns = 32}) {
final parsed = parseSpacerChannelName(raw);
if (!parsed.isValid) return raw;
if (parsed.isBlankSpacer) return '';
if (parsed.isRepeating) {
return _repeatSpacerText(parsed.text, repeatColumns);
}
return switch (parsed.specialType) {
SpacerSpecialType.solidLine => '────────',
SpacerSpecialType.dashLine => '╌╌╌╌╌╌╌╌',
SpacerSpecialType.dotLine => '········',
SpacerSpecialType.dashDotLine => '─╶─╶─╶─╶─╶─╶─╶─╶',
SpacerSpecialType.dashDotDotLine => '─╶╶─╶╶─╶╶─╶╶',
null => parsed.text,
};
}
SpacerChannelNameParseResult _invalidSpacerName(String reason) {
return SpacerChannelNameParseResult(
isSpacer: false,
isValid: false,
alignment: null,
isRepeating: false,
uniqueSuffix: '',
text: '',
specialType: null,
isBlankSpacer: false,
reason: reason,
);
}
SpacerSpecialType? _specialTypeForText(String text) {
return switch (text) {
'___' => SpacerSpecialType.solidLine,
'---' => SpacerSpecialType.dashLine,
'...' => SpacerSpecialType.dotLine,
'-.-' => SpacerSpecialType.dashDotLine,
'-..' => SpacerSpecialType.dashDotDotLine,
_ => null,
};
}
String _repeatSpacerText(String pattern, int repeatColumns) {
if (pattern.isEmpty) return '────────';
final buffer = StringBuffer();
while (buffer.length < repeatColumns) {
buffer.write(pattern);
}
return buffer.toString().substring(0, repeatColumns);
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../design/chanora_tokens.dart';
import '../l10n/generated/app_localizations.dart';
enum ConnectionPhase {
/// App is disconnected, no error.
idle,
/// Connecting to server.
connecting,
/// Connected but still synchronizing snapshot.
synchronizing,
/// Connected and ready.
connected,
/// Connection lost, auto-reconnecting.
reconnecting,
/// Explicitly disconnected by user.
disconnected,
}
extension ConnectionPhaseState on ConnectionPhase {
bool get isServerReachable =>
this == ConnectionPhase.connected ||
this == ConnectionPhase.synchronizing ||
this == ConnectionPhase.reconnecting;
bool get canOpenChat =>
this == ConnectionPhase.connected ||
this == ConnectionPhase.synchronizing;
bool get canDisconnect => canOpenChat;
ConnectionTokens tokens(ColorScheme colorScheme) {
switch (this) {
case ConnectionPhase.idle:
case ConnectionPhase.disconnected:
return ConnectionTokens.disconnected(colorScheme);
case ConnectionPhase.connecting:
return ConnectionTokens.connecting(colorScheme);
case ConnectionPhase.synchronizing:
return ConnectionTokens.synchronizing(colorScheme);
case ConnectionPhase.connected:
return ConnectionTokens.connected(colorScheme);
case ConnectionPhase.reconnecting:
return ConnectionTokens.reconnecting(colorScheme);
}
}
}
String connectionStatusText({
required ConnectionPhase phase,
required AppL10n l10n,
String? serverName,
String? lostReason,
int? reconnectAttempt,
int? reconnectDelay,
}) {
switch (phase) {
case ConnectionPhase.idle:
return l10n.statusIdle;
case ConnectionPhase.connecting:
return l10n.statusConnecting;
case ConnectionPhase.synchronizing:
return 'Synchronizing...';
case ConnectionPhase.connected:
return l10n.statusConnected(serverName ?? '');
case ConnectionPhase.reconnecting:
return reconnectAttempt != null
? l10n.statusReconnecting(reconnectAttempt, reconnectDelay ?? 0)
: l10n.statusConnectionLost(lostReason ?? '');
case ConnectionPhase.disconnected:
return l10n.statusIdle;
}
}
@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkTrustService extends ChangeNotifier {
static LinkTrustService? _instance;
@@ -71,13 +69,12 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) => setDialogState(() => remember = v ?? false),
onChanged: (v) =>
setDialogState(() => remember = v ?? false),
),
),
const SizedBox(width: 8),
const Flexible(
child: Text('Trust all links from this domain'),
),
const Flexible(child: Text('Trust all links from this domain')),
],
),
],
@@ -0,0 +1,65 @@
import '../src/rust/api.dart' as rust;
class OwnClientSnapshotState {
const OwnClientSnapshotState({
required this.channelId,
required this.inputMuted,
required this.outputMuted,
required this.talkPowerOk,
required this.talkPower,
required this.talkPowerGranted,
this.neededTalkPower,
});
final BigInt channelId;
final bool inputMuted;
final bool outputMuted;
final bool talkPowerOk;
final int talkPower;
final bool talkPowerGranted;
final int? neededTalkPower;
}
OwnClientSnapshotState? ownClientSnapshotState(rust.BridgeSnapshot snapshot) {
for (final client in snapshot.clients) {
if (client.id != snapshot.ownClientId) continue;
final neededTalkPower = _channelById(
snapshot,
client.channel,
)?.neededTalkPower;
return OwnClientSnapshotState(
channelId: client.channel,
inputMuted: client.inputMuted,
outputMuted: client.outputMuted,
talkPowerOk: _talkPowerOk(client, neededTalkPower),
talkPower: client.talkPower,
talkPowerGranted: client.talkPowerGranted,
neededTalkPower: neededTalkPower,
);
}
return null;
}
String snapshotChannelName(rust.BridgeSnapshot? snapshot, BigInt? channelId) {
if (snapshot == null || channelId == null) return '';
return _channelById(snapshot, channelId)?.name ?? '';
}
int? snapshotNeededTalkPower(rust.BridgeSnapshot? snapshot, BigInt? channelId) {
if (snapshot == null || channelId == null) return null;
return _channelById(snapshot, channelId)?.neededTalkPower;
}
rust.BridgeChannel? _channelById(
rust.BridgeSnapshot snapshot,
BigInt channelId,
) {
return snapshot.channels
.where((channel) => channel.id == channelId)
.firstOrNull;
}
bool _talkPowerOk(rust.BridgeClient client, int? neededTalkPower) {
if (client.talkPowerGranted) return true;
return neededTalkPower == null || client.talkPower >= neededTalkPower;
}
@@ -0,0 +1,92 @@
typedef Ts3ServerLinkHandler = Future<void> Function(Ts3ServerLink link);
class Ts3ServerLink {
const Ts3ServerLink({
required this.host,
required this.hostWithPort,
this.port,
this.nickname,
this.password,
this.channel,
this.cid,
this.channelPassword,
this.token,
this.addBookmark,
});
final String host;
final String hostWithPort;
final int? port;
final String? nickname;
final String? password;
final String? channel;
final String? cid;
final String? channelPassword;
final String? token;
final String? addBookmark;
}
Ts3ServerLink? parseTs3ServerLink(String rawUrl) {
final trimmed = rawUrl.trim();
if (!trimmed.toLowerCase().startsWith('ts3server://')) return null;
var body = trimmed.substring('ts3server://'.length);
body = body.replaceFirst(RegExp(r'/+$'), '');
if (body.isEmpty) return null;
final split = _splitAuthorityAndQuery(body);
final hostPart = Uri.decodeComponent(split.authority).trim();
if (hostPart.isEmpty) return null;
final params = split.query == null
? const <String, String>{}
: Uri.splitQueryString(split.query!);
final port = int.tryParse(params['port'] ?? '');
final hostWithPort = port == null || _hasExplicitPort(hostPart)
? hostPart
: '$hostPart:$port';
return Ts3ServerLink(
host: hostPart,
hostWithPort: hostWithPort,
port: port,
nickname: _emptyToNull(params['nickname']),
password: _emptyToNull(params['password']),
channel: _emptyToNull(params['channel']),
cid: _emptyToNull(params['cid']),
channelPassword: _emptyToNull(params['channelpassword']),
token: _emptyToNull(params['token']),
addBookmark: _emptyToNull(params['addbookmark']),
);
}
({String authority, String? query}) _splitAuthorityAndQuery(String body) {
final rawQueryIndex = body.indexOf('?');
if (rawQueryIndex >= 0) {
return (
authority: body.substring(0, rawQueryIndex),
query: body.substring(rawQueryIndex + 1),
);
}
final encodedQueryIndex = body.toLowerCase().indexOf('%3f');
if (encodedQueryIndex >= 0) {
return (
authority: body.substring(0, encodedQueryIndex),
query: Uri.decodeComponent(body.substring(encodedQueryIndex + 3)),
);
}
return (authority: body, query: null);
}
bool _hasExplicitPort(String host) {
final lastColon = host.lastIndexOf(':');
if (lastColon <= 0 || lastColon == host.length - 1) return false;
return int.tryParse(host.substring(lastColon + 1)) != null;
}
String? _emptyToNull(String? value) {
if (value == null || value.isEmpty) return null;
return value;
}
@@ -1,21 +1,15 @@
import 'package:shared_preferences/shared_preferences.dart';
class UiSettings {
const UiSettings({
this.host = '',
this.nickname = '',
this.showPokeDialogs = true,
});
const UiSettings({this.host = '', this.nickname = ''});
final String host;
final String nickname;
final bool showPokeDialogs;
}
class UiPreferencesService {
static const _hostKey = 'ui.host';
static const _nicknameKey = 'ui.nickname';
static const _showPokeDialogsKey = 'ui.show_poke_dialogs';
static const _permissionsExplainedKey = 'perms_explained';
const UiPreferencesService();
@@ -25,21 +19,13 @@ class UiPreferencesService {
return UiSettings(
host: prefs.getString(_hostKey) ?? '',
nickname: prefs.getString(_nicknameKey) ?? '',
showPokeDialogs: prefs.getBool(_showPokeDialogsKey) ?? true,
);
}
Future<void> saveSettings({
String? host,
String? nickname,
bool? showPokeDialogs,
}) async {
Future<void> saveSettings({String? host, String? nickname}) async {
final prefs = await SharedPreferences.getInstance();
if (host != null) await prefs.setString(_hostKey, host);
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
if (showPokeDialogs != null) {
await prefs.setBool(_showPokeDialogsKey, showPokeDialogs);
}
}
Future<bool> hasExplainedPermissions() async {
+220 -29
View File
@@ -10,7 +10,7 @@ import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'api.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
@@ -46,15 +46,6 @@ Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
void handleRouteChange({required BridgeAudioRoute route}) =>
RustLib.instance.api.crateApiHandleRouteChange(route: route);
/// Handle iOS AVAudioSession media-services reset (legacy, no route arg).
///
/// Called by the existing FRB-generated Dart binding. Uses
/// `AudioRoute::Unknown` which triggers a route-change recompute.
/// The AppDelegate now also calls `handle_media_services_reset_with_route`
/// directly after rebuilding the session.
void handleMediaServicesReset() =>
RustLib.instance.api.crateApiHandleMediaServicesReset();
/// Handle iOS AVAudioSession media-services reset with the current
/// route class. Called by AppDelegate after rebuilding the session.
///
@@ -74,11 +65,11 @@ void handleInterruptionEnded({required bool shouldResume}) => RustLib
.api
.crateApiHandleInterruptionEnded(shouldResume: shouldResume);
/// Set the push-to-talk state.
/// Set the focused/on-screen push-to-talk hold state.
///
/// Superseded in v1 by [`set_transmit_mode`] + the binding capture
/// dialog. Retained so legacy callers and integration tests keep
/// working; the new VoiceBar UI no longer invokes this.
/// Binding capture chooses which physical key drives PTT, while this
/// command carries the actual press/release edge for fallback focused
/// keyboard handling and touch controls.
Future<void> setPtt({required bool active}) =>
RustLib.instance.api.crateApiSetPtt(active: active);
@@ -131,19 +122,17 @@ Future<void> setPttBinding({
platformKey: platformKey,
);
/// Read the current PTT capability descriptor. Returns a
/// `(level, backend_id, bound_input_class)` triple matching the
/// privacy-safe `BridgeEvent::PttCapability` event shape; useful
/// for the initial UI render before the first event arrives.
Future<(String, String, String)> pttDescriptor() =>
/// Read the current PTT capability descriptor. Matches the privacy-safe
/// `BridgeEvent::PttCapability` event shape; useful for the initial UI
/// render before the first event arrives.
Future<BridgePttDescriptor> pttDescriptor() =>
RustLib.instance.api.crateApiPttDescriptor();
/// Return the persisted PTT binding as a
/// `(input_class, platform_key)` pair so the UI can hydrate its
/// display state at launch (e.g. show "PTT: Space" next to the
/// badge before the user re-opens the binding dialog). Empty
/// strings mean no binding has been persisted yet.
Future<(String, String)> getPttBinding() =>
/// Return the persisted PTT binding so the UI can hydrate its display
/// state at launch (e.g. show "PTT: Space" next to the badge before the
/// user re-opens the binding dialog). Empty strings mean no binding has
/// been persisted yet.
Future<BridgePttBinding> getPttBinding() =>
RustLib.instance.api.crateApiGetPttBinding();
/// Move our own client to `channel_id`. Optional channel password
@@ -176,6 +165,27 @@ Future<void> setOutputMuted({required bool muted}) =>
Future<void> setOutputGain({required double gain}) =>
RustLib.instance.api.crateApiSetOutputGain(gain: gain);
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op when client has no active voice queue. Volume is
/// applied directly to the tsclientlib AudioQueue and takes effect
/// immediately on the next render callback.
Future<void> setClientVolume({
required BigInt clientId,
required double volume,
}) => RustLib.instance.api.crateApiSetClientVolume(
clientId: clientId,
volume: volume,
);
/// Send a text message to the specified target.
Future<void> sendChatMessage({
required String message,
required BridgeMessageTarget target,
}) => RustLib.instance.api.crateApiSendChatMessage(
message: message,
target: target,
);
/// User-initiated diagnostic export. Returns a multi-line text
/// blob, redacted per the production policy, that the user can
/// share or copy. DEC-016 forbids automatic uploads — this is the
@@ -256,6 +266,19 @@ Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() =>
Future<BridgeAudioProcessingStats> audioProcessingStats() =>
RustLib.instance.api.crateApiAudioProcessingStats();
/// List available audio input and output devices from the platform.
Future<BridgeAudioDeviceList> listAudioDevices() =>
RustLib.instance.api.crateApiListAudioDevices();
/// Set the preferred input device by name. Takes effect on next
/// `start_audio`.
Future<void> setInputDevice({String? name}) =>
RustLib.instance.api.crateApiSetInputDevice(name: name);
/// Set the preferred output device by name.
Future<void> setOutputDevice({String? name}) =>
RustLib.instance.api.crateApiSetOutputDevice(name: name);
/// Configure the VAD model path.
Future<void> setVadModelPath({required String path}) =>
RustLib.instance.api.crateApiSetVadModelPath(path: path);
@@ -273,6 +296,14 @@ Future<void> setIosVoiceProcessingMode({
required BridgeIosVoiceProcessingMode mode,
}) => RustLib.instance.api.crateApiSetIosVoiceProcessingMode(mode: mode);
/// Set the preferred audio output route (Android/iOS).
void setAudioOutputRoute({required BridgeAudioRoute route}) =>
RustLib.instance.api.crateApiSetAudioOutputRoute(route: route);
/// Called from Flutter when the app enters background/foreground.
void recordLifecycleEvent({required String state}) =>
RustLib.instance.api.crateApiRecordLifecycleEvent(state: state);
/// Bridge processing backend.
enum BridgeAudioBackend {
/// Platform voice processing.
@@ -288,6 +319,53 @@ enum BridgeAudioBackend {
noop,
}
/// Audio device info from the platform.
class BridgeAudioDevice {
/// Human-readable device name.
final String name;
/// True if the OS reports this as the default device.
final bool isDefault;
const BridgeAudioDevice({required this.name, required this.isDefault});
@override
int get hashCode => name.hashCode ^ isDefault.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgeAudioDevice &&
runtimeType == other.runtimeType &&
name == other.name &&
isDefault == other.isDefault;
}
/// List of available audio devices.
class BridgeAudioDeviceList {
/// Available input devices.
final List<BridgeAudioDevice> inputDevices;
/// Available output devices.
final List<BridgeAudioDevice> outputDevices;
const BridgeAudioDeviceList({
required this.inputDevices,
required this.outputDevices,
});
@override
int get hashCode => inputDevices.hashCode ^ outputDevices.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgeAudioDeviceList &&
runtimeType == other.runtimeType &&
inputDevices == other.inputDevices &&
outputDevices == other.outputDevices;
}
/// P1 audio-processing configuration DTO.
class BridgeAudioProcessingConfig {
/// Route class.
@@ -628,12 +706,17 @@ class BridgeChannel {
/// True when the server marks the channel as password-protected.
final bool hasPassword;
/// Talk power threshold required to speak in this channel.
/// None means no talk-power restriction.
final int? neededTalkPower;
const BridgeChannel({
required this.id,
required this.parent,
required this.name,
required this.order,
required this.hasPassword,
this.neededTalkPower,
});
@override
@@ -642,7 +725,8 @@ class BridgeChannel {
parent.hashCode ^
name.hashCode ^
order.hashCode ^
hasPassword.hashCode;
hasPassword.hashCode ^
neededTalkPower.hashCode;
@override
bool operator ==(Object other) =>
@@ -653,7 +737,8 @@ class BridgeChannel {
parent == other.parent &&
name == other.name &&
order == other.order &&
hasPassword == other.hasPassword;
hasPassword == other.hasPassword &&
neededTalkPower == other.neededTalkPower;
}
/// Client as seen by Dart.
@@ -679,6 +764,12 @@ class BridgeClient {
/// True for TeamSpeak ServerQuery clients.
final bool isServerQuery;
/// Current talk power value assigned by the server.
final int talkPower;
/// True when the server has granted talk power regardless of numeric value.
final bool talkPowerGranted;
const BridgeClient({
required this.id,
required this.channel,
@@ -687,6 +778,8 @@ class BridgeClient {
required this.outputMuted,
required this.isSpeaking,
required this.isServerQuery,
required this.talkPower,
required this.talkPowerGranted,
});
@override
@@ -697,7 +790,9 @@ class BridgeClient {
inputMuted.hashCode ^
outputMuted.hashCode ^
isSpeaking.hashCode ^
isServerQuery.hashCode;
isServerQuery.hashCode ^
talkPower.hashCode ^
talkPowerGranted.hashCode;
@override
bool operator ==(Object other) =>
@@ -710,7 +805,9 @@ class BridgeClient {
inputMuted == other.inputMuted &&
outputMuted == other.outputMuted &&
isSpeaking == other.isSpeaking &&
isServerQuery == other.isServerQuery;
isServerQuery == other.isServerQuery &&
talkPower == other.talkPower &&
talkPowerGranted == other.talkPowerGranted;
}
/// Bridge effect owner for AEC/NS/AGC.
@@ -863,6 +960,27 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Resolved permission state.
required PermissionStateKind state,
}) = BridgeEvent_PermissionState;
/// A text message received from the server.
const factory BridgeEvent.chatMessage({
/// Client id of the sender.
required BigInt senderId,
/// Nickname of the sender.
required String senderName,
/// Message content.
required String message,
/// Target scope (server/channel/private/poke).
required BridgeMessageTarget target,
}) = BridgeEvent_ChatMessage;
/// Audio route changed (speaker/earpiece/BT/wired).
const factory BridgeEvent.audioRouteChanged({
/// The new audio route.
required BridgeAudioRoute route,
}) = BridgeEvent_AudioRouteChanged;
}
/// Bridge iOS voice-processing mode.
@@ -874,6 +992,25 @@ enum BridgeIosVoiceProcessingMode {
sonoraExperimental,
}
@freezed
sealed class BridgeMessageTarget with _$BridgeMessageTarget {
const BridgeMessageTarget._();
/// Broadcast to entire server.
const factory BridgeMessageTarget.server() = BridgeMessageTarget_Server;
/// Broadcast to current channel.
const factory BridgeMessageTarget.channel() = BridgeMessageTarget_Channel;
/// Private message to a specific client.
const factory BridgeMessageTarget.client(BigInt field0) =
BridgeMessageTarget_Client;
/// Poke a specific client.
const factory BridgeMessageTarget.poke(BigInt field0) =
BridgeMessageTarget_Poke;
}
/// Coarse OS-reported network state. Mirrors
/// [`chanora_core::NetworkState`] across the bridge.
enum BridgeNetworkState {
@@ -887,6 +1024,60 @@ enum BridgeNetworkState {
offline,
}
/// Persisted PTT binding display state for the UI.
class BridgePttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
final String inputClass;
/// Display-only key label; empty when no binding is active.
final String keyLabel;
const BridgePttBinding({required this.inputClass, required this.keyLabel});
@override
int get hashCode => inputClass.hashCode ^ keyLabel.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgePttBinding &&
runtimeType == other.runtimeType &&
inputClass == other.inputClass &&
keyLabel == other.keyLabel;
}
/// Privacy-safe PTT capability descriptor for the UI.
class BridgePttDescriptor {
/// Stable capability level name.
final String level;
/// Stable backend identifier.
final String backendId;
/// Coarse bound input class; empty when no binding is active.
final String boundInputClass;
const BridgePttDescriptor({
required this.level,
required this.backendId,
required this.boundInputClass,
});
@override
int get hashCode =>
level.hashCode ^ backendId.hashCode ^ boundInputClass.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgePttDescriptor &&
runtimeType == other.runtimeType &&
level == other.level &&
backendId == other.backendId &&
boundInputClass == other.boundInputClass;
}
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
/// the bridge never carries raw key codes.
enum BridgePttInputClass {
@@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,TResult Function( BridgeEvent_ChatMessage value)? chatMessage,TResult Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -69,7 +69,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapabilit
return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that);case _:
return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that);case _:
return orElse();
}
@@ -87,7 +89,7 @@ return permissionState(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,required TResult Function( BridgeEvent_ChatMessage value) chatMessage,required TResult Function( BridgeEvent_AudioRouteChanged value) audioRouteChanged,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected():
@@ -101,7 +103,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability():
return pttCapability(_that);case BridgeEvent_VoiceState():
return voiceState(_that);case BridgeEvent_InterruptionState():
return interruptionState(_that);case BridgeEvent_PermissionState():
return permissionState(_that);}
return permissionState(_that);case BridgeEvent_ChatMessage():
return chatMessage(_that);case BridgeEvent_AudioRouteChanged():
return audioRouteChanged(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
@@ -115,7 +119,7 @@ return permissionState(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,TResult? Function( BridgeEvent_ChatMessage value)? chatMessage,TResult? Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -129,7 +133,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapabilit
return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that);case _:
return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that);case _:
return null;
}
@@ -146,7 +152,7 @@ return permissionState(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( BridgeAudioRoute route)? audioRouteChanged,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -159,7 +165,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case _:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case _:
return orElse();
}
@@ -177,7 +185,7 @@ return permissionState(_that.permission,_that.state);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( BridgeAudioRoute route) audioRouteChanged,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected():
return connected(_that.serverName);case BridgeEvent_Lost():
@@ -190,7 +198,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState():
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
return permissionState(_that.permission,_that.state);}
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged():
return audioRouteChanged(_that.route);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -204,7 +214,7 @@ return permissionState(_that.permission,_that.state);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -217,7 +227,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case _:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case _:
return null;
}
@@ -230,7 +242,7 @@ return permissionState(_that.permission,_that.state);case _:
class BridgeEvent_Connected extends BridgeEvent {
const BridgeEvent_Connected({required this.serverName}): super._();
/// Server name reported by the server snapshot.
final String serverName;
@@ -297,7 +309,7 @@ as String,
class BridgeEvent_Lost extends BridgeEvent {
const BridgeEvent_Lost({required this.reason}): super._();
/// Reason classification from the protocol layer.
final String reason;
@@ -364,7 +376,7 @@ as String,
class BridgeEvent_Reconnecting extends BridgeEvent {
const BridgeEvent_Reconnecting({required this.attempt, required this.delaySecs}): super._();
/// 1-based attempt counter for the current outage.
final int attempt;
@@ -434,7 +446,7 @@ as int,
class BridgeEvent_Disconnected extends BridgeEvent {
const BridgeEvent_Disconnected({required this.reason}): super._();
/// Reason classification.
final String reason;
@@ -501,7 +513,7 @@ as String,
class BridgeEvent_AudioStarted extends BridgeEvent {
const BridgeEvent_AudioStarted(): super._();
@@ -533,7 +545,7 @@ String toString() {
class BridgeEvent_AudioStopped extends BridgeEvent {
const BridgeEvent_AudioStopped(): super._();
@@ -565,7 +577,7 @@ String toString() {
class BridgeEvent_SnapshotChanged extends BridgeEvent {
const BridgeEvent_SnapshotChanged({required this.channels, required this.clients}): super._();
/// Latest channel count.
final int channels;
@@ -635,7 +647,7 @@ as int,
class BridgeEvent_PttCapability extends BridgeEvent {
const BridgeEvent_PttCapability({required this.level, required this.backendId, required this.boundInputClass}): super._();
/// Stable capability identifier (`"L0Focused"`,
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
@@ -711,7 +723,7 @@ as String,
class BridgeEvent_VoiceState extends BridgeEvent {
const BridgeEvent_VoiceState({required this.inChannel, required this.transmitMode, required this.mute, required this.releaseTailMs, this.currentChannelId, this.pendingTargetChannelId, required this.canJoin, required this.canLeave, required this.joinSyncState, this.joinErrorCode}): super._();
/// True when the session is currently joined to a voice
/// channel and the audio engine is running.
@@ -806,7 +818,7 @@ as BridgeVoiceJoinErrorCode?,
class BridgeEvent_InterruptionState extends BridgeEvent {
const BridgeEvent_InterruptionState({required this.began, required this.shouldResume}): super._();
/// True when interruption began, false when it ended.
final bool began;
@@ -876,7 +888,7 @@ as bool,
class BridgeEvent_PermissionState extends BridgeEvent {
const BridgeEvent_PermissionState({required this.permission, required this.state}): super._();
/// Canonical Android permission identifier.
final String permission;
@@ -939,6 +951,526 @@ as PermissionStateKind,
}
}
/// @nodoc
class BridgeEvent_ChatMessage extends BridgeEvent {
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
/// Client id of the sender.
final BigInt senderId;
/// Nickname of the sender.
final String senderName;
/// Message content.
final String message;
/// Target scope (server/channel/private/poke).
final BridgeMessageTarget target;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeEvent_ChatMessageCopyWith<BridgeEvent_ChatMessage> get copyWith => _$BridgeEvent_ChatMessageCopyWithImpl<BridgeEvent_ChatMessage>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target));
}
@override
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target);
@override
String toString() {
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)';
}
}
/// @nodoc
abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
@useResult
$Res call({
BigInt senderId, String senderName, String message, BridgeMessageTarget target
});
$BridgeMessageTargetCopyWith<$Res> get target;
}
/// @nodoc
class _$BridgeEvent_ChatMessageCopyWithImpl<$Res>
implements $BridgeEvent_ChatMessageCopyWith<$Res> {
_$BridgeEvent_ChatMessageCopyWithImpl(this._self, this._then);
final BridgeEvent_ChatMessage _self;
final $Res Function(BridgeEvent_ChatMessage) _then;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) {
return _then(BridgeEvent_ChatMessage(
senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable
as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable
as BridgeMessageTarget,
));
}
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$BridgeMessageTargetCopyWith<$Res> get target {
return $BridgeMessageTargetCopyWith<$Res>(_self.target, (value) {
return _then(_self.copyWith(target: value));
});
}
}
/// @nodoc
class BridgeEvent_AudioRouteChanged extends BridgeEvent {
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
/// The new audio route.
final BridgeAudioRoute route;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeEvent_AudioRouteChangedCopyWith<BridgeEvent_AudioRouteChanged> get copyWith => _$BridgeEvent_AudioRouteChangedCopyWithImpl<BridgeEvent_AudioRouteChanged>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_AudioRouteChanged&&(identical(other.route, route) || other.route == route));
}
@override
int get hashCode => Object.hash(runtimeType,route);
@override
String toString() {
return 'BridgeEvent.audioRouteChanged(route: $route)';
}
}
/// @nodoc
abstract mixin class $BridgeEvent_AudioRouteChangedCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
factory $BridgeEvent_AudioRouteChangedCopyWith(BridgeEvent_AudioRouteChanged value, $Res Function(BridgeEvent_AudioRouteChanged) _then) = _$BridgeEvent_AudioRouteChangedCopyWithImpl;
@useResult
$Res call({
BridgeAudioRoute route
});
}
/// @nodoc
class _$BridgeEvent_AudioRouteChangedCopyWithImpl<$Res>
implements $BridgeEvent_AudioRouteChangedCopyWith<$Res> {
_$BridgeEvent_AudioRouteChangedCopyWithImpl(this._self, this._then);
final BridgeEvent_AudioRouteChanged _self;
final $Res Function(BridgeEvent_AudioRouteChanged) _then;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? route = null,}) {
return _then(BridgeEvent_AudioRouteChanged(
route: null == route ? _self.route : route // ignore: cast_nullable_to_non_nullable
as BridgeAudioRoute,
));
}
}
/// @nodoc
mixin _$BridgeMessageTarget {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'BridgeMessageTarget()';
}
}
/// @nodoc
class $BridgeMessageTargetCopyWith<$Res> {
$BridgeMessageTargetCopyWith(BridgeMessageTarget _, $Res Function(BridgeMessageTarget) __);
}
/// Adds pattern-matching-related methods to [BridgeMessageTarget].
extension BridgeMessageTargetPatterns on BridgeMessageTarget {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeMessageTarget_Server value)? server,TResult Function( BridgeMessageTarget_Channel value)? channel,TResult Function( BridgeMessageTarget_Client value)? client,TResult Function( BridgeMessageTarget_Poke value)? poke,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeMessageTarget_Server() when server != null:
return server(_that);case BridgeMessageTarget_Channel() when channel != null:
return channel(_that);case BridgeMessageTarget_Client() when client != null:
return client(_that);case BridgeMessageTarget_Poke() when poke != null:
return poke(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeMessageTarget_Server value) server,required TResult Function( BridgeMessageTarget_Channel value) channel,required TResult Function( BridgeMessageTarget_Client value) client,required TResult Function( BridgeMessageTarget_Poke value) poke,}){
final _that = this;
switch (_that) {
case BridgeMessageTarget_Server():
return server(_that);case BridgeMessageTarget_Channel():
return channel(_that);case BridgeMessageTarget_Client():
return client(_that);case BridgeMessageTarget_Poke():
return poke(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeMessageTarget_Server value)? server,TResult? Function( BridgeMessageTarget_Channel value)? channel,TResult? Function( BridgeMessageTarget_Client value)? client,TResult? Function( BridgeMessageTarget_Poke value)? poke,}){
final _that = this;
switch (_that) {
case BridgeMessageTarget_Server() when server != null:
return server(_that);case BridgeMessageTarget_Channel() when channel != null:
return channel(_that);case BridgeMessageTarget_Client() when client != null:
return client(_that);case BridgeMessageTarget_Poke() when poke != null:
return poke(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? server,TResult Function()? channel,TResult Function( BigInt field0)? client,TResult Function( BigInt field0)? poke,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeMessageTarget_Server() when server != null:
return server();case BridgeMessageTarget_Channel() when channel != null:
return channel();case BridgeMessageTarget_Client() when client != null:
return client(_that.field0);case BridgeMessageTarget_Poke() when poke != null:
return poke(_that.field0);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() server,required TResult Function() channel,required TResult Function( BigInt field0) client,required TResult Function( BigInt field0) poke,}) {final _that = this;
switch (_that) {
case BridgeMessageTarget_Server():
return server();case BridgeMessageTarget_Channel():
return channel();case BridgeMessageTarget_Client():
return client(_that.field0);case BridgeMessageTarget_Poke():
return poke(_that.field0);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? server,TResult? Function()? channel,TResult? Function( BigInt field0)? client,TResult? Function( BigInt field0)? poke,}) {final _that = this;
switch (_that) {
case BridgeMessageTarget_Server() when server != null:
return server();case BridgeMessageTarget_Channel() when channel != null:
return channel();case BridgeMessageTarget_Client() when client != null:
return client(_that.field0);case BridgeMessageTarget_Poke() when poke != null:
return poke(_that.field0);case _:
return null;
}
}
}
/// @nodoc
class BridgeMessageTarget_Server extends BridgeMessageTarget {
const BridgeMessageTarget_Server(): super._();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Server);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'BridgeMessageTarget.server()';
}
}
/// @nodoc
class BridgeMessageTarget_Channel extends BridgeMessageTarget {
const BridgeMessageTarget_Channel(): super._();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Channel);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'BridgeMessageTarget.channel()';
}
}
/// @nodoc
class BridgeMessageTarget_Client extends BridgeMessageTarget {
const BridgeMessageTarget_Client(this.field0): super._();
final BigInt field0;
/// Create a copy of BridgeMessageTarget
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeMessageTarget_ClientCopyWith<BridgeMessageTarget_Client> get copyWith => _$BridgeMessageTarget_ClientCopyWithImpl<BridgeMessageTarget_Client>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Client&&(identical(other.field0, field0) || other.field0 == field0));
}
@override
int get hashCode => Object.hash(runtimeType,field0);
@override
String toString() {
return 'BridgeMessageTarget.client(field0: $field0)';
}
}
/// @nodoc
abstract mixin class $BridgeMessageTarget_ClientCopyWith<$Res> implements $BridgeMessageTargetCopyWith<$Res> {
factory $BridgeMessageTarget_ClientCopyWith(BridgeMessageTarget_Client value, $Res Function(BridgeMessageTarget_Client) _then) = _$BridgeMessageTarget_ClientCopyWithImpl;
@useResult
$Res call({
BigInt field0
});
}
/// @nodoc
class _$BridgeMessageTarget_ClientCopyWithImpl<$Res>
implements $BridgeMessageTarget_ClientCopyWith<$Res> {
_$BridgeMessageTarget_ClientCopyWithImpl(this._self, this._then);
final BridgeMessageTarget_Client _self;
final $Res Function(BridgeMessageTarget_Client) _then;
/// Create a copy of BridgeMessageTarget
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) {
return _then(BridgeMessageTarget_Client(
null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable
as BigInt,
));
}
}
/// @nodoc
class BridgeMessageTarget_Poke extends BridgeMessageTarget {
const BridgeMessageTarget_Poke(this.field0): super._();
final BigInt field0;
/// Create a copy of BridgeMessageTarget
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeMessageTarget_PokeCopyWith<BridgeMessageTarget_Poke> get copyWith => _$BridgeMessageTarget_PokeCopyWithImpl<BridgeMessageTarget_Poke>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Poke&&(identical(other.field0, field0) || other.field0 == field0));
}
@override
int get hashCode => Object.hash(runtimeType,field0);
@override
String toString() {
return 'BridgeMessageTarget.poke(field0: $field0)';
}
}
/// @nodoc
abstract mixin class $BridgeMessageTarget_PokeCopyWith<$Res> implements $BridgeMessageTargetCopyWith<$Res> {
factory $BridgeMessageTarget_PokeCopyWith(BridgeMessageTarget_Poke value, $Res Function(BridgeMessageTarget_Poke) _then) = _$BridgeMessageTarget_PokeCopyWithImpl;
@useResult
$Res call({
BigInt field0
});
}
/// @nodoc
class _$BridgeMessageTarget_PokeCopyWithImpl<$Res>
implements $BridgeMessageTarget_PokeCopyWith<$Res> {
_$BridgeMessageTarget_PokeCopyWithImpl(this._self, this._then);
final BridgeMessageTarget_Poke _self;
final $Res Function(BridgeMessageTarget_Poke) _then;
/// Create a copy of BridgeMessageTarget
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) {
return _then(BridgeMessageTarget_Poke(
null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable
as BigInt,
));
}
}
// dart format on
File diff suppressed because it is too large Load Diff
@@ -40,17 +40,29 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
@protected
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
@protected
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
@protected
BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw);
@protected
BridgeAudioDeviceList dco_decode_bridge_audio_device_list(dynamic raw);
@protected
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
dynamic raw,
@@ -90,9 +102,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
BridgeMessageTarget dco_decode_bridge_message_target(dynamic raw);
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
@protected
BridgePttDescriptor dco_decode_bridge_ptt_descriptor(dynamic raw);
@protected
BridgePttInputClass dco_decode_bridge_ptt_input_class(dynamic raw);
@@ -120,6 +141,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
@protected
List<BridgeBookmark> dco_decode_list_bridge_bookmark(dynamic raw);
@@ -132,22 +156,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@protected
(String, String, String) dco_decode_record_string_string_string(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@@ -185,11 +209,19 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeMessageTarget sse_decode_box_autoadd_bridge_message_target(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
@@ -198,6 +230,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeAudioDevice sse_decode_bridge_audio_device(
SseDeserializer deserializer,
);
@protected
BridgeAudioDeviceList sse_decode_bridge_audio_device_list(
SseDeserializer deserializer,
);
@protected
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
SseDeserializer deserializer,
@@ -239,11 +281,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeMessageTarget sse_decode_bridge_message_target(
SseDeserializer deserializer,
);
@protected
BridgeNetworkState sse_decode_bridge_network_state(
SseDeserializer deserializer,
);
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
@protected
BridgePttDescriptor sse_decode_bridge_ptt_descriptor(
SseDeserializer deserializer,
);
@protected
BridgePttInputClass sse_decode_bridge_ptt_input_class(
SseDeserializer deserializer,
@@ -279,6 +334,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
);
@protected
List<BridgeBookmark> sse_decode_list_bridge_bookmark(
SseDeserializer deserializer,
@@ -297,12 +357,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@@ -311,16 +377,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
);
@protected
(String, String, String) sse_decode_record_string_string_string(
SseDeserializer deserializer,
);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@@ -363,12 +419,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_message_target(
BridgeMessageTarget self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
@@ -378,6 +443,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_device(
BridgeAudioDevice self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_device_list(
BridgeAudioDeviceList self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_processing_config(
BridgeAudioProcessingConfig self,
@@ -432,12 +509,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_message_target(
BridgeMessageTarget self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_network_state(
BridgeNetworkState self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_descriptor(
BridgePttDescriptor self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_input_class(
BridgePttInputClass self,
@@ -483,6 +578,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_bridge_bookmark(
List<BridgeBookmark> self,
@@ -507,12 +608,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@@ -522,18 +629,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string(
(String, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string_string(
(String, String, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@@ -42,17 +42,29 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
@protected
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
@protected
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
@protected
BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw);
@protected
BridgeAudioDeviceList dco_decode_bridge_audio_device_list(dynamic raw);
@protected
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
dynamic raw,
@@ -92,9 +104,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
BridgeMessageTarget dco_decode_bridge_message_target(dynamic raw);
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
@protected
BridgePttDescriptor dco_decode_bridge_ptt_descriptor(dynamic raw);
@protected
BridgePttInputClass dco_decode_bridge_ptt_input_class(dynamic raw);
@@ -122,6 +143,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
@protected
List<BridgeBookmark> dco_decode_list_bridge_bookmark(dynamic raw);
@@ -134,22 +158,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@protected
(String, String, String) dco_decode_record_string_string_string(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@@ -187,11 +211,19 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeMessageTarget sse_decode_box_autoadd_bridge_message_target(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
@@ -200,6 +232,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeAudioDevice sse_decode_bridge_audio_device(
SseDeserializer deserializer,
);
@protected
BridgeAudioDeviceList sse_decode_bridge_audio_device_list(
SseDeserializer deserializer,
);
@protected
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
SseDeserializer deserializer,
@@ -241,11 +283,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeMessageTarget sse_decode_bridge_message_target(
SseDeserializer deserializer,
);
@protected
BridgeNetworkState sse_decode_bridge_network_state(
SseDeserializer deserializer,
);
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
@protected
BridgePttDescriptor sse_decode_bridge_ptt_descriptor(
SseDeserializer deserializer,
);
@protected
BridgePttInputClass sse_decode_bridge_ptt_input_class(
SseDeserializer deserializer,
@@ -281,6 +336,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
);
@protected
List<BridgeBookmark> sse_decode_list_bridge_bookmark(
SseDeserializer deserializer,
@@ -299,12 +359,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@@ -313,16 +379,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
);
@protected
(String, String, String) sse_decode_record_string_string_string(
SseDeserializer deserializer,
);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@@ -365,12 +421,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_message_target(
BridgeMessageTarget self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
@@ -380,6 +445,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_device(
BridgeAudioDevice self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_device_list(
BridgeAudioDeviceList self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_processing_config(
BridgeAudioProcessingConfig self,
@@ -434,12 +511,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_message_target(
BridgeMessageTarget self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_network_state(
BridgeNetworkState self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_descriptor(
BridgePttDescriptor self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_input_class(
BridgePttInputClass self,
@@ -485,6 +580,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_bridge_bookmark(
List<BridgeBookmark> self,
@@ -509,12 +610,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@@ -524,18 +631,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string(
(String, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string_string(
(String, String, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@@ -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,
);
}
+32 -8
View File
@@ -169,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@@ -177,14 +185,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_style:
dependency: transitive
description:
@@ -637,6 +637,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
url: "https://pub.dev"
source: hosted
version: "13.1.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
shared_preferences:
dependency: "direct main"
description:
@@ -850,6 +866,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
+1 -3
View File
@@ -34,9 +34,6 @@ dependencies:
sdk: flutter
intl: any
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_rust_bridge: 2.12.0
freezed_annotation: ^3.1.0
connectivity_plus: ^7.1.1
@@ -80,6 +77,7 @@ dependencies:
flutter_foreground_task: ^9.2.2
url_launcher: ^6.3.2
shared_preferences: ^2.5.5
share_plus: ^13.1.0
dev_dependencies:
flutter_test:
@@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/android_audio_output_devices.dart';
void main() {
test('parses Android audio output devices and skips non-maps', () {
final devices = parseAndroidAudioOutputDevices([
{
'id': 12,
'name': 'Speaker',
'type': 'speaker',
'isSelected': true,
'isAvailableForCommunication': true,
},
'bad',
{'id': null, 'name': null},
]);
expect(devices, hasLength(2));
expect(devices.first.id, '12');
expect(devices.first.name, 'Speaker');
expect(devices.first.type, 'speaker');
expect(devices.first.isSelected, isTrue);
expect(devices.first.isAvailableForCommunication, isTrue);
expect(devices.last.id, isEmpty);
expect(devices.last.name, isEmpty);
expect(devices.last.type, 'unknown');
});
test('finds selected Android audio output device', () {
final selected = selectedAndroidAudioOutputDevice([
const AndroidAudioOutputDevice(
id: '1',
name: 'Speaker',
type: 'speaker',
isSelected: false,
isAvailableForCommunication: true,
),
const AndroidAudioOutputDevice(
id: '2',
name: 'Headset',
type: 'wiredHeadset',
isSelected: true,
isAvailableForCommunication: true,
),
]);
expect(selected?.id, '2');
expect(selectedAndroidAudioOutputDevice(const []), isNull);
});
}
@@ -0,0 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/app_bootstrap.dart';
void main() {
test('app version appends platform build number', () {
expect(
appVersionFromBuildNumber(
semverBaseline: 'v1.2.3-rc.4',
buildNumber: '56',
),
'v1.2.3-rc.4+56',
);
});
test('app version keeps baseline when build number is empty', () {
expect(
appVersionFromBuildNumber(semverBaseline: 'v1.2.3-rc.4', buildNumber: ''),
'v1.2.3-rc.4',
);
});
}
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/audio_lifecycle_service.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
test('parseBridgeAudioRoute maps platform route names', () {
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
expect(
parseBridgeAudioRoute('WiredHeadset'),
rust.BridgeAudioRoute.wiredHeadset,
);
expect(
parseBridgeAudioRoute('BluetoothHfp'),
rust.BridgeAudioRoute.bluetoothHfp,
);
expect(
parseBridgeAudioRoute('BluetoothA2dp'),
rust.BridgeAudioRoute.bluetoothA2Dp,
);
expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown);
expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown);
});
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/services/channel_join_error_mapper.dart';
import 'package:chanora_flutter/src/rust/lib.dart' as rust_err;
void main() {
Future<AppL10n> loadEnglishL10n(WidgetTester tester) async {
late AppL10n l10n;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(
builder: (context) {
l10n = AppL10n.of(context);
return const SizedBox.shrink();
},
),
),
);
return l10n;
}
testWidgets('maps known TS3 server rejection codes', (tester) async {
final l10n = await loadEnglishL10n(tester);
expect(
channelJoinErrorMessage(
l10n,
const rust_err.BridgeError.serverRejected(
code: 0x030d,
message: 'invalid password',
),
),
l10n.channelJoinFailedPassword,
);
expect(
channelJoinErrorMessage(
l10n,
const rust_err.BridgeError.serverRejected(
code: 0x0a08,
message: 'insufficient permissions',
),
),
l10n.channelJoinFailedPermission,
);
});
testWidgets('falls back for generic and unknown errors', (tester) async {
final l10n = await loadEnglishL10n(tester);
expect(
channelJoinErrorMessage(
l10n,
const rust_err.BridgeError.serverRejected(
code: 0xffff,
message: 'custom server error',
),
),
l10n.channelJoinFailedGeneric('custom server error'),
);
expect(
channelJoinErrorMessage(l10n, 'plain error'),
l10n.channelJoinFailedGeneric('plain error'),
);
});
}
@@ -0,0 +1,127 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/channel_spacer.dart';
void main() {
test('classifies only valid bracketed spacer tags as spacer channels', () {
expect(isSpacerChannelName('[Spacer0]Lobby'), isTrue);
expect(isSpacerChannelName('[cSpacer]Lobby'), isTrue);
expect(isSpacerChannelName('[*spacer0]#=='), isTrue);
expect(isSpacerChannelName('Lobby spacer room'), isFalse);
expect(isSpacerChannelName('prefix [Spacer0]Lobby'), isFalse);
expect(isSpacerChannelName('[xSpacer0]Lobby'), isFalse);
});
test('parses valid spacer names without requiring numeric suffixes', () {
final parsed = parseSpacerChannelName('[cSpAcErabc-01] Lobby ');
expect(parsed.isSpacer, isTrue);
expect(parsed.isValid, isTrue);
expect(parsed.alignment, SpacerAlignment.center);
expect(parsed.isRepeating, isFalse);
expect(parsed.uniqueSuffix, 'abc-01');
expect(parsed.text, ' Lobby ');
expect(parsed.specialType, isNull);
expect(parsed.isBlankSpacer, isFalse);
expect(parsed.reason, isNull);
});
test('parses all special separator line values', () {
expect(
parseSpacerChannelName('[Spacer0]___').specialType,
SpacerSpecialType.solidLine,
);
expect(
parseSpacerChannelName('[Spacer0]---').specialType,
SpacerSpecialType.dashLine,
);
expect(
parseSpacerChannelName('[Spacer0]...').specialType,
SpacerSpecialType.dotLine,
);
expect(
parseSpacerChannelName('[Spacer0]-.-').specialType,
SpacerSpecialType.dashDotLine,
);
expect(
parseSpacerChannelName('[Spacer0]-..').specialType,
SpacerSpecialType.dashDotDotLine,
);
});
test('parses repeating spacer text exactly', () {
final parsed = parseSpacerChannelName('[*spacer0]#==');
expect(parsed.isSpacer, isTrue);
expect(parsed.isValid, isTrue);
expect(parsed.isRepeating, isTrue);
expect(parsed.uniqueSuffix, '0');
expect(parsed.text, '#==');
expect(channelSpacerLabel('[*spacer0]#=='), startsWith('#==#=='));
});
test('parses known blank-looking right spacer', () {
final parsed = parseSpacerChannelName('[rSpacer0].');
expect(parsed.isSpacer, isTrue);
expect(parsed.alignment, SpacerAlignment.right);
expect(parsed.text, '.');
expect(parsed.isBlankSpacer, isTrue);
expect(channelSpacerLabel('[rSpacer0].'), isEmpty);
});
test('reports malformed spacer-like names', () {
final missingBracket = parseSpacerChannelName('[cSpacer0');
expect(missingBracket.isSpacer, isFalse);
expect(missingBracket.isValid, isFalse);
expect(missingBracket.reason, 'missing closing bracket');
final invalidFlag = parseSpacerChannelName('[xSpacer0]Lobby');
expect(invalidFlag.isSpacer, isFalse);
expect(invalidFlag.isValid, isFalse);
expect(invalidFlag.reason, 'invalid spacer tag');
final duplicateAlignment = parseSpacerChannelName('[lcSpacer0]Lobby');
expect(duplicateAlignment.isSpacer, isFalse);
expect(duplicateAlignment.isValid, isFalse);
expect(duplicateAlignment.reason, 'multiple alignment flags');
});
test('reports ordinary channel names as non-spacers', () {
final parsed = parseSpacerChannelName('A channel with spacer in its name');
expect(parsed.isSpacer, isFalse);
expect(parsed.isValid, isFalse);
expect(parsed.reason, 'not a spacer channel name');
expect(
channelSpacerLabel('A channel with spacer in its name'),
'A channel with spacer in its name',
);
});
test('formats spacer names deterministically', () {
final formatted = formatSpacerChannelName(
const SpacerChannelNameFormatOptions(
alignment: SpacerAlignment.right,
isRepeating: true,
uniqueSuffix: 'abc',
text: '#==',
),
);
expect(formatted, '[*rSpacerabc]#==');
final parsed = parseSpacerChannelName(formatted);
expect(parsed.isSpacer, isTrue);
expect(parsed.alignment, SpacerAlignment.right);
expect(parsed.isRepeating, isTrue);
expect(parsed.uniqueSuffix, 'abc');
expect(parsed.text, '#==');
});
test('formats existing display labels through the utility', () {
expect(channelSpacerLabel('[cSpacer]Lobby'), 'Lobby');
expect(channelSpacerLabel('[Spacer0]___'), '────────');
expect(channelSpacerLabel('Lobby'), 'Lobby');
});
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations_en.dart';
import 'package:chanora_flutter/services/connection_phase_state.dart';
void main() {
final l10n = AppL10nEn();
test('connection phases expose shared predicates', () {
expect(ConnectionPhase.idle.isServerReachable, isFalse);
expect(ConnectionPhase.connecting.isServerReachable, isFalse);
expect(ConnectionPhase.synchronizing.isServerReachable, isTrue);
expect(ConnectionPhase.connected.isServerReachable, isTrue);
expect(ConnectionPhase.reconnecting.isServerReachable, isTrue);
expect(ConnectionPhase.disconnected.isServerReachable, isFalse);
expect(ConnectionPhase.synchronizing.canOpenChat, isTrue);
expect(ConnectionPhase.connected.canOpenChat, isTrue);
expect(ConnectionPhase.reconnecting.canOpenChat, isFalse);
expect(ConnectionPhase.connected.canDisconnect, isTrue);
});
test('connection status text maps all phases', () {
expect(
connectionStatusText(phase: ConnectionPhase.idle, l10n: l10n),
l10n.statusIdle,
);
expect(
connectionStatusText(phase: ConnectionPhase.connecting, l10n: l10n),
l10n.statusConnecting,
);
expect(
connectionStatusText(phase: ConnectionPhase.synchronizing, l10n: l10n),
'Synchronizing...',
);
expect(
connectionStatusText(
phase: ConnectionPhase.connected,
l10n: l10n,
serverName: 'Server',
),
'Connected to Server',
);
expect(
connectionStatusText(
phase: ConnectionPhase.reconnecting,
l10n: l10n,
reconnectAttempt: 2,
reconnectDelay: 5,
),
l10n.statusReconnecting(2, 5),
);
expect(
connectionStatusText(
phase: ConnectionPhase.reconnecting,
l10n: l10n,
lostReason: 'network',
),
'Connection lost: network',
);
expect(
connectionStatusText(phase: ConnectionPhase.disconnected, l10n: l10n),
l10n.statusIdle,
);
});
test('connection phases map to token icons', () {
final scheme = ColorScheme.fromSeed(seedColor: Colors.indigo);
expect(ConnectionPhase.idle.tokens(scheme).icon, Icons.cloud_off);
expect(ConnectionPhase.connecting.tokens(scheme).icon, Icons.sync);
expect(
ConnectionPhase.synchronizing.tokens(scheme).icon,
Icons.hourglass_top,
);
expect(ConnectionPhase.connected.tokens(scheme).icon, Icons.cloud_done);
expect(ConnectionPhase.reconnecting.tokens(scheme).icon, Icons.restart_alt);
expect(ConnectionPhase.disconnected.tokens(scheme).icon, Icons.cloud_off);
});
}
@@ -0,0 +1,152 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/snapshot_state_mapper.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
rust.BridgeChannel channel({int neededTalkPower = 0}) {
return rust.BridgeChannel(
id: BigInt.one,
parent: BigInt.zero,
name: 'Lobby',
order: 0,
hasPassword: false,
neededTalkPower: neededTalkPower,
);
}
rust.BridgeClient client({
BigInt? id,
BigInt? channelId,
int talkPower = 0,
bool talkPowerGranted = false,
bool inputMuted = false,
bool outputMuted = false,
}) {
return rust.BridgeClient(
id: id ?? BigInt.from(7),
channel: channelId ?? BigInt.one,
name: 'Me',
inputMuted: inputMuted,
outputMuted: outputMuted,
isSpeaking: false,
isServerQuery: false,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
);
}
rust.BridgeSnapshot snapshot({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
BigInt? ownClientId,
}) {
return rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: channels,
clients: clients,
ownClientId: ownClientId ?? BigInt.from(7),
);
}
test('extracts own channel and mute state', () {
final state = ownClientSnapshotState(
snapshot(
channels: [channel()],
clients: [client(inputMuted: true, outputMuted: true)],
),
);
expect(state?.channelId, BigInt.one);
expect(state?.inputMuted, isTrue);
expect(state?.outputMuted, isTrue);
expect(state?.talkPowerOk, isTrue);
expect(state?.talkPower, 0);
expect(state?.talkPowerGranted, isFalse);
expect(state?.neededTalkPower, 0);
});
test('detects insufficient talk power', () {
final state = ownClientSnapshotState(
snapshot(
channels: [channel(neededTalkPower: 20)],
clients: [client(talkPower: 10)],
),
);
expect(state?.talkPowerOk, isFalse);
expect(state?.talkPower, 10);
expect(state?.neededTalkPower, 20);
});
test('accepts granted or sufficient talk power', () {
final sufficient = ownClientSnapshotState(
snapshot(
channels: [channel(neededTalkPower: 20)],
clients: [client(talkPower: 20)],
),
);
final granted = ownClientSnapshotState(
snapshot(
channels: [channel(neededTalkPower: 20)],
clients: [client(talkPowerGranted: true)],
),
);
expect(sufficient?.talkPowerOk, isTrue);
expect(granted?.talkPowerOk, isTrue);
});
test('returns null when own client is absent', () {
final state = ownClientSnapshotState(
snapshot(
channels: [channel()],
clients: [client(id: BigInt.from(9))],
),
);
expect(state, isNull);
});
test('resolves channel name by id', () {
final snap = snapshot(
channels: [
channel(),
rust.BridgeChannel(
id: BigInt.from(2),
parent: BigInt.zero,
name: 'Raid Room',
order: 1,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: [client()],
);
expect(snapshotChannelName(snap, BigInt.from(2)), 'Raid Room');
});
test('returns empty channel name when snapshot or channel is absent', () {
final snap = snapshot(channels: [channel()], clients: [client()]);
expect(snapshotChannelName(null, BigInt.one), isEmpty);
expect(snapshotChannelName(snap, null), isEmpty);
expect(snapshotChannelName(snap, BigInt.from(99)), isEmpty);
});
test('resolves needed talk power by channel id', () {
final snap = snapshot(
channels: [channel(neededTalkPower: 30)],
clients: [client()],
);
expect(snapshotNeededTalkPower(snap, BigInt.one), 30);
expect(snapshotNeededTalkPower(null, BigInt.one), isNull);
expect(snapshotNeededTalkPower(snap, null), isNull);
expect(snapshotNeededTalkPower(snap, BigInt.from(99)), isNull);
});
}
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/ts3_server_link.dart';
void main() {
test('parses encoded add-bookmark TeamSpeak link', () {
final link = parseTs3ServerLink(
'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/',
);
expect(link, isNotNull);
expect(link!.host, 'teamspeak.app');
expect(link.hostWithPort, 'teamspeak.app');
expect(link.addBookmark, 'Vigorous Pro');
});
test('parses full TeamSpeak server link parameters', () {
final link = parseTs3ServerLink(
'ts3server://ts3.hoster.com?port=9987&nickname=UserNickname'
'&password=serverPassword&channel=MyDefaultChannel&cid=123'
'&channelpassword=defaultChannelPassword&token=TokenKey'
'&addbookmark=MyBookMarkLabel',
);
expect(link, isNotNull);
expect(link!.host, 'ts3.hoster.com');
expect(link.hostWithPort, 'ts3.hoster.com:9987');
expect(link.port, 9987);
expect(link.nickname, 'UserNickname');
expect(link.password, 'serverPassword');
expect(link.channel, 'MyDefaultChannel');
expect(link.cid, '123');
expect(link.channelPassword, 'defaultChannelPassword');
expect(link.token, 'TokenKey');
expect(link.addBookmark, 'MyBookMarkLabel');
});
test('keeps explicit host port without duplicating port query', () {
final link = parseTs3ServerLink('ts3server://ts3.hoster.com:9987');
expect(link, isNotNull);
expect(link!.hostWithPort, 'ts3.hoster.com:9987');
});
}
@@ -16,7 +16,6 @@ void main() {
expect(settings.host, isEmpty);
expect(settings.nickname, isEmpty);
expect(settings.showPokeDialogs, isTrue);
});
test('saves and loads host and nickname independently', () async {
@@ -33,21 +32,6 @@ void main() {
expect(settings.nickname, 'Chanora');
});
test('saves poke dialog preference independently', () async {
await service.saveSettings(showPokeDialogs: false);
var settings = await service.loadSettings();
expect(settings.showPokeDialogs, isFalse);
expect(settings.host, isEmpty);
expect(settings.nickname, isEmpty);
await service.saveSettings(host: 'example.com');
settings = await service.loadSettings();
expect(settings.showPokeDialogs, isFalse);
expect(settings.host, 'example.com');
});
test('tracks permission explanation flag', () async {
expect(await service.hasExplainedPermissions(), isFalse);
@@ -190,6 +190,34 @@ void main() {
expect(states, [true, false]);
});
testWidgets('on-screen PTT can release from pan gestures', (tester) async {
final states = <bool>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: VoicePttButton(
active: false,
listenForPan: true,
onHeldChanged: states.add,
),
),
),
);
final center = tester.getCenter(find.byType(VoicePttButton));
final gesture = await tester.startGesture(center);
await tester.pump();
await gesture.moveBy(const Offset(0, 24));
await tester.pump();
await gesture.up();
await tester.pump();
expect(states, [true, false]);
});
testWidgets('iOS permission service maps channel states and settings', (
tester,
) async {
@@ -0,0 +1,32 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/audio_device_list_tile.dart';
void main() {
testWidgets(
'audio device tile renders loading state before devices resolve',
(tester) async {
final pendingDevices = Completer<rust.BridgeAudioDeviceList>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AudioDeviceListTile(
label: 'Input',
kind: AudioDeviceKind.input,
loadDevices: () => pendingDevices.future,
),
),
),
);
expect(find.text('Input'), findsOneWidget);
expect(find.text('Loading...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
},
);
}
@@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/audio_processing_config_state.dart';
void main() {
const baseConfig = rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.unknown,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: rust.BridgeVadBackend.disabled,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.off,
agc: rust.BridgeEffectOwner.webrtcApm,
hpfEnabled: true,
limiterEnabled: false,
vadHangoverMs: 500,
vadPreRollMs: 160,
vadMinTxMs: 200,
debugWavDumpEnabled: true,
);
test('normalizes hidden disabled VAD backend for UI state', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig);
expect(state.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(state.preferHardware, isTrue);
expect(state.nsEnabled, isFalse);
expect(state.aecEnabled, isTrue);
expect(state.agcEnabled, isTrue);
});
test('default config uses platform processing and Silero VAD', () {
expect(
defaultAudioProcessingConfig.processingBackend,
rust.BridgeAudioBackend.platformVoiceProcessing,
);
expect(
defaultAudioProcessingConfig.vadBackend,
rust.BridgeVadBackend.sileroOnnx,
);
expect(defaultAudioProcessingConfig.aec, rust.BridgeEffectOwner.platform);
expect(defaultAudioProcessingConfig.ns, rust.BridgeEffectOwner.platform);
expect(defaultAudioProcessingConfig.agc, rust.BridgeEffectOwner.platform);
});
test('builds Android hardware config consistently', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig)
..preferHardware = true
..nsEnabled = true
..aecEnabled = false
..agcEnabled = true;
final config = state.buildConfig(base: baseConfig, isAndroid: true);
expect(
config.processingBackend,
rust.BridgeAudioBackend.platformVoiceProcessing,
);
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(config.aec, rust.BridgeEffectOwner.off);
expect(config.ns, rust.BridgeEffectOwner.platform);
expect(config.agc, rust.BridgeEffectOwner.platform);
expect(config.debugWavDumpEnabled, isTrue);
});
test('builds Sonora config with WebRTC-owned enabled effects', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig)
..iosMode = rust.BridgeIosVoiceProcessingMode.sonoraExperimental
..nsEnabled = true
..aecEnabled = false
..agcEnabled = true;
final config = state.buildConfig(base: baseConfig, isAndroid: false);
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
expect(config.aec, rust.BridgeEffectOwner.off);
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
});
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/services/link_trust_service.dart';
import 'package:chanora_flutter/services/ts3_server_link.dart';
import 'package:chanora_flutter/widgets/bbcode_text.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('renders links without underline decoration', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: BbCodeText(
'https://example.com',
linkTrust: LinkTrustService.instance,
),
),
);
final linkText = tester.widget<Text>(find.text('https://example.com'));
expect(linkText.style?.color, Colors.blue);
expect(linkText.style?.decoration, isNull);
});
testWidgets('handles TeamSpeak server links without browser launch', (
tester,
) async {
Ts3ServerLink? tapped;
await tester.pumpWidget(
MaterialApp(
home: BbCodeText(
'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/',
linkTrust: LinkTrustService.instance,
onTs3ServerLink: (link) async => tapped = link,
),
),
);
await tester.tap(
find.text('ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/'),
);
await tester.pump();
expect(tapped, isNotNull);
expect(tapped!.host, 'teamspeak.app');
expect(tapped!.addBookmark, 'Vigorous Pro');
});
}
@@ -331,25 +331,98 @@ void main() {
);
});
testWidgets('chat hub does not expose pokes as a chat tab', (tester) async {
testWidgets('chat sidebar keeps server and channel fixed above privates', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alpha',
message: 'Private',
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
),
entry(const rust.BridgeMessageTarget.server()),
],
snapshot: snapshot(channels: const [], clients: const []),
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.zero),
],
),
),
),
);
expect(find.text('Direct Messages'), findsOneWidget);
expect(find.text('Server Activity'), findsOneWidget);
expect(find.text('Server'), findsWidgets);
expect(find.text('Channel'), findsOneWidget);
expect(find.text('Alpha'), findsWidgets);
expect(find.text('Pokes'), findsNothing);
expect(find.text('No pokes'), findsNothing);
final serverTop = tester.getTopLeft(find.text('Server').first).dy;
final channelTop = tester.getTopLeft(find.text('Channel')).dy;
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
expect(serverTop, lessThan(channelTop));
expect(channelTop, lessThan(privateTop));
});
testWidgets(
'plus opens user picker and close removes selected private chat',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alpha',
message: 'Private',
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
),
],
snapshot: snapshot(
channels: const [],
clients: [
client(
id: BigInt.from(2),
name: 'Alpha',
channelId: BigInt.zero,
),
client(
id: BigInt.from(3),
name: 'Bravo',
channelId: BigInt.zero,
),
],
),
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
initialClientName: 'Alpha',
),
),
);
expect(find.text('Alpha'), findsWidgets);
expect(find.byTooltip('Close chat'), findsOneWidget);
await tester.tap(find.byTooltip('Close chat'));
await tester.pump();
expect(find.text('Alpha'), findsNothing);
expect(find.text('Server Activity'), findsOneWidget);
await tester.tap(find.byTooltip('New private chat'));
await tester.pumpAndSettle();
expect(find.text('Search clients...'), findsOneWidget);
expect(find.text('Bravo'), findsOneWidget);
},
);
testWidgets('channel chat displays localized poke history in English', (
tester,
) async {
@@ -0,0 +1,437 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/snapshot_view.dart';
void main() {
rust.BridgeChannel channel({
required int id,
int parent = 0,
required String name,
int order = 0,
bool hasPassword = false,
int neededTalkPower = 0,
}) {
return rust.BridgeChannel(
id: BigInt.from(id),
parent: BigInt.from(parent),
name: name,
order: order,
hasPassword: hasPassword,
neededTalkPower: neededTalkPower,
);
}
rust.BridgeClient client({
required int id,
required int channelId,
required String name,
bool speaking = false,
int talkPower = 0,
bool talkPowerGranted = false,
}) {
return rust.BridgeClient(
id: BigInt.from(id),
channel: BigInt.from(channelId),
name: name,
inputMuted: false,
outputMuted: false,
isSpeaking: speaking,
isServerQuery: false,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
);
}
Widget snapshotHarness({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
BigInt? ownClientId,
BigInt? currentVoiceChannelId,
rust.BridgeAudioStats? audioStats,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: channels,
clients: clients,
ownClientId: ownClientId ?? BigInt.from(100),
),
audioStats: audioStats,
currentVoiceChannelId: currentVoiceChannelId,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
);
}
testWidgets('renders channel tree with users and subchannels expanded', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Quiet Zone'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
client(id: 102, channelId: 3, name: 'Carol'),
],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
),
);
await tester.pumpAndSettle();
expect(find.text('Default Channel'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Default Sub Channel'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Quiet Zone'), findsOneWidget);
expect(find.text('Carol'), findsOneWidget);
expect(
tester.getTopLeft(find.text('Alice')).dy,
greaterThan(tester.getTopLeft(find.text('Default Channel')).dy),
);
expect(
tester.getTopLeft(find.text('Default Sub Channel')).dy,
greaterThan(tester.getTopLeft(find.text('Alice')).dy),
);
expect(
tester.getTopLeft(find.text('Bob')).dy,
greaterThan(tester.getTopLeft(find.text('Default Sub Channel')).dy),
);
});
testWidgets('collapsing a channel hides users and child channels', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Quiet Zone'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
client(id: 102, channelId: 3, name: 'Carol'),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.expand_more).first);
await tester.pumpAndSettle();
expect(find.text('Default Channel'), findsOneWidget);
expect(find.text('Alice'), findsNothing);
expect(find.text('Default Sub Channel'), findsNothing);
expect(find.text('Bob'), findsNothing);
expect(find.text('Quiet Zone'), findsOneWidget);
expect(find.text('Carol'), findsOneWidget);
await tester.tap(find.byIcon(Icons.chevron_right).first);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Default Sub Channel'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
});
testWidgets('channel tree uses compact fixed columns', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Empty Channel'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
],
),
);
await tester.pumpAndSettle();
final parentX = tester.getTopLeft(find.text('Default Channel')).dx;
final parentUserX = tester.getTopLeft(find.text('Alice')).dx;
final childX = tester.getTopLeft(find.text('Default Sub Channel')).dx;
final childUserX = tester.getTopLeft(find.text('Bob')).dx;
expect(find.text('Channels'), findsNothing);
expect(find.byIcon(Icons.tag), findsNWidgets(3));
expect(parentX, inInclusiveRange(58, 66));
expect(parentUserX - parentX, inInclusiveRange(22, 28));
expect(childX - parentX, inInclusiveRange(10, 14));
expect(childUserX - childX, inInclusiveRange(22, 28));
});
testWidgets('password channel shows lock at row end', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Locked Channel', hasPassword: true)],
clients: const [],
),
);
await tester.pumpAndSettle();
final rowRight = tester.getTopRight(find.text('Locked Channel')).dx;
final lockLeft = tester.getTopLeft(find.byIcon(Icons.lock_outline)).dx;
expect(find.byIcon(Icons.tag), findsOneWidget);
expect(lockLeft, greaterThan(rowRight));
});
testWidgets('current user row does not show speaking background while idle', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [client(id: 100, channelId: 1, name: 'Alice')],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
),
);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.byIcon(Icons.tag), findsOneWidget);
expect(find.byIcon(Icons.mic_none), findsOneWidget);
expect(find.byType(ListTile), findsOneWidget);
final userHighlight = tester.widget<AnimatedContainer>(
find.ancestor(
of: find.text('Alice'),
matching: find.byType(AnimatedContainer),
),
);
expect(userHighlight.decoration, isNull);
});
testWidgets(
'local voice activity does not light speaking state when blocked',
(tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel', neededTalkPower: 10),
],
clients: [client(id: 100, channelId: 1, name: 'Alice', talkPower: 0)],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
audioStats: const rust.BridgeAudioStats(
framesSent: 1,
framesReceived: 0,
pttActive: true,
),
),
);
await tester.pumpAndSettle();
final userText = tester.widget<Text>(find.text('Alice'));
expect(find.byIcon(Icons.volume_off), findsOneWidget);
expect(find.byIcon(Icons.mic), findsNothing);
expect(userText.style?.fontWeight, isNot(FontWeight.w600));
},
);
testWidgets('spacer channels render as layout rows and keep channel taps', (
tester,
) async {
final spacerChannel = rust.BridgeChannel(
id: BigInt.from(42),
parent: BigInt.from(7),
name: '[cSpacerabc]Spacer Heading',
order: 99,
hasPassword: false,
neededTalkPower: 12,
);
final normalChannel = rust.BridgeChannel(
id: BigInt.from(43),
parent: BigInt.zero,
name: 'Normal Room',
order: 100,
hasPassword: false,
neededTalkPower: 0,
);
rust.BridgeChannel? tapped;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [spacerChannel, normalChannel],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (channel) => tapped = channel,
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Spacer Heading'), findsOneWidget);
expect(find.text('[cSpacerabc]Spacer Heading'), findsNothing);
expect(find.byIcon(Icons.tag), findsOneWidget);
final spacerText = tester.widget<Text>(find.text('Spacer Heading'));
expect(spacerText.textAlign, TextAlign.center);
await tester.tap(find.text('Spacer Heading'));
await tester.pump();
expect(tapped, isNotNull);
expect(tapped!.id, BigInt.from(42));
expect(tapped!.parent, BigInt.from(7));
expect(tapped!.name, '[cSpacerabc]Spacer Heading');
expect(tapped!.order, 99);
expect(tapped!.hasPassword, isFalse);
expect(tapped!.neededTalkPower, 12);
});
testWidgets('separator spacers render as line painters without raw text', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: BigInt.from(1),
parent: BigInt.zero,
name: '[spacer]---',
order: 1,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('[spacer]---'), findsNothing);
expect(find.text('---'), findsNothing);
expect(find.byType(CustomPaint), findsWidgets);
expect(find.byIcon(Icons.tag), findsNothing);
});
testWidgets('repeating and blank spacers hide raw tags', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: BigInt.from(1),
parent: BigInt.zero,
name: '[*spacer0]#==',
order: 1,
hasPassword: false,
neededTalkPower: 0,
),
rust.BridgeChannel(
id: BigInt.from(2),
parent: BigInt.zero,
name: '[rSpacer0].',
order: 2,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('[*spacer0]#=='), findsNothing);
expect(find.text('[rSpacer0].'), findsNothing);
expect(find.text('.'), findsNothing);
expect(find.textContaining('#==#=='), findsOneWidget);
expect(find.byIcon(Icons.tag), findsNothing);
});
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/widgets/talk_power_warning.dart';
void main() {
test('talk power policy only blocks insufficient ungranted clients', () {
expect(
isTalkPowerBlocked(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
),
isTrue,
);
expect(
isTalkPowerBlocked(
talkPower: 10,
neededTalkPower: 10,
talkPowerGranted: false,
),
isFalse,
);
expect(
isTalkPowerBlocked(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: true,
),
isFalse,
);
expect(
isTalkPowerBlocked(
talkPower: null,
neededTalkPower: 10,
talkPowerGranted: false,
),
isFalse,
);
});
testWidgets('talk power warning hides when not blocked', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: TalkPowerWarning(
talkPower: 10,
neededTalkPower: 10,
talkPowerGranted: false,
),
),
);
expect(find.textContaining('Insufficient talk power'), findsNothing);
expect(find.byType(SizedBox), findsOneWidget);
});
testWidgets('talk power warning renders blocked values', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: TalkPowerWarning(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
),
),
),
);
expect(find.text('Insufficient talk power (5 < 10)'), findsOneWidget);
expect(find.byIcon(Icons.warning_amber), findsOneWidget);
});
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/voice_settings_controls.dart';
void main() {
test('shared transmit mode segments expose all modes', () {
expect(transmitModeSegments.map((s) => s.value), [
rust.BridgeTransmitMode.ptt,
rust.BridgeTransmitMode.continuous,
rust.BridgeTransmitMode.voiceActivity,
]);
});
test('shared Android processing segments expose hardware and WebRTC', () {
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
});
test('shared iOS processing segments expose VPIO and Sonora', () {
expect(iosProcessingSegments.map((s) => s.value), [
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
]);
});
test('shared VAD segments expose supported non-disabled backends', () {
expect(vadBackendSegments.map((s) => s.value), [
rust.BridgeVadBackend.webrtcVad,
rust.BridgeVadBackend.sileroOnnx,
rust.BridgeVadBackend.tenVad,
]);
});
testWidgets('shared segmented style applies compact visual density', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
final style = voiceSegmentedButtonStyle(Theme.of(context));
return SegmentedButton<bool>(
style: style,
segments: androidProcessingSegments,
selected: const {true},
);
},
),
),
);
expect(find.byType(SegmentedButton<bool>), findsOneWidget);
});
testWidgets('audio processing toggle row renders dense layout', (
tester,
) async {
var value = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
return AudioProcessingToggleRow(
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: value,
onChanged: (next) => setState(() => value = next),
);
},
),
),
),
);
await tester.tap(find.byType(Switch));
await tester.pump();
expect(value, isTrue);
expect(find.text('Noise suppression'), findsOneWidget);
expect(find.text('Wiener filter'), findsOneWidget);
});
}
@@ -0,0 +1,106 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations_en.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/voice_status_summary.dart';
void main() {
final l10n = AppL10nEn();
test('voice mode labels follow localization', () {
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.ptt),
l10n.voiceModePtt,
);
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.continuous),
l10n.voiceModeContinuous,
);
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.voiceActivity),
l10n.voiceModeVoiceActivity,
);
});
test('PTT summary shows touch hold hint and release tail', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 200,
pttBoundKeyLabel: '',
isTouchOnly: true,
inputMuted: false,
outputMuted: false,
pttActive: false,
);
expect(summary.line1, '${l10n.voiceModePtt} · ${l10n.voicePttHoldHint}');
expect(summary.line2, '200${l10n.voiceReleaseTailHint} release tail · off');
expect(summary.statusText, l10n.voiceMicOff);
expect(summary.micOn, isFalse);
});
test('PTT summary shows bound key on hardware hosts', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 120,
pttBoundKeyLabel: 'Space',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: true,
);
expect(summary.line1, '${l10n.voiceModePtt} · Space');
expect(summary.micOn, isTrue);
expect(summary.statusText, l10n.voiceMicOn);
});
test('continuous mode is active unless muted or talk power blocked', () {
final active = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: false,
);
final muted = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: true,
outputMuted: false,
pttActive: false,
);
expect(active.micOn, isTrue);
expect(muted.micOn, isFalse);
expect(muted.statusText, '${l10n.voiceMicOff} (muted)');
});
test('talk power block overrides active mic state', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: true,
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
);
expect(summary.talkPowerBlocked, isTrue);
expect(summary.micOn, isFalse);
expect(summary.statusText, 'Insufficient permission');
});
}
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
share_plus
url_launcher_windows
)