chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -352,22 +352,7 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
|
||||
).firstOrNull { File(it).resolve("cargo").exists() }
|
||||
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
|
||||
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
|
||||
val hostArch = System.getProperty("os.arch").lowercase()
|
||||
val defaultRustupToolchain = when {
|
||||
org.gradle.internal.os.OperatingSystem.current().isMacOsX && hostArch.contains("aarch64") ->
|
||||
"stable-aarch64-apple-darwin"
|
||||
org.gradle.internal.os.OperatingSystem.current().isMacOsX ->
|
||||
"stable-x86_64-apple-darwin"
|
||||
org.gradle.internal.os.OperatingSystem.current().isWindows && hostArch.contains("aarch64") ->
|
||||
"stable-aarch64-pc-windows-msvc"
|
||||
org.gradle.internal.os.OperatingSystem.current().isWindows ->
|
||||
"stable-x86_64-pc-windows-msvc"
|
||||
hostArch.contains("aarch64") || hostArch.contains("arm64") ->
|
||||
"stable-aarch64-unknown-linux-gnu"
|
||||
else ->
|
||||
"stable-x86_64-unknown-linux-gnu"
|
||||
}
|
||||
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: defaultRustupToolchain
|
||||
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
|
||||
@@ -553,7 +538,7 @@ fun registerJniLibsCopyTask(profile: String): TaskProvider<Task> {
|
||||
// version bump replacing libc++_shared.so).
|
||||
inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi")
|
||||
inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi")
|
||||
inputs.file(llvmStrip).withPropertyName("llvmStrip")
|
||||
inputs.file(llvmStrip).withPropertyName("llvmStrip_$abi")
|
||||
outputs.file(strippedBridgeSo)
|
||||
outputs.file(File(jniLibsDir, "$abi/libchanora_bridge.so"))
|
||||
outputs.file(File(jniLibsDir, "$abi/libc++_shared.so"))
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -34,54 +34,32 @@ import AVFoundation
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
// Mode rationale (re-revisited after the "low playback
|
||||
// volume" investigation, May 2026):
|
||||
mode: .voiceChat,
|
||||
// Mode rationale (May 2026, .voiceChat reinstated):
|
||||
//
|
||||
// We've cycled through .voiceChat -> .default -> .voiceChat
|
||||
// -> .default. Final answer is .default with
|
||||
// .defaultToSpeaker, driven by these findings:
|
||||
// We previously used .default mode after discovering that
|
||||
// .voiceChat routed output through iOS's in-call audio
|
||||
// channel, which made speaker output barely audible. That
|
||||
// bug was caused by cpal's RemoteIO unit binding to a stale
|
||||
// physical transducer — after migrating to coreaudio-rs +
|
||||
// kAudioUnitSubType_VoiceProcessingIO (see
|
||||
// crates/chanora_audio/src/ios_voice_unit.rs) the route
|
||||
// binding is correct under either mode because VPIO re-binds
|
||||
// on overrideOutputAudioPort.
|
||||
//
|
||||
// The earlier "speaker selector silent" bug under
|
||||
// .voiceChat was caused by cpal's RemoteIO unit binding
|
||||
// to a stale physical transducer. After migrating to
|
||||
// coreaudio-rs + kAudioUnitSubType_VoiceProcessingIO
|
||||
// (see crates/chanora_audio/src/ios_voice_unit.rs) the
|
||||
// route binding is correct under either mode because
|
||||
// VPIO is the canonical voice unit and re-binds on
|
||||
// overrideOutputAudioPort. So route switching is no
|
||||
// longer a deciding factor.
|
||||
// .voiceChat advantages over .default:
|
||||
// * Tells iOS this is a VoIP session — other apps' audio
|
||||
// is properly ducked/paused instead of competing.
|
||||
// * Enables correct Bluetooth HFP negotiation without
|
||||
// manual workarounds.
|
||||
// * iOS treats the audio session as a "call" for priority
|
||||
// purposes (won't be interrupted by notification sounds).
|
||||
// * System-level CallKit integration (lock-screen controls).
|
||||
//
|
||||
// The "broken playback quality" bug under either
|
||||
// .voiceChat or .default (with VPIO) was actually NOT
|
||||
// a VPIO problem at all. iOS has TWO independent audio
|
||||
// channels: the in-call channel (used by .voiceChat /
|
||||
// .videoChat modes) and the media channel (used by
|
||||
// .default). The in-call channel:
|
||||
// * Routes through the phone-call audio path
|
||||
// * Aggressively ducks non-voice content to the
|
||||
// earpiece (Apple's "speakerphone vs ear" UX)
|
||||
// * Volume controlled by separate in-call volume
|
||||
// hardware, not the side buttons (when not in a
|
||||
// phone call)
|
||||
// The media channel:
|
||||
// * Routes through the standard media playback path
|
||||
// * No automatic ducking
|
||||
// * Volume controlled by the side volume buttons
|
||||
//
|
||||
// Even with VPIO + .voiceChat producing a perfectly
|
||||
// good signal, iOS's in-call channel routing made it
|
||||
// play at "earpiece" loudness on the speaker too \u2014
|
||||
// user-perceived as "broken and poor" because the
|
||||
// signal is technically there but barely audible against
|
||||
// the loud iPhone speaker's noise floor.
|
||||
//
|
||||
// Twilio's video-quickstart-ios and Daily.co's patched
|
||||
// WebRTC both document the same workaround: use .default
|
||||
// mode with .defaultToSpeaker option even when using
|
||||
// VPIO for AEC. The VPIO unit itself still does its job
|
||||
// (echo cancellation, noise suppression, AGC on the mic
|
||||
// path) \u2014 only the playback routing changes.
|
||||
// .defaultToSpeaker ensures output goes to the main speaker
|
||||
// (not the earpiece) by default when no headphones are
|
||||
// connected, compensating for the in-call channel's tendency
|
||||
// to route to the earpiece.
|
||||
//
|
||||
// References:
|
||||
// * https://github.com/twilio/video-quickstart-ios/issues/522
|
||||
@@ -91,9 +69,6 @@ import AVFoundation
|
||||
// .defaultToSpeaker : route output to the main speaker
|
||||
// (not the earpiece) by default
|
||||
// when no headphones are connected.
|
||||
// This is what makes the audio
|
||||
// actually audible at normal
|
||||
// loudness.
|
||||
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
||||
// Profile headsets as both input
|
||||
// and output.
|
||||
@@ -245,7 +220,7 @@ import AVFoundation
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
|
||||
@@ -171,7 +171,7 @@ List<PlatformCapability> _windowsCapabilities() => [
|
||||
List<PlatformCapability> _linuxCapabilities() => [
|
||||
const PlatformCapability(
|
||||
feature: 'Voice capture',
|
||||
description: 'PipeWire voice I/O with PulseAudio fallback.',
|
||||
description: 'PulseAudio/ALSA via cpal.',
|
||||
tier: CapabilityTier.supported,
|
||||
),
|
||||
const PlatformCapability(
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"refreshAction": "Refresh",
|
||||
"diagnosticsAction": "Diagnostics",
|
||||
"diagnosticsSaveAction": "Save export",
|
||||
"diagnosticsLiveUpdating": "Live updating",
|
||||
"shareAction": "Share",
|
||||
"diagnosticsSaved": "Diagnostic export saved to {path}",
|
||||
"@diagnosticsSaved": {
|
||||
"placeholders": { "path": { "type": "String" } }
|
||||
@@ -39,6 +41,16 @@
|
||||
"aboutThirdPartyBody": "Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.",
|
||||
"copyAction": "Copy",
|
||||
"closeAction": "Close",
|
||||
"openAction": "Open",
|
||||
"cancelAction": "Cancel",
|
||||
"retryAction": "Retry",
|
||||
"chatAction": "Chat",
|
||||
"chatCloseAction": "Close chat",
|
||||
"chatNewPrivateAction": "New private chat",
|
||||
"chatSearchClientsHint": "Search clients...",
|
||||
"chatDirectMessageAction": "Private message",
|
||||
"chatPokeAction": "Poke",
|
||||
"clientInfoAction": "Info",
|
||||
"startAudioAction": "Start audio",
|
||||
"pttHoldToTalk": "Hold to talk",
|
||||
"pttTransmitting": "Transmitting…",
|
||||
@@ -119,6 +131,7 @@
|
||||
|
||||
"channelsHeading": "Channels",
|
||||
"clientsHeading": "Online clients",
|
||||
"serverWelcomeHeading": "Server welcome message",
|
||||
"countChannelsAndClients": "{channels} channels • {clients} online",
|
||||
"@countChannelsAndClients": {
|
||||
"placeholders": {
|
||||
@@ -172,6 +185,10 @@
|
||||
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
|
||||
"microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
|
||||
"permissionGrantAction": "Grant",
|
||||
"startupPermissionsTitle": "Permissions",
|
||||
"startupPermissionsBody": "Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.",
|
||||
"startupPermissionsNotNow": "Not now",
|
||||
"startupPermissionsAllow": "Allow",
|
||||
"audioRouteSystemDefault": "System default",
|
||||
"audioRouteEarpiece": "Earpiece",
|
||||
"audioRouteUsbHeadset": "USB headset",
|
||||
@@ -181,6 +198,47 @@
|
||||
"audioRouteChangeFailed": "Could not change audio output.",
|
||||
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
|
||||
"iosAudioResuming": "Audio resuming",
|
||||
"linkTrustTitle": "Open external link?",
|
||||
"linkTrustBody": "You are about to open a link to:\n\n{domain}",
|
||||
"@linkTrustBody": {
|
||||
"placeholders": {
|
||||
"domain": { "type": "String" }
|
||||
}
|
||||
},
|
||||
"linkTrustRememberDomain": "Trust all links from this domain",
|
||||
"clientInfoFetchingProfile": "Fetching TeamSpeak profile",
|
||||
"clientInfoLoadingProfile": "Loading profile...",
|
||||
"clientInfoProfileUnavailable": "Profile unavailable",
|
||||
"clientInfoProfileUnavailableBody": "The server did not return profile details for this client.",
|
||||
"clientInfoIdentitySection": "Identity",
|
||||
"clientInfoMembershipSection": "Membership",
|
||||
"clientInfoConnectionSection": "Connection",
|
||||
"clientInfoHistorySection": "History",
|
||||
"clientInfoTransferSection": "Transfer",
|
||||
"clientInfoClientId": "Client ID",
|
||||
"clientInfoDatabaseId": "Database ID",
|
||||
"clientInfoUniqueId": "Unique ID",
|
||||
"clientInfoDescription": "Description",
|
||||
"clientInfoAvatar": "Avatar",
|
||||
"clientInfoServerGroups": "Server groups",
|
||||
"clientInfoChannelGroup": "Channel group",
|
||||
"clientInfoChannelId": "Channel ID",
|
||||
"clientInfoOnline": "Online",
|
||||
"clientInfoIdle": "Idle",
|
||||
"clientInfoPing": "Ping",
|
||||
"clientInfoAddress": "Address",
|
||||
"clientInfoPacketLossClientToServer": "Packet loss C->S",
|
||||
"clientInfoPacketLossServerToClient": "Packet loss S->C",
|
||||
"clientInfoFirstConnected": "First connected",
|
||||
"clientInfoLastConnected": "Last connected",
|
||||
"clientInfoConnections": "Connections",
|
||||
"clientInfoDownloadedMonth": "Downloaded this month",
|
||||
"clientInfoUploadedMonth": "Uploaded this month",
|
||||
"clientInfoDownloadedTotal": "Downloaded total",
|
||||
"clientInfoUploadedTotal": "Uploaded total",
|
||||
"clientInfoUnknown": "Unknown",
|
||||
"clientInfoHidden": "Hidden",
|
||||
"clientInfoNone": "None",
|
||||
"pokeSnackBarClearAction": "Clear",
|
||||
"pokeSnackBarMoreIndicator": "...",
|
||||
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"refreshAction": "刷新",
|
||||
"diagnosticsAction": "诊断信息",
|
||||
"diagnosticsSaveAction": "保存导出",
|
||||
"diagnosticsLiveUpdating": "实时更新中",
|
||||
"shareAction": "分享",
|
||||
"diagnosticsSaved": "诊断导出已保存到 {path}",
|
||||
"aboutAction": "关于",
|
||||
"aboutVersion": "版本 {version}",
|
||||
@@ -32,6 +34,16 @@
|
||||
"aboutThirdPartyBody": "Chanora 基于 tsclientlib、flutter_rust_bridge、平台原生音频后端、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。",
|
||||
"copyAction": "复制",
|
||||
"closeAction": "关闭",
|
||||
"openAction": "打开",
|
||||
"cancelAction": "取消",
|
||||
"retryAction": "重试",
|
||||
"chatAction": "聊天",
|
||||
"chatCloseAction": "关闭聊天",
|
||||
"chatNewPrivateAction": "新建私聊",
|
||||
"chatSearchClientsHint": "搜索用户...",
|
||||
"chatDirectMessageAction": "私聊",
|
||||
"chatPokeAction": "戳一下",
|
||||
"clientInfoAction": "信息",
|
||||
"startAudioAction": "启动语音",
|
||||
"pttHoldToTalk": "按住说话",
|
||||
"pttTransmitting": "正在发送…",
|
||||
@@ -79,6 +91,7 @@
|
||||
|
||||
"channelsHeading": "频道",
|
||||
"clientsHeading": "在线用户",
|
||||
"serverWelcomeHeading": "服务器欢迎信息",
|
||||
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线",
|
||||
|
||||
"voiceModePtt": "按键说话",
|
||||
@@ -126,6 +139,10 @@
|
||||
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
|
||||
"microphonePermissionRequiredForVoice": "语音发送需要麦克风权限。",
|
||||
"permissionGrantAction": "授权",
|
||||
"startupPermissionsTitle": "权限",
|
||||
"startupPermissionsBody": "Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。",
|
||||
"startupPermissionsNotNow": "暂不",
|
||||
"startupPermissionsAllow": "允许",
|
||||
"audioRouteSystemDefault": "系统默认",
|
||||
"audioRouteEarpiece": "听筒",
|
||||
"audioRouteUsbHeadset": "USB 耳机",
|
||||
@@ -135,6 +152,42 @@
|
||||
"audioRouteChangeFailed": "无法切换音频输出。",
|
||||
"iosAudioInterrupted": "系统已中断音频(电话通话)",
|
||||
"iosAudioResuming": "音频正在恢复",
|
||||
"linkTrustTitle": "打开外部链接?",
|
||||
"linkTrustBody": "你将打开指向以下域名的链接:\n\n{domain}",
|
||||
"linkTrustRememberDomain": "信任来自此域名的所有链接",
|
||||
"clientInfoFetchingProfile": "正在获取 TeamSpeak 资料",
|
||||
"clientInfoLoadingProfile": "正在加载资料…",
|
||||
"clientInfoProfileUnavailable": "资料不可用",
|
||||
"clientInfoProfileUnavailableBody": "服务器没有返回此用户的资料详情。",
|
||||
"clientInfoIdentitySection": "身份",
|
||||
"clientInfoMembershipSection": "成员关系",
|
||||
"clientInfoConnectionSection": "连接",
|
||||
"clientInfoHistorySection": "历史",
|
||||
"clientInfoTransferSection": "传输",
|
||||
"clientInfoClientId": "用户 ID",
|
||||
"clientInfoDatabaseId": "数据库 ID",
|
||||
"clientInfoUniqueId": "唯一 ID",
|
||||
"clientInfoDescription": "描述",
|
||||
"clientInfoAvatar": "头像",
|
||||
"clientInfoServerGroups": "服务器组",
|
||||
"clientInfoChannelGroup": "频道组",
|
||||
"clientInfoChannelId": "频道 ID",
|
||||
"clientInfoOnline": "在线时长",
|
||||
"clientInfoIdle": "空闲",
|
||||
"clientInfoPing": "延迟",
|
||||
"clientInfoAddress": "地址",
|
||||
"clientInfoPacketLossClientToServer": "丢包 C->S",
|
||||
"clientInfoPacketLossServerToClient": "丢包 S->C",
|
||||
"clientInfoFirstConnected": "首次连接",
|
||||
"clientInfoLastConnected": "上次连接",
|
||||
"clientInfoConnections": "连接次数",
|
||||
"clientInfoDownloadedMonth": "本月下载",
|
||||
"clientInfoUploadedMonth": "本月上传",
|
||||
"clientInfoDownloadedTotal": "总下载",
|
||||
"clientInfoUploadedTotal": "总上传",
|
||||
"clientInfoUnknown": "未知",
|
||||
"clientInfoHidden": "隐藏",
|
||||
"clientInfoNone": "无",
|
||||
"pokeSnackBarClearAction": "清除",
|
||||
"pokeSnackBarMoreIndicator": "...",
|
||||
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
|
||||
|
||||
@@ -199,6 +199,18 @@ abstract class AppL10n {
|
||||
/// **'Save export'**
|
||||
String get diagnosticsSaveAction;
|
||||
|
||||
/// No description provided for @diagnosticsLiveUpdating.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Live updating'**
|
||||
String get diagnosticsLiveUpdating;
|
||||
|
||||
/// No description provided for @shareAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Share'**
|
||||
String get shareAction;
|
||||
|
||||
/// No description provided for @diagnosticsSaved.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -265,6 +277,66 @@ abstract class AppL10n {
|
||||
/// **'Close'**
|
||||
String get closeAction;
|
||||
|
||||
/// No description provided for @openAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open'**
|
||||
String get openAction;
|
||||
|
||||
/// No description provided for @cancelAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel'**
|
||||
String get cancelAction;
|
||||
|
||||
/// No description provided for @retryAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Retry'**
|
||||
String get retryAction;
|
||||
|
||||
/// No description provided for @chatAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Chat'**
|
||||
String get chatAction;
|
||||
|
||||
/// No description provided for @chatCloseAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Close chat'**
|
||||
String get chatCloseAction;
|
||||
|
||||
/// No description provided for @chatNewPrivateAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New private chat'**
|
||||
String get chatNewPrivateAction;
|
||||
|
||||
/// No description provided for @chatSearchClientsHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search clients...'**
|
||||
String get chatSearchClientsHint;
|
||||
|
||||
/// No description provided for @chatDirectMessageAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Private message'**
|
||||
String get chatDirectMessageAction;
|
||||
|
||||
/// No description provided for @chatPokeAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Poke'**
|
||||
String get chatPokeAction;
|
||||
|
||||
/// No description provided for @clientInfoAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Info'**
|
||||
String get clientInfoAction;
|
||||
|
||||
/// No description provided for @startAudioAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -523,6 +595,12 @@ abstract class AppL10n {
|
||||
/// **'Online clients'**
|
||||
String get clientsHeading;
|
||||
|
||||
/// No description provided for @serverWelcomeHeading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server welcome message'**
|
||||
String get serverWelcomeHeading;
|
||||
|
||||
/// No description provided for @countChannelsAndClients.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -769,6 +847,30 @@ abstract class AppL10n {
|
||||
/// **'Grant'**
|
||||
String get permissionGrantAction;
|
||||
|
||||
/// No description provided for @startupPermissionsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Permissions'**
|
||||
String get startupPermissionsTitle;
|
||||
|
||||
/// No description provided for @startupPermissionsBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.'**
|
||||
String get startupPermissionsBody;
|
||||
|
||||
/// No description provided for @startupPermissionsNotNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not now'**
|
||||
String get startupPermissionsNotNow;
|
||||
|
||||
/// No description provided for @startupPermissionsAllow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Allow'**
|
||||
String get startupPermissionsAllow;
|
||||
|
||||
/// No description provided for @audioRouteSystemDefault.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -823,6 +925,222 @@ abstract class AppL10n {
|
||||
/// **'Audio resuming'**
|
||||
String get iosAudioResuming;
|
||||
|
||||
/// No description provided for @linkTrustTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Open external link?'**
|
||||
String get linkTrustTitle;
|
||||
|
||||
/// No description provided for @linkTrustBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'You are about to open a link to:\n\n{domain}'**
|
||||
String linkTrustBody(String domain);
|
||||
|
||||
/// No description provided for @linkTrustRememberDomain.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Trust all links from this domain'**
|
||||
String get linkTrustRememberDomain;
|
||||
|
||||
/// No description provided for @clientInfoFetchingProfile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fetching TeamSpeak profile'**
|
||||
String get clientInfoFetchingProfile;
|
||||
|
||||
/// No description provided for @clientInfoLoadingProfile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Loading profile...'**
|
||||
String get clientInfoLoadingProfile;
|
||||
|
||||
/// No description provided for @clientInfoProfileUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Profile unavailable'**
|
||||
String get clientInfoProfileUnavailable;
|
||||
|
||||
/// No description provided for @clientInfoProfileUnavailableBody.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The server did not return profile details for this client.'**
|
||||
String get clientInfoProfileUnavailableBody;
|
||||
|
||||
/// No description provided for @clientInfoIdentitySection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Identity'**
|
||||
String get clientInfoIdentitySection;
|
||||
|
||||
/// No description provided for @clientInfoMembershipSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Membership'**
|
||||
String get clientInfoMembershipSection;
|
||||
|
||||
/// No description provided for @clientInfoConnectionSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connection'**
|
||||
String get clientInfoConnectionSection;
|
||||
|
||||
/// No description provided for @clientInfoHistorySection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'History'**
|
||||
String get clientInfoHistorySection;
|
||||
|
||||
/// No description provided for @clientInfoTransferSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Transfer'**
|
||||
String get clientInfoTransferSection;
|
||||
|
||||
/// No description provided for @clientInfoClientId.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Client ID'**
|
||||
String get clientInfoClientId;
|
||||
|
||||
/// No description provided for @clientInfoDatabaseId.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Database ID'**
|
||||
String get clientInfoDatabaseId;
|
||||
|
||||
/// No description provided for @clientInfoUniqueId.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unique ID'**
|
||||
String get clientInfoUniqueId;
|
||||
|
||||
/// No description provided for @clientInfoDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Description'**
|
||||
String get clientInfoDescription;
|
||||
|
||||
/// No description provided for @clientInfoAvatar.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Avatar'**
|
||||
String get clientInfoAvatar;
|
||||
|
||||
/// No description provided for @clientInfoServerGroups.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Server groups'**
|
||||
String get clientInfoServerGroups;
|
||||
|
||||
/// No description provided for @clientInfoChannelGroup.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Channel group'**
|
||||
String get clientInfoChannelGroup;
|
||||
|
||||
/// No description provided for @clientInfoChannelId.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Channel ID'**
|
||||
String get clientInfoChannelId;
|
||||
|
||||
/// No description provided for @clientInfoOnline.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Online'**
|
||||
String get clientInfoOnline;
|
||||
|
||||
/// No description provided for @clientInfoIdle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Idle'**
|
||||
String get clientInfoIdle;
|
||||
|
||||
/// No description provided for @clientInfoPing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ping'**
|
||||
String get clientInfoPing;
|
||||
|
||||
/// No description provided for @clientInfoAddress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Address'**
|
||||
String get clientInfoAddress;
|
||||
|
||||
/// No description provided for @clientInfoPacketLossClientToServer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Packet loss C->S'**
|
||||
String get clientInfoPacketLossClientToServer;
|
||||
|
||||
/// No description provided for @clientInfoPacketLossServerToClient.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Packet loss S->C'**
|
||||
String get clientInfoPacketLossServerToClient;
|
||||
|
||||
/// No description provided for @clientInfoFirstConnected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'First connected'**
|
||||
String get clientInfoFirstConnected;
|
||||
|
||||
/// No description provided for @clientInfoLastConnected.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last connected'**
|
||||
String get clientInfoLastConnected;
|
||||
|
||||
/// No description provided for @clientInfoConnections.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Connections'**
|
||||
String get clientInfoConnections;
|
||||
|
||||
/// No description provided for @clientInfoDownloadedMonth.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloaded this month'**
|
||||
String get clientInfoDownloadedMonth;
|
||||
|
||||
/// No description provided for @clientInfoUploadedMonth.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Uploaded this month'**
|
||||
String get clientInfoUploadedMonth;
|
||||
|
||||
/// No description provided for @clientInfoDownloadedTotal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Downloaded total'**
|
||||
String get clientInfoDownloadedTotal;
|
||||
|
||||
/// No description provided for @clientInfoUploadedTotal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Uploaded total'**
|
||||
String get clientInfoUploadedTotal;
|
||||
|
||||
/// No description provided for @clientInfoUnknown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unknown'**
|
||||
String get clientInfoUnknown;
|
||||
|
||||
/// No description provided for @clientInfoHidden.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hidden'**
|
||||
String get clientInfoHidden;
|
||||
|
||||
/// No description provided for @clientInfoNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'None'**
|
||||
String get clientInfoNone;
|
||||
|
||||
/// No description provided for @pokeSnackBarClearAction.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -62,6 +62,12 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get diagnosticsSaveAction => 'Save export';
|
||||
|
||||
@override
|
||||
String get diagnosticsLiveUpdating => 'Live updating';
|
||||
|
||||
@override
|
||||
String get shareAction => 'Share';
|
||||
|
||||
@override
|
||||
String diagnosticsSaved(String path) {
|
||||
return 'Diagnostic export saved to $path';
|
||||
@@ -102,6 +108,36 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get closeAction => 'Close';
|
||||
|
||||
@override
|
||||
String get openAction => 'Open';
|
||||
|
||||
@override
|
||||
String get cancelAction => 'Cancel';
|
||||
|
||||
@override
|
||||
String get retryAction => 'Retry';
|
||||
|
||||
@override
|
||||
String get chatAction => 'Chat';
|
||||
|
||||
@override
|
||||
String get chatCloseAction => 'Close chat';
|
||||
|
||||
@override
|
||||
String get chatNewPrivateAction => 'New private chat';
|
||||
|
||||
@override
|
||||
String get chatSearchClientsHint => 'Search clients...';
|
||||
|
||||
@override
|
||||
String get chatDirectMessageAction => 'Private message';
|
||||
|
||||
@override
|
||||
String get chatPokeAction => 'Poke';
|
||||
|
||||
@override
|
||||
String get clientInfoAction => 'Info';
|
||||
|
||||
@override
|
||||
String get startAudioAction => 'Start audio';
|
||||
|
||||
@@ -256,6 +292,9 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get clientsHeading => 'Online clients';
|
||||
|
||||
@override
|
||||
String get serverWelcomeHeading => 'Server welcome message';
|
||||
|
||||
@override
|
||||
String countChannelsAndClients(int channels, int clients) {
|
||||
return '$channels channels • $clients online';
|
||||
@@ -388,6 +427,19 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get permissionGrantAction => 'Grant';
|
||||
|
||||
@override
|
||||
String get startupPermissionsTitle => 'Permissions';
|
||||
|
||||
@override
|
||||
String get startupPermissionsBody =>
|
||||
'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.';
|
||||
|
||||
@override
|
||||
String get startupPermissionsNotNow => 'Not now';
|
||||
|
||||
@override
|
||||
String get startupPermissionsAllow => 'Allow';
|
||||
|
||||
@override
|
||||
String get audioRouteSystemDefault => 'System default';
|
||||
|
||||
@@ -415,6 +467,117 @@ class AppL10nEn extends AppL10n {
|
||||
@override
|
||||
String get iosAudioResuming => 'Audio resuming';
|
||||
|
||||
@override
|
||||
String get linkTrustTitle => 'Open external link?';
|
||||
|
||||
@override
|
||||
String linkTrustBody(String domain) {
|
||||
return 'You are about to open a link to:\n\n$domain';
|
||||
}
|
||||
|
||||
@override
|
||||
String get linkTrustRememberDomain => 'Trust all links from this domain';
|
||||
|
||||
@override
|
||||
String get clientInfoFetchingProfile => 'Fetching TeamSpeak profile';
|
||||
|
||||
@override
|
||||
String get clientInfoLoadingProfile => 'Loading profile...';
|
||||
|
||||
@override
|
||||
String get clientInfoProfileUnavailable => 'Profile unavailable';
|
||||
|
||||
@override
|
||||
String get clientInfoProfileUnavailableBody =>
|
||||
'The server did not return profile details for this client.';
|
||||
|
||||
@override
|
||||
String get clientInfoIdentitySection => 'Identity';
|
||||
|
||||
@override
|
||||
String get clientInfoMembershipSection => 'Membership';
|
||||
|
||||
@override
|
||||
String get clientInfoConnectionSection => 'Connection';
|
||||
|
||||
@override
|
||||
String get clientInfoHistorySection => 'History';
|
||||
|
||||
@override
|
||||
String get clientInfoTransferSection => 'Transfer';
|
||||
|
||||
@override
|
||||
String get clientInfoClientId => 'Client ID';
|
||||
|
||||
@override
|
||||
String get clientInfoDatabaseId => 'Database ID';
|
||||
|
||||
@override
|
||||
String get clientInfoUniqueId => 'Unique ID';
|
||||
|
||||
@override
|
||||
String get clientInfoDescription => 'Description';
|
||||
|
||||
@override
|
||||
String get clientInfoAvatar => 'Avatar';
|
||||
|
||||
@override
|
||||
String get clientInfoServerGroups => 'Server groups';
|
||||
|
||||
@override
|
||||
String get clientInfoChannelGroup => 'Channel group';
|
||||
|
||||
@override
|
||||
String get clientInfoChannelId => 'Channel ID';
|
||||
|
||||
@override
|
||||
String get clientInfoOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get clientInfoIdle => 'Idle';
|
||||
|
||||
@override
|
||||
String get clientInfoPing => 'Ping';
|
||||
|
||||
@override
|
||||
String get clientInfoAddress => 'Address';
|
||||
|
||||
@override
|
||||
String get clientInfoPacketLossClientToServer => 'Packet loss C->S';
|
||||
|
||||
@override
|
||||
String get clientInfoPacketLossServerToClient => 'Packet loss S->C';
|
||||
|
||||
@override
|
||||
String get clientInfoFirstConnected => 'First connected';
|
||||
|
||||
@override
|
||||
String get clientInfoLastConnected => 'Last connected';
|
||||
|
||||
@override
|
||||
String get clientInfoConnections => 'Connections';
|
||||
|
||||
@override
|
||||
String get clientInfoDownloadedMonth => 'Downloaded this month';
|
||||
|
||||
@override
|
||||
String get clientInfoUploadedMonth => 'Uploaded this month';
|
||||
|
||||
@override
|
||||
String get clientInfoDownloadedTotal => 'Downloaded total';
|
||||
|
||||
@override
|
||||
String get clientInfoUploadedTotal => 'Uploaded total';
|
||||
|
||||
@override
|
||||
String get clientInfoUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get clientInfoHidden => 'Hidden';
|
||||
|
||||
@override
|
||||
String get clientInfoNone => 'None';
|
||||
|
||||
@override
|
||||
String get pokeSnackBarClearAction => 'Clear';
|
||||
|
||||
|
||||
@@ -59,6 +59,12 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get diagnosticsSaveAction => '保存导出';
|
||||
|
||||
@override
|
||||
String get diagnosticsLiveUpdating => '实时更新中';
|
||||
|
||||
@override
|
||||
String get shareAction => '分享';
|
||||
|
||||
@override
|
||||
String diagnosticsSaved(String path) {
|
||||
return '诊断导出已保存到 $path';
|
||||
@@ -99,6 +105,36 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get closeAction => '关闭';
|
||||
|
||||
@override
|
||||
String get openAction => '打开';
|
||||
|
||||
@override
|
||||
String get cancelAction => '取消';
|
||||
|
||||
@override
|
||||
String get retryAction => '重试';
|
||||
|
||||
@override
|
||||
String get chatAction => '聊天';
|
||||
|
||||
@override
|
||||
String get chatCloseAction => '关闭聊天';
|
||||
|
||||
@override
|
||||
String get chatNewPrivateAction => '新建私聊';
|
||||
|
||||
@override
|
||||
String get chatSearchClientsHint => '搜索用户...';
|
||||
|
||||
@override
|
||||
String get chatDirectMessageAction => '私聊';
|
||||
|
||||
@override
|
||||
String get chatPokeAction => '戳一下';
|
||||
|
||||
@override
|
||||
String get clientInfoAction => '信息';
|
||||
|
||||
@override
|
||||
String get startAudioAction => '启动语音';
|
||||
|
||||
@@ -249,6 +285,9 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get clientsHeading => '在线用户';
|
||||
|
||||
@override
|
||||
String get serverWelcomeHeading => '服务器欢迎信息';
|
||||
|
||||
@override
|
||||
String countChannelsAndClients(int channels, int clients) {
|
||||
return '$channels 个频道 • $clients 在线';
|
||||
@@ -378,6 +417,19 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get permissionGrantAction => '授权';
|
||||
|
||||
@override
|
||||
String get startupPermissionsTitle => '权限';
|
||||
|
||||
@override
|
||||
String get startupPermissionsBody =>
|
||||
'Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。';
|
||||
|
||||
@override
|
||||
String get startupPermissionsNotNow => '暂不';
|
||||
|
||||
@override
|
||||
String get startupPermissionsAllow => '允许';
|
||||
|
||||
@override
|
||||
String get audioRouteSystemDefault => '系统默认';
|
||||
|
||||
@@ -405,6 +457,116 @@ class AppL10nZh extends AppL10n {
|
||||
@override
|
||||
String get iosAudioResuming => '音频正在恢复';
|
||||
|
||||
@override
|
||||
String get linkTrustTitle => '打开外部链接?';
|
||||
|
||||
@override
|
||||
String linkTrustBody(String domain) {
|
||||
return '你将打开指向以下域名的链接:\n\n$domain';
|
||||
}
|
||||
|
||||
@override
|
||||
String get linkTrustRememberDomain => '信任来自此域名的所有链接';
|
||||
|
||||
@override
|
||||
String get clientInfoFetchingProfile => '正在获取 TeamSpeak 资料';
|
||||
|
||||
@override
|
||||
String get clientInfoLoadingProfile => '正在加载资料…';
|
||||
|
||||
@override
|
||||
String get clientInfoProfileUnavailable => '资料不可用';
|
||||
|
||||
@override
|
||||
String get clientInfoProfileUnavailableBody => '服务器没有返回此用户的资料详情。';
|
||||
|
||||
@override
|
||||
String get clientInfoIdentitySection => '身份';
|
||||
|
||||
@override
|
||||
String get clientInfoMembershipSection => '成员关系';
|
||||
|
||||
@override
|
||||
String get clientInfoConnectionSection => '连接';
|
||||
|
||||
@override
|
||||
String get clientInfoHistorySection => '历史';
|
||||
|
||||
@override
|
||||
String get clientInfoTransferSection => '传输';
|
||||
|
||||
@override
|
||||
String get clientInfoClientId => '用户 ID';
|
||||
|
||||
@override
|
||||
String get clientInfoDatabaseId => '数据库 ID';
|
||||
|
||||
@override
|
||||
String get clientInfoUniqueId => '唯一 ID';
|
||||
|
||||
@override
|
||||
String get clientInfoDescription => '描述';
|
||||
|
||||
@override
|
||||
String get clientInfoAvatar => '头像';
|
||||
|
||||
@override
|
||||
String get clientInfoServerGroups => '服务器组';
|
||||
|
||||
@override
|
||||
String get clientInfoChannelGroup => '频道组';
|
||||
|
||||
@override
|
||||
String get clientInfoChannelId => '频道 ID';
|
||||
|
||||
@override
|
||||
String get clientInfoOnline => '在线时长';
|
||||
|
||||
@override
|
||||
String get clientInfoIdle => '空闲';
|
||||
|
||||
@override
|
||||
String get clientInfoPing => '延迟';
|
||||
|
||||
@override
|
||||
String get clientInfoAddress => '地址';
|
||||
|
||||
@override
|
||||
String get clientInfoPacketLossClientToServer => '丢包 C->S';
|
||||
|
||||
@override
|
||||
String get clientInfoPacketLossServerToClient => '丢包 S->C';
|
||||
|
||||
@override
|
||||
String get clientInfoFirstConnected => '首次连接';
|
||||
|
||||
@override
|
||||
String get clientInfoLastConnected => '上次连接';
|
||||
|
||||
@override
|
||||
String get clientInfoConnections => '连接次数';
|
||||
|
||||
@override
|
||||
String get clientInfoDownloadedMonth => '本月下载';
|
||||
|
||||
@override
|
||||
String get clientInfoUploadedMonth => '本月上传';
|
||||
|
||||
@override
|
||||
String get clientInfoDownloadedTotal => '总下载';
|
||||
|
||||
@override
|
||||
String get clientInfoUploadedTotal => '总上传';
|
||||
|
||||
@override
|
||||
String get clientInfoUnknown => '未知';
|
||||
|
||||
@override
|
||||
String get clientInfoHidden => '隐藏';
|
||||
|
||||
@override
|
||||
String get clientInfoNone => '无';
|
||||
|
||||
@override
|
||||
String get pokeSnackBarClearAction => '清除';
|
||||
|
||||
|
||||
+720
-624
File diff suppressed because it is too large
Load Diff
@@ -18,19 +18,17 @@ Future<void>? _storageInitFuture;
|
||||
Future<void>? _vadBootstrapFuture;
|
||||
StorageDirectoryProvider _storageDirectoryProvider =
|
||||
getApplicationSupportDirectory;
|
||||
StorageDirectoryProvider _vadModelDirectoryProvider =
|
||||
getApplicationSupportDirectory;
|
||||
StorageInitializer _storageInitializer = _defaultStorageInitializer;
|
||||
|
||||
Future<void> _defaultStorageInitializer(String dir) {
|
||||
return rust.initStorage(dir: dir);
|
||||
}
|
||||
|
||||
Future<File> _copyBundledAssetToManagedDirectory({
|
||||
Future<File> _copyBundledAssetToDocuments({
|
||||
required String assetPath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final dir = await _vadModelDirectoryProvider();
|
||||
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);
|
||||
@@ -61,7 +59,7 @@ Future<void> configureBundledVadModels() async {
|
||||
}
|
||||
|
||||
Future<void> _configureBundledVadModelsImpl() async {
|
||||
final silero = await _copyBundledAssetToManagedDirectory(
|
||||
final silero = await _copyBundledAssetToDocuments(
|
||||
assetPath: _sileroVadAsset,
|
||||
fileName: 'silero_vad.onnx',
|
||||
);
|
||||
@@ -126,15 +124,12 @@ Future<void> _wireStorageImpl() async {
|
||||
@visibleForTesting
|
||||
void debugResetStorageBootstrap({
|
||||
StorageDirectoryProvider? storageDirectoryProvider,
|
||||
StorageDirectoryProvider? vadModelDirectoryProvider,
|
||||
StorageInitializer? storageInitializer,
|
||||
}) {
|
||||
_storageInitFuture = null;
|
||||
_vadBootstrapFuture = null;
|
||||
_storageDirectoryProvider =
|
||||
storageDirectoryProvider ?? getApplicationSupportDirectory;
|
||||
_vadModelDirectoryProvider =
|
||||
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
|
||||
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ extension ConnectionPhaseState on ConnectionPhase {
|
||||
|
||||
bool get canDisconnect => canOpenChat;
|
||||
|
||||
bool shouldShowSnapshotLoading({required bool hasSnapshot}) =>
|
||||
isServerReachable && !hasSnapshot;
|
||||
|
||||
bool canOpenChatWithSnapshot({required bool hasSnapshot}) =>
|
||||
canOpenChat && hasSnapshot;
|
||||
|
||||
ConnectionTokens tokens(ColorScheme colorScheme) {
|
||||
switch (this) {
|
||||
case ConnectionPhase.idle:
|
||||
@@ -52,6 +58,15 @@ extension ConnectionPhaseState on ConnectionPhase {
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionPhase phaseAfterConnectedEvent(
|
||||
ConnectionPhase phase, {
|
||||
required bool hasSnapshot,
|
||||
}) {
|
||||
return phase == ConnectionPhase.connected && hasSnapshot
|
||||
? ConnectionPhase.connected
|
||||
: ConnectionPhase.synchronizing;
|
||||
}
|
||||
|
||||
String connectionStatusText({
|
||||
required ConnectionPhase phase,
|
||||
required AppL10n l10n,
|
||||
@@ -77,3 +92,9 @@ String connectionStatusText({
|
||||
return l10n.statusIdle;
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionPhase phaseAfterSnapshotApplied(ConnectionPhase phase) {
|
||||
return phase == ConnectionPhase.synchronizing
|
||||
? ConnectionPhase.connected
|
||||
: phase;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class LinkTrustService extends ChangeNotifier {
|
||||
@@ -54,42 +56,45 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('Open external link?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('You are about to open a link to:\n\n$domain'),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: remember,
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => remember = v ?? false),
|
||||
builder: (ctx, setDialogState) {
|
||||
final l10n = AppL10n.of(ctx);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.linkTrustTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.linkTrustBody(domain)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: remember,
|
||||
onChanged: (v) =>
|
||||
setDialogState(() => remember = v ?? false),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Flexible(child: Text('Trust all links from this domain')),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text(l10n.linkTrustRememberDomain)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(null),
|
||||
child: Text(l10n.cancelAction),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(remember),
|
||||
child: Text(l10n.openAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(remember),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'dart:async';
|
||||
|
||||
class PrefetchDebouncer {
|
||||
PrefetchDebouncer({
|
||||
required this.onPrefetch,
|
||||
this.delay = const Duration(milliseconds: 700),
|
||||
});
|
||||
|
||||
final Future<void> Function(String host) onPrefetch;
|
||||
final Duration delay;
|
||||
|
||||
Timer? _timer;
|
||||
bool _disposed = false;
|
||||
|
||||
void schedule(String rawHost) {
|
||||
if (_disposed) return;
|
||||
_timer?.cancel();
|
||||
final host = rawHost.trim();
|
||||
if (host.isEmpty) return;
|
||||
_timer = Timer(delay, () {
|
||||
if (_disposed) return;
|
||||
unawaited(onPrefetch(host));
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
class StartupDependencyCheckResult {
|
||||
const StartupDependencyCheckResult({
|
||||
required this.issues,
|
||||
required this.platformLabel,
|
||||
});
|
||||
|
||||
final List<StartupDependencyIssue> issues;
|
||||
final String platformLabel;
|
||||
|
||||
bool get hasIssues => issues.isNotEmpty;
|
||||
bool get hasBlockingIssues => issues.any((issue) => issue.isRequired);
|
||||
bool get hasOnnxRuntime => !issues.any((issue) => issue.id == 'linux-onnxruntime');
|
||||
}
|
||||
|
||||
class StartupDependencyIssue {
|
||||
const StartupDependencyIssue({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.summary,
|
||||
required this.details,
|
||||
required this.severity,
|
||||
this.installHints = const [],
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String summary;
|
||||
final List<String> details;
|
||||
final StartupDependencySeverity severity;
|
||||
final List<StartupInstallHint> installHints;
|
||||
|
||||
bool get isRequired => severity == StartupDependencySeverity.required;
|
||||
}
|
||||
|
||||
class StartupInstallHint {
|
||||
const StartupInstallHint({required this.label, required this.command});
|
||||
|
||||
final String label;
|
||||
final String command;
|
||||
}
|
||||
|
||||
enum StartupDependencySeverity { required, recommended }
|
||||
|
||||
enum _LinuxDistro { debian, fedora, arch, other }
|
||||
|
||||
enum _LinuxArch { x64, arm64, other }
|
||||
|
||||
typedef _LibraryProbe = bool Function(String candidate);
|
||||
typedef _FileExists = Future<bool> Function(String path);
|
||||
typedef _ResolvedExecutableProvider = String Function();
|
||||
typedef _CurrentDirectoryProvider = String Function();
|
||||
typedef _OsReleaseProvider = Future<String?> Function();
|
||||
typedef _PlatformIsLinux = bool Function();
|
||||
typedef _LogFilePathProvider = String Function();
|
||||
typedef _CurrentAbiProvider = Abi Function();
|
||||
|
||||
_LibraryProbe _libraryProbe = _defaultLibraryProbe;
|
||||
_FileExists _fileExists = _defaultFileExists;
|
||||
_ResolvedExecutableProvider _resolvedExecutableProvider = () =>
|
||||
Platform.resolvedExecutable;
|
||||
_CurrentDirectoryProvider _currentDirectoryProvider = () =>
|
||||
Directory.current.path;
|
||||
_OsReleaseProvider _osReleaseProvider = _defaultOsReleaseProvider;
|
||||
_PlatformIsLinux _platformIsLinux = () => Platform.isLinux;
|
||||
_LogFilePathProvider _logFilePathProvider = rust.logFilePathStr;
|
||||
_CurrentAbiProvider _currentAbiProvider = Abi.current;
|
||||
|
||||
Future<void> logStartupDependencyIssues(
|
||||
StartupDependencyCheckResult result,
|
||||
) async {
|
||||
if (!result.hasIssues) return;
|
||||
|
||||
final path = _logFilePathProvider();
|
||||
if (path.isEmpty) return;
|
||||
|
||||
final issues = result.issues
|
||||
.map(
|
||||
(issue) =>
|
||||
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}:'
|
||||
'${_sanitizeLogValue(issue.title)}',
|
||||
)
|
||||
.join(', ');
|
||||
final line =
|
||||
'${DateTime.now().toUtc().toIso8601String()} '
|
||||
'[startup_dependency_check] '
|
||||
'platform="${result.platformLabel}" '
|
||||
'blocking=${result.hasBlockingIssues} '
|
||||
'issues=[$issues]';
|
||||
|
||||
try {
|
||||
await File(
|
||||
path,
|
||||
).writeAsString('$line\n', mode: FileMode.append, flush: true);
|
||||
} catch (_) {
|
||||
// Best-effort only: a missing/unwritable log file must not block app startup.
|
||||
}
|
||||
}
|
||||
|
||||
String _sanitizeLogValue(String value) => value.replaceAll('"', "'");
|
||||
|
||||
Future<StartupDependencyCheckResult> checkStartupDependencies() async {
|
||||
if (!_platformIsLinux()) {
|
||||
return const StartupDependencyCheckResult(
|
||||
issues: [],
|
||||
platformLabel: 'default',
|
||||
);
|
||||
}
|
||||
|
||||
final distro = await _detectLinuxDistro();
|
||||
final arch = _detectLinuxArch();
|
||||
final executableDir = File(_resolvedExecutableProvider()).parent.path;
|
||||
final issues = <StartupDependencyIssue>[];
|
||||
|
||||
if (!_hasAnyLoadableLibrary(const [
|
||||
'libpipewire-0.3.so.0',
|
||||
'libpipewire-0.3.so',
|
||||
])) {
|
||||
issues.add(_buildPipeWireIssue(distro));
|
||||
}
|
||||
|
||||
if (!_hasAnyLoadableLibrary(const [
|
||||
'libpulse.so.0',
|
||||
'libpulse.so',
|
||||
'libpulse-simple.so.0',
|
||||
'libpulse-simple.so',
|
||||
])) {
|
||||
issues.add(_buildPulseAudioIssue(distro));
|
||||
}
|
||||
|
||||
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
|
||||
issues.add(_buildOnnxIssue(executableDir: executableDir, arch: arch));
|
||||
}
|
||||
|
||||
return StartupDependencyCheckResult(
|
||||
issues: issues,
|
||||
platformLabel: switch (distro) {
|
||||
_LinuxDistro.debian => 'Debian / Ubuntu',
|
||||
_LinuxDistro.fedora => 'Fedora',
|
||||
_LinuxDistro.arch => 'Arch Linux',
|
||||
_LinuxDistro.other => 'Linux',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool _defaultLibraryProbe(String candidate) {
|
||||
try {
|
||||
DynamicLibrary.open(candidate);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _defaultFileExists(String path) => File(path).exists();
|
||||
|
||||
Future<String?> _defaultOsReleaseProvider() async {
|
||||
const path = '/etc/os-release';
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
return file.readAsString();
|
||||
}
|
||||
|
||||
bool _hasAnyLoadableLibrary(List<String> candidates) {
|
||||
for (final candidate in candidates) {
|
||||
if (_libraryProbe(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _hasOnnxRuntime({required String executableDir}) async {
|
||||
final envPath = Platform.environment['ORT_DYLIB_PATH'];
|
||||
if (envPath != null && envPath.isNotEmpty && await _fileExists(envPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final candidates = <String>{
|
||||
'$executableDir/lib/libonnxruntime.so',
|
||||
'$executableDir/libonnxruntime.so',
|
||||
'${_currentDirectoryProvider()}/libonnxruntime.so',
|
||||
'/usr/lib/libonnxruntime.so',
|
||||
'/usr/lib64/libonnxruntime.so',
|
||||
'/usr/local/lib/libonnxruntime.so',
|
||||
'/lib/x86_64-linux-gnu/libonnxruntime.so',
|
||||
'/usr/lib/x86_64-linux-gnu/libonnxruntime.so',
|
||||
'/lib/aarch64-linux-gnu/libonnxruntime.so',
|
||||
'/usr/lib/aarch64-linux-gnu/libonnxruntime.so',
|
||||
};
|
||||
|
||||
for (final candidate in candidates) {
|
||||
if (await _fileExists(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (final dir in const [
|
||||
'/usr/lib',
|
||||
'/usr/lib64',
|
||||
'/usr/local/lib',
|
||||
'/usr/lib/x86_64-linux-gnu',
|
||||
'/usr/lib/aarch64-linux-gnu',
|
||||
]) {
|
||||
final match = await _firstMatchingDirEntry(dir, 'libonnxruntime.so');
|
||||
if (match != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return _hasAnyLoadableLibrary(const ['libonnxruntime.so']);
|
||||
}
|
||||
|
||||
Future<String?> _firstMatchingDirEntry(String dir, String prefix) async {
|
||||
final directory = Directory(dir);
|
||||
if (!await directory.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await for (final entity in directory.list(followLinks: false)) {
|
||||
if (entity is! File) {
|
||||
continue;
|
||||
}
|
||||
final name = entity.uri.pathSegments.isEmpty
|
||||
? ''
|
||||
: entity.uri.pathSegments.last;
|
||||
if (name == prefix || name.startsWith('$prefix.')) {
|
||||
return entity.path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<_LinuxDistro> _detectLinuxDistro() async {
|
||||
final content = await _osReleaseProvider();
|
||||
if (content == null || content.isEmpty) {
|
||||
return _LinuxDistro.other;
|
||||
}
|
||||
|
||||
final fields = _parseOsRelease(content);
|
||||
final id = fields['id'] ?? '';
|
||||
final idLike = (fields['id_like'] ?? '').split(RegExp(r'\s+'));
|
||||
final ids = {id, ...idLike};
|
||||
if (ids.contains('fedora')) {
|
||||
return _LinuxDistro.fedora;
|
||||
}
|
||||
if (ids.contains('ubuntu') || ids.contains('debian')) {
|
||||
return _LinuxDistro.debian;
|
||||
}
|
||||
if (ids.contains('arch')) {
|
||||
return _LinuxDistro.arch;
|
||||
}
|
||||
return _LinuxDistro.other;
|
||||
}
|
||||
|
||||
Map<String, String> _parseOsRelease(String content) {
|
||||
final fields = <String, String>{};
|
||||
for (final line in content.split('\n')) {
|
||||
final separator = line.indexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
final key = line.substring(0, separator).trim().toLowerCase();
|
||||
var value = line.substring(separator + 1).trim().toLowerCase();
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
_LinuxArch _detectLinuxArch() {
|
||||
return switch (_currentAbiProvider()) {
|
||||
Abi.linuxX64 => _LinuxArch.x64,
|
||||
Abi.linuxArm64 => _LinuxArch.arm64,
|
||||
_ => _LinuxArch.other,
|
||||
};
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
|
||||
final List<StartupInstallHint> hints = switch (distro) {
|
||||
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Debian / Ubuntu',
|
||||
command: 'sudo apt install libpipewire-0.3-0',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install pipewire-libs',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Arch Linux',
|
||||
command: 'sudo pacman -S pipewire',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||
};
|
||||
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary:
|
||||
'Chanora uses PipeWire as the primary Linux voice backend. Without it, Chanora will try the PulseAudio fallback.',
|
||||
details: const [
|
||||
'Install the PipeWire runtime package for your distribution.',
|
||||
'After installing it, restart Chanora and tap Recheck.',
|
||||
],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: hints,
|
||||
);
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildPulseAudioIssue(_LinuxDistro distro) {
|
||||
final List<StartupInstallHint> hints = switch (distro) {
|
||||
_LinuxDistro.debian => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Debian / Ubuntu',
|
||||
command: 'sudo apt install libpulse0',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.fedora => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install pulseaudio-libs',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.arch => const <StartupInstallHint>[
|
||||
StartupInstallHint(
|
||||
label: 'Arch Linux',
|
||||
command: 'sudo pacman -S libpulse',
|
||||
),
|
||||
],
|
||||
_LinuxDistro.other => const <StartupInstallHint>[],
|
||||
};
|
||||
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-pulseaudio-runtime',
|
||||
title: 'PulseAudio runtime is missing',
|
||||
summary:
|
||||
'Chanora uses PulseAudio as the Linux fallback voice backend when PipeWire is unavailable.',
|
||||
details: const [
|
||||
'Install the PulseAudio client library package for your distribution.',
|
||||
'After installing it, restart Chanora and tap Recheck.',
|
||||
],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: hints,
|
||||
);
|
||||
}
|
||||
|
||||
StartupDependencyIssue _buildOnnxIssue({
|
||||
required String executableDir,
|
||||
required _LinuxArch arch,
|
||||
}) {
|
||||
final bundlePath = '$executableDir/lib/libonnxruntime.so';
|
||||
final archivePrefix = switch (arch) {
|
||||
_LinuxArch.x64 => 'onnxruntime-linux-x64-<version>.tgz',
|
||||
_LinuxArch.arm64 => 'onnxruntime-linux-aarch64-<version>.tgz',
|
||||
_LinuxArch.other => 'onnxruntime-linux-<arch>-<version>.tgz',
|
||||
};
|
||||
final archiveDescription = switch (arch) {
|
||||
_LinuxArch.x64 =>
|
||||
'This machine needs the Linux x64 CPU archive ($archivePrefix).',
|
||||
_LinuxArch.arm64 =>
|
||||
'This machine needs the Linux ARM64 / aarch64 CPU archive ($archivePrefix).',
|
||||
_LinuxArch.other =>
|
||||
'Download the Linux CPU archive that matches this machine ($archivePrefix).',
|
||||
};
|
||||
return StartupDependencyIssue(
|
||||
id: 'linux-onnxruntime',
|
||||
title: 'ONNX Runtime is not available',
|
||||
summary:
|
||||
'Silero voice activity detection needs libonnxruntime.so. Chanora can continue, but it will fall back to simpler voice detection.',
|
||||
details: [
|
||||
archiveDescription,
|
||||
'Open the official ONNX Runtime releases page, download the matching Linux CPU archive, then extract it.',
|
||||
'Inside the extracted archive, use the file in lib/ named libonnxruntime.so (or the versioned libonnxruntime.so.* file it points to).',
|
||||
'Use a Chanora build that already bundles ONNX Runtime, or place libonnxruntime.so in the app bundle lib/ directory.',
|
||||
'You can also point Chanora at an existing ONNX Runtime shared library with ORT_DYLIB_PATH.',
|
||||
],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: [
|
||||
StartupInstallHint(
|
||||
label: switch (arch) {
|
||||
_LinuxArch.x64 => 'Download Linux x64 archive',
|
||||
_LinuxArch.arm64 => 'Download Linux ARM64 archive',
|
||||
_LinuxArch.other => 'Open release downloads',
|
||||
},
|
||||
command: 'https://github.com/microsoft/onnxruntime/releases',
|
||||
),
|
||||
StartupInstallHint(
|
||||
label: 'Archive name to look for',
|
||||
command: archivePrefix,
|
||||
),
|
||||
const StartupInstallHint(
|
||||
label: 'Official install reference',
|
||||
command: 'https://onnxruntime.ai/docs/install/',
|
||||
),
|
||||
const StartupInstallHint(
|
||||
label: 'Temporary shell setup',
|
||||
command: 'export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so',
|
||||
),
|
||||
StartupInstallHint(
|
||||
label: 'Bundle into this app',
|
||||
command: 'cp /absolute/path/to/libonnxruntime.so $bundlePath',
|
||||
),
|
||||
StartupInstallHint(label: 'Bundled file location', command: bundlePath),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
void debugResetStartupDependencyCheck({
|
||||
bool Function(String)? libraryProbe,
|
||||
Future<bool> Function(String path)? fileExists,
|
||||
String Function()? resolvedExecutableProvider,
|
||||
String Function()? currentDirectoryProvider,
|
||||
Future<String?> Function()? osReleaseProvider,
|
||||
bool Function()? platformIsLinux,
|
||||
String Function()? logFilePathProvider,
|
||||
Abi Function()? currentAbiProvider,
|
||||
}) {
|
||||
_libraryProbe = libraryProbe ?? _defaultLibraryProbe;
|
||||
_fileExists = fileExists ?? _defaultFileExists;
|
||||
_resolvedExecutableProvider =
|
||||
resolvedExecutableProvider ?? (() => Platform.resolvedExecutable);
|
||||
_currentDirectoryProvider =
|
||||
currentDirectoryProvider ?? (() => Directory.current.path);
|
||||
_osReleaseProvider = osReleaseProvider ?? _defaultOsReleaseProvider;
|
||||
_platformIsLinux = platformIsLinux ?? (() => Platform.isLinux);
|
||||
_logFilePathProvider = logFilePathProvider ?? rust.logFilePathStr;
|
||||
_currentAbiProvider = currentAbiProvider ?? Abi.current;
|
||||
}
|
||||
@@ -1,53 +1,35 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
enum UiThemeMode {
|
||||
system,
|
||||
light,
|
||||
dark;
|
||||
|
||||
static UiThemeMode fromStorage(String? value) {
|
||||
return UiThemeMode.values.firstWhere(
|
||||
(mode) => mode.name == value,
|
||||
orElse: () => UiThemeMode.system,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UiSettings {
|
||||
const UiSettings({this.host = '', this.nickname = ''});
|
||||
const UiSettings({
|
||||
this.host = '',
|
||||
this.nickname = '',
|
||||
this.themeMode = UiThemeMode.system,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final String nickname;
|
||||
}
|
||||
|
||||
class ClientPlaybackPreference {
|
||||
const ClientPlaybackPreference({this.volume = 1.0, this.muted = false});
|
||||
|
||||
final double volume;
|
||||
final bool muted;
|
||||
|
||||
double get appliedVolume => muted ? 0.0 : volume;
|
||||
|
||||
ClientPlaybackPreference copyWith({double? volume, bool? muted}) {
|
||||
return ClientPlaybackPreference(
|
||||
volume: _clampVolume(volume ?? this.volume),
|
||||
muted: muted ?? this.muted,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object> toJson() => {
|
||||
'volume': _clampVolume(volume),
|
||||
'muted': muted,
|
||||
};
|
||||
|
||||
static ClientPlaybackPreference fromJson(Map<String, dynamic> json) {
|
||||
final volume = json['volume'];
|
||||
return ClientPlaybackPreference(
|
||||
volume: _clampVolume(volume is num ? volume.toDouble() : 1.0),
|
||||
muted: json['muted'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
static double _clampVolume(double volume) {
|
||||
if (!volume.isFinite) return 1.0;
|
||||
return volume.clamp(0.0, 4.0);
|
||||
}
|
||||
final UiThemeMode themeMode;
|
||||
}
|
||||
|
||||
class UiPreferencesService {
|
||||
static const _hostKey = 'ui.host';
|
||||
static const _nicknameKey = 'ui.nickname';
|
||||
static const _themeModeKey = 'ui.theme_mode';
|
||||
static const _permissionsExplainedKey = 'perms_explained';
|
||||
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
|
||||
|
||||
const UiPreferencesService();
|
||||
|
||||
@@ -56,6 +38,7 @@ class UiPreferencesService {
|
||||
return UiSettings(
|
||||
host: prefs.getString(_hostKey) ?? '',
|
||||
nickname: prefs.getString(_nicknameKey) ?? '',
|
||||
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +48,11 @@ class UiPreferencesService {
|
||||
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
|
||||
}
|
||||
|
||||
Future<void> saveThemeMode(UiThemeMode themeMode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_themeModeKey, themeMode.name);
|
||||
}
|
||||
|
||||
Future<bool> hasExplainedPermissions() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_permissionsExplainedKey) ?? false;
|
||||
@@ -74,80 +62,4 @@ class UiPreferencesService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_permissionsExplainedKey, true);
|
||||
}
|
||||
|
||||
Future<Map<String, ClientPlaybackPreference>>
|
||||
loadClientPlaybackPreferencesForServer(String serverHost) async {
|
||||
final normalizedHost = _normalizeServerHost(serverHost);
|
||||
if (normalizedHost.isEmpty) return const {};
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_clientPlaybackPrefsKey);
|
||||
if (raw == null || raw.isEmpty) return const {};
|
||||
|
||||
final decoded = _decodeClientPlaybackPreferences(raw);
|
||||
|
||||
final result = <String, ClientPlaybackPreference>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
final (host, uid) = _splitCompositeKey(key);
|
||||
if (host != normalizedHost ||
|
||||
uid.isEmpty ||
|
||||
value is! Map<String, dynamic>) {
|
||||
continue;
|
||||
}
|
||||
result[uid] = ClientPlaybackPreference.fromJson(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> saveClientPlaybackPreference({
|
||||
required String serverHost,
|
||||
required String userUid,
|
||||
double? volume,
|
||||
bool? muted,
|
||||
}) async {
|
||||
final normalizedHost = _normalizeServerHost(serverHost);
|
||||
final normalizedUid = userUid.trim();
|
||||
if (normalizedHost.isEmpty || normalizedUid.isEmpty) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final current = await loadClientPlaybackPreferencesForServer(serverHost);
|
||||
final previous = current[normalizedUid] ?? const ClientPlaybackPreference();
|
||||
final updated = previous.copyWith(volume: volume, muted: muted);
|
||||
|
||||
final raw = prefs.getString(_clientPlaybackPrefsKey);
|
||||
final decoded = _decodeClientPlaybackPreferences(raw);
|
||||
final compositeKey = _compositeKey(normalizedHost, normalizedUid);
|
||||
|
||||
if (updated.volume == 1.0 && !updated.muted) {
|
||||
decoded.remove(compositeKey);
|
||||
} else {
|
||||
decoded[compositeKey] = updated.toJson();
|
||||
}
|
||||
|
||||
await prefs.setString(_clientPlaybackPrefsKey, jsonEncode(decoded));
|
||||
}
|
||||
|
||||
String _compositeKey(String normalizedHost, String normalizedUid) {
|
||||
return '$normalizedHost|$normalizedUid';
|
||||
}
|
||||
|
||||
(String, String) _splitCompositeKey(String key) {
|
||||
final index = key.indexOf('|');
|
||||
if (index == -1) return ('', '');
|
||||
return (key.substring(0, index), key.substring(index + 1));
|
||||
}
|
||||
|
||||
static String _normalizeServerHost(String host) => host.trim().toLowerCase();
|
||||
|
||||
Map<String, dynamic> _decodeClientPlaybackPreferences(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return <String, dynamic>{};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
|
||||
} catch (_) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`, `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 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`, `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`, `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`, `from`
|
||||
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
||||
|
||||
/// Return the platform-conventional log-file path as a string, or
|
||||
@@ -33,9 +33,19 @@ Future<BridgeSnapshot> connect({
|
||||
password: password,
|
||||
);
|
||||
|
||||
/// Warm server address resolution for the active host field. This is
|
||||
/// intentionally fire-and-forget from the UI perspective: it schedules
|
||||
/// Rust-side prefetch work and never opens a TS3 session.
|
||||
Future<void> prefetchServer({required String host}) =>
|
||||
RustLib.instance.api.crateApiPrefetchServer(host: host);
|
||||
|
||||
/// Re-fetch a fresh snapshot from the active connection.
|
||||
Future<BridgeSnapshot> snapshot() => RustLib.instance.api.crateApiSnapshot();
|
||||
|
||||
/// Fetch richer profile and live connection details for one online client.
|
||||
Future<BridgeClientProfile> clientProfile({required BigInt clientId}) =>
|
||||
RustLib.instance.api.crateApiClientProfile(clientId: clientId);
|
||||
|
||||
/// Disconnect from the server. No-op if not connected.
|
||||
Future<void> disconnect() => RustLib.instance.api.crateApiDisconnect();
|
||||
|
||||
@@ -198,8 +208,7 @@ Future<void> sendChatMessage({
|
||||
/// On non-Android targets or before a voice session opens the
|
||||
/// section is omitted. Per SDD-090 every field in that section is
|
||||
/// a device-side technical scalar — no PII admitted.
|
||||
Future<String> exportDiagnostics() =>
|
||||
RustLib.instance.api.crateApiExportDiagnostics();
|
||||
String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics();
|
||||
|
||||
/// Wire the identity persistence store to a platform-private
|
||||
/// directory. Should be called once on app start after Flutter has
|
||||
@@ -796,9 +805,6 @@ class BridgeClient {
|
||||
/// Stable client id.
|
||||
final BigInt id;
|
||||
|
||||
/// Stable TeamSpeak unique identifier.
|
||||
final String uid;
|
||||
|
||||
/// Channel id the client is currently in.
|
||||
final BigInt channel;
|
||||
|
||||
@@ -825,7 +831,6 @@ class BridgeClient {
|
||||
|
||||
const BridgeClient({
|
||||
required this.id,
|
||||
required this.uid,
|
||||
required this.channel,
|
||||
required this.name,
|
||||
required this.inputMuted,
|
||||
@@ -839,7 +844,6 @@ class BridgeClient {
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
uid.hashCode ^
|
||||
channel.hashCode ^
|
||||
name.hashCode ^
|
||||
inputMuted.hashCode ^
|
||||
@@ -855,7 +859,6 @@ class BridgeClient {
|
||||
other is BridgeClient &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
uid == other.uid &&
|
||||
channel == other.channel &&
|
||||
name == other.name &&
|
||||
inputMuted == other.inputMuted &&
|
||||
@@ -866,6 +869,172 @@ class BridgeClient {
|
||||
talkPowerGranted == other.talkPowerGranted;
|
||||
}
|
||||
|
||||
/// Rich profile and live connection details for one online client.
|
||||
class BridgeClientProfile {
|
||||
/// Stable online client id.
|
||||
final BigInt id;
|
||||
|
||||
/// Channel the client is currently in.
|
||||
final BigInt channel;
|
||||
|
||||
/// Nickname.
|
||||
final String name;
|
||||
|
||||
/// TeamSpeak unique id.
|
||||
final String uniqueId;
|
||||
|
||||
/// Stable TeamSpeak database id, when visible.
|
||||
final BigInt? databaseId;
|
||||
|
||||
/// ISO country code, when visible.
|
||||
final String countryCode;
|
||||
|
||||
/// User description, when visible.
|
||||
final String description;
|
||||
|
||||
/// Client version string.
|
||||
final String version;
|
||||
|
||||
/// Client platform string.
|
||||
final String platform;
|
||||
|
||||
/// Account creation time as Unix seconds.
|
||||
final PlatformInt64? createdUnixSeconds;
|
||||
|
||||
/// Last connection time as Unix seconds.
|
||||
final PlatformInt64? lastConnectedUnixSeconds;
|
||||
|
||||
/// Total historical connections.
|
||||
final BigInt? connectionsTotal;
|
||||
|
||||
/// Current online duration in seconds.
|
||||
final PlatformInt64? onlineSeconds;
|
||||
|
||||
/// Current idle time in milliseconds.
|
||||
final PlatformInt64? idleMilliseconds;
|
||||
|
||||
/// Current ping in milliseconds.
|
||||
final PlatformInt64? pingMilliseconds;
|
||||
|
||||
/// Client address. Empty when permission-gated.
|
||||
final String clientAddress;
|
||||
|
||||
/// Resolved server group names.
|
||||
final List<String> serverGroups;
|
||||
|
||||
/// Resolved channel group name.
|
||||
final String channelGroup;
|
||||
|
||||
/// TeamSpeak avatar file path suffix.
|
||||
final String avatarPath;
|
||||
|
||||
/// Downloaded bytes this month.
|
||||
final BigInt? bytesDownloadedMonth;
|
||||
|
||||
/// Uploaded bytes this month.
|
||||
final BigInt? bytesUploadedMonth;
|
||||
|
||||
/// Downloaded bytes across all time.
|
||||
final BigInt? bytesDownloadedTotal;
|
||||
|
||||
/// Uploaded bytes across all time.
|
||||
final BigInt? bytesUploadedTotal;
|
||||
|
||||
/// Client-to-server total packet loss ratio.
|
||||
final double? packetLossClientToServerTotal;
|
||||
|
||||
/// Server-to-client total packet loss ratio.
|
||||
final double? packetLossServerToClientTotal;
|
||||
|
||||
const BridgeClientProfile({
|
||||
required this.id,
|
||||
required this.channel,
|
||||
required this.name,
|
||||
required this.uniqueId,
|
||||
this.databaseId,
|
||||
required this.countryCode,
|
||||
required this.description,
|
||||
required this.version,
|
||||
required this.platform,
|
||||
this.createdUnixSeconds,
|
||||
this.lastConnectedUnixSeconds,
|
||||
this.connectionsTotal,
|
||||
this.onlineSeconds,
|
||||
this.idleMilliseconds,
|
||||
this.pingMilliseconds,
|
||||
required this.clientAddress,
|
||||
required this.serverGroups,
|
||||
required this.channelGroup,
|
||||
required this.avatarPath,
|
||||
this.bytesDownloadedMonth,
|
||||
this.bytesUploadedMonth,
|
||||
this.bytesDownloadedTotal,
|
||||
this.bytesUploadedTotal,
|
||||
this.packetLossClientToServerTotal,
|
||||
this.packetLossServerToClientTotal,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
channel.hashCode ^
|
||||
name.hashCode ^
|
||||
uniqueId.hashCode ^
|
||||
databaseId.hashCode ^
|
||||
countryCode.hashCode ^
|
||||
description.hashCode ^
|
||||
version.hashCode ^
|
||||
platform.hashCode ^
|
||||
createdUnixSeconds.hashCode ^
|
||||
lastConnectedUnixSeconds.hashCode ^
|
||||
connectionsTotal.hashCode ^
|
||||
onlineSeconds.hashCode ^
|
||||
idleMilliseconds.hashCode ^
|
||||
pingMilliseconds.hashCode ^
|
||||
clientAddress.hashCode ^
|
||||
serverGroups.hashCode ^
|
||||
channelGroup.hashCode ^
|
||||
avatarPath.hashCode ^
|
||||
bytesDownloadedMonth.hashCode ^
|
||||
bytesUploadedMonth.hashCode ^
|
||||
bytesDownloadedTotal.hashCode ^
|
||||
bytesUploadedTotal.hashCode ^
|
||||
packetLossClientToServerTotal.hashCode ^
|
||||
packetLossServerToClientTotal.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is BridgeClientProfile &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
channel == other.channel &&
|
||||
name == other.name &&
|
||||
uniqueId == other.uniqueId &&
|
||||
databaseId == other.databaseId &&
|
||||
countryCode == other.countryCode &&
|
||||
description == other.description &&
|
||||
version == other.version &&
|
||||
platform == other.platform &&
|
||||
createdUnixSeconds == other.createdUnixSeconds &&
|
||||
lastConnectedUnixSeconds == other.lastConnectedUnixSeconds &&
|
||||
connectionsTotal == other.connectionsTotal &&
|
||||
onlineSeconds == other.onlineSeconds &&
|
||||
idleMilliseconds == other.idleMilliseconds &&
|
||||
pingMilliseconds == other.pingMilliseconds &&
|
||||
clientAddress == other.clientAddress &&
|
||||
serverGroups == other.serverGroups &&
|
||||
channelGroup == other.channelGroup &&
|
||||
avatarPath == other.avatarPath &&
|
||||
bytesDownloadedMonth == other.bytesDownloadedMonth &&
|
||||
bytesUploadedMonth == other.bytesUploadedMonth &&
|
||||
bytesDownloadedTotal == other.bytesDownloadedTotal &&
|
||||
bytesUploadedTotal == other.bytesUploadedTotal &&
|
||||
packetLossClientToServerTotal ==
|
||||
other.packetLossClientToServerTotal &&
|
||||
packetLossServerToClientTotal == other.packetLossServerToClientTotal;
|
||||
}
|
||||
|
||||
/// Bridge effect owner for AEC/NS/AGC.
|
||||
enum BridgeEffectOwner {
|
||||
/// Platform-owned effect.
|
||||
|
||||
@@ -248,7 +248,7 @@ return audioRouteChanged(_that.route);case _:
|
||||
|
||||
class BridgeEvent_Connected extends BridgeEvent {
|
||||
const BridgeEvent_Connected({required this.serverName}): super._();
|
||||
|
||||
|
||||
|
||||
/// Server name reported by the server snapshot.
|
||||
final String serverName;
|
||||
@@ -315,7 +315,7 @@ as String,
|
||||
|
||||
class BridgeEvent_Lost extends BridgeEvent {
|
||||
const BridgeEvent_Lost({required this.reason}): super._();
|
||||
|
||||
|
||||
|
||||
/// Reason classification from the protocol layer.
|
||||
final String reason;
|
||||
@@ -382,7 +382,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;
|
||||
@@ -452,7 +452,7 @@ as int,
|
||||
|
||||
class BridgeEvent_Disconnected extends BridgeEvent {
|
||||
const BridgeEvent_Disconnected({required this.reason}): super._();
|
||||
|
||||
|
||||
|
||||
/// Reason classification.
|
||||
final String reason;
|
||||
@@ -519,7 +519,7 @@ as String,
|
||||
|
||||
class BridgeEvent_AudioStarted extends BridgeEvent {
|
||||
const BridgeEvent_AudioStarted(): super._();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -551,7 +551,7 @@ String toString() {
|
||||
|
||||
class BridgeEvent_AudioStopped extends BridgeEvent {
|
||||
const BridgeEvent_AudioStopped(): super._();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -583,7 +583,7 @@ String toString() {
|
||||
|
||||
class BridgeEvent_SnapshotChanged extends BridgeEvent {
|
||||
const BridgeEvent_SnapshotChanged({required this.channels, required this.clients}): super._();
|
||||
|
||||
|
||||
|
||||
/// Latest channel count.
|
||||
final int channels;
|
||||
@@ -653,7 +653,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"`,
|
||||
@@ -729,7 +729,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.
|
||||
@@ -824,7 +824,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;
|
||||
@@ -894,7 +894,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;
|
||||
@@ -964,7 +964,7 @@ as PermissionStateKind,
|
||||
|
||||
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;
|
||||
@@ -1037,7 +1037,7 @@ as BridgeMessageTarget,
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$BridgeMessageTargetCopyWith<$Res> get target {
|
||||
|
||||
|
||||
return $BridgeMessageTargetCopyWith<$Res>(_self.target, (value) {
|
||||
return _then(_self.copyWith(target: value));
|
||||
});
|
||||
@@ -1049,7 +1049,7 @@ $BridgeMessageTargetCopyWith<$Res> get target {
|
||||
|
||||
class BridgeEvent_ServerActivity extends BridgeEvent {
|
||||
const BridgeEvent_ServerActivity({required this.message}): super._();
|
||||
|
||||
|
||||
|
||||
/// TeamSpeak-style activity line.
|
||||
final String message;
|
||||
@@ -1116,7 +1116,7 @@ as String,
|
||||
|
||||
class BridgeEvent_AudioRouteChanged extends BridgeEvent {
|
||||
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
|
||||
|
||||
|
||||
|
||||
/// The new audio route.
|
||||
final BridgeAudioRoute route;
|
||||
@@ -1355,7 +1355,7 @@ return poke(_that.field0);case _:
|
||||
|
||||
class BridgeMessageTarget_Server extends BridgeMessageTarget {
|
||||
const BridgeMessageTarget_Server(): super._();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1387,7 +1387,7 @@ String toString() {
|
||||
|
||||
class BridgeMessageTarget_Channel extends BridgeMessageTarget {
|
||||
const BridgeMessageTarget_Channel(): super._();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1419,7 +1419,7 @@ String toString() {
|
||||
|
||||
class BridgeMessageTarget_Client extends BridgeMessageTarget {
|
||||
const BridgeMessageTarget_Client(this.field0): super._();
|
||||
|
||||
|
||||
|
||||
final BigInt field0;
|
||||
|
||||
@@ -1485,7 +1485,7 @@ as BigInt,
|
||||
|
||||
class BridgeMessageTarget_Poke extends BridgeMessageTarget {
|
||||
const BridgeMessageTarget_Poke(this.field0): super._();
|
||||
|
||||
|
||||
|
||||
final BigInt field0;
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
String get codegenVersion => '2.12.0';
|
||||
|
||||
@override
|
||||
int get rustContentHash => -560177922;
|
||||
int get rustContentHash => 281698435;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
@@ -87,6 +87,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiBridgeInit();
|
||||
|
||||
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
|
||||
|
||||
Future<BridgeSnapshot> crateApiConnect({
|
||||
required String host,
|
||||
required String nickname,
|
||||
@@ -101,7 +103,7 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Stream<BridgeEvent> crateApiEventsStream();
|
||||
|
||||
Future<String> crateApiExportDiagnostics();
|
||||
String crateApiExportDiagnostics();
|
||||
|
||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
|
||||
|
||||
@@ -134,6 +136,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
required String password,
|
||||
});
|
||||
|
||||
Future<void> crateApiPrefetchServer({required String host});
|
||||
|
||||
Future<BridgePttDescriptor> crateApiPttDescriptor();
|
||||
|
||||
void crateApiRecordLifecycleEvent({required String state});
|
||||
@@ -314,6 +318,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
|
||||
const TaskConstMeta(debugName: "bridge_init", argNames: []);
|
||||
|
||||
@override
|
||||
Future<BridgeClientProfile> crateApiClientProfile({
|
||||
required BigInt clientId,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_u_64(clientId, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 5,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_bridge_client_profile,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiClientProfileConstMeta,
|
||||
argValues: [clientId],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiClientProfileConstMeta =>
|
||||
const TaskConstMeta(debugName: "client_profile", argNames: ["clientId"]);
|
||||
|
||||
@override
|
||||
Future<BridgeSnapshot> crateApiConnect({
|
||||
required String host,
|
||||
@@ -330,7 +364,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 5,
|
||||
funcId: 6,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -360,7 +394,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 6,
|
||||
funcId: 7,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -387,7 +421,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 7,
|
||||
funcId: 8,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -415,7 +449,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 8,
|
||||
funcId: 9,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -448,7 +482,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 9,
|
||||
funcId: 10,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -469,21 +503,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]);
|
||||
|
||||
@override
|
||||
Future<String> crateApiExportDiagnostics() {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
String crateApiExportDiagnostics() {
|
||||
return handler.executeSync(
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 10,
|
||||
port: port_,
|
||||
);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
decodeErrorData: null,
|
||||
),
|
||||
constMeta: kCrateApiExportDiagnosticsConstMeta,
|
||||
argValues: [],
|
||||
@@ -504,7 +533,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 11,
|
||||
funcId: 12,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -534,7 +563,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 12,
|
||||
funcId: 13,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -561,7 +590,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 13,
|
||||
funcId: 14,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -588,7 +617,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 14,
|
||||
funcId: 15,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -612,7 +641,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -635,7 +664,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bool(shouldResume, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -661,7 +690,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(routeClass, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -687,7 +716,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -715,7 +744,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 19,
|
||||
funcId: 20,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -742,7 +771,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 20,
|
||||
funcId: 21,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -769,7 +798,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 21,
|
||||
funcId: 22,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -796,7 +825,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 22,
|
||||
funcId: 23,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -820,7 +849,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -850,7 +879,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 24,
|
||||
funcId: 25,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -870,6 +899,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
argNames: ["channelId", "password"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> crateApiPrefetchServer({required String host}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(host, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 26,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiPrefetchServerConstMeta,
|
||||
argValues: [host],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiPrefetchServerConstMeta =>
|
||||
const TaskConstMeta(debugName: "prefetch_server", argNames: ["host"]);
|
||||
|
||||
@override
|
||||
Future<BridgePttDescriptor> crateApiPttDescriptor() {
|
||||
return handler.executeNormal(
|
||||
@@ -879,7 +936,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 25,
|
||||
funcId: 27,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -904,7 +961,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -937,7 +994,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 27,
|
||||
funcId: 29,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -964,7 +1021,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -998,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 29,
|
||||
funcId: 31,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1033,7 +1090,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 30,
|
||||
funcId: 32,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1063,7 +1120,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 31,
|
||||
funcId: 33,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1091,7 +1148,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 32,
|
||||
funcId: 34,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1119,7 +1176,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 33,
|
||||
funcId: 35,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1149,7 +1206,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 34,
|
||||
funcId: 36,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1177,7 +1234,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_network_state(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -1203,7 +1260,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 36,
|
||||
funcId: 38,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1231,7 +1288,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 37,
|
||||
funcId: 39,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1259,7 +1316,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 38,
|
||||
funcId: 40,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1287,7 +1344,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 39,
|
||||
funcId: 41,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1319,7 +1376,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 40,
|
||||
funcId: 42,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1349,7 +1406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 41,
|
||||
funcId: 43,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1377,7 +1434,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 42,
|
||||
funcId: 44,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1405,7 +1462,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 43,
|
||||
funcId: 45,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1432,7 +1489,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 44,
|
||||
funcId: 46,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1460,7 +1517,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 45,
|
||||
funcId: 47,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1492,7 +1549,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 46,
|
||||
funcId: 48,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1521,7 +1578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 47,
|
||||
funcId: 49,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1594,12 +1651,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return dco_decode_bridge_voice_join_error_code(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
double dco_decode_box_autoadd_f_32(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return raw as double;
|
||||
}
|
||||
|
||||
@protected
|
||||
int dco_decode_box_autoadd_i_32(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return raw as int;
|
||||
}
|
||||
|
||||
@protected
|
||||
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return dco_decode_i_64(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
BigInt dco_decode_box_autoadd_u_64(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -1756,19 +1825,53 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
BridgeClient dco_decode_bridge_client(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 10)
|
||||
throw Exception('unexpected arr length: expect 10 but see ${arr.length}');
|
||||
if (arr.length != 9)
|
||||
throw Exception('unexpected arr length: expect 9 but see ${arr.length}');
|
||||
return BridgeClient(
|
||||
id: dco_decode_u_64(arr[0]),
|
||||
uid: dco_decode_String(arr[1]),
|
||||
channel: dco_decode_u_64(arr[2]),
|
||||
name: dco_decode_String(arr[3]),
|
||||
inputMuted: dco_decode_bool(arr[4]),
|
||||
outputMuted: dco_decode_bool(arr[5]),
|
||||
isSpeaking: dco_decode_bool(arr[6]),
|
||||
isServerQuery: dco_decode_bool(arr[7]),
|
||||
talkPower: dco_decode_i_32(arr[8]),
|
||||
talkPowerGranted: dco_decode_bool(arr[9]),
|
||||
channel: dco_decode_u_64(arr[1]),
|
||||
name: dco_decode_String(arr[2]),
|
||||
inputMuted: dco_decode_bool(arr[3]),
|
||||
outputMuted: dco_decode_bool(arr[4]),
|
||||
isSpeaking: dco_decode_bool(arr[5]),
|
||||
isServerQuery: dco_decode_bool(arr[6]),
|
||||
talkPower: dco_decode_i_32(arr[7]),
|
||||
talkPowerGranted: dco_decode_bool(arr[8]),
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 25)
|
||||
throw Exception('unexpected arr length: expect 25 but see ${arr.length}');
|
||||
return BridgeClientProfile(
|
||||
id: dco_decode_u_64(arr[0]),
|
||||
channel: dco_decode_u_64(arr[1]),
|
||||
name: dco_decode_String(arr[2]),
|
||||
uniqueId: dco_decode_String(arr[3]),
|
||||
databaseId: dco_decode_opt_box_autoadd_u_64(arr[4]),
|
||||
countryCode: dco_decode_String(arr[5]),
|
||||
description: dco_decode_String(arr[6]),
|
||||
version: dco_decode_String(arr[7]),
|
||||
platform: dco_decode_String(arr[8]),
|
||||
createdUnixSeconds: dco_decode_opt_box_autoadd_i_64(arr[9]),
|
||||
lastConnectedUnixSeconds: dco_decode_opt_box_autoadd_i_64(arr[10]),
|
||||
connectionsTotal: dco_decode_opt_box_autoadd_u_64(arr[11]),
|
||||
onlineSeconds: dco_decode_opt_box_autoadd_i_64(arr[12]),
|
||||
idleMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[13]),
|
||||
pingMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[14]),
|
||||
clientAddress: dco_decode_String(arr[15]),
|
||||
serverGroups: dco_decode_list_String(arr[16]),
|
||||
channelGroup: dco_decode_String(arr[17]),
|
||||
avatarPath: dco_decode_String(arr[18]),
|
||||
bytesDownloadedMonth: dco_decode_opt_box_autoadd_u_64(arr[19]),
|
||||
bytesUploadedMonth: dco_decode_opt_box_autoadd_u_64(arr[20]),
|
||||
bytesDownloadedTotal: dco_decode_opt_box_autoadd_u_64(arr[21]),
|
||||
bytesUploadedTotal: dco_decode_opt_box_autoadd_u_64(arr[22]),
|
||||
packetLossClientToServerTotal: dco_decode_opt_box_autoadd_f_32(arr[23]),
|
||||
packetLossServerToClientTotal: dco_decode_opt_box_autoadd_f_32(arr[24]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2004,6 +2107,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return dcoDecodeI64(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
List<String> dco_decode_list_String(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return (raw as List<dynamic>).map(dco_decode_String).toList();
|
||||
}
|
||||
|
||||
@protected
|
||||
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -2049,12 +2158,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
: dco_decode_box_autoadd_bridge_voice_join_error_code(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
double? dco_decode_opt_box_autoadd_f_32(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return raw == null ? null : dco_decode_box_autoadd_f_32(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
int? dco_decode_opt_box_autoadd_i_32(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return raw == null ? null : dco_decode_box_autoadd_i_32(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
return raw == null ? null : dco_decode_box_autoadd_i_64(raw);
|
||||
}
|
||||
|
||||
@protected
|
||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
@@ -2152,12 +2273,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return (sse_decode_bridge_voice_join_error_code(deserializer));
|
||||
}
|
||||
|
||||
@protected
|
||||
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
return (sse_decode_f_32(deserializer));
|
||||
}
|
||||
|
||||
@protected
|
||||
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
return (sse_decode_i_32(deserializer));
|
||||
}
|
||||
|
||||
@protected
|
||||
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
return (sse_decode_i_64(deserializer));
|
||||
}
|
||||
|
||||
@protected
|
||||
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -2358,7 +2491,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var var_id = sse_decode_u_64(deserializer);
|
||||
var var_uid = sse_decode_String(deserializer);
|
||||
var var_channel = sse_decode_u_64(deserializer);
|
||||
var var_name = sse_decode_String(deserializer);
|
||||
var var_inputMuted = sse_decode_bool(deserializer);
|
||||
@@ -2369,7 +2501,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
var var_talkPowerGranted = sse_decode_bool(deserializer);
|
||||
return BridgeClient(
|
||||
id: var_id,
|
||||
uid: var_uid,
|
||||
channel: var_channel,
|
||||
name: var_name,
|
||||
inputMuted: var_inputMuted,
|
||||
@@ -2381,6 +2512,75 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeClientProfile sse_decode_bridge_client_profile(
|
||||
SseDeserializer deserializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
var var_id = sse_decode_u_64(deserializer);
|
||||
var var_channel = sse_decode_u_64(deserializer);
|
||||
var var_name = sse_decode_String(deserializer);
|
||||
var var_uniqueId = sse_decode_String(deserializer);
|
||||
var var_databaseId = sse_decode_opt_box_autoadd_u_64(deserializer);
|
||||
var var_countryCode = sse_decode_String(deserializer);
|
||||
var var_description = sse_decode_String(deserializer);
|
||||
var var_version = sse_decode_String(deserializer);
|
||||
var var_platform = sse_decode_String(deserializer);
|
||||
var var_createdUnixSeconds = sse_decode_opt_box_autoadd_i_64(deserializer);
|
||||
var var_lastConnectedUnixSeconds = sse_decode_opt_box_autoadd_i_64(
|
||||
deserializer,
|
||||
);
|
||||
var var_connectionsTotal = sse_decode_opt_box_autoadd_u_64(deserializer);
|
||||
var var_onlineSeconds = sse_decode_opt_box_autoadd_i_64(deserializer);
|
||||
var var_idleMilliseconds = sse_decode_opt_box_autoadd_i_64(deserializer);
|
||||
var var_pingMilliseconds = sse_decode_opt_box_autoadd_i_64(deserializer);
|
||||
var var_clientAddress = sse_decode_String(deserializer);
|
||||
var var_serverGroups = sse_decode_list_String(deserializer);
|
||||
var var_channelGroup = sse_decode_String(deserializer);
|
||||
var var_avatarPath = sse_decode_String(deserializer);
|
||||
var var_bytesDownloadedMonth = sse_decode_opt_box_autoadd_u_64(
|
||||
deserializer,
|
||||
);
|
||||
var var_bytesUploadedMonth = sse_decode_opt_box_autoadd_u_64(deserializer);
|
||||
var var_bytesDownloadedTotal = sse_decode_opt_box_autoadd_u_64(
|
||||
deserializer,
|
||||
);
|
||||
var var_bytesUploadedTotal = sse_decode_opt_box_autoadd_u_64(deserializer);
|
||||
var var_packetLossClientToServerTotal = sse_decode_opt_box_autoadd_f_32(
|
||||
deserializer,
|
||||
);
|
||||
var var_packetLossServerToClientTotal = sse_decode_opt_box_autoadd_f_32(
|
||||
deserializer,
|
||||
);
|
||||
return BridgeClientProfile(
|
||||
id: var_id,
|
||||
channel: var_channel,
|
||||
name: var_name,
|
||||
uniqueId: var_uniqueId,
|
||||
databaseId: var_databaseId,
|
||||
countryCode: var_countryCode,
|
||||
description: var_description,
|
||||
version: var_version,
|
||||
platform: var_platform,
|
||||
createdUnixSeconds: var_createdUnixSeconds,
|
||||
lastConnectedUnixSeconds: var_lastConnectedUnixSeconds,
|
||||
connectionsTotal: var_connectionsTotal,
|
||||
onlineSeconds: var_onlineSeconds,
|
||||
idleMilliseconds: var_idleMilliseconds,
|
||||
pingMilliseconds: var_pingMilliseconds,
|
||||
clientAddress: var_clientAddress,
|
||||
serverGroups: var_serverGroups,
|
||||
channelGroup: var_channelGroup,
|
||||
avatarPath: var_avatarPath,
|
||||
bytesDownloadedMonth: var_bytesDownloadedMonth,
|
||||
bytesUploadedMonth: var_bytesUploadedMonth,
|
||||
bytesDownloadedTotal: var_bytesDownloadedTotal,
|
||||
bytesUploadedTotal: var_bytesUploadedTotal,
|
||||
packetLossClientToServerTotal: var_packetLossClientToServerTotal,
|
||||
packetLossServerToClientTotal: var_packetLossServerToClientTotal,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||
SseDeserializer deserializer,
|
||||
@@ -2680,6 +2880,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
return deserializer.buffer.getPlatformInt64();
|
||||
}
|
||||
|
||||
@protected
|
||||
List<String> sse_decode_list_String(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
var len_ = sse_decode_i_32(deserializer);
|
||||
var ans_ = <String>[];
|
||||
for (var idx_ = 0; idx_ < len_; ++idx_) {
|
||||
ans_.add(sse_decode_String(deserializer));
|
||||
}
|
||||
return ans_;
|
||||
}
|
||||
|
||||
@protected
|
||||
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
|
||||
SseDeserializer deserializer,
|
||||
@@ -2770,6 +2982,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
if (sse_decode_bool(deserializer)) {
|
||||
return (sse_decode_box_autoadd_f_32(deserializer));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -2781,6 +3004,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
if (sse_decode_bool(deserializer)) {
|
||||
return (sse_decode_box_autoadd_i_64(deserializer));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -2898,12 +3132,27 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_bridge_voice_join_error_code(self, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_f_32(self, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_i_32(self, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_i_64(
|
||||
PlatformInt64 self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_i_64(self, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -3056,6 +3305,45 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_bool(self.talkPowerGranted, serializer);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_client_profile(
|
||||
BridgeClientProfile self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_u_64(self.id, serializer);
|
||||
sse_encode_u_64(self.channel, serializer);
|
||||
sse_encode_String(self.name, serializer);
|
||||
sse_encode_String(self.uniqueId, serializer);
|
||||
sse_encode_opt_box_autoadd_u_64(self.databaseId, serializer);
|
||||
sse_encode_String(self.countryCode, serializer);
|
||||
sse_encode_String(self.description, serializer);
|
||||
sse_encode_String(self.version, serializer);
|
||||
sse_encode_String(self.platform, serializer);
|
||||
sse_encode_opt_box_autoadd_i_64(self.createdUnixSeconds, serializer);
|
||||
sse_encode_opt_box_autoadd_i_64(self.lastConnectedUnixSeconds, serializer);
|
||||
sse_encode_opt_box_autoadd_u_64(self.connectionsTotal, serializer);
|
||||
sse_encode_opt_box_autoadd_i_64(self.onlineSeconds, serializer);
|
||||
sse_encode_opt_box_autoadd_i_64(self.idleMilliseconds, serializer);
|
||||
sse_encode_opt_box_autoadd_i_64(self.pingMilliseconds, serializer);
|
||||
sse_encode_String(self.clientAddress, serializer);
|
||||
sse_encode_list_String(self.serverGroups, serializer);
|
||||
sse_encode_String(self.channelGroup, serializer);
|
||||
sse_encode_String(self.avatarPath, serializer);
|
||||
sse_encode_opt_box_autoadd_u_64(self.bytesDownloadedMonth, serializer);
|
||||
sse_encode_opt_box_autoadd_u_64(self.bytesUploadedMonth, serializer);
|
||||
sse_encode_opt_box_autoadd_u_64(self.bytesDownloadedTotal, serializer);
|
||||
sse_encode_opt_box_autoadd_u_64(self.bytesUploadedTotal, serializer);
|
||||
sse_encode_opt_box_autoadd_f_32(
|
||||
self.packetLossClientToServerTotal,
|
||||
serializer,
|
||||
);
|
||||
sse_encode_opt_box_autoadd_f_32(
|
||||
self.packetLossServerToClientTotal,
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_effect_owner(
|
||||
BridgeEffectOwner self,
|
||||
@@ -3330,6 +3618,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
serializer.buffer.putPlatformInt64(self);
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_list_String(List<String> self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
sse_encode_i_32(self.length, serializer);
|
||||
for (final item in self) {
|
||||
sse_encode_String(item, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_list_bridge_audio_device(
|
||||
List<BridgeAudioDevice> self,
|
||||
@@ -3411,6 +3708,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
sse_encode_bool(self != null, serializer);
|
||||
if (self != null) {
|
||||
sse_encode_box_autoadd_f_32(self, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -3421,6 +3728,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_i_64(
|
||||
PlatformInt64? self,
|
||||
SseSerializer serializer,
|
||||
) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
sse_encode_bool(self != null, serializer);
|
||||
if (self != null) {
|
||||
sse_encode_box_autoadd_i_64(self, serializer);
|
||||
}
|
||||
}
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
|
||||
@@ -48,9 +48,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
double dco_decode_box_autoadd_f_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_box_autoadd_i_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
|
||||
|
||||
@@ -88,6 +94,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeClient dco_decode_bridge_client(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
|
||||
|
||||
@@ -141,6 +150,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
PlatformInt64 dco_decode_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<String> dco_decode_list_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
|
||||
|
||||
@@ -163,9 +175,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
BridgeVoiceJoinErrorCode?
|
||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
||||
|
||||
@protected
|
||||
double? dco_decode_opt_box_autoadd_f_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
||||
|
||||
@@ -219,9 +237,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
|
||||
|
||||
@@ -265,6 +289,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeClientProfile sse_decode_bridge_client_profile(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||
SseDeserializer deserializer,
|
||||
@@ -334,6 +363,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<String> sse_decode_list_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
|
||||
SseDeserializer deserializer,
|
||||
@@ -366,9 +398,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
||||
|
||||
@@ -431,9 +469,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_i_64(
|
||||
PlatformInt64 self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
|
||||
|
||||
@@ -491,6 +538,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_client_profile(
|
||||
BridgeClientProfile self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_effect_owner(
|
||||
BridgeEffectOwner self,
|
||||
@@ -578,6 +631,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_String(List<String> self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_bridge_audio_device(
|
||||
List<BridgeAudioDevice> self,
|
||||
@@ -617,9 +673,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_i_64(
|
||||
PlatformInt64? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||
|
||||
|
||||
@@ -50,9 +50,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
double dco_decode_box_autoadd_f_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_box_autoadd_i_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
|
||||
|
||||
@@ -90,6 +96,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeClient dco_decode_bridge_client(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw);
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
|
||||
|
||||
@@ -143,6 +152,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
PlatformInt64 dco_decode_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<String> dco_decode_list_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
|
||||
|
||||
@@ -165,9 +177,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
BridgeVoiceJoinErrorCode?
|
||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
||||
|
||||
@protected
|
||||
double? dco_decode_opt_box_autoadd_f_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
|
||||
|
||||
@@ -221,9 +239,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
|
||||
|
||||
@@ -267,6 +291,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BridgeClientProfile sse_decode_bridge_client_profile(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
BridgeEffectOwner sse_decode_bridge_effect_owner(
|
||||
SseDeserializer deserializer,
|
||||
@@ -336,6 +365,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<String> sse_decode_list_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
|
||||
SseDeserializer deserializer,
|
||||
@@ -368,9 +400,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
|
||||
|
||||
@@ -433,9 +471,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_i_64(
|
||||
PlatformInt64 self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
|
||||
|
||||
@@ -493,6 +540,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_client_profile(
|
||||
BridgeClientProfile self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_bridge_effect_owner(
|
||||
BridgeEffectOwner self,
|
||||
@@ -580,6 +633,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
@protected
|
||||
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_String(List<String> self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_bridge_audio_device(
|
||||
List<BridgeAudioDevice> self,
|
||||
@@ -619,9 +675,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_i_64(
|
||||
PlatformInt64? self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
|
||||
|
||||
|
||||
@@ -313,47 +313,51 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.72;
|
||||
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,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
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.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,
|
||||
),
|
||||
selected: device.isSelected,
|
||||
onTap: device.isAvailableForCommunication
|
||||
? () => Navigator.of(context).pop(device.id)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -201,32 +201,19 @@ bool androidShowsLimiterControl(AudioProcessingConfigState state) {
|
||||
|
||||
/// Never leave the UI on the hidden disabled backend.
|
||||
///
|
||||
/// Desktop keeps Silero as the default, but still allows a user-chosen
|
||||
/// WebRTC fallback when ONNX Runtime is unavailable.
|
||||
/// Windows and Linux use Silero as the primary VAD; WebRTC is still
|
||||
/// available internally as a runtime fallback.
|
||||
rust.BridgeVadBackend normalizedVadBackend(
|
||||
rust.BridgeVadBackend backend, {
|
||||
bool? isWindows,
|
||||
bool? isLinux,
|
||||
bool onnxRuntimeAvailable = true,
|
||||
}) {
|
||||
final normalized = backend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.sileroOnnx
|
||||
: backend;
|
||||
final desktop =
|
||||
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
|
||||
if (desktop &&
|
||||
!onnxRuntimeAvailable &&
|
||||
normalized == rust.BridgeVadBackend.sileroOnnx) {
|
||||
return rust.BridgeVadBackend.webrtcVad;
|
||||
}
|
||||
if (!desktop) {
|
||||
return normalized;
|
||||
}
|
||||
return switch (normalized) {
|
||||
rust.BridgeVadBackend.webrtcVad ||
|
||||
rust.BridgeVadBackend.sileroOnnx => normalized,
|
||||
_ => rust.BridgeVadBackend.sileroOnnx,
|
||||
};
|
||||
if (desktop) return rust.BridgeVadBackend.sileroOnnx;
|
||||
return backend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.sileroOnnx
|
||||
: backend;
|
||||
}
|
||||
|
||||
rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
@@ -9,12 +11,9 @@ import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
|
||||
const double _chatSidebarTileExtent = 92;
|
||||
const double _chatSidebarIndicatorExtent = 76;
|
||||
const double _chatSidebarIconExtent = 24;
|
||||
const double _chatSidebarIconSize = 20;
|
||||
const BorderRadius _chatSidebarIndicatorRadius = BorderRadius.all(
|
||||
Radius.circular(20),
|
||||
);
|
||||
const double _chatSidebarCompactTileExtent = 76;
|
||||
const double _chatSidebarCompactHeight = 84;
|
||||
const double _chatMobileBreakpoint = 600;
|
||||
|
||||
/// One chat/activity message shown in the chat hub.
|
||||
class ChatEntry {
|
||||
@@ -616,12 +615,22 @@ class _ChatPageState extends State<ChatPage> {
|
||||
final messages = _messages;
|
||||
final currentChannelId = _currentChannelId;
|
||||
final channelName = snapshotChannelName(snapshot, currentChannelId);
|
||||
final l10n = AppL10n.of(context);
|
||||
final detail = _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: snapshot,
|
||||
messages: messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Chat — ${snapshot.serverName}'),
|
||||
title: Text('${l10n.chatAction} - ${snapshot.serverName}'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Close chat',
|
||||
tooltip: l10n.chatCloseAction,
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _selectedPrivateClientId == null
|
||||
? null
|
||||
@@ -629,9 +638,10 @@ class _ChatPageState extends State<ChatPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Row(
|
||||
children: [
|
||||
_ChatSidebar(
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sidebar = _ChatSidebar(
|
||||
compact: constraints.maxWidth < _chatMobileBreakpoint,
|
||||
selectedTarget: _selectedTarget,
|
||||
privateChats: _privateChats,
|
||||
onSelect: _selectTarget,
|
||||
@@ -639,20 +649,24 @@ class _ChatPageState extends State<ChatPage> {
|
||||
_closedPrivateChats.remove(id);
|
||||
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
|
||||
}),
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(
|
||||
child: _ChatDetailView(
|
||||
target: _selectedTarget,
|
||||
clientName: _selectedClientName,
|
||||
snapshot: snapshot,
|
||||
messages: messages,
|
||||
currentChannelId: currentChannelId,
|
||||
channelName: channelName,
|
||||
onTs3ServerLink: widget.onTs3ServerLink,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (constraints.maxWidth < _chatMobileBreakpoint) {
|
||||
return Column(
|
||||
children: [
|
||||
sidebar,
|
||||
const Divider(height: 1),
|
||||
Expanded(child: detail),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
sidebar,
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: detail),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -740,12 +754,14 @@ class _PrivateChatItem {
|
||||
|
||||
class _ChatSidebar extends StatelessWidget {
|
||||
const _ChatSidebar({
|
||||
required this.compact,
|
||||
required this.selectedTarget,
|
||||
required this.privateChats,
|
||||
required this.onSelect,
|
||||
required this.onNewPrivateChat,
|
||||
});
|
||||
|
||||
final bool compact;
|
||||
final rust.BridgeMessageTarget selectedTarget;
|
||||
final List<_PrivateChatItem> privateChats;
|
||||
final void Function(rust.BridgeMessageTarget target, {String name}) onSelect;
|
||||
@@ -753,64 +769,85 @@ class _ChatSidebar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dividerColor = theme.colorScheme.outlineVariant.withValues(
|
||||
alpha: 0.45,
|
||||
final l10n = AppL10n.of(context);
|
||||
final fixedItems = [
|
||||
_ChatSidebarItem(
|
||||
compact: compact,
|
||||
icon: Icons.dns_outlined,
|
||||
label: 'Server',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Server,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
|
||||
),
|
||||
_ChatSidebarItem(
|
||||
compact: compact,
|
||||
icon: Icons.tag,
|
||||
label: 'Channel',
|
||||
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
|
||||
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
|
||||
),
|
||||
];
|
||||
final privateItems = [
|
||||
for (final chat in privateChats)
|
||||
_ChatSidebarItem(
|
||||
compact: compact,
|
||||
icon: Icons.person_outline,
|
||||
label: chat.name.isNotEmpty ? chat.name : 'Direct',
|
||||
selected: switch (selectedTarget) {
|
||||
rust.BridgeMessageTarget_Client(:final field0) => field0 == chat.id,
|
||||
_ => false,
|
||||
},
|
||||
onTap: () => onSelect(
|
||||
rust.BridgeMessageTarget.client(chat.id),
|
||||
name: chat.name,
|
||||
),
|
||||
),
|
||||
];
|
||||
final addButton = Padding(
|
||||
padding: EdgeInsets.all(compact ? 4 : 8),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: l10n.chatNewPrivateAction,
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
),
|
||||
);
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
child: Column(
|
||||
final compactDivider = ColoredBox(
|
||||
color: Theme.of(context).dividerColor,
|
||||
child: const SizedBox(width: 1, height: double.infinity),
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return SizedBox(
|
||||
height: _chatSidebarCompactHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_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()),
|
||||
),
|
||||
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
||||
...fixedItems,
|
||||
compactDivider,
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 8, 12),
|
||||
child: IconButton.filledTonal(
|
||||
tooltip: 'New private chat',
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: onNewPrivateChat,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
children: privateItems,
|
||||
),
|
||||
),
|
||||
compactDivider,
|
||||
addButton,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
child: Column(
|
||||
children: [
|
||||
...fixedItems,
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView(padding: EdgeInsets.zero, children: privateItems),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
addButton,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -818,12 +855,14 @@ class _ChatSidebar extends StatelessWidget {
|
||||
|
||||
class _ChatSidebarItem extends StatelessWidget {
|
||||
const _ChatSidebarItem({
|
||||
required this.compact,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool compact;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
@@ -832,63 +871,57 @@ class _ChatSidebarItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final indicatorColor = selected
|
||||
? scheme.primaryContainer
|
||||
: Colors.transparent;
|
||||
final contentColor = selected
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onSurfaceVariant;
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: contentColor,
|
||||
height: 1.15,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: _chatSidebarTileExtent,
|
||||
height: _chatSidebarTileExtent,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: _chatSidebarIndicatorRadius,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _chatSidebarIndicatorExtent,
|
||||
height: _chatSidebarIndicatorExtent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: indicatorColor,
|
||||
borderRadius: _chatSidebarIndicatorRadius,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _chatSidebarIconExtent,
|
||||
height: _chatSidebarIconExtent,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: _chatSidebarIconSize,
|
||||
color: contentColor,
|
||||
),
|
||||
final colorScheme = theme.colorScheme;
|
||||
final fg = selected
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onSurface;
|
||||
final tileExtent = compact
|
||||
? _chatSidebarCompactTileExtent
|
||||
: _chatSidebarTileExtent;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
width: tileExtent,
|
||||
height: compact ? 72 : tileExtent,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? colorScheme.secondaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 6 : 8,
|
||||
vertical: compact ? 8 : 10,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [Icon(icon, size: 18, color: fg)],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: _chatSidebarIndicatorExtent - 16,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: labelStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
height: 1.1,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -926,6 +959,7 @@ class _ClientPickerDialogState extends State<_ClientPickerDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
final groups = filterChatClientPickerGroups(
|
||||
channels: widget.channels,
|
||||
clientsByChannel: widget.byChannel,
|
||||
@@ -943,10 +977,10 @@ class _ClientPickerDialogState extends State<_ClientPickerDialog> {
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
child: TextField(
|
||||
controller: _searchCtl,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search clients...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.chatSearchClientsHint,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (v) => setState(() => _query = v),
|
||||
@@ -1044,7 +1078,6 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
final _scrollCtl = ScrollController();
|
||||
int _lastRenderedMessageCount = -1;
|
||||
rust.BridgeMessageTarget? _lastRenderedTarget;
|
||||
bool _sending = false;
|
||||
|
||||
Iterable<ChatEntry> get _filtered {
|
||||
if (widget.target is rust.BridgeMessageTarget_Channel) {
|
||||
@@ -1074,51 +1107,30 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
void _send() {
|
||||
final text = _textCtl.text.trim();
|
||||
if (text.isEmpty || !_canSend || _sending) return;
|
||||
if (text.isEmpty || !_canSend) return;
|
||||
_textCtl.clear();
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
await rust.sendChatMessage(message: text, target: widget.target);
|
||||
if (!mounted) return;
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
ChatEntry(
|
||||
senderId: ownId,
|
||||
senderName: widget.target is rust.BridgeMessageTarget_Poke
|
||||
? widget.clientName
|
||||
: 'You',
|
||||
message: text,
|
||||
target: widget.target,
|
||||
isSelf: true,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
if (widget.messages.length > 200) {
|
||||
widget.messages.removeRange(0, widget.messages.length - 200);
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
_textCtl.text = text;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(
|
||||
'Could not send message: $error',
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
unawaited(rust.sendChatMessage(message: text, target: widget.target));
|
||||
final ownId = widget.snapshot.ownClientId;
|
||||
setState(() {
|
||||
widget.messages.add(
|
||||
ChatEntry(
|
||||
senderId: ownId,
|
||||
senderName: widget.target is rust.BridgeMessageTarget_Poke
|
||||
? widget.clientName
|
||||
: 'You',
|
||||
message: text,
|
||||
target: widget.target,
|
||||
isSelf: true,
|
||||
timestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _sending = false);
|
||||
if (widget.messages.length > 200) {
|
||||
widget.messages.removeRange(0, widget.messages.length - 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
/// Bottom sheet that presents richer TeamSpeak client profile data.
|
||||
class ClientInfoSheet extends StatefulWidget {
|
||||
/// Construct a client info sheet.
|
||||
const ClientInfoSheet({
|
||||
super.key,
|
||||
required this.clientName,
|
||||
required this.loadProfile,
|
||||
});
|
||||
|
||||
/// Display name used while the profile request is loading.
|
||||
final String clientName;
|
||||
|
||||
/// Fetch richer profile data from the active protocol connection.
|
||||
final Future<rust.BridgeClientProfile> Function() loadProfile;
|
||||
|
||||
@override
|
||||
State<ClientInfoSheet> createState() => _ClientInfoSheetState();
|
||||
}
|
||||
|
||||
class _ClientInfoSheetState extends State<ClientInfoSheet> {
|
||||
late Future<rust.BridgeClientProfile> _profileFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_profileFuture = widget.loadProfile();
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
_profileFuture = widget.loadProfile();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.34,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(
|
||||
child: FutureBuilder<rust.BridgeClientProfile>(
|
||||
future: _profileFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _ClientInfoLoading(name: widget.clientName);
|
||||
}
|
||||
if (snapshot.hasError || !snapshot.hasData) {
|
||||
return _ClientInfoError(
|
||||
name: widget.clientName,
|
||||
onRetry: _retry,
|
||||
);
|
||||
}
|
||||
return _ClientInfoContent(profile: snapshot.data!);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientInfoLoading extends StatelessWidget {
|
||||
const _ClientInfoLoading({required this.name});
|
||||
|
||||
final String name;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppL10n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ClientInfoHeader(name: name, subtitle: l10n.clientInfoFetchingProfile),
|
||||
const SizedBox(height: 24),
|
||||
LinearProgressIndicator(
|
||||
minHeight: 3,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.clientInfoLoadingProfile, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientInfoError extends StatelessWidget {
|
||||
const _ClientInfoError({required this.name, required this.onRetry});
|
||||
|
||||
final String name;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppL10n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_ClientInfoHeader(
|
||||
name: name,
|
||||
subtitle: l10n.clientInfoProfileUnavailable,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.clientInfoProfileUnavailableBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(l10n.retryAction),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientInfoContent extends StatelessWidget {
|
||||
const _ClientInfoContent({required this.profile});
|
||||
|
||||
final rust.BridgeClientProfile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppL10n.of(context);
|
||||
return ListView(
|
||||
children: [
|
||||
_ClientInfoHeader(
|
||||
name: profile.name,
|
||||
subtitle: _joinNonEmpty([
|
||||
profile.platform,
|
||||
profile.version,
|
||||
profile.countryCode,
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoIdentitySection,
|
||||
rows: [
|
||||
_InfoRowData(l10n.clientInfoClientId, profile.id.toString()),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoDatabaseId,
|
||||
_formatBigInt(profile.databaseId, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoUniqueId,
|
||||
_emptyAsHidden(profile.uniqueId, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoDescription,
|
||||
_emptyAsHidden(profile.description, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoAvatar,
|
||||
_emptyAsNone(profile.avatarPath, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoMembershipSection,
|
||||
rows: [
|
||||
_InfoRowData(
|
||||
l10n.clientInfoServerGroups,
|
||||
profile.serverGroups.isEmpty
|
||||
? l10n.clientInfoUnknown
|
||||
: profile.serverGroups.join(', '),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoChannelGroup,
|
||||
_emptyAsHidden(profile.channelGroup, l10n),
|
||||
),
|
||||
_InfoRowData(l10n.clientInfoChannelId, profile.channel.toString()),
|
||||
],
|
||||
),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoConnectionSection,
|
||||
rows: [
|
||||
_InfoRowData(
|
||||
l10n.clientInfoOnline,
|
||||
_formatSeconds(profile.onlineSeconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoIdle,
|
||||
_formatMilliseconds(profile.idleMilliseconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoPing,
|
||||
_formatMilliseconds(profile.pingMilliseconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoAddress,
|
||||
_emptyAsHidden(profile.clientAddress, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoPacketLossClientToServer,
|
||||
_formatLoss(profile.packetLossClientToServerTotal),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoPacketLossServerToClient,
|
||||
_formatLoss(profile.packetLossServerToClientTotal),
|
||||
),
|
||||
],
|
||||
),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoHistorySection,
|
||||
rows: [
|
||||
_InfoRowData(
|
||||
l10n.clientInfoFirstConnected,
|
||||
_formatUnix(profile.createdUnixSeconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoLastConnected,
|
||||
_formatUnix(profile.lastConnectedUnixSeconds, l10n),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoConnections,
|
||||
_formatBigInt(profile.connectionsTotal, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
_InfoSection(
|
||||
title: l10n.clientInfoTransferSection,
|
||||
rows: [
|
||||
_InfoRowData(
|
||||
l10n.clientInfoDownloadedMonth,
|
||||
_formatBytes(profile.bytesDownloadedMonth),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoUploadedMonth,
|
||||
_formatBytes(profile.bytesUploadedMonth),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoDownloadedTotal,
|
||||
_formatBytes(profile.bytesDownloadedTotal),
|
||||
),
|
||||
_InfoRowData(
|
||||
l10n.clientInfoUploadedTotal,
|
||||
_formatBytes(profile.bytesUploadedTotal),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientInfoHeader extends StatelessWidget {
|
||||
const _ClientInfoHeader({required this.name, required this.subtitle});
|
||||
|
||||
final String name;
|
||||
final String subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: theme.colorScheme.primaryContainer,
|
||||
foregroundColor: theme.colorScheme.onPrimaryContainer,
|
||||
child: Text(
|
||||
_initials(name),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoSection extends StatelessWidget {
|
||||
const _InfoSection({required this.title, required this.rows});
|
||||
|
||||
final String title;
|
||||
final List<_InfoRowData> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: theme.dividerColor),
|
||||
bottom: BorderSide(color: theme.dividerColor),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < rows.length; i++) ...[
|
||||
_InfoRow(row: rows[i]),
|
||||
if (i != rows.length - 1) const Divider(height: 1),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
const _InfoRow({required this.row});
|
||||
|
||||
final _InfoRowData row;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 132,
|
||||
child: Text(
|
||||
row.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
row.value,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRowData {
|
||||
const _InfoRowData(this.label, this.value);
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
}
|
||||
|
||||
String _initials(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return '?';
|
||||
final parts = trimmed.split(RegExp(r'\s+')).where((p) => p.isNotEmpty);
|
||||
final chars = parts.take(2).map((p) => p.characters.first.toUpperCase());
|
||||
return chars.join();
|
||||
}
|
||||
|
||||
String _joinNonEmpty(Iterable<String> values) {
|
||||
return values.where((value) => value.trim().isNotEmpty).join(' / ');
|
||||
}
|
||||
|
||||
String _emptyAsHidden(String value, AppL10n l10n) =>
|
||||
value.trim().isEmpty ? l10n.clientInfoHidden : value;
|
||||
|
||||
String _emptyAsNone(String value, AppL10n l10n) =>
|
||||
value.trim().isEmpty ? l10n.clientInfoNone : value;
|
||||
|
||||
String _formatBigInt(BigInt? value, AppL10n l10n) =>
|
||||
value?.toString() ?? l10n.clientInfoUnknown;
|
||||
|
||||
String _formatUnix(Object? seconds, AppL10n l10n) {
|
||||
final raw = _intFromPlatform(seconds);
|
||||
if (raw == null || raw <= 0) return l10n.clientInfoUnknown;
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||
raw * 1000,
|
||||
isUtc: true,
|
||||
).toLocal();
|
||||
return date.toString().split('.').first;
|
||||
}
|
||||
|
||||
String _formatSeconds(Object? seconds, AppL10n l10n) {
|
||||
final raw = _intFromPlatform(seconds);
|
||||
if (raw == null) return l10n.clientInfoUnknown;
|
||||
if (raw < 60) return '$raw s';
|
||||
final minutes = raw ~/ 60;
|
||||
final hours = minutes ~/ 60;
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes % 60}m ${raw % 60}s';
|
||||
}
|
||||
return '${minutes}m ${raw % 60}s';
|
||||
}
|
||||
|
||||
String _formatMilliseconds(Object? milliseconds, AppL10n l10n) {
|
||||
final raw = _intFromPlatform(milliseconds);
|
||||
if (raw == null) return l10n.clientInfoUnknown;
|
||||
if (raw < 1000) return '$raw ms';
|
||||
return '${(raw / 1000).toStringAsFixed(2)} s';
|
||||
}
|
||||
|
||||
String _formatBytes(BigInt? value) {
|
||||
if (value == null) return 'Unknown';
|
||||
final bytes = value.toDouble();
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var amount = bytes;
|
||||
var unitIndex = 0;
|
||||
while (amount >= 1024 && unitIndex < units.length - 1) {
|
||||
amount /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
final digits = unitIndex == 0 ? 0 : 1;
|
||||
return '${amount.toStringAsFixed(digits)} ${units[unitIndex]}';
|
||||
}
|
||||
|
||||
String _formatLoss(double? value) {
|
||||
if (value == null) return 'Unknown';
|
||||
return '${(value * 100).toStringAsFixed(2)}%';
|
||||
}
|
||||
|
||||
int? _intFromPlatform(Object? value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is BigInt) return value.toInt();
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
@@ -65,7 +65,6 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
keyboardType: TextInputType.url,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _nickFocus.requestFocus(),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
inputFormatters: [
|
||||
@@ -90,7 +89,6 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
focusNode: _nickFocus,
|
||||
onTapOutside: _onTapOutside,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _passwordFocus.requestFocus(),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
@@ -105,7 +103,6 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
onTapOutside: _onTapOutside,
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => widget.onConnect(),
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
@@ -115,22 +112,37 @@ class _ConnectFormState extends State<ConnectForm> {
|
||||
),
|
||||
),
|
||||
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(
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const stackedActionsMaxWidth = 400.0;
|
||||
final connectButton = FilledButton.icon(
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(l10n.connectAction),
|
||||
onPressed: widget.onConnect,
|
||||
);
|
||||
final bookmarkButton = OutlinedButton.icon(
|
||||
icon: const Icon(Icons.bookmark_add_outlined),
|
||||
label: Text(l10n.bookmarkAddAction),
|
||||
onPressed: widget.onAddBookmark,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (constraints.maxWidth <= stackedActionsMaxWidth) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
connectButton,
|
||||
const SizedBox(height: 8),
|
||||
bookmarkButton,
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: connectButton),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: bookmarkButton),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,9 +4,6 @@ import 'package:flutter/services.dart';
|
||||
import '../l10n/generated/app_localizations.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
const int pttMouseBackButtonBitmask = 0x08;
|
||||
const int pttMouseForwardButtonBitmask = 0x10;
|
||||
|
||||
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
|
||||
/// stored by the PTT binding flow.
|
||||
String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
@@ -49,27 +46,6 @@ String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String? pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey key) {
|
||||
return switch (key) {
|
||||
LogicalKeyboardKey.browserBack ||
|
||||
LogicalKeyboardKey.goBack => 'mouse-side-button:$pttMouseBackButtonBitmask',
|
||||
LogicalKeyboardKey.browserForward =>
|
||||
'mouse-side-button:$pttMouseForwardButtonBitmask',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String? pttMouseSideButtonPlatformKeyForButtons(int buttons) {
|
||||
if ((buttons & pttMouseBackButtonBitmask) == pttMouseBackButtonBitmask) {
|
||||
return 'mouse-side-button:$pttMouseBackButtonBitmask';
|
||||
}
|
||||
if ((buttons & pttMouseForwardButtonBitmask) ==
|
||||
pttMouseForwardButtonBitmask) {
|
||||
return 'mouse-side-button:$pttMouseForwardButtonBitmask';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Result of a successful PTT binding capture.
|
||||
class CapturedBinding {
|
||||
const CapturedBinding({required this.inputClass, required this.platformKey});
|
||||
@@ -205,16 +181,6 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final mouseSideButton = pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
event.logicalKey,
|
||||
);
|
||||
if (mouseSideButton != null) {
|
||||
setState(() {
|
||||
_captured = mouseSideButton;
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final label = pttDisplayLabelForKey(event.logicalKey);
|
||||
if (label == null) return KeyEventResult.ignored;
|
||||
setState(() {
|
||||
@@ -224,9 +190,9 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _captureMouseSideButton(String platformKey) {
|
||||
void _captureMouseSideButton(int button) {
|
||||
setState(() {
|
||||
_captured = platformKey;
|
||||
_captured = 'mouse-side-button:$button';
|
||||
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
|
||||
});
|
||||
}
|
||||
@@ -237,57 +203,59 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
|
||||
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) {
|
||||
final platformKey = pttMouseSideButtonPlatformKeyForButtons(
|
||||
e.buttons,
|
||||
);
|
||||
if (platformKey != null) {
|
||||
_captureMouseSideButton(platformKey);
|
||||
}
|
||||
},
|
||||
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,
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: SingleChildScrollView(
|
||||
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,
|
||||
),
|
||||
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),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
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,
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttConfigurePrivacyNote,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -54,43 +54,48 @@ class PttCapabilityBadge extends StatelessWidget {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) {
|
||||
final theme = Theme.of(sheetContext);
|
||||
final maxHeight = MediaQuery.sizeOf(sheetContext).height * 0.72;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.pttCapabilityExplainTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedHeading,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pttCapabilityExplainFocusedBody,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_explainBodyForPlatform(l10n),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
child: Text(l10n.closeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -8,17 +6,10 @@ import '../services/channel_spacer.dart';
|
||||
import '../services/link_trust_service.dart';
|
||||
import '../services/snapshot_state_mapper.dart';
|
||||
import '../services/ts3_server_link.dart';
|
||||
import '../services/ui_preferences_service.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
import 'bbcode_text.dart';
|
||||
import 'talk_power_warning.dart';
|
||||
|
||||
typedef ClientPlaybackPreferenceChanged =
|
||||
Future<void> Function(
|
||||
rust.BridgeClient client,
|
||||
ClientPlaybackPreference preference,
|
||||
);
|
||||
|
||||
/// Connected-server snapshot with welcome text, channels, and clients.
|
||||
class SnapshotView extends StatefulWidget {
|
||||
/// Construct a snapshot view.
|
||||
@@ -34,8 +25,10 @@ class SnapshotView extends StatefulWidget {
|
||||
required this.canJoinVoiceChannel,
|
||||
required this.onJoinChannel,
|
||||
required this.onJoinChannelWithPassword,
|
||||
this.clientPlaybackPreferences = const {},
|
||||
this.onClientPlaybackPreferenceChanged,
|
||||
this.enableClientLongPressMenu = false,
|
||||
this.onOpenClientInfo,
|
||||
this.onOpenClientChat,
|
||||
this.onOpenClientPoke,
|
||||
this.onTs3ServerLink,
|
||||
});
|
||||
|
||||
@@ -69,11 +62,17 @@ class SnapshotView extends StatefulWidget {
|
||||
/// Join a password-protected channel.
|
||||
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
|
||||
|
||||
/// Persisted per-client playback preferences keyed by TeamSpeak UID.
|
||||
final Map<String, ClientPlaybackPreference> clientPlaybackPreferences;
|
||||
/// True on touch-only mobile hosts where long-press opens client actions.
|
||||
final bool enableClientLongPressMenu;
|
||||
|
||||
/// Apply an updated per-client playback preference.
|
||||
final ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged;
|
||||
/// Open richer profile details for a non-self client.
|
||||
final ValueChanged<rust.BridgeClient>? onOpenClientInfo;
|
||||
|
||||
/// Open a direct chat with a non-self client.
|
||||
final ValueChanged<rust.BridgeClient>? onOpenClientChat;
|
||||
|
||||
/// Open a poke composer for a non-self client.
|
||||
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
|
||||
|
||||
/// Handle TeamSpeak server links embedded in server-provided text.
|
||||
final Ts3ServerLinkHandler? onTs3ServerLink;
|
||||
@@ -88,11 +87,10 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
static const _channelIconColumnWidth = 24.0;
|
||||
static const _channelTextGap = 8.0;
|
||||
static const _userRowStartIndent = 32.0;
|
||||
static const _clientPlaybackVolumePresets = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
final Map<BigInt, bool> _channelExpandedById = {};
|
||||
bool _welcomeExpanded = true;
|
||||
bool _welcomeExpanded = false;
|
||||
double _welcomeHeight = 0;
|
||||
final _welcomeKey = GlobalKey();
|
||||
|
||||
@@ -293,6 +291,12 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
fontWeight: FontWeight.w600,
|
||||
)
|
||||
: null;
|
||||
final isSelf = client.id == widget.snapshot.ownClientId;
|
||||
final canOpenPeerActions =
|
||||
!isSelf &&
|
||||
(widget.onOpenClientChat != null || widget.onOpenClientPoke != null);
|
||||
final canOpenClientMenu =
|
||||
widget.onOpenClientInfo != null || canOpenPeerActions;
|
||||
|
||||
final decoration = status.isSpeaking
|
||||
? BoxDecoration(
|
||||
@@ -311,40 +315,90 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
],
|
||||
)
|
||||
: null;
|
||||
final canAdjustPlayback = _canAdjustClientPlayback(client);
|
||||
Offset? secondaryTapPosition;
|
||||
|
||||
return Padding(
|
||||
final tile = Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: channelIndent + _userRowStartIndent,
|
||||
right: 8,
|
||||
),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: canAdjustPlayback
|
||||
? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true))
|
||||
: null,
|
||||
onSecondaryTapDown: canAdjustPlayback
|
||||
? (details) => secondaryTapPosition = details.globalPosition
|
||||
: null,
|
||||
onSecondaryTap: canAdjustPlayback
|
||||
? () => unawaited(
|
||||
_showClientPlaybackMenu(client, anchor: secondaryTapPosition),
|
||||
)
|
||||
: null,
|
||||
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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!canOpenClientMenu) return tile;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onSecondaryTapDown: (details) =>
|
||||
_showClientMenu(client, details.globalPosition),
|
||||
onLongPressStart: widget.enableClientLongPressMenu
|
||||
? (details) => _showClientMenu(client, details.globalPosition)
|
||||
: null,
|
||||
child: tile,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showClientMenu(
|
||||
rust.BridgeClient client,
|
||||
Offset globalPosition,
|
||||
) async {
|
||||
final overlay = Overlay.of(context).context.findRenderObject();
|
||||
if (overlay is! RenderBox) return;
|
||||
final isSelf = client.id == widget.snapshot.ownClientId;
|
||||
|
||||
final selected = await showMenu<_ClientMenuAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(
|
||||
Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0),
|
||||
Offset.zero & overlay.size,
|
||||
),
|
||||
items: [
|
||||
if (widget.onOpenClientInfo != null)
|
||||
PopupMenuItem(
|
||||
value: _ClientMenuAction.info,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(AppL10n.of(context).clientInfoAction),
|
||||
),
|
||||
),
|
||||
if (!isSelf && widget.onOpenClientChat != null)
|
||||
PopupMenuItem(
|
||||
value: _ClientMenuAction.directMessage,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: Text(AppL10n.of(context).chatDirectMessageAction),
|
||||
),
|
||||
),
|
||||
if (!isSelf && widget.onOpenClientPoke != null)
|
||||
PopupMenuItem(
|
||||
value: _ClientMenuAction.poke,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.notifications_active_outlined),
|
||||
title: Text(AppL10n.of(context).chatPokeAction),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!mounted || selected == null) return;
|
||||
switch (selected) {
|
||||
case _ClientMenuAction.info:
|
||||
widget.onOpenClientInfo?.call(client);
|
||||
case _ClientMenuAction.directMessage:
|
||||
widget.onOpenClientChat?.call(client);
|
||||
case _ClientMenuAction.poke:
|
||||
widget.onOpenClientPoke?.call(client);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _expandButton(
|
||||
@@ -386,81 +440,6 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
});
|
||||
}
|
||||
|
||||
bool _canAdjustClientPlayback(rust.BridgeClient client) {
|
||||
return widget.onClientPlaybackPreferenceChanged != null &&
|
||||
client.id != widget.snapshot.ownClientId &&
|
||||
!client.isServerQuery &&
|
||||
client.uid.isNotEmpty;
|
||||
}
|
||||
|
||||
ClientPlaybackPreference _clientPlaybackPreference(rust.BridgeClient client) {
|
||||
return widget.clientPlaybackPreferences[client.uid] ??
|
||||
const ClientPlaybackPreference();
|
||||
}
|
||||
|
||||
Future<void> _showClientPlaybackMenu(
|
||||
rust.BridgeClient client, {
|
||||
Offset? anchor,
|
||||
bool withHaptic = false,
|
||||
}) async {
|
||||
if (!_canAdjustClientPlayback(client)) return;
|
||||
|
||||
final onChanged = widget.onClientPlaybackPreferenceChanged;
|
||||
if (onChanged == null) return;
|
||||
|
||||
if (withHaptic) {
|
||||
unawaited(HapticFeedback.selectionClick().catchError((_) {}));
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
final preference = _clientPlaybackPreference(client);
|
||||
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
final position = anchor ?? overlay.size.center(Offset.zero);
|
||||
final selected = await showMenu<_ClientPlaybackMenuAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromLTRB(
|
||||
position.dx,
|
||||
position.dy,
|
||||
overlay.size.width - position.dx,
|
||||
overlay.size.height - position.dy,
|
||||
),
|
||||
items: [
|
||||
PopupMenuItem<_ClientPlaybackMenuAction>(
|
||||
value: const _ToggleMuteClientPlaybackMenuAction(),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
preference.muted ? Icons.volume_off : Icons.volume_up,
|
||||
),
|
||||
title: Text(preference.muted ? 'Unmute playback' : 'Mute playback'),
|
||||
subtitle: Text(client.name),
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
..._clientPlaybackVolumePresets.map(
|
||||
(preset) => CheckedPopupMenuItem<_ClientPlaybackMenuAction>(
|
||||
value: _VolumeClientPlaybackMenuAction(preset),
|
||||
checked:
|
||||
!preference.muted && (preference.volume - preset).abs() < 0.001,
|
||||
child: Text('Volume ${(preset * 100).round()}%'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
final next = switch (selected) {
|
||||
_ToggleMuteClientPlaybackMenuAction() => preference.copyWith(
|
||||
muted: !preference.muted,
|
||||
),
|
||||
_VolumeClientPlaybackMenuAction(:final volume) => preference.copyWith(
|
||||
volume: volume,
|
||||
muted: false,
|
||||
),
|
||||
};
|
||||
await onChanged(client, next);
|
||||
}
|
||||
|
||||
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
|
||||
ThemeData theme,
|
||||
rust.BridgeClient client,
|
||||
@@ -530,6 +509,8 @@ class _SnapshotViewState extends State<SnapshotView> {
|
||||
}
|
||||
}
|
||||
|
||||
enum _ClientMenuAction { info, directMessage, poke }
|
||||
|
||||
class _SpacerChannelContent extends StatelessWidget {
|
||||
const _SpacerChannelContent({required this.spacer});
|
||||
|
||||
@@ -673,20 +654,6 @@ class _ChannelTreeNode {
|
||||
final List<_ChannelTreeNode> children = [];
|
||||
}
|
||||
|
||||
sealed class _ClientPlaybackMenuAction {
|
||||
const _ClientPlaybackMenuAction();
|
||||
}
|
||||
|
||||
class _ToggleMuteClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
|
||||
const _ToggleMuteClientPlaybackMenuAction();
|
||||
}
|
||||
|
||||
class _VolumeClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
|
||||
const _VolumeClientPlaybackMenuAction(this.volume);
|
||||
|
||||
final double volume;
|
||||
}
|
||||
|
||||
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
|
||||
final byParent = <BigInt, List<rust.BridgeChannel>>{};
|
||||
final knownIds = {for (final channel in channels) channel.id};
|
||||
@@ -757,7 +724,7 @@ class _WelcomeMessageTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Server welcome message',
|
||||
AppL10n.of(context).serverWelcomeHeading,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -766,20 +733,22 @@ class _WelcomeMessageTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: expanded
|
||||
? Padding(
|
||||
key: const ValueKey('welcome-expanded'),
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: BbCodeText(
|
||||
welcomeMessage,
|
||||
linkTrust: LinkTrustService.instance,
|
||||
onTs3ServerLink: onTs3ServerLink,
|
||||
),
|
||||
)
|
||||
: const SizedBox(
|
||||
key: ValueKey('welcome-collapsed'),
|
||||
width: double.infinity,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import 'dart:async' show unawaited;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/startup_dependency_check.dart';
|
||||
|
||||
class StartupDependencyGate extends StatefulWidget {
|
||||
const StartupDependencyGate({
|
||||
required this.child,
|
||||
this.checker = checkStartupDependencies,
|
||||
this.logger = logStartupDependencyIssues,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Future<StartupDependencyCheckResult> Function() checker;
|
||||
final Future<void> Function(StartupDependencyCheckResult result) logger;
|
||||
|
||||
@override
|
||||
State<StartupDependencyGate> createState() => _StartupDependencyGateState();
|
||||
}
|
||||
|
||||
class StartupDependencyScope extends InheritedWidget {
|
||||
const StartupDependencyScope({
|
||||
required this.result,
|
||||
required super.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StartupDependencyCheckResult result;
|
||||
|
||||
static StartupDependencyCheckResult? maybeOf(BuildContext context) {
|
||||
return context
|
||||
.dependOnInheritedWidgetOfExactType<StartupDependencyScope>()
|
||||
?.result;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(StartupDependencyScope oldWidget) {
|
||||
return result != oldWidget.result;
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupDependencyGateState extends State<StartupDependencyGate> {
|
||||
late Future<StartupDependencyCheckResult> _future;
|
||||
bool _dismissedForSession = false;
|
||||
String? _lastLoggedIssueSignature;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.checker();
|
||||
}
|
||||
|
||||
void _recheck() {
|
||||
setState(() {
|
||||
_future = widget.checker();
|
||||
_dismissedForSession = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_dismissedForSession) {
|
||||
return FutureBuilder<StartupDependencyCheckResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
return StartupDependencyScope(
|
||||
result:
|
||||
snapshot.data ??
|
||||
const StartupDependencyCheckResult(
|
||||
issues: [],
|
||||
platformLabel: 'default',
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder<StartupDependencyCheckResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const _StartupCheckLoadingView();
|
||||
}
|
||||
|
||||
final result = snapshot.data;
|
||||
if (result == null || !result.hasIssues) {
|
||||
_lastLoggedIssueSignature = null;
|
||||
return StartupDependencyScope(
|
||||
result:
|
||||
result ??
|
||||
const StartupDependencyCheckResult(
|
||||
issues: [],
|
||||
platformLabel: 'default',
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
_logIssueScreenShown(result);
|
||||
return StartupDependencyScope(
|
||||
result: result,
|
||||
child: StartupDependencyScreen(
|
||||
result: result,
|
||||
onContinue: () {
|
||||
setState(() {
|
||||
_dismissedForSession = true;
|
||||
});
|
||||
},
|
||||
onRecheck: _recheck,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _logIssueScreenShown(StartupDependencyCheckResult result) {
|
||||
final signature = [
|
||||
result.platformLabel,
|
||||
for (final issue in result.issues)
|
||||
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}',
|
||||
].join('|');
|
||||
if (_lastLoggedIssueSignature == signature) {
|
||||
return;
|
||||
}
|
||||
_lastLoggedIssueSignature = signature;
|
||||
unawaited(widget.logger(result));
|
||||
}
|
||||
}
|
||||
|
||||
class StartupDependencyScreen extends StatelessWidget {
|
||||
const StartupDependencyScreen({
|
||||
required this.result,
|
||||
required this.onContinue,
|
||||
required this.onRecheck,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StartupDependencyCheckResult result;
|
||||
final VoidCallback onContinue;
|
||||
final VoidCallback onRecheck;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final hasBlockingIssues = result.hasBlockingIssues;
|
||||
final content = Column(
|
||||
children: [
|
||||
Icon(
|
||||
hasBlockingIssues
|
||||
? Icons.warning_amber_rounded
|
||||
: Icons.info_outline_rounded,
|
||||
size: 52,
|
||||
color: hasBlockingIssues
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Finish Linux setup',
|
||||
style: theme.textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
hasBlockingIssues
|
||||
? 'Chanora started, but some Linux runtime packages are still missing. Install them, then recheck.'
|
||||
: 'Chanora started, but a few optional Linux runtime components are still missing. You can install them now or continue with limited functionality.',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Detected platform: ${result.platformLabel}',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
...result.issues.map((issue) => _DependencyIssueCard(issue: issue)),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onRecheck,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Recheck'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onContinue,
|
||||
icon: const Icon(Icons.arrow_forward_rounded),
|
||||
label: Text(
|
||||
hasBlockingIssues
|
||||
? 'Continue with limited mode'
|
||||
: 'Continue anyway',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Scrollbar(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 880),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DependencyIssueCard extends StatelessWidget {
|
||||
const _DependencyIssueCard({required this.issue});
|
||||
|
||||
final StartupDependencyIssue issue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final containerColor = issue.isRequired
|
||||
? scheme.errorContainer
|
||||
: scheme.secondaryContainer;
|
||||
final onContainerColor = issue.isRequired
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSecondaryContainer;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
color: containerColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
issue.isRequired
|
||||
? Icons.error_outline_rounded
|
||||
: Icons.settings_suggest_rounded,
|
||||
color: onContainerColor,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
issue.title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
issue.summary,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SeverityBadge(issue: issue),
|
||||
],
|
||||
),
|
||||
if (issue.details.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
...issue.details.map(
|
||||
(detail) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'• $detail',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (issue.installHints.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Install help',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: onContainerColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...issue.installHints.map(
|
||||
(hint) => _InstallHintTile(
|
||||
hint: hint,
|
||||
foregroundColor: onContainerColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InstallHintTile extends StatelessWidget {
|
||||
const _InstallHintTile({required this.hint, required this.foregroundColor});
|
||||
|
||||
final StartupInstallHint hint;
|
||||
final Color foregroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final isUrl = _isWebUrl(hint.command);
|
||||
final VoidCallback? onTap = isUrl
|
||||
? () => unawaited(_openUrl(context, Uri.parse(hint.command)))
|
||||
: null;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: foregroundColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
hint.label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () async {
|
||||
if (isUrl) {
|
||||
await _openUrl(context, Uri.parse(hint.command));
|
||||
return;
|
||||
}
|
||||
await Clipboard.setData(ClipboardData(text: hint.command));
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Install command copied')),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
isUrl
|
||||
? Icons.open_in_new_rounded
|
||||
: Icons.content_copy_rounded,
|
||||
),
|
||||
label: Text(isUrl ? 'Open' : 'Copy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
hint.command,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: foregroundColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isWebUrl(String value) {
|
||||
final uri = Uri.tryParse(value);
|
||||
return uri != null &&
|
||||
(uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.hasAuthority;
|
||||
}
|
||||
|
||||
Future<void> _openUrl(BuildContext context, Uri uri) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!opened && context.mounted) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Could not open ${uri.toString()}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SeverityBadge extends StatelessWidget {
|
||||
const _SeverityBadge({required this.issue});
|
||||
|
||||
final StartupDependencyIssue issue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final foregroundColor = issue.isRequired
|
||||
? scheme.onErrorContainer
|
||||
: scheme.onSecondaryContainer;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: foregroundColor.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: foregroundColor.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Text(
|
||||
issue.isRequired ? 'Required' : 'Recommended',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupCheckLoadingView extends StatelessWidget {
|
||||
const _StartupCheckLoadingView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Checking Linux runtime dependencies…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,6 @@ Future<void> showVoiceDetailsSheet(
|
||||
required String pttBoundInputClass,
|
||||
required bool isTouchOnly,
|
||||
required rust.BridgeAudioProcessingConfig initialAudioConfig,
|
||||
bool onnxRuntimeAvailable = true,
|
||||
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
|
||||
required ValueChanged<int> onReleaseTailChanged,
|
||||
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
|
||||
@@ -389,7 +388,6 @@ Future<void> showVoiceDetailsSheet(
|
||||
pttBoundInputClass: pttBoundInputClass,
|
||||
isTouchOnly: isTouchOnly,
|
||||
initialAudioConfig: initialAudioConfig,
|
||||
onnxRuntimeAvailable: onnxRuntimeAvailable,
|
||||
onModeChanged: onModeChanged,
|
||||
onReleaseTailChanged: onReleaseTailChanged,
|
||||
onAudioConfigChanged: onAudioConfigChanged,
|
||||
@@ -413,7 +411,6 @@ class _VoiceSheetBody extends StatefulWidget {
|
||||
required this.pttBoundInputClass,
|
||||
required this.isTouchOnly,
|
||||
required this.initialAudioConfig,
|
||||
required this.onnxRuntimeAvailable,
|
||||
required this.onModeChanged,
|
||||
required this.onReleaseTailChanged,
|
||||
required this.onAudioConfigChanged,
|
||||
@@ -431,7 +428,6 @@ class _VoiceSheetBody extends StatefulWidget {
|
||||
final String pttBoundInputClass;
|
||||
final bool isTouchOnly;
|
||||
final rust.BridgeAudioProcessingConfig initialAudioConfig;
|
||||
final bool onnxRuntimeAvailable;
|
||||
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
|
||||
final ValueChanged<int> onReleaseTailChanged;
|
||||
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
|
||||
@@ -466,10 +462,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||||
widget.initialAudioConfig,
|
||||
);
|
||||
_audioProcessing.vadBackend = normalizedVadBackend(
|
||||
_audioProcessing.vadBackend,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
);
|
||||
|
||||
// Poll audio stats at 250 ms so TX/RX counters and the level meter
|
||||
// update in real time while the sheet is open, independent of the parent.
|
||||
@@ -785,32 +777,15 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
const SizedBox(height: 2),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegmentsForAvailability(
|
||||
desktop: _isDesktopSileroVadHost,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
),
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) {
|
||||
setState(
|
||||
() => _audioProcessing.vadBackend = normalizedVadBackend(
|
||||
s.first,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
),
|
||||
);
|
||||
setState(() => _audioProcessing.vadBackend = s.first);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
if (_isDesktopSileroVadHost) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.onnxRuntimeAvailable
|
||||
? 'Silero needs ONNX Runtime. WebRTC works without it.'
|
||||
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Debug.
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -67,7 +67,6 @@ class VoiceSettingsDialog extends StatefulWidget {
|
||||
this.pttLevel = '',
|
||||
this.pttBackendId = '',
|
||||
this.pttBoundInputClass = '',
|
||||
this.onnxRuntimeAvailable = true,
|
||||
this.talkPower,
|
||||
this.neededTalkPower,
|
||||
this.talkPowerGranted,
|
||||
@@ -79,7 +78,6 @@ class VoiceSettingsDialog extends StatefulWidget {
|
||||
final String pttLevel;
|
||||
final String pttBackendId;
|
||||
final String pttBoundInputClass;
|
||||
final bool onnxRuntimeAvailable;
|
||||
final int? talkPower;
|
||||
final int? neededTalkPower;
|
||||
final bool? talkPowerGranted;
|
||||
@@ -103,10 +101,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
_audioProcessing = AudioProcessingConfigState.fromConfig(
|
||||
widget.initialAudioConfig,
|
||||
);
|
||||
_audioProcessing.vadBackend = normalizedVadBackend(
|
||||
_audioProcessing.vadBackend,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
);
|
||||
}
|
||||
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
@@ -297,29 +291,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
const VoiceSubHeader('Backend'),
|
||||
SegmentedButton<rust.BridgeVadBackend>(
|
||||
style: voiceSegmentedButtonStyle(theme),
|
||||
segments: vadBackendSegmentsForAvailability(
|
||||
desktop: _isDesktopSileroVadHost,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
),
|
||||
segments: _isDesktopSileroVadHost
|
||||
? desktopVadBackendSegments
|
||||
: vadBackendSegments,
|
||||
selected: {_audioProcessing.vadBackend},
|
||||
onSelectionChanged: (s) => setState(
|
||||
() => _audioProcessing.vadBackend = normalizedVadBackend(
|
||||
s.first,
|
||||
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
|
||||
),
|
||||
),
|
||||
onSelectionChanged: (s) =>
|
||||
setState(() => _audioProcessing.vadBackend = s.first),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_isDesktopSileroVadHost)
|
||||
Text(
|
||||
widget.onnxRuntimeAvailable
|
||||
? 'Silero gives the best quality when ONNX Runtime is installed. WebRTC works without ONNX Runtime and is the safer fallback if Linux setup is incomplete.'
|
||||
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected until libonnxruntime.so is installed or bundled.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_isDesktopSileroVadHost) const SizedBox(height: 8),
|
||||
|
||||
// ── PTT capability badge ────────────────────────────────
|
||||
if (_mode == rust.BridgeTransmitMode.ptt &&
|
||||
|
||||
@@ -59,30 +59,15 @@ const vadBackendSegments = [
|
||||
|
||||
/// Desktop VAD selector segments.
|
||||
///
|
||||
/// Desktop keeps Silero as the default, but WebRTC remains a supported
|
||||
/// manual fallback when ONNX Runtime is missing or when the user wants a
|
||||
/// smaller dependency surface.
|
||||
const desktopVadBackendSegments = vadBackendSegments;
|
||||
|
||||
List<ButtonSegment<rust.BridgeVadBackend>> vadBackendSegmentsForAvailability({
|
||||
required bool desktop,
|
||||
required bool onnxRuntimeAvailable,
|
||||
}) {
|
||||
final segments = desktop ? desktopVadBackendSegments : vadBackendSegments;
|
||||
if (!desktop || onnxRuntimeAvailable) return segments;
|
||||
return [
|
||||
for (final segment in segments)
|
||||
if (segment.value == rust.BridgeVadBackend.sileroOnnx)
|
||||
ButtonSegment<rust.BridgeVadBackend>(
|
||||
value: segment.value,
|
||||
label: segment.label,
|
||||
icon: segment.icon,
|
||||
enabled: false,
|
||||
)
|
||||
else
|
||||
segment,
|
||||
];
|
||||
}
|
||||
/// Windows and Linux use Silero as the primary VAD. WebRTC remains an
|
||||
/// internal runtime fallback when the model/runtime is unavailable.
|
||||
const desktopVadBackendSegments = [
|
||||
ButtonSegment(
|
||||
value: rust.BridgeVadBackend.sileroOnnx,
|
||||
label: Text('Silero'),
|
||||
icon: Icon(Icons.psychology, size: 14),
|
||||
),
|
||||
];
|
||||
|
||||
/// Section subheader used by both voice settings surfaces.
|
||||
class VoiceSubHeader extends StatelessWidget {
|
||||
|
||||
@@ -9,21 +9,6 @@ set(BINARY_NAME "chanora_flutter")
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
set(APPLICATION_ID "app.chanora.chanora_flutter")
|
||||
|
||||
# Repository root for Rust bridge builds / bundle staging.
|
||||
set(CHANORA_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../..")
|
||||
|
||||
# Optional path to a Linux ONNX Runtime shared library for Silero VAD.
|
||||
# When set, the bundle carries a local `lib/libonnxruntime.so` sidecar and the
|
||||
# runner exports `ORT_DYLIB_PATH` to that file before Flutter starts.
|
||||
set(CHANORA_ONNXRUNTIME_SHARED_LIB "$ENV{CHANORA_ONNXRUNTIME_SHARED_LIB}" CACHE FILEPATH
|
||||
"Absolute path to libonnxruntime.so for Linux bundle packaging")
|
||||
|
||||
# Optional path to a prebuilt Rust bridge shared library. When unset, the Linux
|
||||
# bundle build runs `cargo build -p chanora_bridge` for the host profile and
|
||||
# stages the resulting `libchanora_bridge.so` automatically.
|
||||
set(CHANORA_BRIDGE_SHARED_LIB "$ENV{CHANORA_BRIDGE_SHARED_LIB}" CACHE FILEPATH
|
||||
"Absolute path to libchanora_bridge.so for Linux bundle packaging")
|
||||
|
||||
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
|
||||
# versions of CMake.
|
||||
cmake_policy(SET CMP0063 NEW)
|
||||
@@ -68,7 +53,6 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
# System-level dependencies.
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||
find_program(CARGO_EXECUTABLE cargo REQUIRED)
|
||||
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
@@ -76,27 +60,6 @@ add_subdirectory("runner")
|
||||
# Run the Flutter tool portions of the build. This must not be removed.
|
||||
add_dependencies(${BINARY_NAME} flutter_assemble)
|
||||
|
||||
if(CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
set(CHANORA_BRIDGE_PROFILE_DIR "debug")
|
||||
set(CHANORA_BRIDGE_CARGO_ARGS build --package chanora_bridge)
|
||||
else()
|
||||
set(CHANORA_BRIDGE_PROFILE_DIR "release")
|
||||
set(CHANORA_BRIDGE_CARGO_ARGS build --release --package chanora_bridge)
|
||||
endif()
|
||||
|
||||
if(CHANORA_BRIDGE_SHARED_LIB)
|
||||
set(CHANORA_BRIDGE_STAGED_LIB "${CHANORA_BRIDGE_SHARED_LIB}")
|
||||
else()
|
||||
set(CHANORA_BRIDGE_STAGED_LIB
|
||||
"${CHANORA_REPO_ROOT}/target/${CHANORA_BRIDGE_PROFILE_DIR}/libchanora_bridge.so")
|
||||
add_custom_target(chanora_bridge_bundle
|
||||
COMMAND "${CARGO_EXECUTABLE}" ${CHANORA_BRIDGE_CARGO_ARGS}
|
||||
WORKING_DIRECTORY "${CHANORA_REPO_ROOT}"
|
||||
COMMENT "Building chanora_bridge for Linux bundle"
|
||||
VERBATIM)
|
||||
add_dependencies(${BINARY_NAME} chanora_bridge_bundle)
|
||||
endif()
|
||||
|
||||
# Only the install-generated bundle's copy of the executable will launch
|
||||
# correctly, since the resources must in the right relative locations. To avoid
|
||||
# people trying to run the unbundled copy, put it in a subdirectory instead of
|
||||
@@ -137,29 +100,6 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}
|
||||
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
|
||||
if(EXISTS "${CHANORA_BRIDGE_STAGED_LIB}")
|
||||
install(FILES "${CHANORA_BRIDGE_STAGED_LIB}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
else()
|
||||
message(WARNING
|
||||
"Chanora bridge shared library was not found for Linux bundling: "
|
||||
"${CHANORA_BRIDGE_STAGED_LIB}")
|
||||
endif()
|
||||
|
||||
if(CHANORA_ONNXRUNTIME_SHARED_LIB)
|
||||
if(EXISTS "${CHANORA_ONNXRUNTIME_SHARED_LIB}")
|
||||
install(FILES "${CHANORA_ONNXRUNTIME_SHARED_LIB}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
RENAME "libonnxruntime.so"
|
||||
COMPONENT Runtime)
|
||||
else()
|
||||
message(WARNING
|
||||
"CHANORA_ONNXRUNTIME_SHARED_LIB was set but the file was not found: "
|
||||
"${CHANORA_ONNXRUNTIME_SHARED_LIB}")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
|
||||
install(FILES "${bundled_library}"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
|
||||
@@ -14,27 +14,6 @@ struct _MyApplication {
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
static void configure_onnxruntime_dylib_path() {
|
||||
const gchar* existing = g_getenv("ORT_DYLIB_PATH");
|
||||
if (existing != nullptr && *existing != '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
g_autofree gchar* exe_path = g_file_read_link("/proc/self/exe", nullptr);
|
||||
if (exe_path == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_autofree gchar* exe_dir = g_path_get_dirname(exe_path);
|
||||
g_autofree gchar* ort_path =
|
||||
g_build_filename(exe_dir, "lib", "libonnxruntime.so", nullptr);
|
||||
if (!g_file_test(ort_path, G_FILE_TEST_IS_REGULAR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
g_setenv("ORT_DYLIB_PATH", ort_path, TRUE);
|
||||
}
|
||||
|
||||
// Called when first Flutter frame received.
|
||||
static void first_frame_cb(MyApplication* self, FlView* view) {
|
||||
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
|
||||
@@ -131,7 +110,6 @@ static void my_application_startup(GApplication* application) {
|
||||
// MyApplication* self = MY_APPLICATION(object);
|
||||
|
||||
// Perform any actions required at application startup.
|
||||
configure_onnxruntime_dylib_path();
|
||||
|
||||
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,69 @@ void main() {
|
||||
expect(ConnectionPhase.connected.canDisconnect, isTrue);
|
||||
});
|
||||
|
||||
test('server-reachable phases show loading until snapshot is available', () {
|
||||
expect(
|
||||
ConnectionPhase.connecting.shouldShowSnapshotLoading(hasSnapshot: false),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.shouldShowSnapshotLoading(
|
||||
hasSnapshot: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.connected.shouldShowSnapshotLoading(hasSnapshot: false),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.reconnecting.shouldShowSnapshotLoading(
|
||||
hasSnapshot: false,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.shouldShowSnapshotLoading(
|
||||
hasSnapshot: true,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('chat only opens after a snapshot is available', () {
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.canOpenChatWithSnapshot(hasSnapshot: false),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.synchronizing.canOpenChatWithSnapshot(hasSnapshot: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.connected.canOpenChatWithSnapshot(hasSnapshot: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ConnectionPhase.reconnecting.canOpenChatWithSnapshot(hasSnapshot: true),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot application completes synchronizing phase', () {
|
||||
expect(
|
||||
phaseAfterSnapshotApplied(ConnectionPhase.synchronizing),
|
||||
ConnectionPhase.connected,
|
||||
);
|
||||
expect(
|
||||
phaseAfterSnapshotApplied(ConnectionPhase.connected),
|
||||
ConnectionPhase.connected,
|
||||
);
|
||||
expect(
|
||||
phaseAfterSnapshotApplied(ConnectionPhase.reconnecting),
|
||||
ConnectionPhase.reconnecting,
|
||||
);
|
||||
});
|
||||
|
||||
test('connection status text maps all phases', () {
|
||||
expect(
|
||||
connectionStatusText(phase: ConnectionPhase.idle, l10n: l10n),
|
||||
@@ -78,4 +141,22 @@ void main() {
|
||||
expect(ConnectionPhase.reconnecting.tokens(scheme).icon, Icons.restart_alt);
|
||||
expect(ConnectionPhase.disconnected.tokens(scheme).icon, Icons.cloud_off);
|
||||
});
|
||||
|
||||
test('connected event preserves connected phase when snapshot exists', () {
|
||||
expect(
|
||||
phaseAfterConnectedEvent(ConnectionPhase.connected, hasSnapshot: true),
|
||||
ConnectionPhase.connected,
|
||||
);
|
||||
});
|
||||
|
||||
test('connected event synchronizes when snapshot is missing', () {
|
||||
expect(
|
||||
phaseAfterConnectedEvent(ConnectionPhase.connected, hasSnapshot: false),
|
||||
ConnectionPhase.synchronizing,
|
||||
);
|
||||
expect(
|
||||
phaseAfterConnectedEvent(ConnectionPhase.reconnecting, hasSnapshot: true),
|
||||
ConnectionPhase.synchronizing,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/prefetch_debouncer.dart';
|
||||
|
||||
void main() {
|
||||
test('debounces host edits and prefetches latest trimmed host', () async {
|
||||
final calls = <String>[];
|
||||
final debouncer = PrefetchDebouncer(
|
||||
delay: const Duration(milliseconds: 20),
|
||||
onPrefetch: (host) async => calls.add(host),
|
||||
);
|
||||
|
||||
debouncer.schedule(' first.example.com ');
|
||||
debouncer.schedule(' second.example.com ');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 35));
|
||||
|
||||
expect(calls, ['second.example.com']);
|
||||
debouncer.dispose();
|
||||
});
|
||||
|
||||
test('skips empty hosts', () async {
|
||||
final calls = <String>[];
|
||||
final debouncer = PrefetchDebouncer(
|
||||
delay: const Duration(milliseconds: 10),
|
||||
onPrefetch: (host) async => calls.add(host),
|
||||
);
|
||||
|
||||
debouncer.schedule(' ');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
|
||||
expect(calls, isEmpty);
|
||||
debouncer.dispose();
|
||||
});
|
||||
|
||||
test('dispose cancels pending prefetch', () async {
|
||||
final calls = <String>[];
|
||||
final debouncer = PrefetchDebouncer(
|
||||
delay: const Duration(milliseconds: 30),
|
||||
onPrefetch: (host) async => calls.add(host),
|
||||
);
|
||||
|
||||
debouncer.schedule('example.com');
|
||||
debouncer.dispose();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 45));
|
||||
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,6 @@ void main() {
|
||||
rust.BridgeClient client({
|
||||
BigInt? id,
|
||||
BigInt? channelId,
|
||||
String uid = '',
|
||||
int talkPower = 0,
|
||||
bool talkPowerGranted = false,
|
||||
bool inputMuted = false,
|
||||
@@ -26,7 +25,6 @@ void main() {
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: id ?? BigInt.from(7),
|
||||
uid: uid,
|
||||
channel: channelId ?? BigInt.one,
|
||||
name: 'Me',
|
||||
inputMuted: inputMuted,
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/startup_dependency_check.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
debugResetStartupDependencyCheck();
|
||||
});
|
||||
|
||||
test('non-linux hosts skip startup dependency issues', () async {
|
||||
debugResetStartupDependencyCheck(platformIsLinux: () => false);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, isEmpty);
|
||||
});
|
||||
|
||||
test('missing Linux audio runtimes are reported as recommended', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => false,
|
||||
fileExists: (_) async => false,
|
||||
osReleaseProvider: () async => 'ID=ubuntu\nID_LIKE=debian\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.hasIssues, isTrue);
|
||||
final pipewire = result.issues.firstWhere(
|
||||
(issue) => issue.id == 'linux-pipewire-runtime',
|
||||
);
|
||||
final pulse = result.issues.firstWhere(
|
||||
(issue) => issue.id == 'linux-pulseaudio-runtime',
|
||||
);
|
||||
expect(pipewire.isRequired, isFalse);
|
||||
expect(pulse.isRequired, isFalse);
|
||||
expect(
|
||||
pipewire.installHints.single.command,
|
||||
'sudo apt install libpipewire-0.3-0',
|
||||
);
|
||||
expect(pulse.installHints.single.command, 'sudo apt install libpulse0');
|
||||
});
|
||||
|
||||
test('distro detection parses quoted ID_LIKE lists', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) => false,
|
||||
fileExists: (_) async => true,
|
||||
osReleaseProvider: () async => 'ID=rocky\nID_LIKE="fedora rhel"\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.platformLabel, 'Fedora');
|
||||
final pipewire = result.issues.singleWhere(
|
||||
(issue) => issue.id == 'linux-pipewire-runtime',
|
||||
);
|
||||
expect(
|
||||
pipewire.installHints.single.command,
|
||||
'sudo dnf install pipewire-libs',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'missing ONNX runtime is reported as recommended when Linux audio runtimes are present',
|
||||
() async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) =>
|
||||
candidate.contains('pipewire') || candidate.contains('pulse'),
|
||||
fileExists: (_) async => false,
|
||||
osReleaseProvider: () async => 'ID=fedora\n',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
currentAbiProvider: () => Abi.linuxX64,
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, hasLength(1));
|
||||
final ort = result.issues.single;
|
||||
expect(ort.id, 'linux-onnxruntime');
|
||||
expect(ort.isRequired, isFalse);
|
||||
expect(
|
||||
ort.installHints.any(
|
||||
(hint) =>
|
||||
hint.command ==
|
||||
'https://github.com/microsoft/onnxruntime/releases',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.installHints.any(
|
||||
(hint) => hint.command == 'onnxruntime-linux-x64-<version>.tgz',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.installHints.any((hint) => hint.command.contains('ORT_DYLIB_PATH')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
ort.details,
|
||||
contains(
|
||||
'This machine needs the Linux x64 CPU archive (onnxruntime-linux-x64-<version>.tgz).',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('bundle-local ONNX runtime clears the recommendation', () async {
|
||||
debugResetStartupDependencyCheck(
|
||||
platformIsLinux: () => true,
|
||||
libraryProbe: (candidate) =>
|
||||
candidate.contains('pipewire') || candidate.contains('pulse'),
|
||||
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
|
||||
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
|
||||
currentDirectoryProvider: () => '/tmp',
|
||||
);
|
||||
|
||||
final result = await checkStartupDependencies();
|
||||
|
||||
expect(result.issues, isEmpty);
|
||||
});
|
||||
|
||||
test('logging missing startup dependencies appends a log line', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'chanora-startup-log',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final logFile = File('${tempDir.path}/chanora.log');
|
||||
|
||||
debugResetStartupDependencyCheck(logFilePathProvider: () => logFile.path);
|
||||
|
||||
const result = StartupDependencyCheckResult(
|
||||
platformLabel: 'Fedora',
|
||||
issues: [
|
||||
StartupDependencyIssue(
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary: 'Primary Linux audio needs PipeWire.',
|
||||
details: ['Install PipeWire and recheck.'],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await logStartupDependencyIssues(result);
|
||||
|
||||
final text = await logFile.readAsString();
|
||||
expect(text, contains('[startup_dependency_check]'));
|
||||
expect(text, contains('platform="Fedora"'));
|
||||
expect(
|
||||
text,
|
||||
contains(
|
||||
'issues=[linux-pipewire-runtime:recommended:PipeWire runtime is missing]',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,7 @@ void main() {
|
||||
|
||||
expect(settings.host, isEmpty);
|
||||
expect(settings.nickname, isEmpty);
|
||||
expect(settings.themeMode, UiThemeMode.system);
|
||||
});
|
||||
|
||||
test('saves and loads host and nickname independently', () async {
|
||||
@@ -40,78 +41,30 @@ void main() {
|
||||
expect(await service.hasExplainedPermissions(), isTrue);
|
||||
});
|
||||
|
||||
test('stores per-user playback preferences per server host', () async {
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'Example.COM',
|
||||
userUid: 'user-a',
|
||||
volume: 0.5,
|
||||
muted: true,
|
||||
);
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'other.example',
|
||||
userUid: 'user-a',
|
||||
volume: 2.0,
|
||||
muted: false,
|
||||
);
|
||||
test('saves and loads theme mode independently', () async {
|
||||
await service.saveSettings(host: 'example.com', nickname: 'Chanora');
|
||||
await service.saveThemeMode(UiThemeMode.dark);
|
||||
|
||||
final examplePrefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
final otherPrefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'other.example',
|
||||
);
|
||||
var settings = await service.loadSettings();
|
||||
|
||||
expect(examplePrefs['user-a']?.volume, 0.5);
|
||||
expect(examplePrefs['user-a']?.muted, isTrue);
|
||||
expect(otherPrefs['user-a']?.volume, 2.0);
|
||||
expect(otherPrefs['user-a']?.muted, isFalse);
|
||||
expect(settings.host, 'example.com');
|
||||
expect(settings.nickname, 'Chanora');
|
||||
expect(settings.themeMode, UiThemeMode.dark);
|
||||
|
||||
await service.saveThemeMode(UiThemeMode.light);
|
||||
settings = await service.loadSettings();
|
||||
|
||||
expect(settings.host, 'example.com');
|
||||
expect(settings.nickname, 'Chanora');
|
||||
expect(settings.themeMode, UiThemeMode.light);
|
||||
});
|
||||
|
||||
test(
|
||||
'drops default playback preferences instead of persisting them',
|
||||
() async {
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'example.com',
|
||||
userUid: 'user-a',
|
||||
volume: 0.2,
|
||||
muted: true,
|
||||
);
|
||||
await service.saveClientPlaybackPreference(
|
||||
serverHost: 'example.com',
|
||||
userUid: 'user-a',
|
||||
volume: 1.0,
|
||||
muted: false,
|
||||
);
|
||||
test('falls back to system theme mode for invalid stored values', () async {
|
||||
SharedPreferences.setMockInitialValues({'ui.theme_mode': 'sepia'});
|
||||
service = const UiPreferencesService();
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
expect(prefs, isEmpty);
|
||||
},
|
||||
);
|
||||
final settings = await service.loadSettings();
|
||||
|
||||
test('ignores corrupt playback preference JSON', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'audio.client_playback_prefs': '{not-json',
|
||||
});
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
|
||||
expect(prefs, isEmpty);
|
||||
});
|
||||
|
||||
test('clamps invalid playback volumes to unity', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'audio.client_playback_prefs':
|
||||
'{"example.com|user-a":{"volume":"invalid","muted":false}}',
|
||||
});
|
||||
|
||||
final prefs = await service.loadClientPlaybackPreferencesForServer(
|
||||
'example.com',
|
||||
);
|
||||
|
||||
expect(prefs['user-a']?.volume, 1.0);
|
||||
expect(settings.themeMode, UiThemeMode.system);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,12 +18,44 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/main.dart';
|
||||
import 'package:chanora_flutter/services/android_permissions_service.dart';
|
||||
import 'package:chanora_flutter/services/ios_permissions_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:chanora_flutter/widgets/permission_state_banner.dart';
|
||||
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('theme menu emits selected mode from user action', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
var selected = ThemeMode.system;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
ChanoraThemeModeMenu(
|
||||
themeMode: ThemeMode.system,
|
||||
onThemeModeChanged: (mode) async {
|
||||
selected = mode;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.palette_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Dark').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(selected, ThemeMode.dark);
|
||||
});
|
||||
|
||||
testWidgets('renders English banner', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
||||
@@ -31,14 +31,14 @@ void main() {
|
||||
expect(state.agcEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('desktop VAD normalization keeps explicit WebRTC selections', () {
|
||||
test('normalizes desktop VAD backend to Silero', () {
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
),
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
@@ -46,33 +46,10 @@ void main() {
|
||||
isWindows: false,
|
||||
isLinux: true,
|
||||
),
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
);
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.energyDebug,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
),
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'desktop VAD normalization falls back when ONNX Runtime is unavailable',
|
||||
() {
|
||||
expect(
|
||||
normalizedVadBackend(
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
isWindows: false,
|
||||
isLinux: true,
|
||||
onnxRuntimeAvailable: false,
|
||||
),
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('default config matches the current platform fallback', () {
|
||||
final fallback = defaultAudioProcessingConfig();
|
||||
final desktop = Platform.isWindows || Platform.isLinux;
|
||||
@@ -126,7 +103,6 @@ void main() {
|
||||
|
||||
test('builds Windows/Linux software WebRTC APM config consistently', () {
|
||||
final state = AudioProcessingConfigState.fromConfig(baseConfig)
|
||||
..vadBackend = rust.BridgeVadBackend.webrtcVad
|
||||
..nsEnabled = true
|
||||
..aecEnabled = false
|
||||
..agcEnabled = true;
|
||||
@@ -151,7 +127,7 @@ void main() {
|
||||
|
||||
for (final config in [windowsConfig, linuxConfig]) {
|
||||
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
|
||||
expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx);
|
||||
expect(config.aec, rust.BridgeEffectOwner.off);
|
||||
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
|
||||
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
|
||||
|
||||
@@ -36,12 +36,10 @@ void main() {
|
||||
required BigInt id,
|
||||
required String name,
|
||||
required BigInt channelId,
|
||||
String uid = '',
|
||||
bool isServerQuery = false,
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: id,
|
||||
uid: uid,
|
||||
channel: channelId,
|
||||
name: name,
|
||||
inputMuted: false,
|
||||
@@ -466,6 +464,8 @@ void main() {
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
|
||||
@@ -498,15 +498,7 @@ void main() {
|
||||
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
|
||||
final serverTileSize = tester.getSize(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byIcon(Icons.dns_outlined),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is SizedBox &&
|
||||
widget.width == 92 &&
|
||||
widget.height == 92,
|
||||
),
|
||||
)
|
||||
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
|
||||
.first,
|
||||
);
|
||||
|
||||
@@ -516,63 +508,23 @@ void main() {
|
||||
expect(serverTileSize.height, 92);
|
||||
});
|
||||
|
||||
testWidgets('chat sidebar uses MD3-style selected container colors', (
|
||||
testWidgets('chat page uses a compact top rail on phone width', (
|
||||
tester,
|
||||
) async {
|
||||
final theme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
home: ChatPage(
|
||||
messages: const [],
|
||||
snapshot: snapshot(channels: const [], clients: const []),
|
||||
initialTarget: const rust.BridgeMessageTarget.server(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final serverIndicator = find.ancestor(
|
||||
of: find.byIcon(Icons.dns_outlined),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
final channelIndicator = find.ancestor(
|
||||
of: find.byIcon(Icons.tag),
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
|
||||
final serverDecoration =
|
||||
tester.widget<AnimatedContainer>(serverIndicator).decoration
|
||||
as BoxDecoration;
|
||||
final channelDecoration =
|
||||
tester.widget<AnimatedContainer>(channelIndicator).decoration
|
||||
as BoxDecoration;
|
||||
final serverLabel = tester.widget<Text>(find.text('Server').last);
|
||||
final channelLabel = tester.widget<Text>(find.text('Channel'));
|
||||
|
||||
expect(tester.getSize(serverIndicator), const Size(76, 76));
|
||||
expect(tester.getSize(channelIndicator), const Size(76, 76));
|
||||
expect(serverDecoration.color, theme.colorScheme.primaryContainer);
|
||||
expect(channelDecoration.color, Colors.transparent);
|
||||
expect(serverLabel.style?.color, theme.colorScheme.onPrimaryContainer);
|
||||
expect(channelLabel.style?.color, theme.colorScheme.onSurfaceVariant);
|
||||
});
|
||||
|
||||
testWidgets('long private chat labels stay within fixed rail indicator', (
|
||||
tester,
|
||||
) async {
|
||||
const longName = 'Very Long Private Chat Name That Should Not Stretch';
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
senderId: BigInt.from(2),
|
||||
senderName: longName,
|
||||
senderName: 'Alpha',
|
||||
message: 'Private',
|
||||
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
|
||||
),
|
||||
@@ -580,29 +532,24 @@ void main() {
|
||||
snapshot: snapshot(
|
||||
channels: const [],
|
||||
clients: [
|
||||
client(
|
||||
id: BigInt.from(2),
|
||||
name: longName,
|
||||
channelId: BigInt.zero,
|
||||
),
|
||||
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.zero),
|
||||
],
|
||||
),
|
||||
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
|
||||
initialClientName: longName,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final selectedIndicator = find.ancestor(
|
||||
of: find.text(longName).first,
|
||||
matching: find.byType(AnimatedContainer),
|
||||
);
|
||||
final privateLabel = tester.widget<Text>(find.text(longName).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(selectedIndicator), const Size(76, 76));
|
||||
expect(privateLabel.maxLines, 2);
|
||||
expect(privateLabel.overflow, TextOverflow.ellipsis);
|
||||
expect(privateLabel.textAlign, TextAlign.center);
|
||||
expect(find.byType(VerticalDivider), findsNothing);
|
||||
final serverTileSize = tester.getSize(
|
||||
find
|
||||
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
|
||||
.first,
|
||||
);
|
||||
expect(serverTileSize.width, lessThan(92));
|
||||
expect(serverTileSize.height, lessThan(92));
|
||||
expect(tester.getTopLeft(find.text('Private').first).dy, greaterThan(92));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -610,6 +557,8 @@ void main() {
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
@@ -715,6 +664,8 @@ void main() {
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
@@ -755,6 +706,8 @@ void main() {
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: messages,
|
||||
snapshot: currentSnapshot,
|
||||
@@ -822,6 +775,8 @@ void main() {
|
||||
Ts3ServerLink? tapped;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: ChatPage(
|
||||
messages: [
|
||||
ChatEntry(
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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/client_info_sheet.dart';
|
||||
|
||||
void main() {
|
||||
rust.BridgeClientProfile profile() {
|
||||
return rust.BridgeClientProfile(
|
||||
id: BigInt.from(101),
|
||||
channel: BigInt.from(7),
|
||||
name: 'Bob',
|
||||
uniqueId: 'client-unique-id',
|
||||
databaseId: BigInt.from(55),
|
||||
countryCode: 'US',
|
||||
description: 'Operator',
|
||||
version: '3.6.2',
|
||||
platform: 'Windows',
|
||||
onlineSeconds: 3661,
|
||||
idleMilliseconds: 42000,
|
||||
pingMilliseconds: 38,
|
||||
clientAddress: '203.0.113.24',
|
||||
serverGroups: const ['Admin', 'Talk Power'],
|
||||
channelGroup: 'Guest',
|
||||
avatarPath: '/avatar_aabbcc',
|
||||
bytesDownloadedMonth: BigInt.from(2048),
|
||||
bytesUploadedMonth: BigInt.from(4096),
|
||||
bytesDownloadedTotal: BigInt.from(1048576),
|
||||
bytesUploadedTotal: BigInt.from(2097152),
|
||||
packetLossClientToServerTotal: 0.0123,
|
||||
packetLossServerToClientTotal: 0.0456,
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders loaded profile data in a modal sheet', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: FilledButton(
|
||||
onPressed: () => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => FractionallySizedBox(
|
||||
heightFactor: 0.86,
|
||||
child: ClientInfoSheet(
|
||||
clientName: 'Bob',
|
||||
loadProfile: () async => profile(),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('Bob'), findsOneWidget);
|
||||
expect(find.text('Identity'), findsOneWidget);
|
||||
expect(find.text('Membership'), findsOneWidget);
|
||||
expect(find.text('Admin, Talk Power'), findsOneWidget);
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -360));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Connection'), findsOneWidget);
|
||||
expect(find.text('1h 1m 1s'), findsOneWidget);
|
||||
expect(find.text('42.00 s'), findsOneWidget);
|
||||
expect(find.text('203.0.113.24'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('loading and error states use localized copy', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('zh'),
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
height: 500,
|
||||
child: ClientInfoSheet(
|
||||
clientName: 'Bob',
|
||||
loadProfile: () => Future<rust.BridgeClientProfile>.error(
|
||||
StateError('not available'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('正在加载资料…'), findsOneWidget);
|
||||
expect(find.text('Loading profile...'), findsNothing);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('资料不可用'), findsOneWidget);
|
||||
expect(find.text('重试'), findsOneWidget);
|
||||
expect(find.text('Profile unavailable'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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/widgets/connect_widgets.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('pressing enter in password field submits connection', (
|
||||
tester,
|
||||
) async {
|
||||
var connectCount = 0;
|
||||
final hostCtl = TextEditingController(text: 'example.com');
|
||||
final nickCtl = TextEditingController(text: 'alice');
|
||||
final passwordCtl = TextEditingController();
|
||||
addTearDown(hostCtl.dispose);
|
||||
addTearDown(nickCtl.dispose);
|
||||
addTearDown(passwordCtl.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ConnectForm(
|
||||
hostCtl: hostCtl,
|
||||
nickCtl: nickCtl,
|
||||
passwordCtl: passwordCtl,
|
||||
onConnect: () => connectCount += 1,
|
||||
onAddBookmark: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.enterText(find.byType(TextField).last, 'secret');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(connectCount, 1);
|
||||
});
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/widgets/input_dialogs.dart';
|
||||
|
||||
void main() {
|
||||
test('browser back and forward logical keys map to mouse side bindings', () {
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
LogicalKeyboardKey.browserBack,
|
||||
),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey.goBack),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForLogicalKey(
|
||||
LogicalKeyboardKey.browserForward,
|
||||
),
|
||||
'mouse-side-button:16',
|
||||
);
|
||||
});
|
||||
|
||||
test('pointer button bitmasks map to mouse side bindings', () {
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x08),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x10),
|
||||
'mouse-side-button:16',
|
||||
);
|
||||
expect(
|
||||
pttMouseSideButtonPlatformKeyForButtons(0x18),
|
||||
'mouse-side-button:8',
|
||||
);
|
||||
expect(pttMouseSideButtonPlatformKeyForButtons(0x00), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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/main.dart';
|
||||
import 'package:chanora_flutter/widgets/connect_widgets.dart';
|
||||
import 'package:chanora_flutter/widgets/input_dialogs.dart';
|
||||
import 'package:chanora_flutter/widgets/ptt_capability_badge.dart';
|
||||
|
||||
void main() {
|
||||
Widget localizedHarness(Widget child, {Locale? locale}) {
|
||||
return MaterialApp(
|
||||
locale: locale,
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('connect actions stack on narrow mobile widths', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: ConnectForm(
|
||||
hostCtl: TextEditingController(text: 'server.example.com'),
|
||||
nickCtl: TextEditingController(text: 'MobileUser'),
|
||||
passwordCtl: TextEditingController(),
|
||||
onConnect: () {},
|
||||
onAddBookmark: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final connectTop = tester.getTopLeft(find.text('Connect')).dy;
|
||||
final bookmarkTop = tester.getTopLeft(find.text('Save bookmark')).dy;
|
||||
|
||||
expect(bookmarkTop, greaterThan(connectTop + 44));
|
||||
});
|
||||
|
||||
testWidgets('connect actions stack at standard phone content widths', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
SizedBox(
|
||||
width: 361,
|
||||
child: ConnectForm(
|
||||
hostCtl: TextEditingController(text: 'server.example.com'),
|
||||
nickCtl: TextEditingController(text: 'MobileUser'),
|
||||
passwordCtl: TextEditingController(),
|
||||
onConnect: () {},
|
||||
onAddBookmark: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final connectTop = tester.getTopLeft(find.text('Connect')).dy;
|
||||
final bookmarkTop = tester.getTopLeft(find.text('Save bookmark')).dy;
|
||||
|
||||
expect(bookmarkTop, greaterThan(connectTop + 44));
|
||||
});
|
||||
|
||||
testWidgets('disconnected mobile chrome keeps content near the safe area', (
|
||||
tester,
|
||||
) async {
|
||||
const primaryContentKey = Key('primary-content');
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(393, 852));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
ChanoraMobileScaffold(
|
||||
compactIdleChrome: true,
|
||||
canDisconnect: false,
|
||||
title: null,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'About',
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
onDisconnect: () {},
|
||||
compactHeader: const SizedBox(height: 44, child: Text('Chanora')),
|
||||
body: const KeyedSubtree(
|
||||
key: primaryContentKey,
|
||||
child: Text('Primary content'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(AppBar), findsNothing);
|
||||
expect(tester.getTopLeft(find.byKey(primaryContentKey)).dy, lessThan(120));
|
||||
});
|
||||
|
||||
testWidgets('PTT explanation sheet is scrollable for mobile text scaling', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
MediaQuery(
|
||||
data: const MediaQueryData(textScaler: TextScaler.linear(1.8)),
|
||||
child: const SizedBox(
|
||||
width: 320,
|
||||
child: PttCapabilityBadge(
|
||||
level: 'L0Focused',
|
||||
backendId: 'focused',
|
||||
boundInputClass: 'keyboard',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.info_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SingleChildScrollView), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('PTT capture dialog wraps content for small screens', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
localizedHarness(
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return Center(
|
||||
child: FilledButton(
|
||||
onPressed: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const PttBindingCaptureDialog(),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SingleChildScrollView), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/services/ui_preferences_service.dart';
|
||||
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||
import 'package:chanora_flutter/widgets/snapshot_view.dart';
|
||||
|
||||
@@ -30,14 +29,12 @@ void main() {
|
||||
required int id,
|
||||
required int channelId,
|
||||
required String name,
|
||||
String uid = '',
|
||||
bool speaking = false,
|
||||
int talkPower = 0,
|
||||
bool talkPowerGranted = false,
|
||||
}) {
|
||||
return rust.BridgeClient(
|
||||
id: BigInt.from(id),
|
||||
uid: uid,
|
||||
channel: BigInt.from(channelId),
|
||||
name: name,
|
||||
inputMuted: false,
|
||||
@@ -52,11 +49,14 @@ void main() {
|
||||
Widget snapshotHarness({
|
||||
required List<rust.BridgeChannel> channels,
|
||||
required List<rust.BridgeClient> clients,
|
||||
String welcomeMessage = '',
|
||||
BigInt? ownClientId,
|
||||
BigInt? currentVoiceChannelId,
|
||||
rust.BridgeAudioStats? audioStats,
|
||||
Map<String, ClientPlaybackPreference> clientPlaybackPreferences = const {},
|
||||
ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged,
|
||||
bool enableClientLongPressMenu = false,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientChat,
|
||||
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
@@ -65,7 +65,7 @@ void main() {
|
||||
body: SnapshotView(
|
||||
snapshot: rust.BridgeSnapshot(
|
||||
serverName: 'Server',
|
||||
welcomeMessage: '',
|
||||
welcomeMessage: welcomeMessage,
|
||||
platform: '',
|
||||
version: '',
|
||||
channels: channels,
|
||||
@@ -81,8 +81,10 @@ void main() {
|
||||
canJoinVoiceChannel: true,
|
||||
onJoinChannel: (_) {},
|
||||
onJoinChannelWithPassword: (_) {},
|
||||
clientPlaybackPreferences: clientPlaybackPreferences,
|
||||
onClientPlaybackPreferenceChanged: onClientPlaybackPreferenceChanged,
|
||||
enableClientLongPressMenu: enableClientLongPressMenu,
|
||||
onOpenClientInfo: onOpenClientInfo,
|
||||
onOpenClientChat: onOpenClientChat,
|
||||
onOpenClientPoke: onOpenClientPoke,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -197,6 +199,27 @@ void main() {
|
||||
expect(childUserX - childX, inInclusiveRange(22, 28));
|
||||
});
|
||||
|
||||
testWidgets('server welcome starts collapsed above channel tree', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
welcomeMessage:
|
||||
'This is a long server welcome that should not push channels '
|
||||
'below the first connected screen.',
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [client(id: 100, channelId: 1, name: 'Alice')],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Server welcome message'), findsOneWidget);
|
||||
expect(find.textContaining('long server welcome'), findsNothing);
|
||||
expect(find.text('Default Channel'), findsOneWidget);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('password channel shows lock at row end', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
@@ -242,6 +265,219 @@ void main() {
|
||||
expect(userHighlight.decoration, isNull);
|
||||
});
|
||||
|
||||
testWidgets('long press opens client menu for other users', (tester) async {
|
||||
rust.BridgeClient? chatClient;
|
||||
rust.BridgeClient? pokeClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientChat: (client) => chatClient = client,
|
||||
onOpenClientPoke: (client) => pokeClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Private message'), findsOneWidget);
|
||||
expect(find.text('Poke'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Private message'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(chatClient?.id, BigInt.from(101));
|
||||
expect(chatClient?.name, 'Bob');
|
||||
expect(pokeClient, isNull);
|
||||
});
|
||||
|
||||
testWidgets('long press is ignored when mobile client menus are disabled', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: false,
|
||||
onOpenClientChat: (_) {},
|
||||
onOpenClientPoke: (_) {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Private message'), findsNothing);
|
||||
expect(find.text('Poke'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('secondary click opens client menu for other users', (
|
||||
tester,
|
||||
) async {
|
||||
rust.BridgeClient? chatClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onOpenClientChat: (client) => chatClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.text('Bob'),
|
||||
buttons: kSecondaryMouseButton,
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Private message'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Private message'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(chatClient?.id, BigInt.from(101));
|
||||
});
|
||||
|
||||
testWidgets('client menu poke action targets selected other user', (
|
||||
tester,
|
||||
) async {
|
||||
rust.BridgeClient? pokeClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientPoke: (client) => pokeClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Poke'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(pokeClient?.id, BigInt.from(101));
|
||||
expect(pokeClient?.name, 'Bob');
|
||||
expect(find.text('Poke'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('client menu info action targets selected other user', (
|
||||
tester,
|
||||
) async {
|
||||
rust.BridgeClient? infoClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientInfo: (client) => infoClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Info'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(infoClient?.id, BigInt.from(101));
|
||||
expect(infoClient?.name, 'Bob');
|
||||
});
|
||||
|
||||
testWidgets('client menu info action is available for self', (tester) async {
|
||||
rust.BridgeClient? infoClient;
|
||||
rust.BridgeClient? chatClient;
|
||||
rust.BridgeClient? pokeClient;
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientInfo: (client) => infoClient = client,
|
||||
onOpenClientChat: (client) => chatClient = client,
|
||||
onOpenClientPoke: (client) => pokeClient = client,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Alice'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Info'), findsOneWidget);
|
||||
expect(find.text('Private message'), findsNothing);
|
||||
expect(find.text('Poke'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Info'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(infoClient?.id, BigInt.from(100));
|
||||
expect(infoClient?.name, 'Alice');
|
||||
expect(chatClient, isNull);
|
||||
expect(pokeClient, isNull);
|
||||
});
|
||||
|
||||
testWidgets('self menu is hidden when only peer actions are available', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Alice'),
|
||||
client(id: 101, channelId: 1, name: 'Bob'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
enableClientLongPressMenu: true,
|
||||
onOpenClientChat: (_) {},
|
||||
onOpenClientPoke: (_) {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.text('Alice'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Private message'), findsNothing);
|
||||
expect(find.text('Poke'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'local voice activity does not light speaking state when blocked',
|
||||
(tester) async {
|
||||
@@ -270,114 +506,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('remote client row does not show playback ellipsis', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
clientPlaybackPreferences: const {
|
||||
'user-b': ClientPlaybackPreference(volume: 0.5, muted: false),
|
||||
},
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.more_horiz), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('remote client long press opens playback menu', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mute playback'), findsOneWidget);
|
||||
expect(find.text('Volume 100%'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('remote client secondary click opens playback menu', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, _) async {},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.widgetWithText(ListTile, 'Bob'),
|
||||
buttons: kSecondaryButton,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mute playback'), findsOneWidget);
|
||||
expect(find.text('Volume 100%'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('client playback menu forwards mute and volume changes', (
|
||||
tester,
|
||||
) async {
|
||||
final changes = <ClientPlaybackPreference>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
snapshotHarness(
|
||||
channels: [channel(id: 1, name: 'Default Channel')],
|
||||
clients: [
|
||||
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
|
||||
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
|
||||
],
|
||||
ownClientId: BigInt.from(100),
|
||||
onClientPlaybackPreferenceChanged: (_, preference) async {
|
||||
changes.add(preference);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Mute playback'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(changes, isNotEmpty);
|
||||
expect(changes.last.muted, isTrue);
|
||||
|
||||
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Volume 200%'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(changes.last.volume, 2.0);
|
||||
expect(changes.last.muted, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('spacer channels render as layout rows and keep channel taps', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/startup_dependency_check.dart';
|
||||
import 'package:chanora_flutter/widgets/startup_dependency_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('startup gate shows install-help screen and can continue', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 1800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final result = StartupDependencyCheckResult(
|
||||
platformLabel: 'Fedora',
|
||||
issues: const [
|
||||
StartupDependencyIssue(
|
||||
id: 'linux-pipewire-runtime',
|
||||
title: 'PipeWire runtime is missing',
|
||||
summary: 'Primary Linux audio needs PipeWire.',
|
||||
details: ['Install PipeWire and recheck.'],
|
||||
severity: StartupDependencySeverity.recommended,
|
||||
installHints: [
|
||||
StartupInstallHint(
|
||||
label: 'Release downloads',
|
||||
command: 'https://github.com/microsoft/onnxruntime/releases',
|
||||
),
|
||||
StartupInstallHint(
|
||||
label: 'Fedora',
|
||||
command: 'sudo dnf install pipewire-libs',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
final loggedResults = <StartupDependencyCheckResult>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StartupDependencyGate(
|
||||
checker: () async => result,
|
||||
logger: (value) async {
|
||||
loggedResults.add(value);
|
||||
},
|
||||
child: const Scaffold(body: Text('ready')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Finish Linux setup'), findsOneWidget);
|
||||
expect(find.text('PipeWire runtime is missing'), findsOneWidget);
|
||||
expect(find.text('Continue anyway'), findsOneWidget);
|
||||
expect(find.text('Open'), findsOneWidget);
|
||||
expect(find.text('Copy'), findsOneWidget);
|
||||
expect(loggedResults, [result]);
|
||||
|
||||
await tester.ensureVisible(find.text('Continue anyway'));
|
||||
await tester.tap(find.text('Continue anyway'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('ready'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -24,29 +24,12 @@ void main() {
|
||||
]);
|
||||
});
|
||||
|
||||
test('desktop VAD segments expose both Silero and WebRTC', () {
|
||||
test('desktop VAD segments expose Silero as the primary backend', () {
|
||||
expect(desktopVadBackendSegments.map((s) => s.value), [
|
||||
rust.BridgeVadBackend.webrtcVad,
|
||||
rust.BridgeVadBackend.sileroOnnx,
|
||||
]);
|
||||
});
|
||||
|
||||
test('desktop VAD segments disable Silero when ONNX Runtime is missing', () {
|
||||
final segments = vadBackendSegmentsForAvailability(
|
||||
desktop: true,
|
||||
onnxRuntimeAvailable: false,
|
||||
);
|
||||
|
||||
final silero = segments.singleWhere(
|
||||
(segment) => segment.value == rust.BridgeVadBackend.sileroOnnx,
|
||||
);
|
||||
final webrtc = segments.singleWhere(
|
||||
(segment) => segment.value == rust.BridgeVadBackend.webrtcVad,
|
||||
);
|
||||
expect(silero.enabled, isFalse);
|
||||
expect(webrtc.enabled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('shared segmented style applies compact visual density', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user