From 7e28791ec299cc5685df806f4cfc2b1b804b4e56 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Sat, 23 May 2026 05:25:36 +0900 Subject: [PATCH] Handle pokes outside chat tabs --- apps/chanora_flutter/lib/l10n/app_en.arb | 56 +- apps/chanora_flutter/lib/l10n/app_zh.arb | 56 +- .../lib/l10n/generated/app_localizations.dart | 102 +- .../l10n/generated/app_localizations_en.dart | 72 +- .../l10n/generated/app_localizations_zh.dart | 70 +- apps/chanora_flutter/lib/main.dart | 2066 ++++++----------- .../lib/services/ui_preferences_service.dart | 54 + .../lib/widgets/chat_views.dart | 910 ++++++++ .../services/ui_preferences_service_test.dart | 58 + .../test/widgets/chat_views_test.dart | 470 ++++ 10 files changed, 2567 insertions(+), 1347 deletions(-) create mode 100644 apps/chanora_flutter/lib/services/ui_preferences_service.dart create mode 100644 apps/chanora_flutter/lib/widgets/chat_views.dart create mode 100644 apps/chanora_flutter/test/services/ui_preferences_service_test.dart create mode 100644 apps/chanora_flutter/test/widgets/chat_views_test.dart diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 2afdf98..e9f48c8 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -14,7 +14,11 @@ "fieldDisplayName": "Display name", "connectAction": "Connect", - "disconnectAction": "Disconnect", + "disconnectAction": "Leave server", + "disconnectConfirmTitle": "Leave server?", + "disconnectConfirmBody": "You will disconnect from the current server.", + "disconnectConfirmCancel": "Stay", + "disconnectConfirmAction": "Leave", "refreshAction": "Refresh", "diagnosticsAction": "Diagnostics", "diagnosticsSaveAction": "Save export", @@ -177,5 +181,55 @@ "audioRouteChangeFailed": "Could not change audio output.", "iosAudioInterrupted": "Audio interrupted by system (phone call)", "iosAudioResuming": "Audio resuming", + "settingsAction": "Settings", + "appSettingsTitle": "Settings", + "pokeAlertsTitle": "Poke alerts", + "pokeAlertsDescription": "Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.", + "pokeDialogTitle": "You were poked", + "pokeDialogCloseAction": "Close", + "pokeDialogIncomingNoMessage": "{time} {sender} pokes you", + "@pokeDialogIncomingNoMessage": { + "placeholders": { + "time": { "type": "String" }, + "sender": { "type": "String" } + } + }, + "pokeDialogIncomingWithMessage": "{time} {sender} pokes you with a message", + "@pokeDialogIncomingWithMessage": { + "placeholders": { + "time": { "type": "String" }, + "sender": { "type": "String" } + } + }, + "pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".", + "@pokeHistorySelfNoMessage": { + "placeholders": { + "time": { "type": "String" }, + "target": { "type": "String" } + } + }, + "pokeHistorySelfWithMessage": "<{time}> You poked \"{target}\" with message: {message}", + "@pokeHistorySelfWithMessage": { + "placeholders": { + "time": { "type": "String" }, + "target": { "type": "String" }, + "message": { "type": "String" } + } + }, + "pokeHistoryIncomingNoMessage": "<{time}> \"{sender}\" pokes you", + "@pokeHistoryIncomingNoMessage": { + "placeholders": { + "time": { "type": "String" }, + "sender": { "type": "String" } + } + }, + "pokeHistoryIncomingWithMessage": "<{time}> \"{sender}\" pokes you: {message}", + "@pokeHistoryIncomingWithMessage": { + "placeholders": { + "time": { "type": "String" }, + "sender": { "type": "String" }, + "message": { "type": "String" } + } + }, "permissionDenied": "Permission Denied" } diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 6c4eaf9..8aceeac 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -13,7 +13,11 @@ "fieldDisplayName": "显示名称", "connectAction": "连接", - "disconnectAction": "断开连接", + "disconnectAction": "离开服务器", + "disconnectConfirmTitle": "离开服务器?", + "disconnectConfirmBody": "你将从当前服务器断开连接。", + "disconnectConfirmCancel": "留下", + "disconnectConfirmAction": "离开", "refreshAction": "刷新", "diagnosticsAction": "诊断信息", "diagnosticsSaveAction": "保存导出", @@ -131,5 +135,55 @@ "audioRouteChangeFailed": "无法切换音频输出。", "iosAudioInterrupted": "系统已中断音频(电话通话)", "iosAudioResuming": "音频正在恢复", + "settingsAction": "设置", + "appSettingsTitle": "设置", + "pokeAlertsTitle": "戳一戳提醒", + "pokeAlertsDescription": "收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。", + "pokeDialogTitle": "你被戳了一下", + "pokeDialogCloseAction": "关闭", + "pokeDialogIncomingNoMessage": "{time} {sender} 戳了你一下", + "@pokeDialogIncomingNoMessage": { + "placeholders": { + "time": { "type": "String" }, + "sender": { "type": "String" } + } + }, + "pokeDialogIncomingWithMessage": "{time} {sender} 戳了你一下并附带消息", + "@pokeDialogIncomingWithMessage": { + "placeholders": { + "time": { "type": "String" }, + "sender": { "type": "String" } + } + }, + "pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。", + "@pokeHistorySelfNoMessage": { + "placeholders": { + "time": { "type": "String" }, + "target": { "type": "String" } + } + }, + "pokeHistorySelfWithMessage": "<{time}> 你戳了“{target}”一下,消息:{message}", + "@pokeHistorySelfWithMessage": { + "placeholders": { + "time": { "type": "String" }, + "target": { "type": "String" }, + "message": { "type": "String" } + } + }, + "pokeHistoryIncomingNoMessage": "<{time}> “{sender}”戳了你一下", + "@pokeHistoryIncomingNoMessage": { + "placeholders": { + "time": { "type": "String" }, + "sender": { "type": "String" } + } + }, + "pokeHistoryIncomingWithMessage": "<{time}> “{sender}”戳了你一下:{message}", + "@pokeHistoryIncomingWithMessage": { + "placeholders": { + "time": { "type": "String" }, + "sender": { "type": "String" }, + "message": { "type": "String" } + } + }, "permissionDenied": "权限被拒绝" } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 090fb78..c368064 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -154,9 +154,33 @@ abstract class AppL10n { /// No description provided for @disconnectAction. /// /// In en, this message translates to: - /// **'Disconnect'** + /// **'Leave server'** String get disconnectAction; + /// No description provided for @disconnectConfirmTitle. + /// + /// In en, this message translates to: + /// **'Leave server?'** + String get disconnectConfirmTitle; + + /// No description provided for @disconnectConfirmBody. + /// + /// In en, this message translates to: + /// **'You will disconnect from the current server.'** + String get disconnectConfirmBody; + + /// No description provided for @disconnectConfirmCancel. + /// + /// In en, this message translates to: + /// **'Stay'** + String get disconnectConfirmCancel; + + /// No description provided for @disconnectConfirmAction. + /// + /// In en, this message translates to: + /// **'Leave'** + String get disconnectConfirmAction; + /// No description provided for @refreshAction. /// /// In en, this message translates to: @@ -799,6 +823,82 @@ abstract class AppL10n { /// **'Audio resuming'** String get iosAudioResuming; + /// No description provided for @settingsAction. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settingsAction; + + /// No description provided for @appSettingsTitle. + /// + /// In en, this message translates to: + /// **'Settings'** + String get appSettingsTitle; + + /// No description provided for @pokeAlertsTitle. + /// + /// In en, this message translates to: + /// **'Poke alerts'** + String get pokeAlertsTitle; + + /// No description provided for @pokeAlertsDescription. + /// + /// In en, this message translates to: + /// **'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.'** + String get pokeAlertsDescription; + + /// No description provided for @pokeDialogTitle. + /// + /// In en, this message translates to: + /// **'You were poked'** + String get pokeDialogTitle; + + /// No description provided for @pokeDialogCloseAction. + /// + /// In en, this message translates to: + /// **'Close'** + String get pokeDialogCloseAction; + + /// No description provided for @pokeDialogIncomingNoMessage. + /// + /// In en, this message translates to: + /// **'{time} {sender} pokes you'** + String pokeDialogIncomingNoMessage(String time, String sender); + + /// No description provided for @pokeDialogIncomingWithMessage. + /// + /// In en, this message translates to: + /// **'{time} {sender} pokes you with a message'** + String pokeDialogIncomingWithMessage(String time, String sender); + + /// No description provided for @pokeHistorySelfNoMessage. + /// + /// In en, this message translates to: + /// **'<{time}> You poked \"{target}\".'** + String pokeHistorySelfNoMessage(String time, String target); + + /// No description provided for @pokeHistorySelfWithMessage. + /// + /// In en, this message translates to: + /// **'<{time}> You poked \"{target}\" with message: {message}'** + String pokeHistorySelfWithMessage(String time, String target, String message); + + /// No description provided for @pokeHistoryIncomingNoMessage. + /// + /// In en, this message translates to: + /// **'<{time}> \"{sender}\" pokes you'** + String pokeHistoryIncomingNoMessage(String time, String sender); + + /// No description provided for @pokeHistoryIncomingWithMessage. + /// + /// In en, this message translates to: + /// **'<{time}> \"{sender}\" pokes you: {message}'** + String pokeHistoryIncomingWithMessage( + String time, + String sender, + String message, + ); + /// No description provided for @permissionDenied. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 63e7644..3b8c288 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -38,7 +38,20 @@ class AppL10nEn extends AppL10n { String get connectAction => 'Connect'; @override - String get disconnectAction => 'Disconnect'; + String get disconnectAction => 'Leave server'; + + @override + String get disconnectConfirmTitle => 'Leave server?'; + + @override + String get disconnectConfirmBody => + 'You will disconnect from the current server.'; + + @override + String get disconnectConfirmCancel => 'Stay'; + + @override + String get disconnectConfirmAction => 'Leave'; @override String get refreshAction => 'Refresh'; @@ -402,6 +415,63 @@ class AppL10nEn extends AppL10n { @override String get iosAudioResuming => 'Audio resuming'; + @override + String get settingsAction => 'Settings'; + + @override + String get appSettingsTitle => 'Settings'; + + @override + String get pokeAlertsTitle => 'Poke alerts'; + + @override + String get pokeAlertsDescription => + 'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.'; + + @override + String get pokeDialogTitle => 'You were poked'; + + @override + String get pokeDialogCloseAction => 'Close'; + + @override + String pokeDialogIncomingNoMessage(String time, String sender) { + return '$time $sender pokes you'; + } + + @override + String pokeDialogIncomingWithMessage(String time, String sender) { + return '$time $sender pokes you with a message'; + } + + @override + String pokeHistorySelfNoMessage(String time, String target) { + return '<$time> You poked \"$target\".'; + } + + @override + String pokeHistorySelfWithMessage( + String time, + String target, + String message, + ) { + return '<$time> You poked \"$target\" with message: $message'; + } + + @override + String pokeHistoryIncomingNoMessage(String time, String sender) { + return '<$time> \"$sender\" pokes you'; + } + + @override + String pokeHistoryIncomingWithMessage( + String time, + String sender, + String message, + ) { + return '<$time> \"$sender\" pokes you: $message'; + } + @override String get permissionDenied => 'Permission Denied'; } diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 5ca9312..4a0ab34 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -36,7 +36,19 @@ class AppL10nZh extends AppL10n { String get connectAction => '连接'; @override - String get disconnectAction => '断开连接'; + String get disconnectAction => '离开服务器'; + + @override + String get disconnectConfirmTitle => '离开服务器?'; + + @override + String get disconnectConfirmBody => '你将从当前服务器断开连接。'; + + @override + String get disconnectConfirmCancel => '留下'; + + @override + String get disconnectConfirmAction => '离开'; @override String get refreshAction => '刷新'; @@ -393,6 +405,62 @@ class AppL10nZh extends AppL10n { @override String get iosAudioResuming => '音频正在恢复'; + @override + String get settingsAction => '设置'; + + @override + String get appSettingsTitle => '设置'; + + @override + String get pokeAlertsTitle => '戳一戳提醒'; + + @override + String get pokeAlertsDescription => '收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。'; + + @override + String get pokeDialogTitle => '你被戳了一下'; + + @override + String get pokeDialogCloseAction => '关闭'; + + @override + String pokeDialogIncomingNoMessage(String time, String sender) { + return '$time $sender 戳了你一下'; + } + + @override + String pokeDialogIncomingWithMessage(String time, String sender) { + return '$time $sender 戳了你一下并附带消息'; + } + + @override + String pokeHistorySelfNoMessage(String time, String target) { + return '<$time> 你戳了“$target”一下。'; + } + + @override + String pokeHistorySelfWithMessage( + String time, + String target, + String message, + ) { + return '<$time> 你戳了“$target”一下,消息:$message'; + } + + @override + String pokeHistoryIncomingNoMessage(String time, String sender) { + return '<$time> “$sender”戳了你一下'; + } + + @override + String pokeHistoryIncomingWithMessage( + String time, + String sender, + String message, + ) { + return '<$time> “$sender”戳了你一下:$message'; + } + @override String get permissionDenied => '权限被拒绝'; } diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index f5eacef..96b4510 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -9,30 +9,36 @@ // (all carried over from v0.3.0-beta.1) import 'dart:async'; -import 'dart:io' show File, Platform, Process; - -import 'package:connectivity_plus/connectivity_plus.dart'; +import 'dart:io' show Platform, Process; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'l10n/generated/app_localizations.dart'; import 'services/android_permissions_service.dart'; +import 'services/app_bootstrap.dart'; +import 'services/audio_lifecycle_service.dart'; +import 'services/channel_join_error_mapper.dart'; +import 'services/connection_phase_state.dart'; import 'services/ios_permissions_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 'src/rust/lib.dart' as rust_err; import 'src/rust/frb_generated.dart'; import 'widgets/permission_state_banner.dart'; +import 'widgets/audio_processing_config_state.dart'; +import 'widgets/chat_views.dart'; +import 'widgets/connect_widgets.dart'; +import 'widgets/input_dialogs.dart'; +import 'widgets/snapshot_view.dart'; import 'widgets/voice_platform.dart'; import 'widgets/voice_bar.dart'; import 'widgets/voice_compact.dart'; import 'widgets/voice_settings.dart'; -import 'widgets/bbcode_text.dart'; -import 'services/link_trust_service.dart'; +import 'package:share_plus/share_plus.dart'; bool get _isMacOS => !kIsWeb && Platform.isMacOS; @@ -40,84 +46,9 @@ const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform'); const Color _appSurfaceColor = Color(0xFFFFFBFE); -const String _sileroVadAsset = 'assets/models/silero_vad.onnx'; -const String _tenVadAsset = 'assets/models/ten_vad.onnx'; - -Future _copyBundledAssetToDocuments({ - required String assetPath, - required String fileName, -}) async { - final dir = await getApplicationDocumentsDirectory(); - final file = File('${dir.path}/$fileName'); - final data = await rootBundle.load(assetPath); - final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); - - if (await file.exists() && await file.length() == bytes.length) { - return file; - } - - await file.writeAsBytes(bytes, flush: true); - return file; -} - -Future _configureBundledVadModels() async { - final silero = await _copyBundledAssetToDocuments( - assetPath: _sileroVadAsset, - fileName: 'silero_vad.onnx', - ); - final ten = await _copyBundledAssetToDocuments( - assetPath: _tenVadAsset, - fileName: 'ten_vad.onnx', - ); - await rust.setVadModelPath(path: silero.path); - await rust.setTenVadModelPath(path: ten.path); -} - /// Top padding for macOS to clear traffic-light buttons. const double _macOSTrafficLightPad = 56.0; -/// Translate a [LogicalKeyboardKey] into the platform-neutral label -/// stored by the PTT binding flow. -String? _pttDisplayLabelForKey(LogicalKeyboardKey k) { - if (k == LogicalKeyboardKey.space) return 'Space'; - if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) { - return 'Enter'; - } - if (k == LogicalKeyboardKey.tab) return 'Tab'; - if (k == LogicalKeyboardKey.escape) return 'Escape'; - if (k == LogicalKeyboardKey.backspace) return 'Backspace'; - if (k == LogicalKeyboardKey.delete) return 'Delete'; - if (k == LogicalKeyboardKey.insert) return 'Insert'; - if (k == LogicalKeyboardKey.home) return 'Home'; - if (k == LogicalKeyboardKey.end) return 'End'; - if (k == LogicalKeyboardKey.pageUp) return 'Page Up'; - if (k == LogicalKeyboardKey.pageDown) return 'Page Down'; - if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up'; - if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down'; - if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left'; - if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right'; - if (k == LogicalKeyboardKey.shift || - k == LogicalKeyboardKey.shiftLeft || - k == LogicalKeyboardKey.shiftRight || - k == LogicalKeyboardKey.control || - k == LogicalKeyboardKey.controlLeft || - k == LogicalKeyboardKey.controlRight || - k == LogicalKeyboardKey.alt || - k == LogicalKeyboardKey.altLeft || - k == LogicalKeyboardKey.altRight || - k == LogicalKeyboardKey.meta || - k == LogicalKeyboardKey.metaLeft || - k == LogicalKeyboardKey.metaRight || - k == LogicalKeyboardKey.capsLock || - k == LogicalKeyboardKey.numLock || - k == LogicalKeyboardKey.scrollLock) { - return null; - } - final fallback = k.keyLabel.trim(); - if (fallback.isEmpty) return null; - return fallback; -} - /// Public version string shown in the About dialog. Resolved at /// app init by combining a hardcoded semver baseline (kept in sync /// with the git tag and pubspec.yaml's `version:` field) with the @@ -136,168 +67,23 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) { /// semver baseline that this codebase already maintains as the /// canonical release identity. /// -/// Bump `_kSemverBaseline` whenever the semver portion of +/// Bump [appSemverBaseline] whenever the semver portion of /// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The /// build-counter suffix changes automatically on every pubspec /// `+` bump because Flutter writes it into Info.plist. -const String _kSemverBaseline = 'v0.1.0'; -String _kAppVersion = _kSemverBaseline; +String _kAppVersion = appSemverBaseline; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); - await _resolveAppVersion(); - unawaited(_wireStorage()); - unawaited(_wireConnectivity()); - _wireIosAudioLifecycle(); - _wireAndroidAudioLifecycle(); - await _configureBundledVadModels(); + _kAppVersion = await resolveAppVersion(); + unawaited(wireStorage()); + unawaited(wireConnectivity()); + wireAudioLifecycle(); + await configureBundledVadModels(); runApp(const ChanoraApp()); } -/// Wire the iOS AVAudioSession lifecycle MethodChannel. -/// -rust.BridgeAudioRoute _parseBridgeAudioRoute(String s) { - switch (s) { - case 'Earpiece': - return rust.BridgeAudioRoute.earpiece; - case 'Speaker': - return rust.BridgeAudioRoute.speaker; - case 'WiredHeadset': - return rust.BridgeAudioRoute.wiredHeadset; - case 'BluetoothHfp': - return rust.BridgeAudioRoute.bluetoothHfp; - case 'BluetoothA2dp': - return rust.BridgeAudioRoute.bluetoothA2Dp; - default: - return rust.BridgeAudioRoute.unknown; - } -} - -/// Swift side (AppDelegate) posts route-change and interruption -/// events through `FlutterMethodChannel` named -/// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to -/// the FRB bridge functions on the Rust side. -void _wireIosAudioLifecycle() { - const channel = MethodChannel('chanora/ios_audio_lifecycle'); - channel.setMethodCallHandler((call) async { - try { - switch (call.method) { - case 'handleRouteChange': - final routeStr = call.arguments as String? ?? 'Unknown'; - final route = _parseBridgeAudioRoute(routeStr); - rust.handleRouteChange(route: route); - break; - case 'handleMediaServicesReset': - rust.handleMediaServicesReset(); - break; - case 'handleInterruptionBegan': - rust.handleInterruptionBegan(); - break; - case 'handleInterruptionEnded': - // `shouldResume` is passed from Swift as a bool argument. - final shouldResume = call.arguments as bool? ?? false; - rust.handleInterruptionEnded(shouldResume: shouldResume); - break; - case 'handleWillResignActive': - case 'handleDidEnterBackground': - rust.handleInterruptionBegan(); - break; - case 'handleWillEnterForeground': - rust.handleInterruptionEnded(shouldResume: true); - break; - case 'handleWillTerminate': - rust.handleInterruptionBegan(); - break; - default: - // Unknown method — ignore gracefully rather than crashing. - break; - } - } catch (_) { - // Errors from the Rust side are already logged there; - // don't propagate exceptions to the iOS framework. - } - }); -} - -/// Wire the Android audio lifecycle MethodChannel. -/// -/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change -/// events through `FlutterMethodChannel` named -/// `"chanora/android_audio_lifecycle"`. This handler dispatches them to -/// the FRB bridge functions on the Rust side, mirroring the iOS pattern. -void _wireAndroidAudioLifecycle() { - if (!Platform.isAndroid) return; - const channel = MethodChannel('chanora/android_audio_lifecycle'); - channel.setMethodCallHandler((call) async { - try { - switch (call.method) { - case 'handleRouteChange': - final args = call.arguments; - final routeStr = args is Map - ? (args['routeType'] as String? ?? 'Unknown') - : (args as String? ?? 'Unknown'); - final route = _parseBridgeAudioRoute(routeStr); - rust.handleRouteChange(route: route); - break; - default: - // Unknown method — ignore gracefully rather than crashing. - break; - } - } catch (_) { - // Errors from the Rust side are already logged there; - // don't propagate exceptions to the Android framework. - } - }); -} - -/// Populate `_kAppVersion` by suffixing the platform-canonical -/// build number to `_kSemverBaseline`. Format: -/// `v1.0.0-rc.8+` (e.g. `v1.0.0-rc.8+64`). The build -/// number is iOS `CFBundleVersion` / Android `versionCode`, -/// kept in sync with pubspec.yaml's `version: +`. -Future _resolveAppVersion() async { - try { - final info = await PackageInfo.fromPlatform(); - // info.buildNumber = "64" (CFBundleVersion on iOS; survives - // the iOS version-string sanitiser that mangles - // CFBundleShortVersionString). - final build = info.buildNumber.isEmpty ? '' : '+${info.buildNumber}'; - _kAppVersion = '$_kSemverBaseline$build'; - } catch (_) { - // Keep the hardcoded baseline. The build counter is lost - // but the semver stays correct. - } -} - -Future _wireStorage() async { - try { - final dir = await getApplicationSupportDirectory(); - await rust.initStorage(dir: dir.path); - } catch (_) { - // Best-effort; missing storage just means no identity persistence - // and no bookmark list this session. - } -} - -rust.BridgeNetworkState _mapConnectivity(List results) { - if (results.isEmpty) return rust.BridgeNetworkState.unknown; - final allNone = results.every((r) => r == ConnectivityResult.none); - if (allNone) return rust.BridgeNetworkState.offline; - return rust.BridgeNetworkState.online; -} - -Future _wireConnectivity() async { - final connectivity = Connectivity(); - try { - final initial = await connectivity.checkConnectivity(); - rust.setNetworkState(state: _mapConnectivity(initial)); - } catch (_) {} - connectivity.onConnectivityChanged.listen((results) { - rust.setNetworkState(state: _mapConnectivity(results)); - }); -} - class ChanoraApp extends StatelessWidget { const ChanoraApp({super.key}); @@ -330,8 +116,6 @@ class ChanoraApp extends StatelessWidget { } } -enum _Phase { idle, connecting, connected } - class _BetaHome extends StatefulWidget { const _BetaHome(); @@ -339,16 +123,29 @@ class _BetaHome extends StatefulWidget { State<_BetaHome> createState() => _BetaHomeState(); } -class _BetaHomeState extends State<_BetaHome> { +class _ReceivedPoke { + const _ReceivedPoke({ + required this.senderName, + required this.message, + required this.receivedAt, + }); + + final String senderName; + final String message; + final DateTime receivedAt; +} + +class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { + static const _wideBreakpoint = 600.0; + final _hostCtl = TextEditingController(text: 'cn.teamspeak.app'); final _nickCtl = TextEditingController(text: 'ChanoraBeta'); final _passwordCtl = TextEditingController(); - _Phase _phase = _Phase.idle; + ConnectionPhase _phase = ConnectionPhase.idle; + bool get _serverReachable => _phase.isServerReachable; rust.BridgeSnapshot? _snapshot; String? _error; - // ignore: unused_field - bool _audioStarted = false; rust.BridgeAudioStats? _audioStats; Timer? _statsTimer; int _statsTick = 0; @@ -361,12 +158,12 @@ class _BetaHomeState extends State<_BetaHome> { rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt; bool _hardMute = false; bool _hardMuteByPermission = false; + bool _hardMuteByTalkPower = false; bool _permissionHardMuteClearInFlight = false; int _releaseTailMs = 200; BigInt? _currentVoiceChannelId; BigInt? _pendingVoiceChannelId; bool _canJoinVoiceChannel = true; - bool _canLeaveVoiceChannel = false; String? _lostReason; int? _reconnectAttempt; @@ -374,8 +171,6 @@ class _BetaHomeState extends State<_BetaHome> { bool _inputMuted = false; bool _outputMuted = false; - // ignore: unused_field - double _outputGain = 1.0; // Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091). // Populated by `BridgeEvent.PttCapability`. Defaults match the @@ -384,14 +179,8 @@ class _BetaHomeState extends State<_BetaHome> { String _pttLevel = 'L0Focused'; String _pttBackendId = 'focused'; String _pttBoundInputClass = 'keyboard'; - // iOS interruption state from BridgeEvent::InterruptionState - // (SDD-101). Can be used by UI surfaces (banner/snackbar). - // ignore: unused_field - bool _iosAudioInterrupted = false; - // ignore: unused_field - bool _iosInterruptionShouldResume = false; // Last platform-neutral key label the user saved in the - // `_PttBindingCaptureDialog` (e.g. "Space", "F10", + // `PttBindingCaptureDialog` (e.g. "Space", "F10", // "mouse-side-button:8"). Surfaced next to the capability // badge so the user can remember which key drives PTT. The // bridge-side `PttController` (SDD-088) holds the authoritative @@ -403,6 +192,15 @@ class _BetaHomeState extends State<_BetaHome> { final Set _focusedPttHeldKeys = {}; List _bookmarks = const []; + final List _chatMessages = []; + int _chatUnread = 0; + bool _chatOpen = false; + bool _showPokeDialogs = true; + final ValueNotifier> _pokeDialogPokes = ValueNotifier( + const [], + ); + bool _pokeDialogShowing = false; + IconData? _audioRoute; // SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service. // Constructed at startup so cold-launch state is captured before the @@ -412,10 +210,12 @@ class _BetaHomeState extends State<_BetaHome> { final AndroidPermissionsService _androidPermissions = AndroidPermissionsService(); final IosPermissionsService _iosPermissions = IosPermissionsService(); + final UiPreferencesService _uiPreferences = const UiPreferencesService(); @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); HardwareKeyboard.instance.addHandler(_handleFocusedPttKey); _eventsSub = rust.eventsStream().listen(_onEvent); // SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged @@ -434,17 +234,76 @@ class _BetaHomeState extends State<_BetaHome> { }); unawaited(_reloadBookmarks()); unawaited(_hydratePttBinding()); + unawaited(_loadUiSettings()); + } + + Future _loadUiSettings() async { + try { + final settings = await _uiPreferences.loadSettings(); + if (settings.host.isNotEmpty) { + _hostCtl.text = settings.host; + } + if (settings.nickname.isNotEmpty) { + _nickCtl.text = settings.nickname; + } + if (mounted) { + setState(() => _showPokeDialogs = settings.showPokeDialogs); + } else { + _showPokeDialogs = settings.showPokeDialogs; + } + } catch (_) {} + } + + Future _saveUiSettings({ + String? host, + String? nickname, + bool? showPokeDialogs, + }) async { + try { + await _uiPreferences.saveSettings( + host: host, + nickname: nickname, + showPokeDialogs: showPokeDialogs, + ); + } catch (_) {} } Future _requestRecordAudioOnStartup() async { try { if (Platform.isAndroid) { - await _androidPermissions.ensureRecordAudio(); + final permsExplained = await _uiPreferences.hasExplainedPermissions(); + if (!permsExplained) { + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Microphone Access'), + content: const Text( + 'Chanora needs microphone access to transmit your voice in ' + 'TeamSpeak channels. Without this permission, you can only ' + 'listen to others.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Not now'), + ), + FilledButton( + onPressed: () { + Navigator.pop(ctx); + _androidPermissions.ensureRecordAudio(); + }, + child: const Text('Allow'), + ), + ], + ), + ); + await _uiPreferences.markPermissionsExplained(); + } else { + await _androidPermissions.ensureRecordAudio(); + } } - } catch (_) { - // Best-effort startup prompt only. The join path still gates on - // ensureRecordAudio() and applies the listen-only hard-mute policy. - } + } catch (_) {} } ValueListenable @@ -490,7 +349,7 @@ class _BetaHomeState extends State<_BetaHome> { } bool _handleFocusedPttKey(KeyEvent event) { - final label = _pttDisplayLabelForKey(event.logicalKey); + final label = pttDisplayLabelForKey(event.logicalKey); final isBoundKey = _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel; if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) { @@ -500,7 +359,7 @@ class _BetaHomeState extends State<_BetaHome> { return true; } if (_pttBackendId != 'focused' || - _phase != _Phase.connected || + !_serverReachable || !_inChannel || _transmitMode != rust.BridgeTransmitMode.ptt || !isBoundKey) { @@ -528,12 +387,12 @@ class _BetaHomeState extends State<_BetaHome> { /// binding has ever been saved. Future _hydratePttBinding() async { try { - final (inputClass, keyLabel) = await rust.getPttBinding(); + final binding = await rust.getPttBinding(); if (!mounted) return; - if (keyLabel.isNotEmpty || inputClass.isNotEmpty) { + if (binding.keyLabel.isNotEmpty || binding.inputClass.isNotEmpty) { setState(() { - _pttBoundKeyLabel = keyLabel; - _pttBoundInputClass = inputClass; + _pttBoundKeyLabel = binding.keyLabel; + _pttBoundInputClass = binding.inputClass; }); } } catch (_) { @@ -556,34 +415,39 @@ class _BetaHomeState extends State<_BetaHome> { switch (evt) { case rust.BridgeEvent_Connected(): setState(() { - _phase = _Phase.connected; + _phase = ConnectionPhase.synchronizing; _lostReason = null; _reconnectAttempt = null; _reconnectDelay = null; }); case rust.BridgeEvent_Lost(:final reason): setState(() { + _phase = ConnectionPhase.reconnecting; _lostReason = reason; _reconnectAttempt = null; _reconnectDelay = null; }); case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs): setState(() { + _phase = ConnectionPhase.reconnecting; _reconnectAttempt = attempt; _reconnectDelay = delaySecs; }); case rust.BridgeEvent_Disconnected(): setState(() { - _phase = _Phase.idle; - _lostReason = null; - _reconnectAttempt = null; - _reconnectDelay = null; + _resetConnectionUiState(phase: ConnectionPhase.disconnected); }); case rust.BridgeEvent_AudioStarted(): - setState(() => _audioStarted = true); + _ensureStatsTimer(); case rust.BridgeEvent_AudioStopped(): - setState(() => _audioStarted = false); + _statsTimer?.cancel(); + _statsTimer = null; case rust.BridgeEvent_SnapshotChanged(): + setState(() { + if (_phase == ConnectionPhase.synchronizing) { + _phase = ConnectionPhase.connected; + } + }); unawaited(_onRefresh()); case rust.BridgeEvent_PttCapability( :final level, @@ -606,7 +470,6 @@ class _BetaHomeState extends State<_BetaHome> { :final currentChannelId, :final pendingTargetChannelId, :final canJoin, - :final canLeave, ): setState(() { _inChannel = inChannel; @@ -614,11 +477,9 @@ class _BetaHomeState extends State<_BetaHome> { _hardMute = mute; if (!mute) _hardMuteByPermission = false; _releaseTailMs = releaseTailMs; - _audioStarted = inChannel; _currentVoiceChannelId = currentChannelId; _pendingVoiceChannelId = pendingTargetChannelId; _canJoinVoiceChannel = canJoin; - _canLeaveVoiceChannel = canLeave; }); if (inChannel) { _ensureStatsTimer(); @@ -635,10 +496,6 @@ class _BetaHomeState extends State<_BetaHome> { :final began, :final shouldResume, ): - setState(() { - _iosAudioInterrupted = began; - _iosInterruptionShouldResume = shouldResume; - }); // Surface iOS audio interruption to the user (SDD-101). // Use unawaited to stay inside the sync _onEvent stream // without blocking it. @@ -678,20 +535,71 @@ class _BetaHomeState extends State<_BetaHome> { state == rust.PermissionStateKind.granted) { unawaited(_clearPermissionHardMute()); } + case rust.BridgeEvent_ChatMessage( + :final senderId, + :final senderName, + :final message, + :final target, + ): + // Skip echo of self-sent messages (already added locally). + if (senderId == _snapshot?.ownClientId) return; + final isPoke = target is rust.BridgeMessageTarget_Poke; + final receivedAt = DateTime.now(); + setState(() { + _chatMessages.add( + ChatEntry( + senderId: senderId, + senderName: senderName, + message: message, + target: target, + isSelf: senderId == _snapshot?.ownClientId, + timestamp: receivedAt, + ), + ); + if (_chatMessages.length > 200) { + _chatMessages.removeRange(0, _chatMessages.length - 200); + } + if (!_chatOpen && !isPoke) { + _chatUnread++; + } + }); + if (isPoke) { + if (_showPokeDialogs) { + _appendPokeDialog( + senderName: senderName, + message: message, + receivedAt: receivedAt, + ); + } + return; + } + if (!_chatOpen && _chatUnread > 0) { + if (!mounted) return; + unawaited(() async { + if (!mounted) return; + _showChatMessageSnackBar( + senderName: senderName, + message: message, + target: target, + ); + }()); + } + case rust.BridgeEvent_AudioRouteChanged(:final route): + setState(() => _audioRoute = _routeIcon(route)); } } + void _stopStatsPolling() { + _statsTimer?.cancel(); + _statsTimer = null; + } + + void _startStatsPollingIfConnected() { + if (_serverReachable) _ensureStatsTimer(); + } + void _ensureStatsTimer() { if (_statsTimer != null) return; - // Poll at 80 ms (~12 Hz) — this is the loop that drives the - // Voice Bar's "PTT=on/off" indicator and the level meter, so - // it needs to be fast enough that users don't perceive a lag - // between physically pressing the bound key and seeing the - // UI change. 80 ms is well below the ~150 ms perceptual - // delay threshold and adds only a dozen tiny FFI calls per - // second to the load. A future push-based BridgeEvent for - // transmit-active transitions would let us drop this poll - // entirely. _statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async { try { final s = await rust.audioStats(); @@ -720,8 +628,27 @@ class _BetaHomeState extends State<_BetaHome> { } } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final stateName = state.name; + debugPrint('chanora: app $stateName'); + rust.recordLifecycleEvent(state: stateName); + switch (state) { + case AppLifecycleState.paused: + _stopStatsPolling(); + case AppLifecycleState.resumed: + _startStatsPollingIfConnected(); + case AppLifecycleState.hidden: + case AppLifecycleState.inactive: + break; + case AppLifecycleState.detached: + _stopStatsPolling(); + } + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey); _eventsSub?.cancel(); _statsTimer?.cancel(); @@ -729,6 +656,7 @@ class _BetaHomeState extends State<_BetaHome> { _hostCtl.dispose(); _nickCtl.dispose(); _passwordCtl.dispose(); + _pokeDialogPokes.dispose(); _androidPermissions.recordAudioState.removeListener( _onRecordAudioPermissionChanged, ); @@ -749,9 +677,10 @@ class _BetaHomeState extends State<_BetaHome> { String? password, }) async { setState(() { - _phase = _Phase.connecting; + _phase = ConnectionPhase.connecting; _error = null; _snapshot = null; + _chatMessages.clear(); }); _releaseFocusedPttIfHeld(); try { @@ -762,9 +691,15 @@ class _BetaHomeState extends State<_BetaHome> { ); if (!mounted) return; setState(() { - _phase = _Phase.connected; + _phase = ConnectionPhase.synchronizing; _applySnapshot(snap); }); + unawaited( + _saveUiSettings( + host: host ?? _hostCtl.text.trim(), + nickname: nickname ?? _nickCtl.text.trim(), + ), + ); try { await FlutterForegroundTask.startService( notificationTitle: 'Chanora', @@ -782,7 +717,7 @@ class _BetaHomeState extends State<_BetaHome> { _showPermissionDeniedDialog(errorStr); } setState(() { - _phase = _Phase.idle; + _phase = ConnectionPhase.idle; _error = errorStr; }); } @@ -841,8 +776,12 @@ class _BetaHomeState extends State<_BetaHome> { } } - // ignore: unused_element Future _setPtt(bool active, {bool reportError = true}) async { + if (active) { + try { + HapticFeedback.lightImpact(); + } catch (_) {} + } try { await rust.setPtt(active: active); } catch (e) { @@ -851,19 +790,6 @@ class _BetaHomeState extends State<_BetaHome> { } } - // ignore: unused_element - Future _toggleInputMute() async { - final next = !_inputMuted; - try { - await rust.setInputMuted(muted: next); - if (!mounted) return; - setState(() => _inputMuted = next); - } catch (e) { - if (!mounted) return; - setState(() => _error = e.toString()); - } - } - Future _toggleOutputMute() async { final next = !_outputMuted; try { @@ -878,17 +804,6 @@ class _BetaHomeState extends State<_BetaHome> { } } - // ignore: unused_element - Future _setOutputGain(double value) async { - setState(() => _outputGain = value); - try { - await rust.setOutputGain(gain: value); - } catch (_) { - // Audio may not be started yet — that's fine; the next start_audio - // picks up the current slider position next time we plumb it. - } - } - Future _onJoinChannel( rust.BridgeChannel ch, { bool askForPassword = false, @@ -950,7 +865,7 @@ class _BetaHomeState extends State<_BetaHome> { }); } } - await _configureBundledVadModels(); + await configureBundledVadModels(); await rust.voiceJoin(channelId: ch.id, password: password ?? ''); if (!mounted) return; unawaited(_onRefresh()); @@ -961,7 +876,7 @@ class _BetaHomeState extends State<_BetaHome> { // pre-connect area and is hidden post-connect). The message // is selected by TS3 error code per the canonical // catalogue at https://github.com/ReSpeak/tsdeclarations. - final message = _channelJoinErrorMessage(l10n, e); + final message = channelJoinErrorMessage(l10n, e); setState(() => _pendingVoiceChannelId = null); messenger.showSnackBar( SnackBar( @@ -973,61 +888,11 @@ class _BetaHomeState extends State<_BetaHome> { } } - /// Map a `voiceJoin` error to a localised user-facing message. - /// Recognises the typed `BridgeError.serverRejected` variant - /// (with a TS3 error code) and falls back to a generic message - /// for everything else. - String _channelJoinErrorMessage(AppL10n l10n, Object e) { - if (e is rust_err.BridgeError) { - return e.when( - invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg), - dnsFailed: (host, reason) => - l10n.channelJoinFailedGeneric('$host: $reason'), - connection: (msg) => l10n.channelJoinFailedGeneric(msg), - notConnected: () => l10n.channelJoinFailedGeneric('not connected'), - alreadyConnected: () => - l10n.channelJoinFailedGeneric('already connected'), - serverRejected: (code, message) { - // Canonical TS3 error codes per ReSpeak/tsdeclarations - // Errors.csv. - switch (code) { - case 0x0001: - // Our sentinel for "snapshot poll didn't confirm". - return l10n.channelJoinFailedTimeout; - case 0x0a08: // permissions_client_insufficient - return l10n.channelJoinFailedPermission; - case 0x0302: // channel_already_in - return l10n.channelJoinAlreadyIn; - case 0x030d: // channel_invalid_password - return l10n.channelJoinFailedPassword; - case 0x0309: // channel_maxclients_reached - return l10n.channelJoinFailedFull; - case 0x030a: // channel_maxfamily_reached - return l10n.channelJoinFailedFamilyFull; - case 0x030e: // channel_is_private_channel - return l10n.channelJoinFailedPrivate; - default: - return l10n.channelJoinFailedGeneric(message); - } - }, - unmapped: (msg) => l10n.channelJoinFailedGeneric(msg), - ); - } - return l10n.channelJoinFailedGeneric(e.toString()); - } - - // ignore: unused_element - Future _onLeaveVoice() async { - if (!_canLeaveVoiceChannel) return; - try { - await rust.voiceLeave(); - } catch (e) { - if (!mounted) return; - setState(() => _error = e.toString()); - } - } - Future _onToggleHardMute() async { + // Don't allow manual unmute when talk-power-muted. + if (_hardMuteByTalkPower && _hardMute) { + return; + } final next = !_hardMute; try { // Hard-mute is two coordinated effects: @@ -1078,33 +943,21 @@ class _BetaHomeState extends State<_BetaHome> { } } + Future _loadAudioProcessingConfig() async { + try { + return await rust.getAudioProcessingConfig(); + } catch (_) { + return defaultAudioProcessingConfig; + } + } + /// Narrow-mode voice controls modal sheet (Plan E status chip /// trigger). On mobile this is the **single** voice-controls /// surface: route picker + inline mode radio + inline release-tail /// slider + level meter + stats + audio processing + (desktop-only) /// capability badge. Zero navigation depth — no nested dialog. Future _onOpenVoiceDetailsSheet() async { - // Load current audio processing config for the sheet. - rust.BridgeAudioProcessingConfig audioConfig; - try { - audioConfig = await rust.getAudioProcessingConfig(); - } catch (_) { - audioConfig = const rust.BridgeAudioProcessingConfig( - route: rust.BridgeAudioRoute.unknown, - iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, - processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing, - vadBackend: rust.BridgeVadBackend.sileroOnnx, - aec: rust.BridgeEffectOwner.platform, - ns: rust.BridgeEffectOwner.platform, - agc: rust.BridgeEffectOwner.platform, - hpfEnabled: true, - limiterEnabled: true, - vadHangoverMs: 500, - vadPreRollMs: 160, - vadMinTxMs: 200, - debugWavDumpEnabled: false, - ); - } + final audioConfig = await _loadAudioProcessingConfig(); if (!mounted) return; await showVoiceDetailsSheet( @@ -1149,28 +1002,7 @@ class _BetaHomeState extends State<_BetaHome> { } Future _onOpenVoiceSettings() async { - // Load the current audio processing config before opening the dialog. - rust.BridgeAudioProcessingConfig audioConfig; - try { - audioConfig = await rust.getAudioProcessingConfig(); - } catch (_) { - // If not connected yet, use a sensible default. - audioConfig = const rust.BridgeAudioProcessingConfig( - route: rust.BridgeAudioRoute.unknown, - iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing, - processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing, - vadBackend: rust.BridgeVadBackend.sileroOnnx, - aec: rust.BridgeEffectOwner.platform, - ns: rust.BridgeEffectOwner.platform, - agc: rust.BridgeEffectOwner.platform, - hpfEnabled: true, - limiterEnabled: true, - vadHangoverMs: 500, - vadPreRollMs: 160, - vadMinTxMs: 200, - debugWavDumpEnabled: false, - ); - } + final audioConfig = await _loadAudioProcessingConfig(); if (!mounted) return; final result = await showDialog( @@ -1179,6 +1011,9 @@ class _BetaHomeState extends State<_BetaHome> { initialMode: _transmitMode, initialReleaseTailMs: _releaseTailMs, initialAudioConfig: audioConfig, + pttLevel: _pttLevel, + pttBackendId: _pttBackendId, + pttBoundInputClass: _pttBoundInputClass, ), ); if (result == null) return; @@ -1209,7 +1044,7 @@ class _BetaHomeState extends State<_BetaHome> { // belonging to the still-popping dialog route. return await showDialog( context: context, - builder: (ctx) => const _ChannelPasswordDialog(), + builder: (ctx) => const ChannelPasswordDialog(), ); } @@ -1225,6 +1060,40 @@ class _BetaHomeState extends State<_BetaHome> { } Future _onDisconnect() async { + _releaseFocusedPttIfHeld(); + if (mounted) { + setState(() { + _resetConnectionUiState(phase: ConnectionPhase.disconnected); + }); + } + unawaited(_finishDisconnect()); + } + + Future _onConfirmDisconnect() async { + final l10n = AppL10n.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.disconnectConfirmTitle), + content: Text(l10n.disconnectConfirmBody), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(l10n.disconnectConfirmCancel), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text(l10n.disconnectConfirmAction), + ), + ], + ), + ); + if (confirmed == true && mounted && _phase.canDisconnect) { + await _onDisconnect(); + } + } + + Future _finishDisconnect() async { _statsTimer?.cancel(); _statsTimer = null; _statsTick = 0; @@ -1235,50 +1104,213 @@ class _BetaHomeState extends State<_BetaHome> { try { await FlutterForegroundTask.stopService(); } catch (_) {} - if (!mounted) return; - setState(() { - _phase = _Phase.idle; - _snapshot = null; - _audioStarted = false; - _audioStats = null; - _error = null; - _inputMuted = false; - _outputMuted = false; - _inChannel = false; - _currentVoiceChannelId = null; - _pendingVoiceChannelId = null; - _canJoinVoiceChannel = true; - _canLeaveVoiceChannel = false; - }); - _releaseFocusedPttIfHeld(); + // Reload bookmarks so auto-saved recent-server entries from the + // just-ended session appear in the connect-page bookmark list. + unawaited(_reloadBookmarks()); } - String _currentVoiceChannelName() { - final id = _currentVoiceChannelId; - final snap = _snapshot; - if (id == null || snap == null) return ''; - for (final ch in snap.channels) { - if (ch.id == id) return ch.name; + void _resetConnectionUiState({required ConnectionPhase phase}) { + _phase = phase; + _snapshot = null; + _audioStats = null; + _error = null; + _inputMuted = false; + _outputMuted = false; + _inChannel = false; + _currentVoiceChannelId = null; + _pendingVoiceChannelId = null; + _canJoinVoiceChannel = true; + _lostReason = null; + _reconnectAttempt = null; + _reconnectDelay = null; + _chatMessages.clear(); + _chatUnread = 0; + _chatOpen = false; + } + + IconData _routeIcon(rust.BridgeAudioRoute r) { + switch (r) { + case rust.BridgeAudioRoute.earpiece: + return Icons.phone_android; + case rust.BridgeAudioRoute.speaker: + return Icons.volume_up; + case rust.BridgeAudioRoute.wiredHeadset: + return Icons.headset; + case rust.BridgeAudioRoute.bluetoothHfp: + case rust.BridgeAudioRoute.bluetoothA2Dp: + return Icons.bluetooth; + case rust.BridgeAudioRoute.unknown: + return Icons.help_outline; } - return ''; + } + + Future _onOpenChat({ + rust.BridgeMessageTarget? target, + String clientName = '', + }) async { + setState(() { + _chatUnread = 0; + _chatOpen = true; + }); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatPage( + messages: _chatMessages, + snapshot: _snapshot!, + initialTarget: + target ?? + resolveInitialChatTarget( + messages: _chatMessages, + currentVoiceChannelId: _currentVoiceChannelId, + ), + initialClientName: clientName, + onTs3ServerLink: _onTs3ServerLink, + ), + ), + ); + if (mounted) setState(() => _chatOpen = false); + } + + void _appendPokeDialog({ + required String senderName, + required String message, + required DateTime receivedAt, + }) { + _pokeDialogPokes.value = [ + ..._pokeDialogPokes.value, + _ReceivedPoke( + senderName: senderName, + message: message, + receivedAt: receivedAt, + ), + ]; + unawaited(_showPokeDialog()); + } + + Future _showPokeDialog() async { + if (_pokeDialogShowing || _pokeDialogPokes.value.isEmpty || !mounted) { + return; + } + _pokeDialogShowing = true; + try { + await showDialog( + context: context, + builder: (ctx) => _PokeDialog(pokes: _pokeDialogPokes), + ); + } finally { + _pokeDialogPokes.value = const []; + _pokeDialogShowing = false; + } + } + + void _showChatMessageSnackBar({ + required String senderName, + required String message, + required rust.BridgeMessageTarget target, + }) { + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: _chatSnackBarMargin(), + duration: const Duration(seconds: 7), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$senderName (${_chatTargetLabel(target)})', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text(message, maxLines: 3, overflow: TextOverflow.ellipsis), + ], + ), + action: SnackBarAction( + label: 'Open', + onPressed: () => unawaited( + _onOpenChat( + target: target, + clientName: _chatClientNameForTarget(target, senderName), + ), + ), + ), + ), + ); + } + + EdgeInsetsGeometry _chatSnackBarMargin() { + const side = 16.0; + var bottom = 16.0; + final wideConnectedLayout = + MediaQuery.sizeOf(context).width >= _wideBreakpoint && + _serverReachable && + _snapshot != null; + + if (_serverReachable && _snapshot != null && !wideConnectedLayout) { + bottom += 64; // VoiceStatusChip. + if (_inChannel && _transmitMode == rust.BridgeTransmitMode.ptt) { + bottom += 64; // 8 dp gap + default 56 dp VoicePttButton. + } + } + + return EdgeInsetsDirectional.fromSTEB(side, 0, side, bottom); + } + + String _chatTargetLabel(rust.BridgeMessageTarget target) { + return switch (target) { + rust.BridgeMessageTarget_Client() => 'Private Chat', + rust.BridgeMessageTarget_Poke() => 'Poke', + rust.BridgeMessageTarget_Channel() => 'Channel Chat', + rust.BridgeMessageTarget_Server() => 'Server', + }; + } + + String _chatClientNameForTarget( + rust.BridgeMessageTarget target, + String senderName, + ) { + return switch (target) { + rust.BridgeMessageTarget_Client() || + rust.BridgeMessageTarget_Poke() => senderName, + _ => '', + }; } void _applySnapshot(rust.BridgeSnapshot snap) { _snapshot = snap; - for (final client in snap.clients) { - if (client.id == snap.ownClientId) { - _currentVoiceChannelId = client.channel; - _inputMuted = client.inputMuted; - _outputMuted = client.outputMuted; - _pendingVoiceChannelId = null; - _inChannel = true; - _canJoinVoiceChannel = true; - _canLeaveVoiceChannel = false; - return; + final own = ownClientSnapshotState(snap); + if (own == null) return; + _currentVoiceChannelId = own.channelId; + _inputMuted = own.inputMuted; + _outputMuted = own.outputMuted; + _pendingVoiceChannelId = null; + _inChannel = true; + _canJoinVoiceChannel = true; + + if (!own.talkPowerOk && !_hardMuteByTalkPower) { + _hardMuteByTalkPower = true; + _hardMute = true; + rust.setHardMute(muted: true); + rust.setInputMuted(muted: true); + } else if (own.talkPowerOk && _hardMuteByTalkPower) { + _hardMuteByTalkPower = false; + if (!_hardMuteByPermission) { + _hardMute = false; + rust.setHardMute(muted: false); + rust.setInputMuted(muted: false); } } } + OwnClientSnapshotState? get _ownClientState { + final snap = _snapshot; + return snap == null ? null : ownClientSnapshotState(snap); + } + Future _onShowDiagnostics(BuildContext context) async { final l10n = AppL10n.of(context); final text = rust.exportDiagnostics(); @@ -1296,24 +1328,9 @@ class _BetaHomeState extends State<_BetaHome> { actions: [ TextButton( onPressed: () async { - try { - final path = await _writeDiagnosticExport(text); - if (!ctx.mounted) return; - Navigator.of(ctx).pop(); - if (!mounted) return; - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar(content: Text(l10n.diagnosticsSaved(path))), - ); - } catch (e) { - if (!ctx.mounted) return; - Navigator.of(ctx).pop(); - if (!mounted) return; - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar(content: Text(l10n.statusError(e.toString()))), - ); - } + await SharePlus.instance.share(ShareParams(text: text)); }, - child: Text(l10n.diagnosticsSaveAction), + child: const Text('Share'), ), TextButton( onPressed: () async { @@ -1332,18 +1349,6 @@ class _BetaHomeState extends State<_BetaHome> { ); } - Future _writeDiagnosticExport(String text) async { - final dir = await getApplicationDocumentsDirectory(); - final stamp = DateTime.now() - .toUtc() - .toIso8601String() - .replaceAll(':', '-') - .replaceAll('.', '-'); - final file = File('${dir.path}/chanora-diagnostics-$stamp.txt'); - await file.writeAsString(text, flush: true); - return file.path; - } - Future _onConfigurePtt(BuildContext context) async { // On the Linux GNOME-Wayland portal backend, the portal hosts // its own system-managed binding dialog (gen2 v0.9.3 / Q3a). @@ -1377,9 +1382,9 @@ class _BetaHomeState extends State<_BetaHome> { // dialog. The bridge carries only the coarse input class and // an opaque platform-key string; the actual key value never // appears in any log record (DEC-027 / SRS-202). - final binding = await showDialog<_CapturedBinding>( + final binding = await showDialog( context: context, - builder: (ctx) => const _PttBindingCaptureDialog(), + builder: (ctx) => const PttBindingCaptureDialog(), ); if (binding == null) return; try { @@ -1492,7 +1497,7 @@ class _BetaHomeState extends State<_BetaHome> { final initial = _hostCtl.text.trim(); final name = await showDialog( context: context, - builder: (ctx) => _BookmarkNameDialog(initialName: initial), + builder: (ctx) => BookmarkNameDialog(initialName: initial), ); if (name == null || name.trim().isEmpty) return; if (!mounted) return; @@ -1530,33 +1535,78 @@ class _BetaHomeState extends State<_BetaHome> { await _onConnect(host: b.host, nickname: b.nickname, password: b.password); } + Future _onTs3ServerLink(Ts3ServerLink link) async { + final host = link.hostWithPort.trim(); + if (host.isEmpty) return; + + final nickname = link.nickname ?? _nickCtl.text.trim(); + final password = link.password ?? ''; + final bookmarkName = link.addBookmark?.trim(); + + _hostCtl.text = host; + if (link.nickname != null) _nickCtl.text = link.nickname!; + if (link.password != null) _passwordCtl.text = link.password!; + await _saveUiSettings(host: host, nickname: _nickCtl.text.trim()); + + if (bookmarkName == null || bookmarkName.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Loaded TeamSpeak server link for $host')), + ); + return; + } + + try { + await rust.addBookmark( + b: rust.BridgeBookmark( + id: 0, + displayName: bookmarkName, + host: host, + nickname: nickname, + password: password, + ), + ); + await _reloadBookmarks(); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"'))); + } catch (e) { + if (!mounted) return; + setState(() => _error = e.toString()); + } + } + + Future _onOpenAppSettings() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _AppSettingsPage( + showPokeDialogs: _showPokeDialogs, + onShowPokeDialogsChanged: (value) { + setState(() => _showPokeDialogs = value); + unawaited(_saveUiSettings(showPokeDialogs: value)); + }, + ), + ), + ); + } + @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); - String statusText() { - switch (_phase) { - case _Phase.idle: - return _error != null ? l10n.statusError(_error!) : l10n.statusIdle; - case _Phase.connecting: - return l10n.statusConnecting; - case _Phase.connected: - return l10n.statusConnected(_snapshot?.serverName ?? ''); - } - } - final headerActions = [ - if (_phase == _Phase.connected && - _inChannel && - MediaQuery.of(context).size.width < 600.0) ...[ + if (_serverReachable && _inChannel) ...[ IconButton( - tooltip: l10n.voiceHardMuteLabel, + tooltip: _hardMuteByTalkPower + ? 'Insufficient talk power to speak in this channel' + : l10n.voiceHardMuteLabel, icon: Icon(_hardMute ? Icons.mic_off : Icons.mic), isSelected: _hardMute, selectedIcon: const Icon(Icons.mic_off), color: _hardMute ? theme.colorScheme.error : null, - onPressed: _onToggleHardMute, + onPressed: _hardMuteByTalkPower ? null : _onToggleHardMute, ), IconButton( tooltip: l10n.voiceOutputMuteLabel, @@ -1567,32 +1617,57 @@ class _BetaHomeState extends State<_BetaHome> { onPressed: _toggleOutputMute, ), ], + IconButton( + tooltip: l10n.settingsAction, + icon: const Icon(Icons.settings_outlined), + onPressed: _onOpenAppSettings, + ), IconButton( tooltip: l10n.aboutAction, icon: const Icon(Icons.info_outline), onPressed: () => _onShowAbout(context), ), - if (_phase == _Phase.connected) ...[ - IconButton( - tooltip: l10n.disconnectAction, - icon: const Icon(Icons.logout), - onPressed: _onDisconnect, + if (_phase.canOpenChat) ...[ + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: Badge( + isLabelVisible: _chatUnread > 0, + label: Text(_chatUnread.toString()), + child: IconButton( + tooltip: 'Chat', + icon: const Icon(Icons.chat_bubble_outline), + onPressed: _onOpenChat, + ), + ), ), ], ]; const headerTitle = SizedBox.shrink(); - final appBarTitle = _phase == _Phase.connected - ? Text(_snapshot?.serverName ?? l10n.appTitle, style: theme.textTheme.titleMedium) + final appBarTitle = _serverReachable + ? Text( + _snapshot?.serverName ?? l10n.appTitle, + style: theme.textTheme.titleMedium, + ) : headerTitle; + final connTokens = _phase.tokens(theme.colorScheme); + final statusText = connectionStatusText( + phase: _phase, + l10n: l10n, + error: _error, + serverName: _snapshot?.serverName, + lostReason: _lostReason, + reconnectAttempt: _reconnectAttempt, + reconnectDelay: _reconnectDelay, + ); + final bodyContent = LayoutBuilder( builder: (ctx, bodyConstraints) { - const wideBreakpoint = 600.0; final isWideSnapshot = - bodyConstraints.maxWidth >= wideBreakpoint && - _phase == _Phase.connected && + bodyConstraints.maxWidth >= _wideBreakpoint && + _serverReachable && _snapshot != null; final banner = Container( padding: const EdgeInsets.all(10), @@ -1609,14 +1684,26 @@ class _BetaHomeState extends State<_BetaHome> { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)], - if (_phase != _Phase.connected) - Text(statusText(), style: theme.textTheme.titleMedium), + if (!_serverReachable) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(connTokens.icon, size: 18, color: connTokens.color), + const SizedBox(width: 6), + Text(statusText, style: theme.textTheme.titleMedium), + ], + ), + if (_serverReachable && _audioRoute != null) ...[ + const SizedBox(width: 8), + Icon(_audioRoute, size: 16, color: theme.colorScheme.tertiary), + ], if (_lostReason != null || _reconnectAttempt != null) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, + color: + connTokens.background ?? theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(8), ), child: Row( @@ -1648,7 +1735,8 @@ class _BetaHomeState extends State<_BetaHome> { ), ], const SizedBox(height: 12), - if (_phase == _Phase.idle) ...[ + if (_phase == ConnectionPhase.idle || + _phase == ConnectionPhase.disconnected) ...[ Expanded( child: AnimatedPadding( duration: const Duration(milliseconds: 180), @@ -1662,7 +1750,7 @@ class _BetaHomeState extends State<_BetaHome> { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _ConnectForm( + ConnectForm( hostCtl: _hostCtl, nickCtl: _nickCtl, passwordCtl: _passwordCtl, @@ -1670,7 +1758,7 @@ class _BetaHomeState extends State<_BetaHome> { onAddBookmark: _onAddCurrentBookmark, ), const SizedBox(height: 16), - _BookmarkList( + BookmarkList( bookmarks: _bookmarks, onConnect: _onUseBookmark, onDelete: _onDeleteBookmark, @@ -1680,14 +1768,14 @@ class _BetaHomeState extends State<_BetaHome> { ), ), ), - ] else if (_phase == _Phase.connecting) ...[ + ] else if (_phase == ConnectionPhase.connecting) ...[ const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator(), ), ), - ] else if (_phase == _Phase.connected && _snapshot != null) ...[ + ] else if (_serverReachable && _snapshot != null) ...[ Expanded( child: LayoutBuilder( builder: (ctx, constraints) { @@ -1696,15 +1784,17 @@ class _BetaHomeState extends State<_BetaHome> { transmitMode: _transmitMode, hardMute: _hardMute, outputMuted: _outputMuted, + talkPowerBlocked: _hardMuteByTalkPower, releaseTailMs: _releaseTailMs, - channelName: _currentVoiceChannelName(), + channelName: snapshotChannelName( + _snapshot, + _currentVoiceChannelId, + ), audioStats: _audioStats, pttLevel: _pttLevel, pttBackendId: _pttBackendId, pttBoundInputClass: _pttBoundInputClass, pttBoundKeyLabel: _pttBoundKeyLabel, - onToggleMute: _onToggleHardMute, - onToggleOutputMute: _toggleOutputMute, onConfigure: _onOpenVoiceSettings, onPttHeldChanged: _onOnscreenPttHeldChanged, ); @@ -1716,7 +1806,7 @@ class _BetaHomeState extends State<_BetaHome> { ensureRecordAudio: _ensureActiveRecordAudio, openAppSettings: _openActivePermissionSettings, ); - final snapshotView = _SnapshotView( + final snapshotView = SnapshotView( snapshot: _snapshot!, audioStats: _audioStats, currentVoiceChannelId: _currentVoiceChannelId, @@ -1728,9 +1818,11 @@ class _BetaHomeState extends State<_BetaHome> { onJoinChannel: (ch) => _onJoinChannel(ch), onJoinChannelWithPassword: (ch) => _onJoinChannel(ch, askForPassword: true), + onTs3ServerLink: _onTs3ServerLink, ); + final ownClientState = _ownClientState; const voiceBarWidthWide = 320.0; - if (constraints.maxWidth >= wideBreakpoint) { + if (constraints.maxWidth >= _wideBreakpoint) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1765,6 +1857,9 @@ class _BetaHomeState extends State<_BetaHome> { isTouchOnly: isTouchOnlyPttHost, inputMuted: _hardMute, outputMuted: _outputMuted, + talkPower: ownClientState?.talkPower, + neededTalkPower: ownClientState?.neededTalkPower, + talkPowerGranted: ownClientState?.talkPowerGranted, onTap: () => _onOpenVoiceDetailsSheet(), ), if (_inChannel && @@ -1814,7 +1909,17 @@ class _BetaHomeState extends State<_BetaHome> { } return Scaffold( - appBar: AppBar(title: appBarTitle, actions: headerActions), + appBar: AppBar( + leading: _phase.canDisconnect + ? IconButton( + tooltip: l10n.disconnectAction, + icon: const Icon(Icons.arrow_back), + onPressed: _onConfirmDisconnect, + ) + : null, + title: appBarTitle, + actions: headerActions, + ), body: SafeArea( top: false, child: Padding(padding: const EdgeInsets.all(16), child: bodyContent), @@ -1823,842 +1928,119 @@ class _BetaHomeState extends State<_BetaHome> { } } -/// 'Save bookmark' name-entry dialog. Owns its own -/// TextEditingController via a StatefulWidget lifecycle so the -/// dispose() runs cleanly at unmount time (after the route pop -/// animation has fully detached the dialog subtree), not -/// synchronously from the caller's `await showDialog` resumption -/// point. -/// -/// Inline dispose-after-showDialog pattern previously caused -/// 'framework.dart line 6268 _dependents.isEmpty' on iOS because -/// the controller was torn down mid-pop while EditableText still -/// had live InheritedWidget dependencies on the dialog route. -class _BookmarkNameDialog extends StatefulWidget { - const _BookmarkNameDialog({required this.initialName}); +class _PokeDialog extends StatelessWidget { + const _PokeDialog({required this.pokes}); - final String initialName; - - @override - State<_BookmarkNameDialog> createState() => _BookmarkNameDialogState(); -} - -class _BookmarkNameDialogState extends State<_BookmarkNameDialog> { - late final TextEditingController _ctl = TextEditingController( - text: widget.initialName, - ); - - @override - void dispose() { - _ctl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - return AlertDialog( - title: Text(l10n.bookmarkAddTitle), - content: TextField( - controller: _ctl, - autofocus: true, - decoration: InputDecoration(labelText: l10n.fieldDisplayName), - onSubmitted: (_) => Navigator.of(context).pop(_ctl.text), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.closeAction), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(_ctl.text), - child: Text(l10n.bookmarkAddAction), - ), - ], - ); - } -} - -/// Channel-password dialog. Same StatefulWidget pattern as -/// [_BookmarkNameDialog] to keep TextEditingController disposal -/// inside the dialog's own lifecycle and avoid the -/// framework.dart:6268 _dependents.isEmpty assertion. -class _ChannelPasswordDialog extends StatefulWidget { - const _ChannelPasswordDialog(); - - @override - State<_ChannelPasswordDialog> createState() => _ChannelPasswordDialogState(); -} - -class _ChannelPasswordDialogState extends State<_ChannelPasswordDialog> { - final TextEditingController _ctl = TextEditingController(); - - @override - void dispose() { - _ctl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - return AlertDialog( - title: Text(l10n.channelPasswordTitle), - content: TextField( - controller: _ctl, - obscureText: true, - autofocus: true, - decoration: InputDecoration(labelText: l10n.fieldPassword), - onSubmitted: (_) => Navigator.of(context).pop(_ctl.text), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.closeAction), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(_ctl.text), - child: Text(l10n.connectAction), - ), - ], - ); - } -} - -class _ConnectForm extends StatefulWidget { - const _ConnectForm({ - required this.hostCtl, - required this.nickCtl, - required this.passwordCtl, - required this.onConnect, - required this.onAddBookmark, - }); - - final TextEditingController hostCtl; - final TextEditingController nickCtl; - final TextEditingController passwordCtl; - final VoidCallback onConnect; - final VoidCallback onAddBookmark; - - @override - State<_ConnectForm> createState() => _ConnectFormState(); -} - -class _ConnectFormState extends State<_ConnectForm> { - // FocusNodes + Listener(onPointerDown) wrappers fix a Flutter-on- - // iOS gesture-arena issue where enclosing scrollables (in our - // case, the SingleChildScrollView wrapping the connect form, and - // the ListView wrapping the channel tree) absorb the first tap - // as a possible scroll-intent. The second tap then succeeds - // because the scrollable's tap-arena participant has already - // decided not to handle a drag. - // - // Two-layer fix: - // * focusNode + autofocus-on-tap inside TextField — works when - // the arena resolves the tap as a TextField gesture - // * Listener(onPointerDown) wrapping the TextField with - // HitTestBehavior.translucent — fires synchronously on - // pointer-down BEFORE arena resolution, so even if the - // scrollable were to win the arena we have already grabbed - // focus. Translucent means the pointer ALSO propagates down - // to the TextField so its normal touch handling still runs. - // - // This is the documented workaround in - // https://github.com/flutter/flutter/issues/22680 and many - // related arena-race threads. - final FocusNode _hostFocus = FocusNode(); - final FocusNode _nickFocus = FocusNode(); - final FocusNode _passwordFocus = FocusNode(); - - // _kickFocus (unfocus + Future.microtask refocus on every onTap) - // was removed after user-reported 500\u20131000 ms keyboard appearance - // latency on iPhone 16 Pro / iOS 26. - // - // The microtask deferral was the source of the lag: it forces - // EditableText's attach-to-TextInput path to wait one frame past - // the user's pointer-up, and iOS 26's keyboard slide-up animation - // then dovetails into that extra frame in a way that adds another - // 200\u2013800 ms before the keyboard actually appears. - // - // The tap-outside-to-unfocus GestureDetector wrapping the connect - // form Column (see build() below) already guarantees the FocusNode - // is in the unfocused state when the user taps a field, because - // any prior keyboard dismissal (tap outside / tap a sibling field) - // goes through FocusScope.unfocus(). Therefore TextField's native - // tap path can attach TextInput on the first frame with no help - // from us, and the keyboard appears instantly. - // - // If iOS regresses again such that focus state desyncs from - // keyboard visibility, the workaround to re-introduce here is the - // canonical pattern from flutter/flutter#181474: - // - // void _kickFocus(FocusNode node) { - // if (node.hasFocus) node.unfocus(); - // Future.microtask(() { - // if (!mounted) return; - // node.requestFocus(); - // }); - // } - // - // and wire onTap: () => _kickFocus(_xxxFocus) on each TextField. - - /// Dismisses the soft keyboard when the user taps outside any - /// TextField in this form. Wired into each TextField via the - /// built-in `onTapOutside` parameter (Flutter 3.10+), which uses - /// the framework's TapRegion machinery to detect taps outside the - /// field's region without us having to install an outer - /// GestureDetector (which previously caused a - /// '_dependents.isEmpty' assertion when modal-sheet pop sequences - /// raced the InheritedWidget dependency cleanup). - /// - /// Uses FocusManager.instance \u2014 a global singleton with no - /// BuildContext dependency \u2014 instead of FocusScope.of(context), - /// to avoid subscribing this widget's Element to any - /// InheritedWidget. - void _onTapOutside(PointerDownEvent _) { - FocusManager.instance.primaryFocus?.unfocus(); - } - - @override - void dispose() { - _hostFocus.dispose(); - _nickFocus.dispose(); - _passwordFocus.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - // Tap-outside-to-unfocus. - // - // Iteration history: - // * 020be77/23bd1c7: Listener pre-arena requestFocus + - // SystemChannels.textInput.show. Caused first-tap-not-focusing - // AND post-attach keystroke lag (Listener wins arena, - // EditableText loses). - // * 79f8360: _kickFocus(unfocus + Future.microtask refocus) on - // every TextField.onTap. Caused 500-1000 ms keyboard slide-up - // lag (microtask deferral collides with iOS keyboard - // CAAnimation). - // * 6955547: GestureDetector(HitTestBehavior.opaque, onTap: - // FocusScope.of(context).unfocus()) wrapping the Column. - // Caused 'package:flutter/src/widgets/framework.dart line - // 6268 _dependents.isEmpty' assertion: the - // FocusScope.of(context) call subscribes the GestureDetector's - // Element to the _FocusScopeMarker InheritedWidget on every - // build, and the modal-sheet pop sequence deactivates that - // InheritedElement before the dependents fully clear, - // tripping the debug assert. - // - // Current approach (no outer GestureDetector at all): - // * Use TextField.onTapOutside (added Flutter 3.10+). Wired - // per field. Uses FocusManager.instance (global, no - // BuildContext dependency, no InheritedWidget race). - // * iOS framework default for touch+mobile is to do NOTHING - // on tap-outside (see flutter/widgets/editable_text.dart - // _EditableTextTapOutsideAction:6748-6755) because the iOS - // UX convention is 'swipe-down on the keyboard' or 'tap - // Done' \u2014 not tap-outside. We override that explicitly - // because most users expect tap-outside-to-dismiss in a - // server-connect form context. - // * No outer GestureDetector means zero gesture-arena - // interference with the TextField's own tap recognizer \u2014 - // keyboard appears synchronously on first tap, no lag. - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( - controller: widget.hostCtl, - focusNode: _hostFocus, - onTapOutside: _onTapOutside, - // Server addresses are URL-shaped: hostname or - // hostname:port, all-lowercase ASCII, never user- - // friendly prose. Configure the on-screen keyboard - // accordingly: - // * keyboardType: .url \u2014 surfaces ".", "/", - // ":" on the primary keyboard plane so the user - // doesn't have to switch to the symbols pane to - // type kr.teamspeak.app:9987. - // * textInputAction: .next \u2014 return key advances - // to the nickname field. - // * autocorrect / enableSuggestions: false \u2014 iOS - // should not autocorrect 'kr.teamspeak.app' to - // 'kr.teamspeak.lap' or suggest 'KR' in caps. - // * textCapitalization: .none \u2014 don't capitalise the - // first letter the way iOS does for sentences. - // * inputFormatters: deny whitespace and uppercase\u2014 - // belt-and-braces in case the user pastes from a - // formatted source (e.g. tab-indented copy). - keyboardType: TextInputType.url, - textCapitalization: TextCapitalization.none, - textInputAction: TextInputAction.next, - autocorrect: false, - enableSuggestions: false, - inputFormatters: [ - FilteringTextInputFormatter.deny(RegExp(r'\s')), - TextInputFormatter.withFunction( - (oldValue, newValue) => newValue.copyWith( - text: newValue.text.toLowerCase(), - selection: newValue.selection, - ), - ), - ], - decoration: InputDecoration( - labelText: l10n.fieldServerHost, - hintText: 'host[:port]', - prefixIcon: const Icon(Icons.dns_outlined), - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - TextField( - controller: widget.nickCtl, - focusNode: _nickFocus, - onTapOutside: _onTapOutside, - textInputAction: TextInputAction.next, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: l10n.fieldNickname, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - TextField( - controller: widget.passwordCtl, - focusNode: _passwordFocus, - onTapOutside: _onTapOutside, - obscureText: true, - textInputAction: TextInputAction.done, - autocorrect: false, - enableSuggestions: false, - decoration: InputDecoration( - labelText: l10n.fieldServerPassword, - helperText: l10n.fieldServerPasswordHelp, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: FilledButton.icon( - icon: const Icon(Icons.login), - label: Text(l10n.connectAction), - onPressed: widget.onConnect, - ), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - icon: const Icon(Icons.bookmark_add_outlined), - label: Text(l10n.bookmarkAddAction), - onPressed: widget.onAddBookmark, - ), - ], - ), - ], - ); - } -} - -class _BookmarkList extends StatelessWidget { - const _BookmarkList({ - required this.bookmarks, - required this.onConnect, - required this.onDelete, - }); - - final List bookmarks; - final ValueChanged onConnect; - final ValueChanged onDelete; - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - final theme = Theme.of(context); - if (bookmarks.isEmpty) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall), - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall), - const SizedBox(height: 4), - for (final b in bookmarks) - Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: ListTile( - title: Text(b.displayName), - subtitle: Text('${b.host} — ${b.nickname}'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.login), - tooltip: l10n.connectAction, - onPressed: () => onConnect(b), - ), - IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: l10n.bookmarkDeleteAction, - onPressed: () => onDelete(b), - ), - ], - ), - ), - ), - ], - ); - } -} - -/// PTT capability badge (gen2 v0.9.3 / SDD-091). -/// -/// Renders the active PTT level + backend in the Voice Bar so the -/// user understands which input path is in effect. When the -/// resolved capability is `L0Focused` an info icon appears that -/// opens a per-platform explanation sheet describing why Global -/// PTT is not active and what the user can do to engage it. -/// -/// Driven by the `BridgeEvent::PttCapability` stream published by -/// the `PttController` (SDD-088). The `_BetaHomeState` listener -class _WelcomeMessageTile extends StatefulWidget { - const _WelcomeMessageTile({required this.welcomeMessage}); - - final String welcomeMessage; - - @override - State<_WelcomeMessageTile> createState() => _WelcomeMessageTileState(); -} - -class _WelcomeMessageTileState extends State<_WelcomeMessageTile> { - bool _expanded = true; + final ValueListenable> pokes; @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( + final l10n = AppL10n.of(context); + + return AlertDialog( + icon: Icon( + Icons.notifications_active_outlined, + color: theme.colorScheme.primary, + ), + title: Text(l10n.pokeDialogTitle), + content: ValueListenableBuilder>( + valueListenable: pokes, + builder: (context, entries, _) => ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360, maxHeight: 360), + child: ListView.separated( + shrinkWrap: true, + itemCount: entries.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (_, index) => _PokeDialogEntry(poke: entries[index]), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.pokeDialogCloseAction), + ), + ], + ); + } +} + +class _AppSettingsPage extends StatefulWidget { + const _AppSettingsPage({ + required this.showPokeDialogs, + required this.onShowPokeDialogsChanged, + }); + + final bool showPokeDialogs; + final ValueChanged onShowPokeDialogsChanged; + + @override + State<_AppSettingsPage> createState() => _AppSettingsPageState(); +} + +class _AppSettingsPageState extends State<_AppSettingsPage> { + late bool _showPokeDialogs = widget.showPokeDialogs; + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + return Scaffold( + appBar: AppBar(title: Text(l10n.appSettingsTitle)), + body: ListView( + children: [ + SwitchListTile( + title: Text(l10n.pokeAlertsTitle), + subtitle: Text(l10n.pokeAlertsDescription), + value: _showPokeDialogs, + onChanged: (value) { + setState(() => _showPokeDialogs = value); + widget.onShowPokeDialogsChanged(value); + }, + ), + ], + ), + ); + } +} + +class _PokeDialogEntry extends StatelessWidget { + const _PokeDialogEntry({required this.poke}); + + final _ReceivedPoke poke; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppL10n.of(context); + final message = poke.message.trim(); + final hasMessage = message.isNotEmpty; + final time = chatTimeLabel(poke.receivedAt); + final detail = hasMessage + ? l10n.pokeDialogIncomingWithMessage(time, poke.senderName) + : l10n.pokeDialogIncomingNoMessage(time, poke.senderName); + + return DecoratedBox( decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(8), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - onTap: () { - HapticFeedback.selectionClick(); - setState(() => _expanded = !_expanded); - }, - borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - children: [ - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - 'Server welcome message', - style: theme.textTheme.labelMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + detail, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, ), ), - ), - AnimatedCrossFade( - firstChild: Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: BbCodeText( - widget.welcomeMessage, - linkTrust: LinkTrustService.instance, - ), - ), - secondChild: const SizedBox(width: double.infinity), - crossFadeState: _expanded - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 200), - ), - ], - ), - ); - } -} - -/// updates the props on each transition. -class _SnapshotView extends StatelessWidget { - const _SnapshotView({ - required this.snapshot, - required this.audioStats, - required this.currentVoiceChannelId, - required this.pendingVoiceChannelId, - required this.localInputMuted, - required this.localOutputMuted, - required this.hasJoinPending, - required this.canJoinVoiceChannel, - required this.onJoinChannel, - required this.onJoinChannelWithPassword, - }); - - final rust.BridgeSnapshot snapshot; - final rust.BridgeAudioStats? audioStats; - final BigInt? currentVoiceChannelId; - final BigInt? pendingVoiceChannelId; - final bool localInputMuted; - final bool localOutputMuted; - final bool hasJoinPending; - final bool canJoinVoiceChannel; - final ValueChanged onJoinChannel; - final ValueChanged onJoinChannelWithPassword; - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - final theme = Theme.of(context); - - // Channels arrive pre-sorted from the bridge: the Rust - // adapter (chanora_protocol::adapter::sort_channels_tree) - // emits them in root-first depth-first order following the - // TeamSpeak linked-list `order` predecessor pointers. We - // therefore trust the server order verbatim — re-sorting by - // `order` numerically here would re-introduce the bug fixed - // in that adapter (TS3 `order` is NOT a numeric rank). - final channels = snapshot.channels; - - // Compute each channel's depth in the parent hierarchy so we - // can render it indented. Root channels (parent == 0) are - // depth 0; their children depth 1; and so on. Depth caps at - // 6 to keep the indent visually bounded on deeply-nested - // servers (the cap plateaus silently — no glyph; the channel - // is still tappable and its real depth lives in the data). - final depthById = {}; - final zero = BigInt.zero; - for (final ch in channels) { - if (ch.parent == zero) { - depthById[ch.id] = 0; - } else { - final parentDepth = depthById[ch.parent] ?? 0; - depthById[ch.id] = (parentDepth + 1).clamp(0, 6); - } - } - const indentPerLevel = 18.0; - - final byChannel = >{}; - for (final c in snapshot.clients) { - byChannel.putIfAbsent(c.channel, () => []).add(c); - } - - return ListView( - children: [ - Text( - l10n.countChannelsAndClients( - snapshot.channels.length, - snapshot.clients.length, - ), - style: theme.textTheme.bodyMedium, - ), - if (snapshot.welcomeMessage.isNotEmpty) ...[ - const SizedBox(height: 8), - _WelcomeMessageTile(welcomeMessage: snapshot.welcomeMessage), - ], - const Divider(height: 24), - Text(l10n.channelsHeading, style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - for (final ch in channels) ...[ - Padding( - padding: EdgeInsets.only( - left: (depthById[ch.id] ?? 0) * indentPerLevel, - ), - child: ListTile( - dense: true, - leading: Icon( - ch.hasPassword ? Icons.lock_outline : Icons.tag, - color: ch.hasPassword - ? theme.colorScheme.onSurfaceVariant - : null, - ), - title: Text(ch.name), - selected: ch.id == currentVoiceChannelId, - onTap: - hasJoinPending || - !canJoinVoiceChannel || - ch.id == currentVoiceChannelId - ? null - : () => ch.hasPassword - ? onJoinChannelWithPassword(ch) - : onJoinChannel(ch), - ), - ), - for (final cl in byChannel[ch.id] ?? const []) - if (!cl.isServerQuery) - _clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel), - ], - ], - ); - } - - Widget _clientTile( - ThemeData theme, - rust.BridgeClient client, - double channelIndent, - ) { - final status = _clientVoiceStatusIcon(theme, client); - final nameStyle = client.isServerQuery - ? TextStyle(color: theme.colorScheme.onSurfaceVariant) - : status.isSpeaking - ? TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ) - : null; - - final tile = ListTile( - dense: true, - visualDensity: VisualDensity.compact, - leading: status.icon, - title: Text(client.name, style: nameStyle), - ); - - return Padding( - padding: EdgeInsets.only(left: channelIndent + 64, right: 8), - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - curve: Curves.easeOut, - decoration: status.isSpeaking - ? BoxDecoration( - color: theme.colorScheme.primaryContainer.withValues( - alpha: 0.45, - ), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.primary.withValues(alpha: 0.55), - width: 1.2, - ), - boxShadow: [ - BoxShadow( - color: theme.colorScheme.primary.withValues(alpha: 0.14), - blurRadius: 8, - spreadRadius: 1, - ), - ], - ) - : null, - child: tile, - ), - ); - } - - ({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon( - ThemeData theme, - rust.BridgeClient client, - ) { - final isSelf = client.id == snapshot.ownClientId; - final inCurrentChannel = client.channel == currentVoiceChannelId; - final outputMuted = isSelf ? localOutputMuted : client.outputMuted; - final inputMuted = isSelf ? localInputMuted : client.inputMuted; - final rawSpeaking = isSelf - ? (audioStats?.pttActive ?? false) - : client.isSpeaking; - final speaking = rawSpeaking && !outputMuted && !inputMuted; - - final IconData icon; - final Color color; - final String tooltip; - if (outputMuted) { - icon = Icons.volume_off; - color = theme.colorScheme.error; - tooltip = 'Speaker muted'; - } else if (inputMuted) { - icon = Icons.mic_off; - color = theme.colorScheme.error; - tooltip = 'Microphone muted'; - } else if (speaking) { - icon = isSelf ? Icons.mic : Icons.volume_up; - color = theme.colorScheme.primary; - tooltip = 'Speaking'; - } else if (inCurrentChannel) { - icon = isSelf ? Icons.mic_none : Icons.volume_up_outlined; - color = theme.colorScheme.onSurfaceVariant; - tooltip = 'Not speaking'; - } else { - icon = Icons.person_outline; - color = theme.colorScheme.onSurfaceVariant; - tooltip = 'Outside current channel'; - } - - return ( - icon: Tooltip( - message: tooltip, - child: Icon(icon, size: 18, color: color), - ), - isSpeaking: speaking, - ); - } -} - -/// Result of a successful PTT binding capture. Carries only the -/// coarse input class and an opaque platform-key label — -/// per DEC-027 the actual key code never crosses out of the -/// capture dialog. -class _CapturedBinding { - const _CapturedBinding({required this.inputClass, required this.platformKey}); - final rust.BridgePttInputClass inputClass; - final String platformKey; -} - -/// Focus-scoped dialog that captures the next key press or mouse -/// side-button click and returns it as a `_CapturedBinding`. The -/// captured value is the platform-neutral `LogicalKeyboardKey` -/// debug label or `"mouse-side-button:{button}"`; the platform -/// backend interprets the string and never logs it. -class _PttBindingCaptureDialog extends StatefulWidget { - const _PttBindingCaptureDialog(); - - @override - State<_PttBindingCaptureDialog> createState() => - _PttBindingCaptureDialogState(); -} - -class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> { - final FocusNode _focusNode = FocusNode(); - String? _captured; - rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } - - KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - final label = _pttDisplayLabelForKey(event.logicalKey); - if (label == null) return KeyEventResult.ignored; - setState(() { - _captured = label; - _capturedClass = rust.BridgePttInputClass.keyboard; - }); - return KeyEventResult.handled; - } - - void _captureMouseSideButton(int button) { - setState(() { - _captured = 'mouse-side-button:$button'; - _capturedClass = rust.BridgePttInputClass.mouseSideButton; - }); - } - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - final theme = Theme.of(context); - return AlertDialog( - title: Text(l10n.pttConfigureTitle), - content: SizedBox( - width: 360, - child: Focus( - focusNode: _focusNode, - onKeyEvent: _onKeyEvent, - autofocus: true, - child: Listener( - // HitTestBehavior.opaque so the Listener fires for - // mouse side-button presses anywhere inside the - // dialog's bounds, not just on top of a child widget. - // Default (deferToChild) only delivers events when a - // child's hit-test claims them; the surrounding padding - // and Container backgrounds don't, so the user had to - // hover over the captured-result Container before the - // side-button click would register. Opaque means the - // entire dialog content area receives PointerDown - // events. - behavior: HitTestBehavior.opaque, - // Capture mouse side buttons (4 and 5) without - // capturing primary / secondary clicks which the - // user uses to interact with the dialog itself. The - // raw button bitmask values are stable per Flutter's - // `PointerEvent.buttons` documentation (back = 0x08, - // forward = 0x10). - onPointerDown: (e) { - const int back = 0x08; - const int forward = 0x10; - if (e.buttons == back || e.buttons == forward) { - _captureMouseSideButton(e.buttons); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.pttConfigurePrompt, - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - _captured == null - ? l10n.pttConfigureWaiting - : '${l10n.pttConfigureCaptured}: $_captured', - style: theme.textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - ), - ), - ), - const SizedBox(height: 12), - Text( - l10n.pttConfigurePrivacyNote, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + if (hasMessage) ...[const SizedBox(height: 8), Text(message)], + ], ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.closeAction), - ), - FilledButton( - onPressed: _captured == null - ? null - : () => Navigator.of(context).pop( - _CapturedBinding( - inputClass: _capturedClass, - platformKey: _captured!, - ), - ), - child: Text(l10n.pttConfigureSaveAction), - ), - ], ); } } diff --git a/apps/chanora_flutter/lib/services/ui_preferences_service.dart b/apps/chanora_flutter/lib/services/ui_preferences_service.dart new file mode 100644 index 0000000..39a4873 --- /dev/null +++ b/apps/chanora_flutter/lib/services/ui_preferences_service.dart @@ -0,0 +1,54 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class UiSettings { + const UiSettings({ + this.host = '', + this.nickname = '', + this.showPokeDialogs = true, + }); + + final String host; + final String nickname; + final bool showPokeDialogs; +} + +class UiPreferencesService { + static const _hostKey = 'ui.host'; + static const _nicknameKey = 'ui.nickname'; + static const _showPokeDialogsKey = 'ui.show_poke_dialogs'; + static const _permissionsExplainedKey = 'perms_explained'; + + const UiPreferencesService(); + + Future loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + return UiSettings( + host: prefs.getString(_hostKey) ?? '', + nickname: prefs.getString(_nicknameKey) ?? '', + showPokeDialogs: prefs.getBool(_showPokeDialogsKey) ?? true, + ); + } + + Future saveSettings({ + String? host, + String? nickname, + bool? showPokeDialogs, + }) async { + final prefs = await SharedPreferences.getInstance(); + if (host != null) await prefs.setString(_hostKey, host); + if (nickname != null) await prefs.setString(_nicknameKey, nickname); + if (showPokeDialogs != null) { + await prefs.setBool(_showPokeDialogsKey, showPokeDialogs); + } + } + + Future hasExplainedPermissions() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_permissionsExplainedKey) ?? false; + } + + Future markPermissionsExplained() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_permissionsExplainedKey, true); + } +} diff --git a/apps/chanora_flutter/lib/widgets/chat_views.dart b/apps/chanora_flutter/lib/widgets/chat_views.dart new file mode 100644 index 0000000..fab7e72 --- /dev/null +++ b/apps/chanora_flutter/lib/widgets/chat_views.dart @@ -0,0 +1,910 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart'; + +import '../l10n/generated/app_localizations.dart'; +import '../services/channel_spacer.dart'; +import '../services/link_trust_service.dart'; +import '../services/snapshot_state_mapper.dart'; +import '../services/ts3_server_link.dart'; +import '../src/rust/api.dart' as rust; +import 'bbcode_text.dart'; + +/// One chat/activity message shown in the chat hub. +class ChatEntry { + /// Construct a chat entry. + const ChatEntry({ + required this.senderId, + required this.senderName, + required this.message, + required this.target, + this.isSelf = false, + this.timestamp, + }); + + /// Sender client id. + final BigInt senderId; + + /// Sender display name. + final String senderName; + + /// Message body. + final String message; + + /// Chat target. + final rust.BridgeMessageTarget target; + + /// True when the local client sent this message. + final bool isSelf; + + /// Local time when this message was received or sent. + final DateTime? timestamp; + + /// True for direct-message targets. + bool get isPrivate => target is rust.BridgeMessageTarget_Client; + + /// True for poke targets. + bool get isPoke => target is rust.BridgeMessageTarget_Poke; +} + +String chatTimeLabel(DateTime? timestamp) { + if (timestamp == null) return '--:--:--'; + String two(int value) => value.toString().padLeft(2, '0'); + return '${two(timestamp.hour)}:${two(timestamp.minute)}:${two(timestamp.second)}'; +} + +String pokeHistoryLine(AppL10n l10n, ChatEntry entry) { + final time = chatTimeLabel(entry.timestamp); + final peerName = entry.senderName.isNotEmpty ? entry.senderName : 'Unknown'; + final message = entry.message.trim(); + if (entry.isSelf) { + if (message.isEmpty) { + return l10n.pokeHistorySelfNoMessage(time, peerName); + } + return l10n.pokeHistorySelfWithMessage(time, peerName, message); + } + if (message.isEmpty) { + return l10n.pokeHistoryIncomingNoMessage(time, peerName); + } + return l10n.pokeHistoryIncomingWithMessage(time, peerName, message); +} + +/// Resolve the best chat target to show when opening the chat page. +/// +/// Returns null to show the chat hub. +rust.BridgeMessageTarget? resolveInitialChatTarget({ + required List messages, + required BigInt? currentVoiceChannelId, +}) { + for (final message in messages.reversed) { + if (message.isPrivate) return message.target; + } + if (currentVoiceChannelId != null) { + return const rust.BridgeMessageTarget.channel(); + } + return null; +} + +class ChatClientGroups { + const ChatClientGroups({ + required this.clientsByChannel, + required this.ungroupedClients, + }); + + final Map> clientsByChannel; + final List ungroupedClients; +} + +class ChatClientPickerGroup { + const ChatClientPickerGroup({ + required this.channelName, + required this.clients, + }); + + final String channelName; + final List clients; +} + +ChatClientGroups groupChatClientsForPicker(rust.BridgeSnapshot snapshot) { + final ownId = snapshot.ownClientId; + final clients = + snapshot.clients + .where((client) => client.id != ownId && !client.isServerQuery) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + final channelIds = {for (final channel in snapshot.channels) channel.id}; + final byChannel = >{}; + final ungrouped = []; + + for (final client in clients) { + if (channelIds.contains(client.channel)) { + byChannel.putIfAbsent(client.channel, () => []).add(client); + } else { + ungrouped.add(client); + } + } + + return ChatClientGroups( + clientsByChannel: byChannel, + ungroupedClients: ungrouped, + ); +} + +List filterChatClientPickerGroups({ + required List channels, + required Map> clientsByChannel, + required List ungroupedClients, + required String query, +}) { + final normalizedQuery = query.trim().toLowerCase(); + bool matches(rust.BridgeClient client) { + return normalizedQuery.isEmpty || + client.name.toLowerCase().contains(normalizedQuery); + } + + final ungrouped = ungroupedClients.where(matches).toList(); + return [ + for (final channel in channels) + if (clientsByChannel.containsKey(channel.id)) + ChatClientPickerGroup( + channelName: channelSpacerLabel(channel.name), + clients: clientsByChannel[channel.id]!.where(matches).toList(), + ), + if (ungrouped.isNotEmpty) + ChatClientPickerGroup(channelName: 'Other', clients: ungrouped), + ].where((group) => group.clients.isNotEmpty).toList(); +} + +String chatTargetTitle( + rust.BridgeMessageTarget target, { + required String channelName, + required String clientName, +}) { + switch (target) { + case rust.BridgeMessageTarget_Server(): + return 'Server Activity'; + case rust.BridgeMessageTarget_Channel(): + return channelName.isNotEmpty ? '# $channelName' : 'Channel'; + case rust.BridgeMessageTarget_Client(): + return clientName.isNotEmpty ? clientName : 'Direct Message'; + case rust.BridgeMessageTarget_Poke(): + return clientName.isNotEmpty ? 'Poke: $clientName' : 'Poke'; + } +} + +String chatEmptyTitle(rust.BridgeMessageTarget target) { + switch (target) { + case rust.BridgeMessageTarget_Server(): + return 'No server activity yet'; + case rust.BridgeMessageTarget_Channel(): + return 'No channel messages yet'; + case rust.BridgeMessageTarget_Client(): + return 'No private messages yet'; + case rust.BridgeMessageTarget_Poke(): + return 'No pokes'; + } +} + +String chatEmptyBody( + rust.BridgeMessageTarget target, { + required String channelName, +}) { + switch (target) { + case rust.BridgeMessageTarget_Server(): + return 'Joins, leaves, and disconnects will appear here.'; + case rust.BridgeMessageTarget_Channel(): + if (channelName.isNotEmpty) { + return 'Start the conversation in #$channelName.'; + } + return 'Join a channel to start chatting.'; + case rust.BridgeMessageTarget_Client(): + return 'Select a user to start a private chat.'; + case rust.BridgeMessageTarget_Poke(): + return 'Pokes will appear here when someone needs your attention.'; + } +} + +String chatInputPlaceholder( + rust.BridgeMessageTarget target, { + required String channelName, + required String clientName, +}) { + switch (target) { + case rust.BridgeMessageTarget_Server(): + return 'Message server...'; + case rust.BridgeMessageTarget_Channel(): + return 'Message #$channelName...'; + case rust.BridgeMessageTarget_Client(): + return 'Message $clientName...'; + case rust.BridgeMessageTarget_Poke(): + return 'Poke message...'; + } +} + +bool canSendToChatTarget( + rust.BridgeMessageTarget target, + BigInt? currentChannelId, +) { + switch (target) { + case rust.BridgeMessageTarget_Channel(): + return currentChannelId != null; + default: + return true; + } +} + +String? chatSendBlockedReason( + rust.BridgeMessageTarget target, + BigInt? currentChannelId, +) { + switch (target) { + case rust.BridgeMessageTarget_Channel(): + if (currentChannelId == null) { + return 'Join a channel to send messages.'; + } + return null; + default: + return null; + } +} + +/// Chat and activity hub page. +class ChatPage extends StatefulWidget { + /// Construct a chat page. + const ChatPage({ + super.key, + required this.messages, + required this.snapshot, + this.initialTarget, + this.initialClientName = '', + this.onTs3ServerLink, + }); + + /// Backing message list. The detail view appends self-sent messages. + final List messages; + + /// Latest TeamSpeak snapshot. + final rust.BridgeSnapshot snapshot; + + /// Non-null to open a specific target directly; null for hub. + final rust.BridgeMessageTarget? initialTarget; + + /// Initial selected client name for direct-message or poke targets. + final String initialClientName; + + /// Handle TeamSpeak server links embedded in chat messages. + final Ts3ServerLinkHandler? onTs3ServerLink; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + rust.BridgeMessageTarget? _detailTarget; + String _selectedClientName = ''; + + @override + void initState() { + super.initState(); + _detailTarget = widget.initialTarget; + _selectedClientName = widget.initialClientName; + } + + BigInt? get _currentChannelId { + return ownClientSnapshotState(widget.snapshot)?.channelId; + } + + @override + Widget build(BuildContext context) { + final currentChannelId = _currentChannelId; + final channelName = snapshotChannelName(widget.snapshot, currentChannelId); + if (_detailTarget != null) { + return _ChatDetailView( + target: _detailTarget!, + clientName: _selectedClientName, + snapshot: widget.snapshot, + messages: widget.messages, + currentChannelId: currentChannelId, + channelName: channelName, + onBack: () => setState(() => _detailTarget = null), + onTs3ServerLink: widget.onTs3ServerLink, + ); + } + return _ChatHub( + snapshot: widget.snapshot, + messages: widget.messages, + currentChannelId: currentChannelId, + channelName: channelName, + onOpen: (t, {String name = ''}) { + setState(() { + _detailTarget = t; + _selectedClientName = name; + }); + }, + onPickClient: (fn) => _pickClient(fn), + ); + } + + void _pickClient(void Function(BigInt id, String name) cb) { + final groups = groupChatClientsForPicker(widget.snapshot); + showDialog( + context: context, + builder: (ctx) => _ClientPickerDialog( + channels: widget.snapshot.channels, + byChannel: groups.clientsByChannel, + ungrouped: groups.ungroupedClients, + onSelected: (c) { + cb(c.id, c.name); + Navigator.pop(ctx); + }, + ), + ); + } +} + +class _ClientPickerDialog extends StatefulWidget { + const _ClientPickerDialog({ + required this.channels, + required this.byChannel, + required this.ungrouped, + required this.onSelected, + }); + + final List channels; + final Map> byChannel; + final List ungrouped; + final void Function(rust.BridgeClient c) onSelected; + + @override + State<_ClientPickerDialog> createState() => _ClientPickerDialogState(); +} + +class _ClientPickerDialogState extends State<_ClientPickerDialog> { + final _searchCtl = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchCtl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final groups = filterChatClientPickerGroups( + channels: widget.channels, + clientsByChannel: widget.byChannel, + ungroupedClients: widget.ungrouped, + query: _query, + ); + + return Dialog( + child: SizedBox( + width: 360, + height: 520, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: TextField( + controller: _searchCtl, + decoration: const InputDecoration( + hintText: 'Search clients...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (v) => setState(() => _query = v), + ), + ), + Expanded( + child: ListView( + children: [ + for (final group in groups) + _ChannelGroup( + channelName: group.channelName, + clients: group.clients, + onSelected: widget.onSelected, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ChannelGroup extends StatelessWidget { + const _ChannelGroup({ + required this.channelName, + required this.clients, + required this.onSelected, + }); + + final String channelName; + final List clients; + final void Function(rust.BridgeClient) onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (clients.isEmpty) return const SizedBox.shrink(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + channelName, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + for (final c in clients) + ListTile( + leading: CircleAvatar( + radius: 12, + child: Text( + c.name.isNotEmpty ? c.name[0].toUpperCase() : '?', + style: const TextStyle(fontSize: 10), + ), + ), + title: Text(c.name), + onTap: () => onSelected(c), + ), + ], + ); + } +} + +class _ChatHub extends StatelessWidget { + const _ChatHub({ + required this.snapshot, + required this.messages, + required this.currentChannelId, + required this.channelName, + required this.onOpen, + required this.onPickClient, + }); + + final rust.BridgeSnapshot snapshot; + final List messages; + final BigInt? currentChannelId; + final String channelName; + final void Function(rust.BridgeMessageTarget t, {String name}) onOpen; + final void Function(void Function(BigInt id, String name) cb) onPickClient; + + Iterable _of(rust.BridgeMessageTarget t) => + messages.where((m) => m.target == t); + + @override + Widget build(BuildContext context) { + final channelMsgs = _of(const rust.BridgeMessageTarget.channel()); + final privateMsgs = messages.where((m) => m.isPrivate); + final serverMsgs = _of(const rust.BridgeMessageTarget.server()); + + return Scaffold( + appBar: AppBar(title: Text('Chat & Activity — ${snapshot.serverName}')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _HubCard( + icon: Icons.tag, + title: channelName.isNotEmpty + ? '# $channelName' + : 'Current Channel', + subtitle: channelMsgs.isNotEmpty + ? '${channelMsgs.length} message${channelMsgs.length == 1 ? '' : 's'}' + : 'No messages yet', + detail: currentChannelId != null + ? 'You can send messages here' + : 'Join a channel to send messages', + onTap: () => onOpen(const rust.BridgeMessageTarget.channel()), + ), + const SizedBox(height: 12), + _HubCard( + icon: Icons.person_outline, + title: 'Direct Messages', + subtitle: privateMsgs.isNotEmpty + ? '${privateMsgs.length} message${privateMsgs.length == 1 ? '' : 's'}' + : 'No private messages', + detail: 'Select a user to start a private chat.', + onTap: () => onPickClient( + (id, name) => + onOpen(rust.BridgeMessageTarget.client(id), name: name), + ), + ), + const SizedBox(height: 12), + _HubCard( + icon: Icons.list_alt_outlined, + title: 'Server Activity', + subtitle: serverMsgs.isNotEmpty + ? '${serverMsgs.length} message${serverMsgs.length == 1 ? '' : 's'}' + : 'No server activity', + detail: serverMsgs.isNotEmpty + ? 'Server messages and events appear here.' + : 'Server-wide messages will appear here.', + onTap: () => onOpen(const rust.BridgeMessageTarget.server()), + ), + ], + ), + ); + } +} + +class _HubCard extends StatelessWidget { + const _HubCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.detail, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final String detail; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: Icon(icon, color: theme.colorScheme.primary), + title: Text(title, style: theme.textTheme.titleSmall), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text(subtitle, style: theme.textTheme.bodyMedium), + Text( + detail, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } +} + +class _ChatDetailView extends StatefulWidget { + const _ChatDetailView({ + required this.target, + required this.clientName, + required this.snapshot, + required this.messages, + required this.currentChannelId, + required this.channelName, + required this.onBack, + this.onTs3ServerLink, + }); + + final rust.BridgeMessageTarget target; + final String clientName; + final rust.BridgeSnapshot snapshot; + final List messages; + final BigInt? currentChannelId; + final String channelName; + final VoidCallback onBack; + final Ts3ServerLinkHandler? onTs3ServerLink; + + @override + State<_ChatDetailView> createState() => _ChatDetailViewState(); +} + +class _ChatDetailViewState extends State<_ChatDetailView> { + final _textCtl = TextEditingController(); + final _scrollCtl = ScrollController(); + + Iterable get _filtered { + if (widget.target is rust.BridgeMessageTarget_Channel) { + return widget.messages.where( + (m) => m.target == widget.target || m.isPoke, + ); + } + return widget.messages.where((m) => m.target == widget.target); + } + + bool get _canSend => + canSendToChatTarget(widget.target, widget.currentChannelId); + + String? get _blockedReason => + chatSendBlockedReason(widget.target, widget.currentChannelId); + + String get _title => chatTargetTitle( + widget.target, + channelName: widget.channelName, + clientName: widget.clientName, + ); + + @override + void dispose() { + _textCtl.dispose(); + _scrollCtl.dispose(); + super.dispose(); + } + + void _send() { + final text = _textCtl.text.trim(); + if (text.isEmpty || !_canSend) return; + _textCtl.clear(); + 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(), + ), + ); + if (widget.messages.length > 200) { + widget.messages.removeRange(0, widget.messages.length - 200); + } + }); + _scrollToBottom(); + } + + void _scrollToBottom() { + if (_scrollCtl.hasClients) { + _scrollCtl.animateTo( + _scrollCtl.position.maxScrollExtent, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final msgs = _filtered.toList(); + final placeholder = chatInputPlaceholder( + widget.target, + channelName: widget.channelName, + clientName: widget.clientName, + ); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text(_title), + actions: [ + TextButton(onPressed: widget.onBack, child: const Text('Activity')), + ], + ), + body: Column( + children: [ + Expanded( + child: msgs.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.forum_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + chatEmptyTitle(widget.target), + style: theme.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + chatEmptyBody( + widget.target, + channelName: widget.channelName, + ), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + : ListView.builder( + controller: _scrollCtl, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: msgs.length, + itemBuilder: (_, i) => _MessageBubble( + entry: msgs[i], + onTs3ServerLink: widget.onTs3ServerLink, + ), + ), + ), + if (_blockedReason != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + color: theme.colorScheme.surfaceContainerHighest, + child: Text( + _blockedReason!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + if (_canSend && _blockedReason == null) + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textCtl, + decoration: InputDecoration( + hintText: placeholder, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + icon: const Icon(Icons.send), + onPressed: _send, + tooltip: 'Send', + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({required this.entry, this.onTs3ServerLink}); + + final ChatEntry entry; + final Ts3ServerLinkHandler? onTs3ServerLink; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (entry.isPoke) { + return _PokeHistoryRow(entry: entry); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 14, + child: Text( + entry.senderName.isNotEmpty + ? entry.senderName[0].toUpperCase() + : '?', + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.senderName, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: entry.isSelf + ? theme.colorScheme.primary + : entry.isPrivate + ? theme.colorScheme.tertiary + : null, + ), + ), + const SizedBox(height: 2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: entry.isSelf + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: DefaultTextStyle.merge( + style: theme.textTheme.bodyMedium, + child: BbCodeText( + entry.message, + linkTrust: LinkTrustService.instance, + onTs3ServerLink: onTs3ServerLink, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _PokeHistoryRow extends StatelessWidget { + const _PokeHistoryRow({required this.entry}); + + final ChatEntry entry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppL10n.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.secondary.withValues(alpha: 0.22), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.notifications_none, + size: 16, + color: theme.colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + pokeHistoryLine(l10n, entry), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/chanora_flutter/test/services/ui_preferences_service_test.dart b/apps/chanora_flutter/test/services/ui_preferences_service_test.dart new file mode 100644 index 0000000..d3da1c8 --- /dev/null +++ b/apps/chanora_flutter/test/services/ui_preferences_service_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chanora_flutter/services/ui_preferences_service.dart'; + +void main() { + late UiPreferencesService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = const UiPreferencesService(); + }); + + test('loads empty settings when preferences are unset', () async { + final settings = await service.loadSettings(); + + expect(settings.host, isEmpty); + expect(settings.nickname, isEmpty); + expect(settings.showPokeDialogs, isTrue); + }); + + test('saves and loads host and nickname independently', () async { + await service.saveSettings(host: 'example.com'); + var settings = await service.loadSettings(); + + expect(settings.host, 'example.com'); + expect(settings.nickname, isEmpty); + + await service.saveSettings(nickname: 'Chanora'); + settings = await service.loadSettings(); + + expect(settings.host, 'example.com'); + expect(settings.nickname, 'Chanora'); + }); + + test('saves poke dialog preference independently', () async { + await service.saveSettings(showPokeDialogs: false); + var settings = await service.loadSettings(); + + expect(settings.showPokeDialogs, isFalse); + expect(settings.host, isEmpty); + expect(settings.nickname, isEmpty); + + await service.saveSettings(host: 'example.com'); + settings = await service.loadSettings(); + + expect(settings.showPokeDialogs, isFalse); + expect(settings.host, 'example.com'); + }); + + test('tracks permission explanation flag', () async { + expect(await service.hasExplainedPermissions(), isFalse); + + await service.markPermissionsExplained(); + + expect(await service.hasExplainedPermissions(), isTrue); + }); +} diff --git a/apps/chanora_flutter/test/widgets/chat_views_test.dart b/apps/chanora_flutter/test/widgets/chat_views_test.dart new file mode 100644 index 0000000..9c5b98a --- /dev/null +++ b/apps/chanora_flutter/test/widgets/chat_views_test.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chanora_flutter/l10n/generated/app_localizations.dart'; +import 'package:chanora_flutter/services/ts3_server_link.dart'; +import 'package:chanora_flutter/src/rust/api.dart' as rust; +import 'package:chanora_flutter/widgets/chat_views.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + ChatEntry entry(rust.BridgeMessageTarget target) { + return ChatEntry( + senderId: BigInt.one, + senderName: 'Sender', + message: 'Message', + target: target, + ); + } + + rust.BridgeChannel channel(BigInt id, String name) { + return rust.BridgeChannel( + id: id, + parent: BigInt.zero, + name: name, + order: 0, + hasPassword: false, + neededTalkPower: 0, + ); + } + + rust.BridgeClient client({ + required BigInt id, + required String name, + required BigInt channelId, + bool isServerQuery = false, + }) { + return rust.BridgeClient( + id: id, + channel: channelId, + name: name, + inputMuted: false, + outputMuted: false, + isSpeaking: false, + isServerQuery: isServerQuery, + talkPower: 0, + talkPowerGranted: false, + ); + } + + rust.BridgeSnapshot snapshot({ + required List channels, + required List clients, + }) { + return rust.BridgeSnapshot( + serverName: 'Server', + welcomeMessage: '', + platform: '', + version: '', + channels: channels, + clients: clients, + ownClientId: BigInt.one, + ); + } + + test('initial chat target opens latest private conversation first', () { + final target = resolveInitialChatTarget( + messages: [ + entry(rust.BridgeMessageTarget.client(BigInt.from(1))), + entry(rust.BridgeMessageTarget.poke(BigInt.from(2))), + entry(rust.BridgeMessageTarget.client(BigInt.from(3))), + ], + currentVoiceChannelId: BigInt.from(9), + ); + + expect(target, rust.BridgeMessageTarget.client(BigInt.from(3))); + }); + + test('initial chat target ignores pokes and falls back to channel', () { + final target = resolveInitialChatTarget( + messages: [ + entry(const rust.BridgeMessageTarget.server()), + entry(rust.BridgeMessageTarget.poke(BigInt.from(2))), + entry(rust.BridgeMessageTarget.poke(BigInt.from(4))), + ], + currentVoiceChannelId: BigInt.from(9), + ); + + expect(target, const rust.BridgeMessageTarget.channel()); + }); + + test('initial chat target falls back to channel when connected to voice', () { + final target = resolveInitialChatTarget( + messages: [ + entry(const rust.BridgeMessageTarget.server()), + entry(const rust.BridgeMessageTarget.channel()), + ], + currentVoiceChannelId: BigInt.from(9), + ); + + expect(target, const rust.BridgeMessageTarget.channel()); + }); + + test('initial chat target falls back to hub when nothing is active', () { + final target = resolveInitialChatTarget( + messages: const [], + currentVoiceChannelId: null, + ); + + expect(target, isNull); + }); + + test( + 'groups chat picker clients by channel and excludes own/query clients', + () { + final groups = groupChatClientsForPicker( + snapshot( + channels: [channel(BigInt.from(10), 'Lobby')], + clients: [ + client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)), + client( + id: BigInt.from(2), + name: 'Zulu', + channelId: BigInt.from(10), + ), + client( + id: BigInt.from(3), + name: 'Alpha', + channelId: BigInt.from(10), + ), + client( + id: BigInt.from(4), + name: 'Query', + channelId: BigInt.from(10), + isServerQuery: true, + ), + client( + id: BigInt.from(5), + name: 'Orphan', + channelId: BigInt.from(99), + ), + ], + ), + ); + + expect( + groups.clientsByChannel[BigInt.from(10)]?.map((client) => client.name), + ['Alpha', 'Zulu'], + ); + expect(groups.ungroupedClients.map((client) => client.name), ['Orphan']); + }, + ); + + test('filters chat picker groups by client name case-insensitively', () { + final groups = filterChatClientPickerGroups( + channels: [ + channel(BigInt.from(10), 'Lobby'), + channel(BigInt.from(20), 'Games'), + ], + clientsByChannel: { + BigInt.from(10): [ + client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)), + ], + BigInt.from(20): [ + client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(20)), + ], + }, + ungroupedClients: [ + client(id: BigInt.from(4), name: 'Alfred', channelId: BigInt.from(99)), + ], + query: 'AL', + ); + + expect(groups.map((group) => group.channelName), ['Lobby', 'Other']); + expect( + groups.expand((group) => group.clients).map((client) => client.name), + ['Alpha', 'Alfred'], + ); + }); + + test('keeps all chat picker groups for blank queries', () { + final groups = filterChatClientPickerGroups( + channels: [channel(BigInt.from(10), 'Lobby')], + clientsByChannel: { + BigInt.from(10): [ + client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)), + client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(10)), + ], + }, + ungroupedClients: [ + client(id: BigInt.from(4), name: 'Orphan', channelId: BigInt.from(99)), + ], + query: ' ', + ); + + expect(groups.map((group) => group.channelName), ['Lobby', 'Other']); + expect(groups.first.clients.map((client) => client.name), [ + 'Alpha', + 'Bravo', + ]); + expect(groups.last.clients.map((client) => client.name), ['Orphan']); + }); + + test('omits empty chat picker groups after filtering', () { + final groups = filterChatClientPickerGroups( + channels: [ + channel(BigInt.from(10), 'Lobby'), + channel(BigInt.from(20), 'Games'), + ], + clientsByChannel: { + BigInt.from(10): [ + client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)), + ], + BigInt.from(20): [ + client(id: BigInt.from(3), name: 'Bravo', channelId: BigInt.from(20)), + ], + }, + ungroupedClients: [ + client(id: BigInt.from(4), name: 'Orphan', channelId: BigInt.from(99)), + ], + query: 'brav', + ); + + expect(groups, hasLength(1)); + expect(groups.single.channelName, 'Games'); + expect(groups.single.clients.single.name, 'Bravo'); + }); + + test('formats spacer channel names for filtered chat picker groups', () { + final groups = filterChatClientPickerGroups( + channels: [channel(BigInt.from(10), '[cSpacer]Lobby')], + clientsByChannel: { + BigInt.from(10): [ + client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.from(10)), + ], + }, + ungroupedClients: const [], + query: '', + ); + + expect(groups.single.channelName, 'Lobby'); + }); + + test('formats chat target titles', () { + expect( + chatTargetTitle( + const rust.BridgeMessageTarget.server(), + channelName: '', + clientName: '', + ), + 'Server Activity', + ); + expect( + chatTargetTitle( + const rust.BridgeMessageTarget.channel(), + channelName: 'Lobby', + clientName: '', + ), + '# Lobby', + ); + expect( + chatTargetTitle( + rust.BridgeMessageTarget.client(BigInt.from(2)), + channelName: '', + clientName: 'Alpha', + ), + 'Alpha', + ); + expect( + chatTargetTitle( + rust.BridgeMessageTarget.poke(BigInt.from(2)), + channelName: '', + clientName: 'Alpha', + ), + 'Poke: Alpha', + ); + }); + + test('formats chat empty-state text', () { + expect( + chatEmptyTitle(const rust.BridgeMessageTarget.server()), + 'No server activity yet', + ); + expect( + chatEmptyBody( + const rust.BridgeMessageTarget.channel(), + channelName: 'Lobby', + ), + 'Start the conversation in #Lobby.', + ); + expect( + chatEmptyBody(const rust.BridgeMessageTarget.channel(), channelName: ''), + 'Join a channel to start chatting.', + ); + expect( + chatEmptyBody( + rust.BridgeMessageTarget.client(BigInt.from(2)), + channelName: '', + ), + 'Select a user to start a private chat.', + ); + }); + + test('formats chat input placeholders', () { + expect( + chatInputPlaceholder( + const rust.BridgeMessageTarget.channel(), + channelName: 'Lobby', + clientName: '', + ), + 'Message #Lobby...', + ); + expect( + chatInputPlaceholder( + rust.BridgeMessageTarget.client(BigInt.from(2)), + channelName: '', + clientName: 'Alpha', + ), + 'Message Alpha...', + ); + expect( + chatInputPlaceholder( + rust.BridgeMessageTarget.poke(BigInt.from(2)), + channelName: '', + clientName: 'Alpha', + ), + 'Poke message...', + ); + }); + + testWidgets('chat hub does not expose pokes as a chat tab', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: ChatPage( + messages: [ + entry(rust.BridgeMessageTarget.poke(BigInt.from(2))), + entry(const rust.BridgeMessageTarget.server()), + ], + snapshot: snapshot(channels: const [], clients: const []), + ), + ), + ); + + expect(find.text('Direct Messages'), findsOneWidget); + expect(find.text('Server Activity'), findsOneWidget); + expect(find.text('Pokes'), findsNothing); + expect(find.text('No pokes'), findsNothing); + }); + + testWidgets('channel chat displays localized poke history in English', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: ChatPage( + messages: [ + ChatEntry( + senderId: BigInt.from(2), + senderName: 'EdisonJwa', + message: '111', + target: rust.BridgeMessageTarget.poke(BigInt.from(2)), + timestamp: DateTime(2026, 5, 23, 5, 11, 17), + ), + ], + snapshot: snapshot(channels: const [], clients: const []), + initialTarget: const rust.BridgeMessageTarget.channel(), + ), + ), + ); + + expect(find.text('<05:11:17> "EdisonJwa" pokes you: 111'), findsOneWidget); + }); + + testWidgets('channel chat displays localized poke history in Chinese', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: AppL10n.localizationsDelegates, + supportedLocales: AppL10n.supportedLocales, + home: ChatPage( + messages: [ + ChatEntry( + senderId: BigInt.from(2), + senderName: 'EdisonJwa', + message: '', + target: rust.BridgeMessageTarget.poke(BigInt.from(2)), + timestamp: DateTime(2026, 5, 23, 5, 10, 37), + ), + ], + snapshot: snapshot(channels: const [], clients: const []), + initialTarget: const rust.BridgeMessageTarget.channel(), + ), + ), + ); + + expect(find.text('<05:10:37> “EdisonJwa”戳了你一下'), findsOneWidget); + }); + + test('blocks channel chat when no voice channel is joined', () { + expect( + canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null), + isFalse, + ); + expect( + chatSendBlockedReason(const rust.BridgeMessageTarget.channel(), null), + 'Join a channel to send messages.', + ); + expect( + canSendToChatTarget( + const rust.BridgeMessageTarget.channel(), + BigInt.from(10), + ), + isTrue, + ); + expect( + chatSendBlockedReason( + const rust.BridgeMessageTarget.channel(), + BigInt.from(10), + ), + isNull, + ); + expect( + canSendToChatTarget( + rust.BridgeMessageTarget.client(BigInt.from(2)), + null, + ), + isTrue, + ); + }); + + testWidgets('chat message TeamSpeak links use internal handler', ( + tester, + ) async { + Ts3ServerLink? tapped; + await tester.pumpWidget( + MaterialApp( + home: ChatPage( + messages: [ + ChatEntry( + senderId: BigInt.one, + senderName: 'Sender', + message: + 'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/', + target: rust.BridgeMessageTarget.server(), + ), + ], + snapshot: snapshot(channels: const [], clients: const []), + initialTarget: const rust.BridgeMessageTarget.server(), + onTs3ServerLink: (link) async => tapped = link, + ), + ), + ); + + await tester.tap( + find.text('ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/'), + ); + await tester.pump(); + + expect(tapped, isNotNull); + expect(tapped!.host, 'teamspeak.app'); + expect(tapped!.addBookmark, 'Vigorous Pro'); + }); +}