diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 5c459fc..9be96e3 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -9,86 +9,35 @@ // (all carried over from v0.3.0-beta.1) import 'dart:async'; -import 'dart:io' show Platform, Process; +import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -import 'design/breakpoints.dart'; -import 'design/viewport_info.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/ios_audio_session_controller.dart'; -import 'services/channel_join_error_mapper.dart'; -import 'services/connection_phase_state.dart'; -import 'services/hard_mute_owners.dart'; -import 'services/ios_permissions_service.dart'; -import 'services/macos_permissions_service.dart'; -import 'services/poke_notification_service.dart'; -import 'services/poke_preferences_service.dart'; -import 'services/prefetch_debouncer.dart'; -import 'services/snapshot_state_mapper.dart'; -import 'services/ts3_server_link.dart'; import 'services/ui_preferences_service.dart'; -import 'services/voice_join_ordering.dart'; +import 'screens/home_screen.dart'; import 'src/rust/api.dart' as rust; import 'src/rust/frb_generated.dart'; -import 'src/rust/lib.dart' as rust_err; -import 'widgets/audio_processing_config_state.dart'; import 'widgets/audio_debug_stats_panel.dart'; -import 'widgets/chat_panel.dart'; -import 'widgets/chat_views.dart'; -import 'widgets/client_info_sheet.dart'; -import 'widgets/connect_widgets.dart'; -import 'widgets/input_dialogs.dart'; -import 'widgets/permission_state_banner.dart'; -import 'widgets/poke_notification_settings.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 'package:share_plus/share_plus.dart'; -bool get _isMacOS => !kIsWeb && Platform.isMacOS; +bool get isMacOS => !kIsWeb && Platform.isMacOS; // Debug-only overlay. `kDebugMode` is a compile-time const in // release builds (= false), so the overlay subtree is tree-shaken // out of release/profile binaries entirely — release users never // see internal audio stats and we don't pay the render cost. -bool get _showAudioDebugOverlay => kDebugMode && _isMacOS; +bool get _showAudioDebugOverlay => kDebugMode && isMacOS; const Color _appSurfaceColor = Color(0xFFFFFBFE); /// Top padding for macOS to clear traffic-light buttons. -const double _macOSTrafficLightPad = 56.0; +const double macOSTrafficLightPad = 56.0; -/// 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 -/// platform-canonical build counter from `package_info_plus`. -/// -/// Why hardcode the semver instead of reading the whole string -/// from `package_info_plus`: iOS rejects non-numeric characters -/// in `CFBundleShortVersionString` and Flutter therefore strips -/// `-rc.8` to `.8` when populating the Info.plist field. The -/// resulting `1.0.0.8` is technically valid on the App Store but -/// useless to humans tracking pre-release builds. -/// `package_info_plus.version` reflects that mangled value. The -/// build counter (`CFBundleVersion` / Android `versionCode`) does -/// pass through unmodified, so we use platform info for the -/// `+` suffix only and pair it with the human-readable -/// semver baseline that this codebase already maintains as the -/// canonical release identity. -/// -/// 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. -String _kAppVersion = appSemverBaseline; +/// Public version string shown in the About dialog. +String kAppVersion = appSemverBaseline; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -106,7 +55,7 @@ Future main() async { Future _finishDeferredStartup() async { try { - _kAppVersion = await resolveAppVersion(); + kAppVersion = await resolveAppVersion(); } catch (_) {} } @@ -149,9 +98,6 @@ class _ChanoraAppState extends State { ), child: MaterialApp( onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle, - // No "DEBUG" banner in the top-right corner. This is purely - // cosmetic for the developer-build experience; release builds - // never render it regardless of this flag. debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, @@ -168,7 +114,7 @@ class _ChanoraAppState extends State { supportedLocales: AppL10n.supportedLocales, home: Stack( children: [ - _BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode), + BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode), if (_showAudioDebugOverlay) const AudioDebugStatsPanel(), ], ), @@ -308,2603 +254,3 @@ class ChanoraMobileScaffold extends StatelessWidget { ); } } - -class _BetaHome extends StatefulWidget { - const _BetaHome({required this.themeMode, required this.onThemeModeChanged}); - - final ThemeMode themeMode; - final Future Function(ThemeMode mode) onThemeModeChanged; - - @override - State<_BetaHome> createState() => _BetaHomeState(); -} - -class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { - final _hostCtl = TextEditingController(text: 'cn.teamspeak.app'); - final _nickCtl = TextEditingController(text: 'ChanoraBeta'); - final _passwordCtl = TextEditingController(); - - ConnectionPhase _phase = ConnectionPhase.idle; - bool get _serverReachable => _phase.isServerReachable; - rust.BridgeSnapshot? _snapshot; - final List _uiDiagnostics = []; - rust.BridgeAudioStats? _audioStats; - double? _inputLevel; - Timer? _statsTimer; - bool _snapshotRefreshInFlight = false; - bool _snapshotRefreshQueued = false; - bool _snapshotRefreshQueuedRecordActivity = false; - bool _snapshotRefreshQueuedReportErrors = false; - int _connectionEpoch = 0; - StreamSubscription? _eventsSub; - StreamSubscription? _inputLevelSub; - late final PrefetchDebouncer _prefetch; - - // v1 voice subsystem state (SDD-094/095/096/097). Driven by - // BridgeEvent::VoiceState. - bool _inChannel = false; - rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt; - bool _hardMute = false; - HardMuteOwners _hardMuteOwners = const HardMuteOwners(); - bool get _hardMuteByPermission => _hardMuteOwners.permission; - bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower; - bool _permissionHardMuteClearInFlight = false; - int _releaseTailMs = 200; - BigInt? _currentVoiceChannelId; - BigInt? _pendingVoiceChannelId; - bool _canJoinVoiceChannel = true; - bool _voiceStateInitialized = false; - - String? _lostReason; - int? _reconnectAttempt; - int? _reconnectDelay; - - bool _inputMuted = false; - bool _outputMuted = false; - - // Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091). - // Populated by `BridgeEvent.PttCapability`. Defaults match the - // universal `FocusedPttBackend::focused()` descriptor so the UI - // shows an honest baseline even before the first event arrives. - String _pttLevel = 'L0Focused'; - String _pttBackendId = 'focused'; - String _pttBoundInputClass = 'keyboard'; - // Last platform-neutral key label the user saved in the - // `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 - // binding; this is a display-only cache that resets on app - // restart. Per DEC-027 the raw OS key code never lives here — - // only the platform-neutral label that already crossed into - // the Rust side. - String _pttBoundKeyLabel = ''; - final Set _focusedPttHeldKeys = {}; - - List _bookmarks = const []; - final List _chatMessages = []; - final ValueNotifier _chatFeedRevision = ValueNotifier(0); - int _chatUnread = 0; - bool _chatOpen = false; - rust.BridgeMessageTarget? _inlineChatTarget; - String _inlineChatClientName = ''; - bool _inlineChatCollapseNoticeShown = false; - - /// Per-target draft text. Populated when switching away from a conversation - /// so the user's unfinished message is preserved. - final Map _chatDrafts = {}; - - /// Channel IDs that have unread chat messages (used for dot indicators). - Set get _unreadChannelIds { - if (_chatOpen) return const {}; - final ids = {}; - for (final entry in _chatMessages) { - if (!entry.countsTowardUnread || entry.isSelf) continue; - final target = entry.target; - if (target is rust.BridgeMessageTarget_Channel) { - // Channel target has no ID payload — it means "current channel". - // We can't distinguish per-channel without the channel ID in the target. - // For now, if there are any unread channel messages, mark the current voice channel. - if (_currentVoiceChannelId != null) { - ids.add(_currentVoiceChannelId!); - } - } - } - return ids; - } - - /// The last chat target before the panel was closed. Used to restore the - /// previous conversation when the user reopens chat. - rust.BridgeMessageTarget? _lastDismissedTarget; - String _lastDismissedClientName = ''; - // SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service. - // Constructed at startup so cold-launch state is captured before the - // first voice_join attempt. On non-Android hosts the service - // short-circuits to "granted" and never wires the MethodChannel - // (see AndroidPermissionsService for the platform branch). - final AndroidPermissionsService _androidPermissions = - AndroidPermissionsService(); - final IosPermissionsService _iosPermissions = IosPermissionsService(); - // SRS-198 / SRS-297 / SRS-300: macOS Input Monitoring, Local Network, - // and Notifications permission service. On non-macOS hosts the service - // short-circuits to "granted" / "unsupported" and never wires the - // MethodChannel. - final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService(); - final UiPreferencesService _uiPreferences = const UiPreferencesService(); - final PokeNotificationService _pokeNotifications = PokeNotificationService(); - final PokePreferencesService _pokePreferences = PokePreferencesService(); - late final Future _pokePreferencesReady; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - HardwareKeyboard.instance.addHandler(_handleFocusedPttKey); - _prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer); - _hostCtl.addListener(_onHostEdited); - _eventsSub = rust.eventsStream().listen(_onEvent); - _inputLevelSub = rust.inputLevelStream().listen((level) { - if (mounted) setState(() => _inputLevel = level); - }); - // SDD-106 §5: subscribe to Kotlin -> Dart permissionStateChanged - // events as early as possible so the listen-only banner reflects - // the system state on first frame. - _androidPermissions.start(); - unawaited(_iosPermissions.start()); - _androidPermissions.recordAudioState.addListener( - _onRecordAudioPermissionChanged, - ); - _iosPermissions.recordAudioState.addListener( - _onRecordAudioPermissionChanged, - ); - // SRS-198 / SRS-297 / SRS-300: start macOS permission service. - // On non-macOS this is a no-op. On macOS, it checks Input - // Monitoring state and begins polling for changes so the PTT - // capability badge upgrades from L0Focused → L1MacOSEventTap - // when the user grants the permission in System Settings. - _macOSPermissions.start(); - _macOSPermissions.pttCapabilityState.addListener( - _onMacOSPttCapabilityChanged, - ); - _macOSPermissions.checkInitialStates(); - unawaited(_pokeNotifications.init()); - _pokePreferencesReady = _pokePreferences.load(); - WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_requestRecordAudioOnStartup()); - }); - 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 (settings.host.isNotEmpty) { - _prefetch.schedule(settings.host); - } - } catch (_) {} - } - - void _onHostEdited() { - _prefetch.schedule(_hostCtl.text); - } - - Future _onPrefetchServer(String host) async { - try { - await rust.prefetchServer(host: host); - } catch (e) { - _recordUiDiagnostic('prefetch server', e); - } - } - - Future _saveUiSettings({String? host, String? nickname}) async { - try { - await _uiPreferences.saveSettings(host: host, nickname: nickname); - } catch (_) {} - } - - Future _requestRecordAudioOnStartup() async { - try { - if (Platform.isAndroid) { - final permsExplained = await _uiPreferences.hasExplainedPermissions(); - if (!permsExplained) { - if (!mounted) return; - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(AppL10n.of(ctx).startupPermissionsTitle), - content: Text(AppL10n.of(ctx).startupPermissionsBody), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text(AppL10n.of(ctx).startupPermissionsNotNow), - ), - FilledButton( - onPressed: () { - Navigator.pop(ctx); - _androidPermissions.ensureStartupPermissions(); - }, - child: Text(AppL10n.of(ctx).startupPermissionsAllow), - ), - ], - ), - ); - await _uiPreferences.markPermissionsExplained(); - } else { - await _androidPermissions.ensureStartupPermissions(); - } - } - } catch (_) {} - } - - ValueListenable - get _activeRecordAudioState => Platform.isIOS - ? _iosPermissions.recordAudioState - : _androidPermissions.recordAudioState; - - Future _ensureActiveRecordAudio() { - return Platform.isIOS - ? _iosPermissions.ensureRecordAudio() - : _androidPermissions.ensureRecordAudio(); - } - - Future _openActivePermissionSettings() { - return Platform.isIOS - ? _iosPermissions.openAppSettings() - : _androidPermissions.openAppSettings(); - } - - void _onRecordAudioPermissionChanged() { - if (_activeRecordAudioState.value == - AndroidRecordAudioPermissionState.granted) { - unawaited(_clearPermissionHardMute()); - } - } - - /// SRS-198 / SRS-297 / SRS-300 / SDD-091: React to macOS Input - /// Monitoring state changes by updating the PTT capability level. - /// When the macOS permissions service detects that Input Monitoring - /// has been granted (via polling), it emits `L1MacOSEventTap` which - /// overrides the bridge-emitted `L0Focused` default. - void _onMacOSPttCapabilityChanged() { - final level = _macOSPermissions.pttCapabilityState.value; - // Only override if the macOS service has a resolved state - // different from the current bridge-emitted level, and only - // on macOS. - if (_isMacOS && level != _pttLevel) { - setState(() { - _pttLevel = level; - if (level == 'L1MacOSEventTap') { - _pttBackendId = 'macos-event-tap'; - } else if (level == 'L0Focused') { - _pttBackendId = 'focused'; - } - }); - } - } - - Future _clearPermissionHardMute() async { - if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return; - _permissionHardMuteClearInFlight = true; - try { - await rust.setHardMute(muted: false); - if (!mounted || !_hardMuteByPermission) return; - setState(() { - _hardMuteOwners = _hardMuteOwners.copyWith(permission: false); - _hardMute = _hardMuteOwners.effective; - }); - } catch (e) { - if (!mounted) return; - _showUiError('clear permission mute', e); - } finally { - _permissionHardMuteClearInFlight = false; - } - } - - void _recordUiDiagnostic(String area, Object error) { - final line = '${DateTime.now().toIso8601String()} [$area] $error'; - debugPrint('chanora: $line'); - _uiDiagnostics.add(line); - if (_uiDiagnostics.length > 100) { - _uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100); - } - } - - void _showUiError(String area, Object error) { - if (!mounted) return; - _showUiErrorSnackBar(area: area, error: error); - } - - void _showUiErrorSnackBar({ - required String area, - required Object error, - String? displayMessage, - }) { - _recordUiDiagnostic(area, error); - final l10n = AppL10n.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - content: Text( - displayMessage ?? l10n.statusError(error.toString()), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - ); - } - - void _showLocalNetworkDeniedSnackBar() { - if (!mounted) return; - final l10n = AppL10n.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 8), - content: Text( - l10n.networkPermissionBody, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - action: SnackBarAction( - label: l10n.networkPermissionOpenSettings, - onPressed: () { - // Fire-and-forget: don't block snackbar dismissal on the - // child process. `Process.run` may throw synchronously - // (e.g. exec ENOENT) or return a future that rejects - // (e.g. macOS denies fork); both paths are swallowed - // because the user can still open Settings manually. - unawaited( - Future(() async { - await Process.run('open', [ - 'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork', - ]); - }).catchError((_) {}), - ); - }, - ), - ), - ); - } - - bool _handleFocusedPttKey(KeyEvent event) { - final label = pttDisplayLabelForKey(event.logicalKey); - final isBoundKey = - _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel; - if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) { - if (_focusedPttHeldKeys.remove(event.logicalKey)) { - unawaited(_setPtt(false)); - } - return true; - } - if (_pttBackendId != 'focused' || - !_serverReachable || - !_inChannel || - _transmitMode != rust.BridgeTransmitMode.ptt || - !isBoundKey) { - return false; - } - if (event is KeyDownEvent) { - if (_focusedPttHeldKeys.add(event.logicalKey)) { - unawaited(_setPtt(true)); - } - return true; - } - return false; - } - - void _releaseFocusedPttIfHeld() { - if (_focusedPttHeldKeys.isEmpty) return; - _focusedPttHeldKeys.clear(); - unawaited(_setPtt(false, reportError: false)); - } - - /// Hydrate the bound-key display state from the bridge so the - /// Voice Bar shows the user's persisted hotkey label immediately - /// at launch — without having to wait for them to open the - /// binding dialog. The bridge returns empty strings when no - /// binding has ever been saved. - Future _hydratePttBinding() async { - try { - final binding = await rust.getPttBinding(); - if (!mounted) return; - if (binding.keyLabel.isNotEmpty || binding.inputClass.isNotEmpty) { - setState(() { - _pttBoundKeyLabel = binding.keyLabel; - _pttBoundInputClass = binding.inputClass; - }); - } - } catch (_) { - // No persisted binding or storage not yet wired; not an error. - } - } - - Future _reloadBookmarks() async { - try { - await wireStorage(); - final list = await rust.listBookmarks(); - if (!mounted) return; - setState(() => _bookmarks = list); - } catch (_) { - // Bookmark store missing on this platform — empty list is fine. - } - } - - void _onEvent(rust.BridgeEvent evt) { - if (!mounted) return; - switch (evt) { - case rust.BridgeEvent_Connected(): - final hadSnapshot = _snapshot != null; - setState(() { - _phase = phaseAfterConnectedEvent(_phase, hasSnapshot: hadSnapshot); - _lostReason = null; - _reconnectAttempt = null; - _reconnectDelay = null; - }); - unawaited(iosAudioSessionController.activate()); - unawaited(_refreshSnapshot(recordActivity: false, reportErrors: true)); - case rust.BridgeEvent_Lost(:final reason): - _recordUiDiagnostic('connection', 'lost: $reason'); - setState(() { - _phase = ConnectionPhase.reconnecting; - _lostReason = reason; - _reconnectAttempt = null; - _reconnectDelay = null; - }); - case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs): - _recordUiDiagnostic( - 'connection', - 'reconnecting attempt=$attempt delay=${delaySecs}s', - ); - setState(() { - _phase = ConnectionPhase.reconnecting; - _reconnectAttempt = attempt; - _reconnectDelay = delaySecs; - }); - case rust.BridgeEvent_Disconnected(): - _recordUiDiagnostic('connection', 'disconnected'); - setState(() { - _resetConnectionUiState(phase: ConnectionPhase.disconnected); - }); - case rust.BridgeEvent_AudioStarted(): - unawaited(iosAudioSessionController.activate()); - _ensureStatsTimer(); - case rust.BridgeEvent_AudioStopped(): - unawaited(iosAudioSessionController.deactivate()); - _statsTimer?.cancel(); - _statsTimer = null; - case rust.BridgeEvent_PttCapability( - :final level, - :final backendId, - :final boundInputClass, - ): - setState(() { - _pttLevel = level; - _pttBackendId = backendId; - _pttBoundInputClass = boundInputClass; - }); - if (backendId != 'focused') { - _releaseFocusedPttIfHeld(); - } - case rust.BridgeEvent_VoiceState( - :final inChannel, - :final transmitMode, - :final mute, - :final releaseTailMs, - :final currentChannelId, - :final pendingTargetChannelId, - :final canJoin, - ): - setState(() { - _voiceStateInitialized = true; - _inChannel = inChannel; - _transmitMode = transmitMode; - _hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute); - _hardMute = _hardMuteOwners.effective; - _releaseTailMs = releaseTailMs; - _currentVoiceChannelId = currentChannelId; - _pendingVoiceChannelId = pendingTargetChannelId; - _canJoinVoiceChannel = canJoin; - }); - if (inChannel) { - _ensureStatsTimer(); - } else { - _statsTimer?.cancel(); - _statsTimer = null; - _releaseFocusedPttIfHeld(); - } - if (transmitMode != rust.BridgeTransmitMode.ptt) { - _releaseFocusedPttIfHeld(); - } - case rust.BridgeEvent_InterruptionState( - :final began, - :final shouldResume, - ): - // Surface iOS audio interruption to the user (SDD-101). - // Use unawaited to stay inside the sync _onEvent stream - // without blocking it. - if (!mounted) return; - unawaited(() async { - if (!mounted) return; - final messenger = ScaffoldMessenger.of(context); - if (began) { - _recordUiDiagnostic('audio', 'interruption began'); - messenger.showSnackBar( - SnackBar( - content: Text(AppL10n.of(context).iosAudioInterrupted), - duration: Duration(seconds: 3), - backgroundColor: Colors.orange, - ), - ); - } else if (shouldResume) { - _recordUiDiagnostic('audio', 'interruption ended, resuming'); - messenger.showSnackBar( - SnackBar( - content: Text(AppL10n.of(context).iosAudioResuming), - duration: Duration(seconds: 2), - backgroundColor: Colors.green, - ), - ); - } - }()); - // SDD-106 §5/§6 / SRS-209: defensive observer of the - // authoritative Rust-side permission stream. The transmit - // clamp is already applied inside `chanora_bridge` before - // this event is broadcast. When permission later becomes - // granted, release only the permission-owned hard-mute; do - // not touch server-side input mute, which may be user-owned. - case rust.BridgeEvent_PermissionState(:final permission, :final state): - debugPrint( - 'bridge permission_state: permission=$permission state=$state', - ); - if (permission == 'android.permission.RECORD_AUDIO' && - state == rust.PermissionStateKind.granted) { - unawaited(_clearPermissionHardMute()); - } - case rust.BridgeEvent_ChatMessage( - :final senderId, - :final senderName, - :final message, - :final target, - :final pokeStrength, - ): - final isPoke = target is rust.BridgeMessageTarget_Poke; - // Skip echo of self-sent non-poke messages (already added locally). - if (!isPoke && senderId == _snapshot?.ownClientId) return; - final receivedAt = DateTime.now(); - setState(() { - _appendChatEntryUnlocked( - ChatEntry( - senderId: senderId, - senderName: senderName, - message: message, - target: target, - isSelf: senderId == _snapshot?.ownClientId, - timestamp: receivedAt, - ), - ); - }); - if (isPoke) { - unawaited( - _handleIncomingPoke( - senderId: senderId, - senderName: senderName, - message: message, - strength: pokeStrength, - ), - ); - return; - } - if (!_chatOpen && _chatUnread > 0) { - if (!mounted) return; - unawaited(() async { - if (!mounted) return; - _showChatMessageSnackBar( - senderName: senderName, - message: message, - target: target, - ); - }()); - } - case rust.BridgeEvent_ServerActivity(:final message): - setState(() { - _appendChatEntryUnlocked( - ChatEntry( - senderId: BigInt.zero, - senderName: 'Server', - message: message, - target: const rust.BridgeMessageTarget.server(), - timestamp: DateTime.now(), - countsTowardUnread: false, - ), - ); - }); - case rust.BridgeEvent_AudioRouteChanged(): - break; - case rust.BridgeEvent_ClientMoved(:final clientId, :final newChannelId): - _applyClientDelta((c) => c.id == clientId, (c) { - final updated = rust.BridgeClient( - id: c.id, - channel: newChannelId, - name: c.name, - inputMuted: c.inputMuted, - outputMuted: c.outputMuted, - isSpeaking: c.isSpeaking, - isServerQuery: c.isServerQuery, - talkPower: c.talkPower, - talkPowerGranted: c.talkPowerGranted, - ); - return updated; - }); - case rust.BridgeEvent_ClientJoined( - :final clientId, - :final channelId, - :final name, - :final inputMuted, - :final outputMuted, - :final isServerQuery, - :final talkPower, - :final talkPowerGranted, - ): - if (!isServerQuery) { - _applyClientAdd( - rust.BridgeClient( - id: clientId, - channel: channelId, - name: name, - inputMuted: inputMuted, - outputMuted: outputMuted, - isSpeaking: false, - isServerQuery: isServerQuery, - talkPower: talkPower, - talkPowerGranted: talkPowerGranted, - ), - ); - } - case rust.BridgeEvent_ClientLeft(:final clientId): - _applyClientRemove(clientId); - case rust.BridgeEvent_ClientUpdated( - :final clientId, - :final inputMuted, - :final outputMuted, - :final isServerQuery, - :final talkPower, - :final talkPowerGranted, - ): - _applyClientDelta((c) => c.id == clientId, (c) { - final updated = rust.BridgeClient( - id: c.id, - channel: c.channel, - name: c.name, - inputMuted: inputMuted, - outputMuted: outputMuted, - isSpeaking: c.isSpeaking, - isServerQuery: isServerQuery, - talkPower: talkPower, - talkPowerGranted: talkPowerGranted, - ); - return updated; - }); - case rust.BridgeEvent_ChannelAdded( - :final id, - :final parent, - :final name, - :final order, - :final hasPassword, - :final neededTalkPower, - ): - _applyChannelAdd( - rust.BridgeChannel( - id: id, - parent: parent, - name: name, - order: order, - hasPassword: hasPassword, - neededTalkPower: neededTalkPower, - ), - ); - case rust.BridgeEvent_ChannelRemoved(:final id): - _applyChannelRemove(id); - case rust.BridgeEvent_ChannelUpdated( - :final id, - :final name, - :final hasPassword, - :final neededTalkPower, - ): - _applyChannelDelta((ch) => ch.id == id, (ch) { - return rust.BridgeChannel( - id: ch.id, - parent: ch.parent, - name: name, - order: ch.order, - hasPassword: hasPassword, - neededTalkPower: neededTalkPower, - ); - }); - } - } - - void _stopStatsPolling() { - _statsTimer?.cancel(); - _statsTimer = null; - } - - void _startStatsPollingIfConnected() { - if (_serverReachable) _ensureStatsTimer(); - } - - DateTime? _lastSpeakingRefresh; - static const _speakingRefreshInterval = Duration(milliseconds: 750); - - void _ensureStatsTimer() { - if (_statsTimer != null) return; - _lastSpeakingRefresh = null; - _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { - try { - final s = await rust.audioStats(); - if (!mounted) return; - setState(() => _audioStats = s); - } catch (_) {} - final now = DateTime.now(); - if (_inChannel && - (_lastSpeakingRefresh == null || - now.difference(_lastSpeakingRefresh!) >= - _speakingRefreshInterval)) { - _lastSpeakingRefresh = now; - unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false)); - } - }); - } - - @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(); - _inputLevelSub?.cancel(); - _statsTimer?.cancel(); - _snapshotRefreshInFlight = false; - _snapshotRefreshQueued = false; - _snapshotRefreshQueuedRecordActivity = false; - _snapshotRefreshQueuedReportErrors = false; - _hostCtl.removeListener(_onHostEdited); - _prefetch.dispose(); - _hostCtl.dispose(); - _nickCtl.dispose(); - _passwordCtl.dispose(); - _chatFeedRevision.dispose(); - _androidPermissions.recordAudioState.removeListener( - _onRecordAudioPermissionChanged, - ); - _iosPermissions.recordAudioState.removeListener( - _onRecordAudioPermissionChanged, - ); - // SRS-198 / SRS-297: detach macOS permission listeners. - _macOSPermissions.pttCapabilityState.removeListener( - _onMacOSPttCapabilityChanged, - ); - // SDD-106: detach the Kotlin -> Dart MethodChannel handler so a - // late invokeMethod from the platform side cannot land on this - // disposed state. - _androidPermissions.stop(); - _iosPermissions.stop(); - _macOSPermissions.stop(); - _pokePreferences.dispose(); - super.dispose(); - } - - Future _onConnect({ - String? host, - String? nickname, - String? password, - }) async { - final connectEpoch = ++_connectionEpoch; - - // Trigger macOS Local Network permission prompt before connecting. - // On macOS 15+ the first network connection triggers the system - // dialog; triggering it via NWBrowser here ensures the prompt - // appears *before* the actual connect attempt so the user can grant - // permission and the connection succeeds in one flow. - final lnState = _macOSPermissions.localNetworkState.value; - if (lnState == MacOSLocalNetworkState.unknown || - lnState == MacOSLocalNetworkState.notDetermined) { - await _macOSPermissions.triggerLocalNetworkPrompt(); - } - - // If the prompt resolved to Denied (or was already Denied), confirm - // with a read-only NWConnection probe to the target host and show - // a snackbar directing the user to System Settings. - final resolvedState = _macOSPermissions.localNetworkState.value; - if (resolvedState == MacOSLocalNetworkState.denied) { - final targetHost = (host ?? _hostCtl.text).trim(); - final accessState = await _macOSPermissions.checkLocalNetworkAccess( - host: targetHost, - port: 9987, - ); - if (accessState == MacOSLocalNetworkState.denied) { - if (!mounted) return; - setState(() { - _phase = ConnectionPhase.idle; - }); - _showLocalNetworkDeniedSnackBar(); - return; - } - } - - setState(() { - _phase = ConnectionPhase.connecting; - _snapshot = null; - _chatMessages.clear(); - }); - _releaseFocusedPttIfHeld(); - await iosAudioSessionController.activate(); - try { - final snap = await rust.connect( - host: (host ?? _hostCtl.text).trim(), - nickname: (nickname ?? _nickCtl.text).trim(), - password: password ?? _passwordCtl.text, - ); - if (!mounted) return; - final connectedHost = host ?? _hostCtl.text.trim(); - final connectedNickname = nickname ?? _nickCtl.text.trim(); - setState(() { - _phase = ConnectionPhase.synchronizing; - _applySnapshot(snap); - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || - _connectionEpoch != connectEpoch || - !_serverReachable || - _snapshot?.serverName != snap.serverName) { - return; - } - unawaited( - _runPostConnectSideEffects( - snap: snap, - host: connectedHost, - nickname: connectedNickname, - ), - ); - }); - } catch (e) { - if (!mounted) return; - setState(() { - _phase = ConnectionPhase.idle; - }); - _showUiErrorSnackBar(area: 'connect', error: e); - } - } - - Future _runPostConnectSideEffects({ - required rust.BridgeSnapshot snap, - required String host, - required String nickname, - }) async { - unawaited(_saveUiSettings(host: host, nickname: nickname)); - try { - await FlutterForegroundTask.startService( - notificationTitle: 'Chanora', - notificationText: 'Connected to ${snap.serverName}', - notificationButtons: const [ - NotificationButton(id: 'disconnect', text: 'Disconnect'), - ], - ); - } catch (_) {} - } - - Future _setPtt(bool active, {bool reportError = true}) async { - if (active) { - try { - HapticFeedback.lightImpact(); - } catch (_) {} - } - try { - await rust.setPtt(active: active); - } catch (e) { - if (!mounted || !reportError) return; - _showUiError('ptt', e); - } - } - - Future _toggleOutputMute() async { - final next = !_outputMuted; - setState(() { - _outputMuted = next; - }); - try { - await rust.setOutputMuted(muted: next); - } catch (e) { - if (!mounted) return; - setState(() { - _outputMuted = !next; - }); - _showUiError('output mute', e); - } - } - - Future _onJoinChannel( - rust.BridgeChannel ch, { - bool askForPassword = false, - }) async { - if (!_canJoinVoiceChannel) return; - if (_pendingVoiceChannelId != null) return; - if (ch.id == _currentVoiceChannelId) return; - final l10n = AppL10n.of(context); - String? password; - if (askForPassword) { - password = await _askChannelPassword(l10n); - if (password == null) return; // cancelled - } - setState(() => _pendingVoiceChannelId = ch.id); - try { - // SDD-106 §1, §6 + SRS-209: Android runtime permission gate. - // Request RECORD_AUDIO at or before voice_join. On denial or - // permanent denial we still proceed (listen-only is a - // first-class mode per SDD-106) but clamp local transmit via - // setHardMute so the audio engine never opens the capture - // stream as a sender. On non-Android the service short-circuits - // to granted and this branch is a no-op. - // - // Trace: SDD-106 §1 (request timing), §2 (listen-only on denial), - // §3 (path to settings on permanent denial), §6 - // (TransmitModeSelector clamp); SRS-209. - final permState = Platform.isAndroid - ? await _androidPermissions.ensureRecordAudio() - : Platform.isIOS - ? await _iosPermissions.ensureRecordAudio() - : AndroidRecordAudioPermissionState.granted; - if (permState != AndroidRecordAudioPermissionState.granted) { - // Listen-only: clamp hard-mute. The permission_state_banner - // surfaces the path-to-grant; the user can re-attempt at any - // time via the Grant / Open Settings action. - try { - await rust.setHardMute(muted: true); - if (mounted) { - setState(() { - _hardMuteOwners = _hardMuteOwners.copyWith(permission: true); - _hardMute = _hardMuteOwners.effective; - }); - } - } catch (_) { - // Best-effort clamp; if the bridge isn't ready we still - // proceed. The capture path also self-clamps on Android - // when RECORD_AUDIO is not granted (SDD-106 §6 Rust-side, - // via BridgeEvent::PermissionState → TransmitModeSelector - // AtomicU8 clamp); this Dart setHardMute is the - // defence-in-depth path. - } - } else if (_hardMuteByPermission) { - await rust.setHardMute(muted: false); - if (mounted) { - setState(() { - _hardMuteOwners = _hardMuteOwners.copyWith(permission: false); - _hardMute = _hardMuteOwners.effective; - }); - } - } - await joinVoiceChannelWithIosAudioSession( - channelId: ch.id, - password: password ?? '', - voiceJoin: rust.voiceJoin, - activateIosAudioSession: iosAudioSessionController.activate, - deactivateIosAudioSession: iosAudioSessionController.deactivate, - // Server says we are already in the target channel: the user is - // still joined to a voice channel, so the iOS audio session must - // stay active. The catch below converts this rethrow into the - // success-on-already-joined branch. - isJoinSuccess: _isAlreadyInChannel, - ); - if (!mounted) return; - setState(() { - _currentVoiceChannelId = ch.id; - _pendingVoiceChannelId = null; - _inChannel = true; - _canJoinVoiceChannel = true; - _voiceStateInitialized = true; - }); - } catch (e) { - if (!mounted) return; - if (_isAlreadyInChannel(e)) { - setState(() { - _currentVoiceChannelId = ch.id; - _pendingVoiceChannelId = null; - _inChannel = true; - }); - return; - } - if (_isFlooding(e)) { - setState(() { - _pendingVoiceChannelId = null; - }); - _showUiErrorSnackBar( - area: 'join channel', - error: e, - displayMessage: l10n.channelJoinFailedFlooding, - ); - return; - } - final message = channelJoinErrorMessage(l10n, e); - setState(() { - _pendingVoiceChannelId = null; - }); - _showUiErrorSnackBar( - area: 'join channel', - error: e, - displayMessage: message, - ); - } - } - - bool _isAlreadyInChannel(Object error) { - if (error is rust_err.BridgeError_ServerRejected) { - return error.code == 0x0302; - } - return false; - } - - bool _isFlooding(Object error) { - if (error is rust_err.BridgeError_ServerRejected) { - return error.code == 0x020c; - } - return false; - } - - Future _onToggleHardMute() async { - // Don't allow manual unmute when talk-power-muted. - if (_hardMuteByTalkPower && _hardMute) { - return; - } - final next = !_hardMute; - final previousInputMuted = _inputMuted; - final previousHardMute = _hardMute; - final previousHardMuteOwners = _hardMuteOwners; - setState(() { - _inputMuted = next; - _hardMuteOwners = _hardMuteOwners.copyWith( - manual: next, - permission: next ? false : null, - ); - _hardMute = _hardMuteOwners.effective; - }); - try { - // Hard-mute is two coordinated effects: - // * setHardMute — local TransmitGate clamp; we stop sending - // Opus frames the instant this returns. - // * setInputMuted — server-side ClientMuted flag so other - // clients see the mic-off icon next to our name and the - // server stops relaying any in-flight frames. - // Sending only one of them is user-confusing; clients see - // silence but no icon, or icon but a beat of audio leaks - // through. Drive them together. - await rust.setHardMute(muted: next); - await rust.setInputMuted(muted: next); - } catch (e) { - if (!mounted) return; - setState(() { - _inputMuted = previousInputMuted; - _hardMute = previousHardMute; - _hardMuteOwners = previousHardMuteOwners; - }); - _showUiError('hard mute', e); - } - } - - /// Touch-only PTT (iOS / iPadOS / Android). The on-screen - /// `_PttHoldButton` calls this with `true` on finger-down and - /// `false` on finger-up (or cancel). The bridge's - /// `setPtt(active:)` routes the press edge through the same - /// release-tail timer + transmit-mode selector the desktop - /// hardware-key paths use (SDD-096 / SAD-083), so the user- - /// visible behaviour is identical across platforms — only the - /// input device changes. - /// - /// Errors are swallowed silently in the held=false branch - /// because the timer's `key_up` is idempotent; a failed send - /// would still let the tail expire naturally. Errors on - /// held=true surface in the UI banner so the user knows the - /// mic didn't open. - Future _onOnscreenPttHeldChanged(bool held) async { - try { - await rust.setPtt(active: held); - } catch (e) { - if (held) { - if (!mounted) return; - _showUiError('onscreen ptt', e); - } - } - } - - Future _loadAudioProcessingConfig() async { - try { - return await rust.getAudioProcessingConfig(); - } catch (_) { - return defaultAudioProcessingConfig(); - } - } - - void _appendChatEntryUnlocked(ChatEntry entry) { - _chatMessages.add(entry); - if (_chatMessages.length > 200) { - _chatMessages.removeRange(0, _chatMessages.length - 200); - } - _notifyChatFeedChanged(); - if (!_chatOpen && !entry.isPoke && entry.countsTowardUnread) { - _chatUnread++; - } - } - - void _notifyChatFeedChanged() { - _chatFeedRevision.value++; - } - - /// 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 { - final audioConfig = await _loadAudioProcessingConfig(); - if (!mounted) return; - - await showVoiceDetailsSheet( - context, - transmitMode: _transmitMode, - releaseTailMs: _releaseTailMs, - pttBoundKeyLabel: _pttBoundKeyLabel, - pttLevel: _pttLevel, - pttBackendId: _pttBackendId, - pttBoundInputClass: _pttBoundInputClass, - isTouchOnly: isTouchOnlyPttHost, - initialAudioConfig: audioConfig, - onModeChanged: (mode) async { - try { - await rust.setTransmitMode(mode: mode); - if (!mounted) return; - setState(() => _transmitMode = mode); - } catch (e) { - if (!mounted) return; - _showUiError('transmit mode', e); - } - }, - onReleaseTailChanged: (ms) async { - try { - await rust.setReleaseTailMs(ms: ms); - if (!mounted) return; - setState(() => _releaseTailMs = ms); - } catch (e) { - if (!mounted) return; - _showUiError('release tail', e); - } - }, - onAudioConfigChanged: (config) async { - try { - await rust.setAudioProcessingConfig(config: config); - } catch (e) { - if (!mounted) return; - _showUiError('audio processing config', e); - } - }, - ); - } - - Future _onOpenVoiceSettings() async { - final audioConfig = await _loadAudioProcessingConfig(); - if (!mounted) return; - - final result = await showDialog( - context: context, - builder: (ctx) => VoiceSettingsDialog( - initialMode: _transmitMode, - initialReleaseTailMs: _releaseTailMs, - initialAudioConfig: audioConfig, - pttLevel: _pttLevel, - pttBackendId: _pttBackendId, - pttBoundInputClass: _pttBoundInputClass, - ), - ); - if (result == null) return; - try { - await rust.setTransmitMode(mode: result.mode); - await rust.setReleaseTailMs(ms: result.releaseTailMs); - await rust.setAudioProcessingConfig(config: result.audioConfig); - } catch (e) { - if (!mounted) return; - _showUiError('voice settings', e); - } - if (result.bindKeyRequested && mounted) { - await _onConfigurePtt(context); - if (mounted) { - unawaited(_onOpenVoiceSettings()); - } - } - } - - Future _onOpenPokeSettings() async { - await _pokePreferences.load(); - if (!mounted) return; - await showDialog( - context: context, - builder: (ctx) => - PokeNotificationSettingsDialog(preferences: _pokePreferences), - ); - } - - Future _askChannelPassword(AppL10n l10n) async { - // Same pattern as _onAddCurrentBookmark: route the dialog - // through a dedicated StatefulWidget so its - // TextEditingController is disposed at unmount time, not - // synchronously after `await showDialog` resumes. Inline - // dispose-after-await caused framework.dart:6268 - // _dependents.isEmpty assertions on iOS \u2014 the controller was - // torn out while EditableText still depended on InheritedWidgets - // belonging to the still-popping dialog route. - return await showDialog( - context: context, - builder: (ctx) => const ChannelPasswordDialog(), - ); - } - - Future _refreshSnapshot({ - required bool recordActivity, - required bool reportErrors, - }) async { - if (_snapshotRefreshInFlight) { - _snapshotRefreshQueued = true; - _snapshotRefreshQueuedRecordActivity = - _snapshotRefreshQueuedRecordActivity || recordActivity; - _snapshotRefreshQueuedReportErrors = - _snapshotRefreshQueuedReportErrors || reportErrors; - return; - } - - _snapshotRefreshInFlight = true; - var nextRecordActivity = recordActivity; - var nextReportErrors = reportErrors; - - try { - while (mounted) { - _snapshotRefreshQueued = false; - _snapshotRefreshQueuedRecordActivity = false; - _snapshotRefreshQueuedReportErrors = false; - - final previousSnapshot = _snapshot; - try { - final snap = await rust.snapshot(); - if (!mounted) return; - final activityEntries = nextRecordActivity && previousSnapshot != null - ? buildServerActivityEntries( - previous: previousSnapshot, - current: snap, - ) - : const []; - setState(() { - _applySnapshot(snap); - for (final entry in activityEntries) { - _appendChatEntryUnlocked(entry); - } - }); - } catch (e) { - if (nextReportErrors && mounted) { - _showUiError('refresh snapshot', e); - } - } - - if (!_snapshotRefreshQueued) { - break; - } - nextRecordActivity = _snapshotRefreshQueuedRecordActivity; - nextReportErrors = _snapshotRefreshQueuedReportErrors; - } - } finally { - _snapshotRefreshInFlight = false; - _snapshotRefreshQueued = false; - _snapshotRefreshQueuedRecordActivity = false; - _snapshotRefreshQueuedReportErrors = false; - } - } - - Future _onDisconnect() async { - _connectionEpoch++; - _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; - _snapshotRefreshInFlight = false; - _snapshotRefreshQueued = false; - _snapshotRefreshQueuedRecordActivity = false; - _snapshotRefreshQueuedReportErrors = false; - try { - await rust.disconnect(); - } catch (_) {} - try { - await FlutterForegroundTask.stopService(); - } catch (_) {} - // Reload bookmarks so auto-saved recent-server entries from the - // just-ended session appear in the connect-page bookmark list. - unawaited(_reloadBookmarks()); - } - - void _resetConnectionUiState({required ConnectionPhase phase}) { - _phase = phase; - _snapshot = null; - _audioStats = null; - _inputMuted = false; - _outputMuted = false; - _hardMute = false; - _hardMuteOwners = const HardMuteOwners(); - _inChannel = false; - _currentVoiceChannelId = null; - _pendingVoiceChannelId = null; - _canJoinVoiceChannel = true; - _voiceStateInitialized = false; - _lostReason = null; - _reconnectAttempt = null; - _reconnectDelay = null; - _chatMessages.clear(); - _chatDrafts.clear(); - _chatUnread = 0; - _chatOpen = false; - _inlineChatTarget = null; - _inlineChatClientName = ''; - _inlineChatCollapseNoticeShown = false; - _lastDismissedTarget = null; - _lastDismissedClientName = ''; - _notifyChatFeedChanged(); - } - - /// Converts a [rust.BridgeMessageTarget] to a stable string key for draft storage. - String _draftKeyForTarget(rust.BridgeMessageTarget target) { - return switch (target) { - rust.BridgeMessageTarget_Server() => 'server', - rust.BridgeMessageTarget_Channel() => 'channel', - rust.BridgeMessageTarget_Client(:final field0) => 'client:$field0', - rust.BridgeMessageTarget_Poke(:final field0) => 'poke:$field0', - }; - } - - /// Returns the current draft text for the inline chat target, or null - /// if no draft has been stored. - /// - /// Draft persistence relies on [ChatDetailView]'s own lifecycle: - /// `didUpdateWidget` flushes the outgoing target's draft via - /// `onDraftChanged` when the parent rebuilds with a new target, and - /// `dispose` flushes the final draft when the panel is torn down - /// (e.g. on [_closeInlineChat]). Both paths feed - /// [_chatDrafts] without requiring an explicit save call from this - /// class. - String? _currentDraftFor(rust.BridgeMessageTarget target) => - _chatDrafts[_draftKeyForTarget(target)]; - - Future _onOpenChat({ - rust.BridgeMessageTarget? target, - String clientName = '', - }) async { - final initialSnapshot = _snapshot!; - - // Resolve the new target. - // If no target passed and panel is already open, switch to current voice channel. - // If no target passed and panel is closed, resolve from history or default. - rust.BridgeMessageTarget newTarget; - if (target != null) { - newTarget = target; - } else if (_chatOpen && _currentVoiceChannelId != null) { - newTarget = const rust.BridgeMessageTarget.channel(); - } else if (_inlineChatTarget != null) { - newTarget = _inlineChatTarget!; - } else { - newTarget = - resolveInitialChatTarget( - messages: _chatMessages, - currentVoiceChannelId: _currentVoiceChannelId, - ) ?? - const rust.BridgeMessageTarget.server(); - } - - final newClientName = clientName.isNotEmpty - ? clientName - : (newTarget == _inlineChatTarget) - ? _inlineChatClientName - : (newTarget == _lastDismissedTarget) - ? _lastDismissedClientName - : ''; - - final isExpanded = - layoutClassFromWidth(MediaQuery.sizeOf(context).width) == - LayoutClass.expanded; - if (isExpanded) { - // Outgoing draft is preserved by ChatDetailView.didUpdateWidget, - // which fires onDraftChanged with the old target's text on rebuild. - setState(() { - _chatUnread = 0; - _chatOpen = true; - _inlineChatTarget = newTarget; - _inlineChatClientName = newClientName; - _inlineChatCollapseNoticeShown = false; - }); - return; - } - setState(() { - _chatUnread = 0; - _chatOpen = true; - }); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ChatPage( - messages: _chatMessages, - snapshot: initialSnapshot, - messagesSource: () => _chatMessages, - snapshotSource: () => _snapshot ?? initialSnapshot, - refreshListenable: _chatFeedRevision, - initialTarget: newTarget, - initialClientName: newClientName, - onTs3ServerLink: _onTs3ServerLink, - ), - ), - ); - if (mounted) setState(() => _chatOpen = false); - } - - void _closeInlineChat() { - // Final draft for the dismissed target is preserved by - // ChatDetailView.dispose, which fires onDraftChanged when the panel - // is removed from the tree on the next rebuild. - setState(() { - _lastDismissedTarget = _inlineChatTarget; - _lastDismissedClientName = _inlineChatClientName; - _chatOpen = false; - // Do NOT null _inlineChatTarget — remember it for reopen. - }); - } - - void _handleInlineChatViewport(LayoutClass layoutClass) { - if (_inlineChatTarget == null) return; - if (layoutClass == LayoutClass.expanded) { - _inlineChatCollapseNoticeShown = false; - if (!_chatOpen) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _inlineChatTarget != null) { - setState(() => _chatOpen = true); - } - }); - } - return; - } - if (_chatOpen) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _inlineChatTarget != null) { - setState(() => _chatOpen = false); - } - }); - } - if (_inlineChatCollapseNoticeShown) return; - _inlineChatCollapseNoticeShown = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _inlineChatTarget == null) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)), - ); - }); - } - - Future _onOpenClientInfo(rust.BridgeClient client) async { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - showDragHandle: false, - builder: (context) => FractionallySizedBox( - heightFactor: 0.86, - child: ClientInfoSheet( - clientName: client.name, - loadProfile: () => rust.clientProfile(clientId: client.id), - ), - ), - ); - } - - Future _handleIncomingPoke({ - required BigInt senderId, - required String senderName, - required String message, - required rust.BridgePokeStrength? strength, - }) async { - if (senderId == _snapshot?.ownClientId) return; - await _pokePreferencesReady; - if (!_pokePreferences.pokesEnabled.value) return; - if (_pokePreferences.isMuted(senderId)) return; - final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed; - if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) { - _showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName); - } - if (_isPokeSenderActiveChat(senderId)) return; - await _pokeNotifications.show( - senderName: senderName, - message: message, - senderId: senderId, - strength: pokeStrength, - ); - } - - bool _isPokeSenderActiveChat(BigInt senderId) { - return isPokeSenderActiveChat( - chatOpen: _chatOpen, - inlineChatTarget: _inlineChatTarget, - senderId: senderId, - ); - } - - void _showPokeOverflowMutePrompt({ - required BigInt senderId, - required String senderName, - }) { - final l10n = AppL10n.of(context); - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - margin: _chatSnackBarMargin(), - duration: const Duration(seconds: 8), - content: Text( - l10n.pokeOverflowMutePrompt(senderName), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - action: SnackBarAction( - label: l10n.pokeOverflowMuteAction, - onPressed: () { - unawaited(_pokePreferences.muteSender(senderId)); - messenger.showSnackBar( - SnackBar( - behavior: SnackBarBehavior.floating, - margin: _chatSnackBarMargin(), - content: Text(l10n.pokeMutedSenderConfirmation(senderName)), - ), - ); - }, - ), - ), - ); - } - - 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: AppL10n.of(context).openAction, - 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 >= ChanoraBreakpoints.medium && - _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 _applyClientDelta( - bool Function(rust.BridgeClient) test, - rust.BridgeClient Function(rust.BridgeClient) update, - ) { - final snap = _snapshot; - if (snap == null) return; - final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList(); - setState(() { - _snapshot = rust.BridgeSnapshot( - serverName: snap.serverName, - welcomeMessage: snap.welcomeMessage, - platform: snap.platform, - version: snap.version, - channels: snap.channels, - clients: clients, - ownClientId: snap.ownClientId, - ); - _notifyChatFeedChanged(); - }); - } - - void _applyClientAdd(rust.BridgeClient client) { - final snap = _snapshot; - if (snap == null) return; - setState(() { - _snapshot = rust.BridgeSnapshot( - serverName: snap.serverName, - welcomeMessage: snap.welcomeMessage, - platform: snap.platform, - version: snap.version, - channels: snap.channels, - clients: [...snap.clients, client], - ownClientId: snap.ownClientId, - ); - _notifyChatFeedChanged(); - }); - } - - void _applyClientRemove(BigInt clientId) { - final snap = _snapshot; - if (snap == null) return; - setState(() { - _snapshot = rust.BridgeSnapshot( - serverName: snap.serverName, - welcomeMessage: snap.welcomeMessage, - platform: snap.platform, - version: snap.version, - channels: snap.channels, - clients: snap.clients.where((c) => c.id != clientId).toList(), - ownClientId: snap.ownClientId, - ); - _notifyChatFeedChanged(); - }); - } - - void _applyChannelAdd(rust.BridgeChannel channel) { - final snap = _snapshot; - if (snap == null) return; - setState(() { - _snapshot = rust.BridgeSnapshot( - serverName: snap.serverName, - welcomeMessage: snap.welcomeMessage, - platform: snap.platform, - version: snap.version, - channels: [...snap.channels, channel], - clients: snap.clients, - ownClientId: snap.ownClientId, - ); - _notifyChatFeedChanged(); - }); - } - - void _applyChannelRemove(BigInt channelId) { - final snap = _snapshot; - if (snap == null) return; - setState(() { - _snapshot = rust.BridgeSnapshot( - serverName: snap.serverName, - welcomeMessage: snap.welcomeMessage, - platform: snap.platform, - version: snap.version, - channels: snap.channels.where((ch) => ch.id != channelId).toList(), - clients: snap.clients, - ownClientId: snap.ownClientId, - ); - _notifyChatFeedChanged(); - }); - } - - void _applyChannelDelta( - bool Function(rust.BridgeChannel) test, - rust.BridgeChannel Function(rust.BridgeChannel) update, - ) { - final snap = _snapshot; - if (snap == null) return; - final channels = snap.channels - .map((ch) => test(ch) ? update(ch) : ch) - .toList(); - setState(() { - _snapshot = rust.BridgeSnapshot( - serverName: snap.serverName, - welcomeMessage: snap.welcomeMessage, - platform: snap.platform, - version: snap.version, - channels: channels, - clients: snap.clients, - ownClientId: snap.ownClientId, - ); - _notifyChatFeedChanged(); - }); - } - - void _applySnapshot(rust.BridgeSnapshot snap) { - _snapshot = snap; - _phase = phaseAfterSnapshotApplied(_phase); - final own = ownClientSnapshotState(snap); - if (own == null) return; - _inputMuted = own.inputMuted; - _outputMuted = own.outputMuted; - if (!_voiceStateInitialized || _currentVoiceChannelId == null) { - _currentVoiceChannelId = own.channelId; - _pendingVoiceChannelId = null; - _inChannel = true; - _canJoinVoiceChannel = true; - _voiceStateInitialized = true; - } else if (_pendingVoiceChannelId == null) { - _currentVoiceChannelId = own.channelId; - } - _notifyChatFeedChanged(); - - if (!own.talkPowerOk && !_hardMuteByTalkPower) { - final talkPowerEpoch = _connectionEpoch; - _hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true); - _hardMute = _hardMuteOwners.effective; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || - _connectionEpoch != talkPowerEpoch || - !_serverReachable || - !_hardMuteByTalkPower) { - return; - } - unawaited(rust.setHardMute(muted: true)); - unawaited(rust.setInputMuted(muted: true)); - }); - } else if (own.talkPowerOk && _hardMuteByTalkPower) { - final talkPowerEpoch = _connectionEpoch; - _hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false); - _hardMute = _hardMuteOwners.effective; - if (!_hardMuteOwners.effective) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || - _connectionEpoch != talkPowerEpoch || - !_serverReachable || - _hardMuteOwners.effective) { - return; - } - unawaited(rust.setHardMute(muted: false)); - unawaited(rust.setInputMuted(muted: false)); - }); - } - } - } - - OwnClientSnapshotState? get _ownClientState { - final snap = _snapshot; - return snap == null ? null : ownClientSnapshotState(snap); - } - - String _diagnosticsText() { - final rustText = rust.exportDiagnostics(); - final uiText = _uiDiagnostics.isEmpty - ? 'UI diagnostics: none' - : ['UI diagnostics:', ..._uiDiagnostics].join('\n'); - return '$uiText\n\nRust diagnostics:\n$rustText'; - } - - Future _onShowDiagnostics() async { - if (!mounted) return; - await showDialog( - context: context, - builder: (ctx) => - _LiveDiagnosticsDialog(diagnosticsTextBuilder: _diagnosticsText), - ); - } - - 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). - // Skip the in-app capture dialog entirely on that backend and - // delegate to the portal via `setPttBinding` with a sentinel - // platform_key. Show a SnackBar so the user isn't surprised - // when their compositor opens a separate dialog. - final l10n = AppL10n.of(context); - if (!mounted) return; - if (_pttBackendId == 'gnome-wayland-portal') { - try { - final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar( - SnackBar(content: Text(l10n.pttConfigurePortalRedirect)), - ); - await rust.setPttBinding( - inputClass: rust.BridgePttInputClass.keyboard, - platformKey: 'portal', - ); - } catch (e) { - if (!mounted) return; - _showUiErrorSnackBar(area: 'ptt portal binding', error: e); - } - return; - } - - // Other backends: open the in-app focus-scoped capture - // 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( - context: context, - builder: (ctx) => const PttBindingCaptureDialog(), - ); - if (binding == null) return; - try { - await rust.setPttBinding( - inputClass: binding.inputClass, - platformKey: binding.platformKey, - ); - // Cache the captured label so the badge can surface "Key: - // Space" / "Key: Mouse4" next to the capability descriptor. - // The bridge holds the authoritative binding; this is only - // for display continuity until the next app restart. - if (mounted) { - setState(() { - _pttBoundKeyLabel = binding.platformKey; - }); - } - } catch (e) { - if (!mounted) return; - // Surface the failure as a snackbar so the user sees that - // their binding did not stick. - _showUiErrorSnackBar(area: 'ptt binding', error: e); - } - } - - Future _onShowAbout(BuildContext context) async { - // DEC-018 / DEC-019 / DEC-020 surface: public name, non- - // affiliation statement, dual-license declaration. The Flutter - // showAboutDialog widget is intentionally bare so the legal - // text comes from us, not a framework default. - final l10n = AppL10n.of(context); - final theme = Theme.of(context); - if (!mounted) return; - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(l10n.aboutAction), - content: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.appTitle, style: theme.textTheme.titleLarge), - const SizedBox(height: 4), - Text( - l10n.aboutVersion(_kAppVersion), - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 4), - Text(l10n.aboutAuthor, style: theme.textTheme.bodySmall), - const SizedBox(height: 16), - Text(l10n.aboutNonAffiliation, style: theme.textTheme.bodyMedium), - const SizedBox(height: 12), - Text(l10n.aboutLicenseHeading, style: theme.textTheme.titleSmall), - const SizedBox(height: 4), - Text(l10n.aboutLicenseBody, style: theme.textTheme.bodySmall), - const SizedBox(height: 12), - Text( - l10n.aboutThirdPartyHeading, - style: theme.textTheme.titleSmall, - ), - const SizedBox(height: 4), - Text(l10n.aboutThirdPartyBody, style: theme.textTheme.bodySmall), - ], - ), - ), - actions: [ - TextButton( - onPressed: () async { - Navigator.of(ctx).pop(); - await Future.delayed(Duration.zero); - if (!mounted) return; - await _onShowDiagnostics(); - }, - child: Text(l10n.diagnosticsAction), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: Text(l10n.closeAction), - ), - ], - ), - ); - } - - Future _onAddCurrentBookmark() async { - // Use the dialog's own context for AppL10n.of(...) inside its - // builder. Capturing the outer _HomePageState context's l10n in - // a closure and reading it inside the dialog's widget tree - // caused the dialog's TextField to depend on InheritedElements - // (Localizations / _LocalizationsScope) that belong to the - // outer route. When the dialog popped, the framework - // deactivated the dialog route's elements first while those - // outer-route InheritedElements were still alive but had - // dependents from the disposed dialog tree \u2014 producing the - // assertion 'package:flutter/src/widgets/framework.dart line - // 6268 _dependents.isEmpty is not true'. - // - // Also: dispose the TextEditingController via the dialog's own - // StatefulBuilder lifecycle instead of an inline dispose() right - // after showDialog returns. The inline dispose runs synchronously - // before the dialog route is fully torn down (the route pop - // animation is still mid-flight on iOS), and tearing the - // controller out from under EditableText while it has a live - // InheritedWidget dependency was the second trigger for the - // same assertion. - final initial = _hostCtl.text.trim(); - final name = await showDialog( - context: context, - builder: (ctx) => BookmarkNameDialog(initialName: initial), - ); - if (name == null || name.trim().isEmpty) return; - if (!mounted) return; - try { - await wireStorage(); - await rust.addBookmark( - b: rust.BridgeBookmark( - id: 0, - displayName: name.trim(), - host: _hostCtl.text.trim(), - nickname: _nickCtl.text.trim(), - password: _passwordCtl.text, - ), - ); - await _reloadBookmarks(); - } catch (e) { - if (!mounted) return; - _showUiError('add bookmark', e); - } - } - - Future _onDeleteBookmark(rust.BridgeBookmark b) async { - try { - await wireStorage(); - await rust.deleteBookmark(id: b.id); - await _reloadBookmarks(); - } catch (e) { - if (!mounted) return; - _showUiError('delete bookmark', e); - } - } - - Future _onUseBookmark(rust.BridgeBookmark b) async { - _hostCtl.text = b.host; - _nickCtl.text = b.nickname; - _passwordCtl.text = b.password; - 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 wireStorage(); - 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; - _showUiError('teamspeak link bookmark', e); - } - } - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - final theme = Theme.of(context); - - final headerActions = [ - IconButton( - tooltip: l10n.aboutAction, - icon: const Icon(Icons.info_outline), - onPressed: () => _onShowAbout(context), - ), - ChanoraThemeModeMenu( - themeMode: widget.themeMode, - onThemeModeChanged: widget.onThemeModeChanged, - ), - IconButton( - tooltip: l10n.pokeSettingsAction, - icon: const Icon(Icons.notifications_outlined), - onPressed: () => unawaited(_onOpenPokeSettings()), - ), - if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[ - Padding( - padding: const EdgeInsetsDirectional.only(end: 12), - child: Badge( - isLabelVisible: _chatUnread > 0, - label: Text(_chatUnread.toString()), - child: IconButton( - tooltip: l10n.chatAction, - icon: const Icon(Icons.chat_bubble_outline), - onPressed: _onOpenChat, - ), - ), - ), - ], - ]; - - const headerTitle = SizedBox.shrink(); - - final appBarTitle = _serverReachable - ? Text( - _snapshot?.serverName ?? l10n.appTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ) - : headerTitle; - final compactIdleChrome = - !_serverReachable && - MediaQuery.sizeOf(context).width < ChanoraBreakpoints.medium; - final compactIdleHeader = Row( - children: [ - Expanded( - child: Text( - l10n.appTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ...headerActions, - ], - ); - - final connTokens = _phase.tokens(theme.colorScheme); - final statusText = connectionStatusText( - phase: _phase, - l10n: l10n, - serverName: _snapshot?.serverName, - lostReason: _lostReason, - reconnectAttempt: _reconnectAttempt, - reconnectDelay: _reconnectDelay, - ); - final awaitingServerSnapshot = _phase.shouldShowSnapshotLoading( - hasSnapshot: _snapshot != null, - ); - - final bodyContent = LayoutBuilder( - builder: (ctx, bodyConstraints) { - final layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth); - _handleInlineChatViewport(layoutClass); - final isWideSnapshot = - bodyConstraints.maxWidth >= ChanoraBreakpoints.medium && - _serverReachable && - _snapshot != null; - final banner = Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: theme.colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - l10n.homeNotProductionReadyBanner, - style: TextStyle(color: theme.colorScheme.onTertiaryContainer), - ), - ); - return ViewportInfo( - layoutClass: layoutClass, - width: bodyConstraints.maxWidth, - height: bodyConstraints.maxHeight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)], - if (!_serverReachable) - Row( - children: [ - Icon(connTokens.icon, size: 18, color: connTokens.color), - const SizedBox(width: 6), - Expanded( - child: Text( - statusText, - softWrap: true, - style: theme.textTheme.titleMedium, - ), - ), - ], - ), - if (_lostReason != null || _reconnectAttempt != null) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: - connTokens.background ?? - theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.onErrorContainer, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - _reconnectAttempt != null - ? l10n.statusReconnecting( - _reconnectAttempt!, - _reconnectDelay ?? 0, - ) - : l10n.statusConnectionLost(_lostReason ?? ''), - style: TextStyle( - color: theme.colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ), - ], - const SizedBox(height: 12), - if (_phase == ConnectionPhase.idle || - _phase == ConnectionPhase.disconnected) ...[ - Expanded( - child: AnimatedPadding( - duration: const Duration(milliseconds: 180), - curve: Curves.easeOut, - padding: EdgeInsets.only( - bottom: MediaQuery.viewInsetsOf(ctx).bottom, - ), - child: SingleChildScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConnectForm( - hostCtl: _hostCtl, - nickCtl: _nickCtl, - passwordCtl: _passwordCtl, - onConnect: () => _onConnect(), - onAddBookmark: _onAddCurrentBookmark, - ), - const SizedBox(height: 16), - BookmarkList( - bookmarks: _bookmarks, - onConnect: _onUseBookmark, - onDelete: _onDeleteBookmark, - ), - ], - ), - ), - ), - ), - ] else if (_phase == ConnectionPhase.connecting || - awaitingServerSnapshot) ...[ - Expanded( - child: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - Text( - statusText, - textAlign: TextAlign.center, - style: theme.textTheme.titleMedium, - ), - ], - ), - ), - ), - ), - ] else if (_serverReachable && _snapshot != null) ...[ - Expanded( - child: LayoutBuilder( - builder: (ctx, constraints) { - final voiceBar = VoiceBar( - inChannel: _inChannel, - transmitMode: _transmitMode, - hardMute: _hardMute, - outputMuted: _outputMuted, - talkPowerBlocked: _hardMuteByTalkPower, - releaseTailMs: _releaseTailMs, - channelName: snapshotChannelName( - _snapshot, - _currentVoiceChannelId, - ), - audioStats: _audioStats, - inputLevel: _inputLevel, - pttLevel: _pttLevel, - pttBackendId: _pttBackendId, - pttBoundInputClass: _pttBoundInputClass, - pttBoundKeyLabel: _pttBoundKeyLabel, - onConfigure: _onOpenVoiceSettings, - onPttHeldChanged: _onOnscreenPttHeldChanged, - ); - // SDD-106 §2/§3 + SRS-209 + SRS-164: listen-only - // banner. Self-hides on granted / unknown. - final permissionBanner = - PermissionStateBanner.fromCallbacks( - recordAudioState: _activeRecordAudioState, - ensureRecordAudio: _ensureActiveRecordAudio, - openAppSettings: _openActivePermissionSettings, - ); - final snapshotView = SnapshotView( - snapshot: _snapshot!, - audioStats: _audioStats, - currentVoiceChannelId: _currentVoiceChannelId, - pendingVoiceChannelId: _pendingVoiceChannelId, - localInputMuted: _inputMuted || _hardMute, - localOutputMuted: _outputMuted, - hasJoinPending: _pendingVoiceChannelId != null, - canJoinVoiceChannel: _canJoinVoiceChannel, - unreadChannelIds: _unreadChannelIds, - onJoinChannel: (ch) => _onJoinChannel(ch), - onJoinChannelWithPassword: (ch) => - _onJoinChannel(ch, askForPassword: true), - enableClientLongPressMenu: isTouchOnlyPttHost, - onOpenClientInfo: (client) => - unawaited(_onOpenClientInfo(client)), - onOpenClientChat: (client) => unawaited( - _onOpenChat( - target: rust.BridgeMessageTarget.client(client.id), - clientName: client.name, - ), - ), - onOpenClientPoke: (client) => unawaited( - _onOpenChat( - target: rust.BridgeMessageTarget.poke(client.id), - clientName: client.name, - ), - ), - onOpenChannelChat: (channel) => unawaited( - _onOpenChat( - target: const rust.BridgeMessageTarget.channel(), - ), - ), - onTs3ServerLink: _onTs3ServerLink, - ); - final ownClientState = _ownClientState; - final layoutInfo = ViewportInfo.of(ctx); - if (layoutInfo.isWide) { - final inlineChatTarget = _inlineChatTarget; - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: ChanoraBreakpoints.voicePanelWidth, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - banner, - const SizedBox(height: 12), - permissionBanner, - voiceBar, - ], - ), - ), - const SizedBox(width: ChanoraBreakpoints.panelGap), - Expanded(child: snapshotView), - if (layoutInfo.isExpanded && - inlineChatTarget != null) ...[ - const SizedBox( - width: ChanoraBreakpoints.panelGap, - ), - ChatPanel( - messages: _chatMessages, - snapshot: _snapshot!, - target: inlineChatTarget, - clientName: _inlineChatClientName, - restoredDraft: _currentDraftFor( - inlineChatTarget, - ), - onDraftChanged: (text) { - final draftKey = _draftKeyForTarget( - inlineChatTarget, - ); - _chatDrafts[draftKey] = text; - }, - onTs3ServerLink: _onTs3ServerLink, - onClose: _closeInlineChat, - ), - ], - ], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded(child: snapshotView), - const SizedBox(height: 8), - permissionBanner, - VoiceStatusChip( - transmitMode: _transmitMode, - releaseTailMs: _releaseTailMs, - pttBoundKeyLabel: _pttBoundKeyLabel, - audioStats: _audioStats, - isTouchOnly: isTouchOnlyPttHost, - inputMuted: _hardMute, - outputMuted: _outputMuted, - hardMuteByTalkPower: _hardMuteByTalkPower, - talkPower: ownClientState?.talkPower, - neededTalkPower: ownClientState?.neededTalkPower, - talkPowerGranted: ownClientState?.talkPowerGranted, - onTap: () => _onOpenVoiceDetailsSheet(), - onToggleInputMute: _onToggleHardMute, - onToggleOutputMute: _toggleOutputMute, - ), - if (_inChannel && - _transmitMode == rust.BridgeTransmitMode.ptt) ...[ - const SizedBox(height: 8), - VoicePttButton( - active: _audioStats?.pttActive ?? false, - onHeldChanged: _onOnscreenPttHeldChanged, - ), - ], - ], - ); - }, - ), - ), - ], - ], - ), - ); - }, - ); - - if (_isMacOS) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.only(top: _macOSTrafficLightPad), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Expanded(child: headerTitle), - ...headerActions, - ], - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(16), - child: bodyContent, - ), - ), - ], - ), - ), - ); - } - - return ChanoraMobileScaffold( - compactIdleChrome: compactIdleChrome, - canDisconnect: _phase.canDisconnect, - title: appBarTitle, - actions: headerActions, - onDisconnect: _onConfirmDisconnect, - compactHeader: compactIdleHeader, - disconnectTooltip: l10n.disconnectAction, - body: bodyContent, - ); - } -} - -class _LiveDiagnosticsDialog extends StatefulWidget { - const _LiveDiagnosticsDialog({required this.diagnosticsTextBuilder}); - - final String Function() diagnosticsTextBuilder; - - @override - State<_LiveDiagnosticsDialog> createState() => _LiveDiagnosticsDialogState(); -} - -class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> { - static const _refreshInterval = Duration(seconds: 1); - - Timer? _refreshTimer; - String _text = ''; - - @override - void initState() { - super.initState(); - _refresh(); - _refreshTimer = Timer.periodic(_refreshInterval, (_) => _refresh()); - } - - @override - void dispose() { - _refreshTimer?.cancel(); - super.dispose(); - } - - void _refresh() { - final next = widget.diagnosticsTextBuilder(); - if (!mounted || next == _text) return; - setState(() => _text = next); - } - - @override - Widget build(BuildContext context) { - final l10n = AppL10n.of(context); - final size = MediaQuery.sizeOf(context); - - return AlertDialog( - title: Row( - children: [ - Expanded(child: Text(l10n.diagnosticsAction)), - const SizedBox(width: 12), - Tooltip( - message: l10n.diagnosticsLiveUpdating, - child: Icon( - Icons.sync, - size: 18, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - content: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 720, - maxHeight: size.height * 0.65, - ), - child: SingleChildScrollView( - child: SelectableText( - _text, - style: const TextStyle(fontFamily: 'monospace', fontSize: 11), - ), - ), - ), - actions: [ - TextButton( - onPressed: () async { - await SharePlus.instance.share(ShareParams(text: _text)); - }, - child: Text(l10n.shareAction), - ), - TextButton( - onPressed: () async { - await Clipboard.setData(ClipboardData(text: _text)); - if (!context.mounted) return; - Navigator.of(context).pop(); - }, - child: Text(l10n.copyAction), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.closeAction), - ), - ], - ); - } -} diff --git a/apps/chanora_flutter/lib/screens/diagnostics_dialog.dart b/apps/chanora_flutter/lib/screens/diagnostics_dialog.dart new file mode 100644 index 0000000..76b1e5d --- /dev/null +++ b/apps/chanora_flutter/lib/screens/diagnostics_dialog.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../l10n/generated/app_localizations.dart'; + +class LiveDiagnosticsDialog extends StatefulWidget { + const LiveDiagnosticsDialog({super.key, required this.diagnosticsTextBuilder}); + + final String Function() diagnosticsTextBuilder; + + @override + State createState() => _LiveDiagnosticsDialogState(); +} + +class _LiveDiagnosticsDialogState extends State { + static const _refreshInterval = Duration(seconds: 1); + + Timer? _refreshTimer; + String _text = ''; + + @override + void initState() { + super.initState(); + _refresh(); + _refreshTimer = Timer.periodic(_refreshInterval, (_) => _refresh()); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + super.dispose(); + } + + void _refresh() { + final next = widget.diagnosticsTextBuilder(); + if (!mounted || next == _text) return; + setState(() => _text = next); + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final size = MediaQuery.sizeOf(context); + + return AlertDialog( + title: Row( + children: [ + Expanded(child: Text(l10n.diagnosticsAction)), + const SizedBox(width: 12), + Tooltip( + message: l10n.diagnosticsLiveUpdating, + child: Icon( + Icons.sync, + size: 18, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + content: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 720, + maxHeight: size.height * 0.65, + ), + child: SingleChildScrollView( + child: SelectableText( + _text, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + ), + ), + actions: [ + TextButton( + onPressed: () async { + await SharePlus.instance.share(ShareParams(text: _text)); + }, + child: Text(l10n.shareAction), + ), + TextButton( + onPressed: () async { + await Clipboard.setData(ClipboardData(text: _text)); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + child: Text(l10n.copyAction), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.closeAction), + ), + ], + ); + } +} diff --git a/apps/chanora_flutter/lib/screens/home_screen.dart b/apps/chanora_flutter/lib/screens/home_screen.dart new file mode 100644 index 0000000..a3be430 --- /dev/null +++ b/apps/chanora_flutter/lib/screens/home_screen.dart @@ -0,0 +1,2374 @@ +import 'dart:async'; +import 'dart:io' show Platform, Process; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; + +import '../design/breakpoints.dart'; +import '../design/viewport_info.dart'; +import '../l10n/generated/app_localizations.dart'; +import '../main.dart' show ChanoraThemeModeMenu, ChanoraMobileScaffold, kAppVersion, isMacOS, macOSTrafficLightPad, isPokeSenderActiveChat; +import '../services/android_permissions_service.dart'; +import '../services/app_bootstrap.dart'; +import '../services/ios_audio_session_controller.dart'; +import '../services/channel_join_error_mapper.dart'; +import '../services/connection_phase_state.dart'; +import '../services/hard_mute_owners.dart'; +import '../services/ios_permissions_service.dart'; +import '../services/macos_permissions_service.dart'; +import '../services/poke_notification_service.dart'; +import '../services/poke_preferences_service.dart'; +import '../services/prefetch_debouncer.dart'; +import '../services/snapshot_state_mapper.dart'; +import '../services/ts3_server_link.dart'; +import '../services/ui_preferences_service.dart'; +import '../services/voice_join_ordering.dart'; +import '../src/rust/api.dart' as rust; +import '../src/rust/lib.dart' as rust_err; +import '../widgets/audio_processing_config_state.dart'; +import '../widgets/chat_panel.dart'; +import '../widgets/chat_views.dart'; +import '../widgets/client_info_sheet.dart'; +import '../widgets/connect_widgets.dart'; +import '../widgets/input_dialogs.dart'; +import '../widgets/permission_state_banner.dart'; +import '../widgets/poke_notification_settings.dart'; +import '../widgets/snapshot_view.dart'; +import '../widgets/voice_bar.dart'; +import '../widgets/voice_compact.dart'; +import '../widgets/voice_platform.dart'; +import '../widgets/voice_settings.dart'; +import 'diagnostics_dialog.dart'; + +class BetaHome extends StatefulWidget { + const BetaHome({super.key, required this.themeMode, required this.onThemeModeChanged}); + + final ThemeMode themeMode; + final Future Function(ThemeMode mode) onThemeModeChanged; + + @override + State createState() => BetaHomeState(); +} + +class BetaHomeState extends State with WidgetsBindingObserver { + final _hostCtl = TextEditingController(text: 'cn.teamspeak.app'); + final _nickCtl = TextEditingController(text: 'ChanoraBeta'); + final _passwordCtl = TextEditingController(); + + ConnectionPhase _phase = ConnectionPhase.idle; + bool get _serverReachable => _phase.isServerReachable; + rust.BridgeSnapshot? _snapshot; + final List _uiDiagnostics = []; + rust.BridgeAudioStats? _audioStats; + double? _inputLevel; + Timer? _statsTimer; + bool _snapshotRefreshInFlight = false; + bool _snapshotRefreshQueued = false; + bool _snapshotRefreshQueuedRecordActivity = false; + bool _snapshotRefreshQueuedReportErrors = false; + int _connectionEpoch = 0; + StreamSubscription? _eventsSub; + StreamSubscription? _inputLevelSub; + late final PrefetchDebouncer _prefetch; + + bool _inChannel = false; + rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt; + bool _hardMute = false; + HardMuteOwners _hardMuteOwners = const HardMuteOwners(); + bool get _hardMuteByPermission => _hardMuteOwners.permission; + bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower; + bool _permissionHardMuteClearInFlight = false; + int _releaseTailMs = 200; + BigInt? _currentVoiceChannelId; + BigInt? _pendingVoiceChannelId; + bool _canJoinVoiceChannel = true; + bool _voiceStateInitialized = false; + + String? _lostReason; + int? _reconnectAttempt; + int? _reconnectDelay; + + bool _inputMuted = false; + bool _outputMuted = false; + + String _pttLevel = 'L0Focused'; + String _pttBackendId = 'focused'; + String _pttBoundInputClass = 'keyboard'; + String _pttBoundKeyLabel = ''; + final Set _focusedPttHeldKeys = {}; + + List _bookmarks = const []; + final List _chatMessages = []; + final ValueNotifier _chatFeedRevision = ValueNotifier(0); + int _chatUnread = 0; + bool _chatOpen = false; + rust.BridgeMessageTarget? _inlineChatTarget; + String _inlineChatClientName = ''; + bool _inlineChatCollapseNoticeShown = false; + + final Map _chatDrafts = {}; + + Set get _unreadChannelIds { + if (_chatOpen) return const {}; + final ids = {}; + for (final entry in _chatMessages) { + if (!entry.countsTowardUnread || entry.isSelf) continue; + final target = entry.target; + if (target is rust.BridgeMessageTarget_Channel) { + if (_currentVoiceChannelId != null) { + ids.add(_currentVoiceChannelId!); + } + } + } + return ids; + } + + rust.BridgeMessageTarget? _lastDismissedTarget; + String _lastDismissedClientName = ''; + final AndroidPermissionsService _androidPermissions = + AndroidPermissionsService(); + final IosPermissionsService _iosPermissions = IosPermissionsService(); + final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService(); + final UiPreferencesService _uiPreferences = const UiPreferencesService(); + final PokeNotificationService _pokeNotifications = PokeNotificationService(); + final PokePreferencesService _pokePreferences = PokePreferencesService(); + late final Future _pokePreferencesReady; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + HardwareKeyboard.instance.addHandler(_handleFocusedPttKey); + _prefetch = PrefetchDebouncer(onPrefetch: _onPrefetchServer); + _hostCtl.addListener(_onHostEdited); + _eventsSub = rust.eventsStream().listen(_onEvent); + _inputLevelSub = rust.inputLevelStream().listen((level) { + if (mounted) setState(() => _inputLevel = level); + }); + _androidPermissions.start(); + unawaited(_iosPermissions.start()); + _androidPermissions.recordAudioState.addListener( + _onRecordAudioPermissionChanged, + ); + _iosPermissions.recordAudioState.addListener( + _onRecordAudioPermissionChanged, + ); + _macOSPermissions.start(); + _macOSPermissions.pttCapabilityState.addListener( + _onMacOSPttCapabilityChanged, + ); + _macOSPermissions.checkInitialStates(); + unawaited(_pokeNotifications.init()); + _pokePreferencesReady = _pokePreferences.load(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_requestRecordAudioOnStartup()); + }); + 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 (settings.host.isNotEmpty) { + _prefetch.schedule(settings.host); + } + // Restore persisted voice settings (SRS-087). + if (settings.transmitModeIndex != null) { + final modes = rust.BridgeTransmitMode.values; + final idx = settings.transmitModeIndex!; + if (idx >= 0 && idx < modes.length) { + _transmitMode = modes[idx]; + } + } + if (settings.releaseTailMs != null) { + _releaseTailMs = settings.releaseTailMs!.clamp(0, 500); + } + // Restore persisted audio device selection. + if (settings.inputDeviceId != null) { + try { + await rust.setInputDevice(id: settings.inputDeviceId); + } catch (_) {} + } + if (settings.outputDeviceId != null) { + try { + await rust.setOutputDevice(id: settings.outputDeviceId); + } catch (_) {} + } + } catch (_) {} + } + + void _onHostEdited() { + _prefetch.schedule(_hostCtl.text); + } + + Future _onPrefetchServer(String host) async { + try { + await rust.prefetchServer(host: host); + } catch (e) { + _recordUiDiagnostic('prefetch server', e); + } + } + + Future _saveUiSettings({String? host, String? nickname}) async { + try { + await _uiPreferences.saveSettings(host: host, nickname: nickname); + } catch (_) {} + } + + Future _requestRecordAudioOnStartup() async { + try { + if (Platform.isAndroid) { + final permsExplained = await _uiPreferences.hasExplainedPermissions(); + if (!permsExplained) { + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(AppL10n.of(ctx).startupPermissionsTitle), + content: Text(AppL10n.of(ctx).startupPermissionsBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(AppL10n.of(ctx).startupPermissionsNotNow), + ), + FilledButton( + onPressed: () { + Navigator.pop(ctx); + _androidPermissions.ensureStartupPermissions(); + }, + child: Text(AppL10n.of(ctx).startupPermissionsAllow), + ), + ], + ), + ); + await _uiPreferences.markPermissionsExplained(); + } else { + await _androidPermissions.ensureStartupPermissions(); + } + } + } catch (_) {} + } + + ValueListenable + get _activeRecordAudioState => Platform.isIOS + ? _iosPermissions.recordAudioState + : _androidPermissions.recordAudioState; + + Future _ensureActiveRecordAudio() { + return Platform.isIOS + ? _iosPermissions.ensureRecordAudio() + : _androidPermissions.ensureRecordAudio(); + } + + Future _openActivePermissionSettings() { + return Platform.isIOS + ? _iosPermissions.openAppSettings() + : _androidPermissions.openAppSettings(); + } + + void _onRecordAudioPermissionChanged() { + if (_activeRecordAudioState.value == + AndroidRecordAudioPermissionState.granted) { + unawaited(_clearPermissionHardMute()); + } + } + + void _onMacOSPttCapabilityChanged() { + final level = _macOSPermissions.pttCapabilityState.value; + if (isMacOS && level != _pttLevel) { + setState(() { + _pttLevel = level; + if (level == 'L1MacOSEventTap') { + _pttBackendId = 'macos-event-tap'; + } else if (level == 'L0Focused') { + _pttBackendId = 'focused'; + } + }); + } + } + + Future _clearPermissionHardMute() async { + if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return; + _permissionHardMuteClearInFlight = true; + try { + await rust.setHardMute(muted: false); + if (!mounted || !_hardMuteByPermission) return; + setState(() { + _hardMuteOwners = _hardMuteOwners.copyWith(permission: false); + _hardMute = _hardMuteOwners.effective; + }); + } catch (e) { + if (!mounted) return; + _showUiError('clear permission mute', e); + } finally { + _permissionHardMuteClearInFlight = false; + } + } + + void _recordUiDiagnostic(String area, Object error) { + final line = '${DateTime.now().toIso8601String()} [$area] $error'; + debugPrint('chanora: $line'); + _uiDiagnostics.add(line); + if (_uiDiagnostics.length > 100) { + _uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100); + } + } + + void _showUiError(String area, Object error) { + if (!mounted) return; + _showUiErrorSnackBar(area: area, error: error); + } + + void _showUiErrorSnackBar({ + required String area, + required Object error, + String? displayMessage, + }) { + _recordUiDiagnostic(area, error); + final l10n = AppL10n.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Text( + displayMessage ?? l10n.statusError(error.toString()), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } + + void _showLocalNetworkDeniedSnackBar() { + if (!mounted) return; + final l10n = AppL10n.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 8), + content: Text( + l10n.networkPermissionBody, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + action: SnackBarAction( + label: l10n.networkPermissionOpenSettings, + onPressed: () { + unawaited( + Future(() async { + await Process.run('open', [ + 'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork', + ]); + }).catchError((_) {}), + ); + }, + ), + ), + ); + } + + bool _handleFocusedPttKey(KeyEvent event) { + final label = pttDisplayLabelForKey(event.logicalKey); + final isBoundKey = + _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel; + if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) { + if (_focusedPttHeldKeys.remove(event.logicalKey)) { + unawaited(_setPtt(false)); + } + return true; + } + if (_pttBackendId != 'focused' || + !_serverReachable || + !_inChannel || + _transmitMode != rust.BridgeTransmitMode.ptt || + !isBoundKey) { + return false; + } + if (event is KeyDownEvent) { + if (_focusedPttHeldKeys.add(event.logicalKey)) { + unawaited(_setPtt(true)); + } + return true; + } + return false; + } + + void _releaseFocusedPttIfHeld() { + if (_focusedPttHeldKeys.isEmpty) return; + _focusedPttHeldKeys.clear(); + unawaited(_setPtt(false, reportError: false)); + } + + Future _hydratePttBinding() async { + try { + final binding = await rust.getPttBinding(); + if (!mounted) return; + if (binding.keyLabel.isNotEmpty || binding.inputClass.isNotEmpty) { + setState(() { + _pttBoundKeyLabel = binding.keyLabel; + _pttBoundInputClass = binding.inputClass; + }); + } + } catch (_) {} + } + + Future _reloadBookmarks() async { + try { + await wireStorage(); + final list = await rust.listBookmarks(); + if (!mounted) return; + setState(() => _bookmarks = list); + } catch (_) {} + } + + void _onEvent(rust.BridgeEvent evt) { + if (!mounted) return; + switch (evt) { + case rust.BridgeEvent_Connected(): + final hadSnapshot = _snapshot != null; + setState(() { + _phase = phaseAfterConnectedEvent(_phase, hasSnapshot: hadSnapshot); + _lostReason = null; + _reconnectAttempt = null; + _reconnectDelay = null; + }); + unawaited(iosAudioSessionController.activate()); + unawaited(_refreshSnapshot(recordActivity: false, reportErrors: true)); + case rust.BridgeEvent_Lost(:final reason): + _recordUiDiagnostic('connection', 'lost: $reason'); + setState(() { + _phase = ConnectionPhase.reconnecting; + _lostReason = reason; + _reconnectAttempt = null; + _reconnectDelay = null; + }); + case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs): + _recordUiDiagnostic( + 'connection', + 'reconnecting attempt=$attempt delay=${delaySecs}s', + ); + setState(() { + _phase = ConnectionPhase.reconnecting; + _reconnectAttempt = attempt; + _reconnectDelay = delaySecs; + }); + case rust.BridgeEvent_Disconnected(): + _recordUiDiagnostic('connection', 'disconnected'); + setState(() { + _resetConnectionUiState(phase: ConnectionPhase.disconnected); + }); + case rust.BridgeEvent_AudioStarted(): + unawaited(iosAudioSessionController.activate()); + _ensureStatsTimer(); + case rust.BridgeEvent_AudioStopped(): + unawaited(iosAudioSessionController.deactivate()); + _statsTimer?.cancel(); + _statsTimer = null; + case rust.BridgeEvent_PttCapability( + :final level, + :final backendId, + :final boundInputClass, + ): + setState(() { + _pttLevel = level; + _pttBackendId = backendId; + _pttBoundInputClass = boundInputClass; + }); + if (backendId != 'focused') { + _releaseFocusedPttIfHeld(); + } + case rust.BridgeEvent_VoiceState( + :final inChannel, + :final transmitMode, + :final mute, + :final releaseTailMs, + :final currentChannelId, + :final pendingTargetChannelId, + :final canJoin, + ): + setState(() { + _voiceStateInitialized = true; + _inChannel = inChannel; + _transmitMode = transmitMode; + _hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute); + _hardMute = _hardMuteOwners.effective; + _releaseTailMs = releaseTailMs; + _currentVoiceChannelId = currentChannelId; + _pendingVoiceChannelId = pendingTargetChannelId; + _canJoinVoiceChannel = canJoin; + }); + if (inChannel) { + _ensureStatsTimer(); + } else { + _statsTimer?.cancel(); + _statsTimer = null; + _releaseFocusedPttIfHeld(); + } + if (transmitMode != rust.BridgeTransmitMode.ptt) { + _releaseFocusedPttIfHeld(); + } + case rust.BridgeEvent_InterruptionState( + :final began, + :final shouldResume, + ): + if (!mounted) return; + unawaited(() async { + if (!mounted) return; + final messenger = ScaffoldMessenger.of(context); + if (began) { + _recordUiDiagnostic('audio', 'interruption began'); + messenger.showSnackBar( + SnackBar( + content: Text(AppL10n.of(context).iosAudioInterrupted), + duration: Duration(seconds: 3), + backgroundColor: Colors.orange, + ), + ); + } else if (shouldResume) { + _recordUiDiagnostic('audio', 'interruption ended, resuming'); + messenger.showSnackBar( + SnackBar( + content: Text(AppL10n.of(context).iosAudioResuming), + duration: Duration(seconds: 2), + backgroundColor: Colors.green, + ), + ); + } + }()); + case rust.BridgeEvent_PermissionState(:final permission, :final state): + debugPrint( + 'bridge permission_state: permission=$permission state=$state', + ); + if (permission == 'android.permission.RECORD_AUDIO' && + state == rust.PermissionStateKind.granted) { + unawaited(_clearPermissionHardMute()); + } + case rust.BridgeEvent_ChatMessage( + :final senderId, + :final senderName, + :final message, + :final target, + :final pokeStrength, + ): + final isPoke = target is rust.BridgeMessageTarget_Poke; + if (!isPoke && senderId == _snapshot?.ownClientId) return; + final receivedAt = DateTime.now(); + setState(() { + _appendChatEntryUnlocked( + ChatEntry( + senderId: senderId, + senderName: senderName, + message: message, + target: target, + isSelf: senderId == _snapshot?.ownClientId, + timestamp: receivedAt, + ), + ); + }); + if (isPoke) { + unawaited( + _handleIncomingPoke( + senderId: senderId, + senderName: senderName, + message: message, + strength: pokeStrength, + ), + ); + return; + } + if (!_chatOpen && _chatUnread > 0) { + if (!mounted) return; + unawaited(() async { + if (!mounted) return; + _showChatMessageSnackBar( + senderName: senderName, + message: message, + target: target, + ); + }()); + } + case rust.BridgeEvent_ServerActivity(:final message): + setState(() { + _appendChatEntryUnlocked( + ChatEntry( + senderId: BigInt.zero, + senderName: 'Server', + message: message, + target: const rust.BridgeMessageTarget.server(), + timestamp: DateTime.now(), + countsTowardUnread: false, + ), + ); + }); + case rust.BridgeEvent_AudioRouteChanged(): + break; + case rust.BridgeEvent_ClientMoved(:final clientId, :final newChannelId): + _applyClientDelta((c) => c.id == clientId, (c) { + final updated = rust.BridgeClient( + id: c.id, + channel: newChannelId, + name: c.name, + inputMuted: c.inputMuted, + outputMuted: c.outputMuted, + isSpeaking: c.isSpeaking, + isServerQuery: c.isServerQuery, + talkPower: c.talkPower, + talkPowerGranted: c.talkPowerGranted, + ); + return updated; + }); + case rust.BridgeEvent_ClientJoined( + :final clientId, + :final channelId, + :final name, + :final inputMuted, + :final outputMuted, + :final isServerQuery, + :final talkPower, + :final talkPowerGranted, + ): + if (!isServerQuery) { + _applyClientAdd( + rust.BridgeClient( + id: clientId, + channel: channelId, + name: name, + inputMuted: inputMuted, + outputMuted: outputMuted, + isSpeaking: false, + isServerQuery: isServerQuery, + talkPower: talkPower, + talkPowerGranted: talkPowerGranted, + ), + ); + } + case rust.BridgeEvent_ClientLeft(:final clientId): + _applyClientRemove(clientId); + case rust.BridgeEvent_ClientUpdated( + :final clientId, + :final inputMuted, + :final outputMuted, + :final isServerQuery, + :final talkPower, + :final talkPowerGranted, + ): + _applyClientDelta((c) => c.id == clientId, (c) { + final updated = rust.BridgeClient( + id: c.id, + channel: c.channel, + name: c.name, + inputMuted: inputMuted, + outputMuted: outputMuted, + isSpeaking: c.isSpeaking, + isServerQuery: isServerQuery, + talkPower: talkPower, + talkPowerGranted: talkPowerGranted, + ); + return updated; + }); + case rust.BridgeEvent_ChannelAdded( + :final id, + :final parent, + :final name, + :final order, + :final hasPassword, + :final neededTalkPower, + ): + _applyChannelAdd( + rust.BridgeChannel( + id: id, + parent: parent, + name: name, + order: order, + hasPassword: hasPassword, + neededTalkPower: neededTalkPower, + ), + ); + case rust.BridgeEvent_ChannelRemoved(:final id): + _applyChannelRemove(id); + case rust.BridgeEvent_ChannelUpdated( + :final id, + :final name, + :final hasPassword, + :final neededTalkPower, + ): + _applyChannelDelta((ch) => ch.id == id, (ch) { + return rust.BridgeChannel( + id: ch.id, + parent: ch.parent, + name: name, + order: ch.order, + hasPassword: hasPassword, + neededTalkPower: neededTalkPower, + ); + }); + } + } + + void _stopStatsPolling() { + _statsTimer?.cancel(); + _statsTimer = null; + } + + void _startStatsPollingIfConnected() { + if (_serverReachable) _ensureStatsTimer(); + } + + DateTime? _lastSpeakingRefresh; + static const _speakingRefreshInterval = Duration(milliseconds: 750); + + void _ensureStatsTimer() { + if (_statsTimer != null) return; + _lastSpeakingRefresh = null; + _statsTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { + try { + final s = await rust.audioStats(); + if (!mounted) return; + setState(() => _audioStats = s); + } catch (_) {} + final now = DateTime.now(); + if (_inChannel && + (_lastSpeakingRefresh == null || + now.difference(_lastSpeakingRefresh!) >= + _speakingRefreshInterval)) { + _lastSpeakingRefresh = now; + unawaited(_refreshSnapshot(recordActivity: false, reportErrors: false)); + } + }); + } + + @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(); + _inputLevelSub?.cancel(); + _statsTimer?.cancel(); + _snapshotRefreshInFlight = false; + _snapshotRefreshQueued = false; + _snapshotRefreshQueuedRecordActivity = false; + _snapshotRefreshQueuedReportErrors = false; + _hostCtl.removeListener(_onHostEdited); + _prefetch.dispose(); + _hostCtl.dispose(); + _nickCtl.dispose(); + _passwordCtl.dispose(); + _chatFeedRevision.dispose(); + _androidPermissions.recordAudioState.removeListener( + _onRecordAudioPermissionChanged, + ); + _iosPermissions.recordAudioState.removeListener( + _onRecordAudioPermissionChanged, + ); + _macOSPermissions.pttCapabilityState.removeListener( + _onMacOSPttCapabilityChanged, + ); + _androidPermissions.stop(); + _iosPermissions.stop(); + _macOSPermissions.stop(); + _pokePreferences.dispose(); + super.dispose(); + } + + Future _onConnect({ + String? host, + String? nickname, + String? password, + }) async { + final connectEpoch = ++_connectionEpoch; + + final lnState = _macOSPermissions.localNetworkState.value; + if (lnState == MacOSLocalNetworkState.unknown || + lnState == MacOSLocalNetworkState.notDetermined) { + await _macOSPermissions.triggerLocalNetworkPrompt(); + } + + final resolvedState = _macOSPermissions.localNetworkState.value; + if (resolvedState == MacOSLocalNetworkState.denied) { + final targetHost = (host ?? _hostCtl.text).trim(); + final accessState = await _macOSPermissions.checkLocalNetworkAccess( + host: targetHost, + port: 9987, + ); + if (accessState == MacOSLocalNetworkState.denied) { + if (!mounted) return; + setState(() { + _phase = ConnectionPhase.idle; + }); + _showLocalNetworkDeniedSnackBar(); + return; + } + } + + setState(() { + _phase = ConnectionPhase.connecting; + _snapshot = null; + _chatMessages.clear(); + }); + _releaseFocusedPttIfHeld(); + await iosAudioSessionController.activate(); + try { + final snap = await rust.connect( + host: (host ?? _hostCtl.text).trim(), + nickname: (nickname ?? _nickCtl.text).trim(), + password: password ?? _passwordCtl.text, + ); + if (!mounted) return; + final connectedHost = host ?? _hostCtl.text.trim(); + final connectedNickname = nickname ?? _nickCtl.text.trim(); + setState(() { + _phase = ConnectionPhase.synchronizing; + _applySnapshot(snap); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + _connectionEpoch != connectEpoch || + !_serverReachable || + _snapshot?.serverName != snap.serverName) { + return; + } + unawaited( + _runPostConnectSideEffects( + snap: snap, + host: connectedHost, + nickname: connectedNickname, + ), + ); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _phase = ConnectionPhase.idle; + }); + _showUiErrorSnackBar(area: 'connect', error: e); + } + } + + Future _runPostConnectSideEffects({ + required rust.BridgeSnapshot snap, + required String host, + required String nickname, + }) async { + unawaited(_saveUiSettings(host: host, nickname: nickname)); + try { + await FlutterForegroundTask.startService( + notificationTitle: 'Chanora', + notificationText: 'Connected to ${snap.serverName}', + notificationButtons: const [ + NotificationButton(id: 'disconnect', text: 'Disconnect'), + ], + ); + } catch (_) {} + } + + Future _setPtt(bool active, {bool reportError = true}) async { + if (active) { + try { + HapticFeedback.lightImpact(); + } catch (_) {} + } + try { + await rust.setPtt(active: active); + } catch (e) { + if (!mounted || !reportError) return; + _showUiError('ptt', e); + } + } + + Future _toggleOutputMute() async { + final next = !_outputMuted; + setState(() { + _outputMuted = next; + }); + try { + await rust.setOutputMuted(muted: next); + } catch (e) { + if (!mounted) return; + setState(() { + _outputMuted = !next; + }); + _showUiError('output mute', e); + } + } + + Future _onJoinChannel( + rust.BridgeChannel ch, { + bool askForPassword = false, + }) async { + if (!_canJoinVoiceChannel) return; + if (_pendingVoiceChannelId != null) return; + if (ch.id == _currentVoiceChannelId) return; + final l10n = AppL10n.of(context); + String? password; + if (askForPassword) { + password = await _askChannelPassword(l10n); + if (password == null) return; + } + setState(() => _pendingVoiceChannelId = ch.id); + try { + final permState = Platform.isAndroid + ? await _androidPermissions.ensureRecordAudio() + : Platform.isIOS + ? await _iosPermissions.ensureRecordAudio() + : AndroidRecordAudioPermissionState.granted; + if (permState != AndroidRecordAudioPermissionState.granted) { + try { + await rust.setHardMute(muted: true); + if (mounted) { + setState(() { + _hardMuteOwners = _hardMuteOwners.copyWith(permission: true); + _hardMute = _hardMuteOwners.effective; + }); + } + } catch (_) {} + } else if (_hardMuteByPermission) { + await rust.setHardMute(muted: false); + if (mounted) { + setState(() { + _hardMuteOwners = _hardMuteOwners.copyWith(permission: false); + _hardMute = _hardMuteOwners.effective; + }); + } + } + await joinVoiceChannelWithIosAudioSession( + channelId: ch.id, + password: password ?? '', + voiceJoin: rust.voiceJoin, + activateIosAudioSession: iosAudioSessionController.activate, + deactivateIosAudioSession: iosAudioSessionController.deactivate, + isJoinSuccess: _isAlreadyInChannel, + ); + if (!mounted) return; + setState(() { + _currentVoiceChannelId = ch.id; + _pendingVoiceChannelId = null; + _inChannel = true; + _canJoinVoiceChannel = true; + _voiceStateInitialized = true; + }); + } catch (e) { + if (!mounted) return; + if (_isAlreadyInChannel(e)) { + setState(() { + _currentVoiceChannelId = ch.id; + _pendingVoiceChannelId = null; + _inChannel = true; + }); + return; + } + if (_isFlooding(e)) { + setState(() { + _pendingVoiceChannelId = null; + }); + _showUiErrorSnackBar( + area: 'join channel', + error: e, + displayMessage: l10n.channelJoinFailedFlooding, + ); + return; + } + final message = channelJoinErrorMessage(l10n, e); + setState(() { + _pendingVoiceChannelId = null; + }); + _showUiErrorSnackBar( + area: 'join channel', + error: e, + displayMessage: message, + ); + } + } + + bool _isAlreadyInChannel(Object error) { + if (error is rust_err.BridgeError_ServerRejected) { + return error.code == 0x0302; + } + return false; + } + + bool _isFlooding(Object error) { + if (error is rust_err.BridgeError_ServerRejected) { + return error.code == 0x020c; + } + return false; + } + + Future _onToggleHardMute() async { + if (_hardMuteByTalkPower && _hardMute) { + return; + } + final next = !_hardMute; + final previousInputMuted = _inputMuted; + final previousHardMute = _hardMute; + final previousHardMuteOwners = _hardMuteOwners; + setState(() { + _inputMuted = next; + _hardMuteOwners = _hardMuteOwners.copyWith( + manual: next, + permission: next ? false : null, + ); + _hardMute = _hardMuteOwners.effective; + }); + try { + await rust.setHardMute(muted: next); + await rust.setInputMuted(muted: next); + } catch (e) { + if (!mounted) return; + setState(() { + _inputMuted = previousInputMuted; + _hardMute = previousHardMute; + _hardMuteOwners = previousHardMuteOwners; + }); + _showUiError('hard mute', e); + } + } + + Future _onOnscreenPttHeldChanged(bool held) async { + try { + await rust.setPtt(active: held); + } catch (e) { + if (held) { + if (!mounted) return; + _showUiError('onscreen ptt', e); + } + } + } + + Future _loadAudioProcessingConfig() async { + try { + return await rust.getAudioProcessingConfig(); + } catch (_) { + return defaultAudioProcessingConfig(); + } + } + + void _appendChatEntryUnlocked(ChatEntry entry) { + _chatMessages.add(entry); + if (_chatMessages.length > 200) { + _chatMessages.removeRange(0, _chatMessages.length - 200); + } + _notifyChatFeedChanged(); + if (!_chatOpen && !entry.isPoke && entry.countsTowardUnread) { + _chatUnread++; + } + } + + void _notifyChatFeedChanged() { + _chatFeedRevision.value++; + } + + Future _onOpenVoiceDetailsSheet() async { + final audioConfig = await _loadAudioProcessingConfig(); + if (!mounted) return; + + await showVoiceDetailsSheet( + context, + transmitMode: _transmitMode, + releaseTailMs: _releaseTailMs, + pttBoundKeyLabel: _pttBoundKeyLabel, + pttLevel: _pttLevel, + pttBackendId: _pttBackendId, + pttBoundInputClass: _pttBoundInputClass, + isTouchOnly: isTouchOnlyPttHost, + initialAudioConfig: audioConfig, + onModeChanged: (mode) async { + try { + await rust.setTransmitMode(mode: mode); + if (!mounted) return; + setState(() => _transmitMode = mode); + unawaited(_uiPreferences.saveTransmitModeIndex(mode.index)); + } catch (e) { + if (!mounted) return; + _showUiError('transmit mode', e); + } + }, + onReleaseTailChanged: (ms) async { + try { + await rust.setReleaseTailMs(ms: ms); + if (!mounted) return; + setState(() => _releaseTailMs = ms); + unawaited(_uiPreferences.saveReleaseTailMs(ms)); + } catch (e) { + if (!mounted) return; + _showUiError('release tail', e); + } + }, + onAudioConfigChanged: (config) async { + try { + await rust.setAudioProcessingConfig(config: config); + } catch (e) { + if (!mounted) return; + _showUiError('audio processing config', e); + } + }, + ); + } + + Future _onOpenVoiceSettings() async { + final audioConfig = await _loadAudioProcessingConfig(); + if (!mounted) return; + + final result = await showDialog( + context: context, + builder: (ctx) => VoiceSettingsDialog( + initialMode: _transmitMode, + initialReleaseTailMs: _releaseTailMs, + initialAudioConfig: audioConfig, + pttLevel: _pttLevel, + pttBackendId: _pttBackendId, + pttBoundInputClass: _pttBoundInputClass, + onInputDeviceChanged: (id) => + unawaited(_uiPreferences.saveInputDeviceId(id)), + onOutputDeviceChanged: (id) => + unawaited(_uiPreferences.saveOutputDeviceId(id)), + ), + ); + if (result == null) return; + try { + await rust.setTransmitMode(mode: result.mode); + await rust.setReleaseTailMs(ms: result.releaseTailMs); + await rust.setAudioProcessingConfig(config: result.audioConfig); + // Persist voice settings (SRS-087). + unawaited(_uiPreferences.saveTransmitModeIndex(result.mode.index)); + unawaited(_uiPreferences.saveReleaseTailMs(result.releaseTailMs)); + } catch (e) { + if (!mounted) return; + _showUiError('voice settings', e); + } + if (result.bindKeyRequested && mounted) { + await _onConfigurePtt(context); + if (mounted) { + unawaited(_onOpenVoiceSettings()); + } + } + } + + Future _onOpenPokeSettings() async { + await _pokePreferences.load(); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => + PokeNotificationSettingsDialog(preferences: _pokePreferences, notificationService: _pokeNotifications), + ); + } + + Future _askChannelPassword(AppL10n l10n) async { + return await showDialog( + context: context, + builder: (ctx) => const ChannelPasswordDialog(), + ); + } + + Future _refreshSnapshot({ + required bool recordActivity, + required bool reportErrors, + }) async { + if (_snapshotRefreshInFlight) { + _snapshotRefreshQueued = true; + _snapshotRefreshQueuedRecordActivity = + _snapshotRefreshQueuedRecordActivity || recordActivity; + _snapshotRefreshQueuedReportErrors = + _snapshotRefreshQueuedReportErrors || reportErrors; + return; + } + + _snapshotRefreshInFlight = true; + var nextRecordActivity = recordActivity; + var nextReportErrors = reportErrors; + + try { + while (mounted) { + _snapshotRefreshQueued = false; + _snapshotRefreshQueuedRecordActivity = false; + _snapshotRefreshQueuedReportErrors = false; + + final previousSnapshot = _snapshot; + try { + final snap = await rust.snapshot(); + if (!mounted) return; + final activityEntries = nextRecordActivity && previousSnapshot != null + ? buildServerActivityEntries( + previous: previousSnapshot, + current: snap, + ) + : const []; + setState(() { + _applySnapshot(snap); + for (final entry in activityEntries) { + _appendChatEntryUnlocked(entry); + } + }); + } catch (e) { + if (nextReportErrors && mounted) { + _showUiError('refresh snapshot', e); + } + } + + if (!_snapshotRefreshQueued) { + break; + } + nextRecordActivity = _snapshotRefreshQueuedRecordActivity; + nextReportErrors = _snapshotRefreshQueuedReportErrors; + } + } finally { + _snapshotRefreshInFlight = false; + _snapshotRefreshQueued = false; + _snapshotRefreshQueuedRecordActivity = false; + _snapshotRefreshQueuedReportErrors = false; + } + } + + Future _onDisconnect() async { + _connectionEpoch++; + _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; + _snapshotRefreshInFlight = false; + _snapshotRefreshQueued = false; + _snapshotRefreshQueuedRecordActivity = false; + _snapshotRefreshQueuedReportErrors = false; + try { + await rust.disconnect(); + } catch (_) {} + try { + await FlutterForegroundTask.stopService(); + } catch (_) {} + unawaited(_reloadBookmarks()); + } + + void _resetConnectionUiState({required ConnectionPhase phase}) { + _phase = phase; + _snapshot = null; + _audioStats = null; + _inputMuted = false; + _outputMuted = false; + _hardMute = false; + _hardMuteOwners = const HardMuteOwners(); + _inChannel = false; + _currentVoiceChannelId = null; + _pendingVoiceChannelId = null; + _canJoinVoiceChannel = true; + _voiceStateInitialized = false; + _lostReason = null; + _reconnectAttempt = null; + _reconnectDelay = null; + _chatMessages.clear(); + _chatDrafts.clear(); + _chatUnread = 0; + _chatOpen = false; + _inlineChatTarget = null; + _inlineChatClientName = ''; + _inlineChatCollapseNoticeShown = false; + _lastDismissedTarget = null; + _lastDismissedClientName = ''; + _notifyChatFeedChanged(); + } + + String _draftKeyForTarget(rust.BridgeMessageTarget target) { + return switch (target) { + rust.BridgeMessageTarget_Server() => 'server', + rust.BridgeMessageTarget_Channel() => 'channel', + rust.BridgeMessageTarget_Client(:final field0) => 'client:$field0', + rust.BridgeMessageTarget_Poke(:final field0) => 'poke:$field0', + }; + } + + String? _currentDraftFor(rust.BridgeMessageTarget target) => + _chatDrafts[_draftKeyForTarget(target)]; + + Future _onOpenChat({ + rust.BridgeMessageTarget? target, + String clientName = '', + }) async { + final initialSnapshot = _snapshot!; + + rust.BridgeMessageTarget newTarget; + if (target != null) { + newTarget = target; + } else if (_chatOpen && _currentVoiceChannelId != null) { + newTarget = const rust.BridgeMessageTarget.channel(); + } else if (_inlineChatTarget != null) { + newTarget = _inlineChatTarget!; + } else { + newTarget = + resolveInitialChatTarget( + messages: _chatMessages, + currentVoiceChannelId: _currentVoiceChannelId, + ) ?? + const rust.BridgeMessageTarget.server(); + } + + final newClientName = clientName.isNotEmpty + ? clientName + : (newTarget == _inlineChatTarget) + ? _inlineChatClientName + : (newTarget == _lastDismissedTarget) + ? _lastDismissedClientName + : ''; + + final isExpanded = + layoutClassFromWidth(MediaQuery.sizeOf(context).width) == + LayoutClass.expanded; + if (isExpanded) { + setState(() { + _chatUnread = 0; + _chatOpen = true; + _inlineChatTarget = newTarget; + _inlineChatClientName = newClientName; + _inlineChatCollapseNoticeShown = false; + }); + return; + } + setState(() { + _chatUnread = 0; + _chatOpen = true; + }); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChatPage( + messages: _chatMessages, + snapshot: initialSnapshot, + messagesSource: () => _chatMessages, + snapshotSource: () => _snapshot ?? initialSnapshot, + refreshListenable: _chatFeedRevision, + initialTarget: newTarget, + initialClientName: newClientName, + onTs3ServerLink: _onTs3ServerLink, + ), + ), + ); + if (mounted) setState(() => _chatOpen = false); + } + + void _closeInlineChat() { + setState(() { + _lastDismissedTarget = _inlineChatTarget; + _lastDismissedClientName = _inlineChatClientName; + _chatOpen = false; + }); + } + + void _handleInlineChatViewport(LayoutClass layoutClass) { + if (_inlineChatTarget == null) return; + if (layoutClass == LayoutClass.expanded) { + _inlineChatCollapseNoticeShown = false; + if (!_chatOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _inlineChatTarget != null) { + setState(() => _chatOpen = true); + } + }); + } + return; + } + if (_chatOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _inlineChatTarget != null) { + setState(() => _chatOpen = false); + } + }); + } + if (_inlineChatCollapseNoticeShown) return; + _inlineChatCollapseNoticeShown = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _inlineChatTarget == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)), + ); + }); + } + + Future _onOpenClientInfo(rust.BridgeClient client) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: false, + builder: (context) => FractionallySizedBox( + heightFactor: 0.86, + child: ClientInfoSheet( + clientName: client.name, + loadProfile: () => rust.clientProfile(clientId: client.id), + ), + ), + ); + } + + Future _handleIncomingPoke({ + required BigInt senderId, + required String senderName, + required String message, + required rust.BridgePokeStrength? strength, + }) async { + if (senderId == _snapshot?.ownClientId) return; + await _pokePreferencesReady; + if (!_pokePreferences.pokesEnabled.value) return; + if (_pokePreferences.isMuted(senderId)) return; + final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed; + if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) { + _showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName); + } + if (_isPokeSenderActiveChat(senderId)) return; + await _pokeNotifications.show( + senderName: senderName, + message: message, + senderId: senderId, + strength: pokeStrength, + ); + } + + bool _isPokeSenderActiveChat(BigInt senderId) { + return isPokeSenderActiveChat( + chatOpen: _chatOpen, + inlineChatTarget: _inlineChatTarget, + senderId: senderId, + ); + } + + void _showPokeOverflowMutePrompt({ + required BigInt senderId, + required String senderName, + }) { + final l10n = AppL10n.of(context); + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: _chatSnackBarMargin(), + duration: const Duration(seconds: 8), + content: Text( + l10n.pokeOverflowMutePrompt(senderName), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + action: SnackBarAction( + label: l10n.pokeOverflowMuteAction, + onPressed: () { + unawaited(_pokePreferences.muteSender(senderId)); + messenger.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: _chatSnackBarMargin(), + content: Text(l10n.pokeMutedSenderConfirmation(senderName)), + ), + ); + }, + ), + ), + ); + } + + 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: AppL10n.of(context).openAction, + 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 >= ChanoraBreakpoints.medium && + _serverReachable && + _snapshot != null; + + if (_serverReachable && _snapshot != null && !wideConnectedLayout) { + bottom += 64; + if (_inChannel && _transmitMode == rust.BridgeTransmitMode.ptt) { + bottom += 64; + } + } + + 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 _applyClientDelta( + bool Function(rust.BridgeClient) test, + rust.BridgeClient Function(rust.BridgeClient) update, + ) { + final snap = _snapshot; + if (snap == null) return; + final clients = snap.clients.map((c) => test(c) ? update(c) : c).toList(); + setState(() { + _snapshot = rust.BridgeSnapshot( + serverName: snap.serverName, + welcomeMessage: snap.welcomeMessage, + platform: snap.platform, + version: snap.version, + channels: snap.channels, + clients: clients, + ownClientId: snap.ownClientId, + ); + _notifyChatFeedChanged(); + }); + } + + void _applyClientAdd(rust.BridgeClient client) { + final snap = _snapshot; + if (snap == null) return; + setState(() { + _snapshot = rust.BridgeSnapshot( + serverName: snap.serverName, + welcomeMessage: snap.welcomeMessage, + platform: snap.platform, + version: snap.version, + channels: snap.channels, + clients: [...snap.clients, client], + ownClientId: snap.ownClientId, + ); + _notifyChatFeedChanged(); + }); + } + + void _applyClientRemove(BigInt clientId) { + final snap = _snapshot; + if (snap == null) return; + setState(() { + _snapshot = rust.BridgeSnapshot( + serverName: snap.serverName, + welcomeMessage: snap.welcomeMessage, + platform: snap.platform, + version: snap.version, + channels: snap.channels, + clients: snap.clients.where((c) => c.id != clientId).toList(), + ownClientId: snap.ownClientId, + ); + _notifyChatFeedChanged(); + }); + } + + void _applyChannelAdd(rust.BridgeChannel channel) { + final snap = _snapshot; + if (snap == null) return; + setState(() { + _snapshot = rust.BridgeSnapshot( + serverName: snap.serverName, + welcomeMessage: snap.welcomeMessage, + platform: snap.platform, + version: snap.version, + channels: [...snap.channels, channel], + clients: snap.clients, + ownClientId: snap.ownClientId, + ); + _notifyChatFeedChanged(); + }); + } + + void _applyChannelRemove(BigInt channelId) { + final snap = _snapshot; + if (snap == null) return; + setState(() { + _snapshot = rust.BridgeSnapshot( + serverName: snap.serverName, + welcomeMessage: snap.welcomeMessage, + platform: snap.platform, + version: snap.version, + channels: snap.channels.where((ch) => ch.id != channelId).toList(), + clients: snap.clients, + ownClientId: snap.ownClientId, + ); + _notifyChatFeedChanged(); + }); + } + + void _applyChannelDelta( + bool Function(rust.BridgeChannel) test, + rust.BridgeChannel Function(rust.BridgeChannel) update, + ) { + final snap = _snapshot; + if (snap == null) return; + final channels = snap.channels + .map((ch) => test(ch) ? update(ch) : ch) + .toList(); + setState(() { + _snapshot = rust.BridgeSnapshot( + serverName: snap.serverName, + welcomeMessage: snap.welcomeMessage, + platform: snap.platform, + version: snap.version, + channels: channels, + clients: snap.clients, + ownClientId: snap.ownClientId, + ); + _notifyChatFeedChanged(); + }); + } + + void _applySnapshot(rust.BridgeSnapshot snap) { + _snapshot = snap; + _phase = phaseAfterSnapshotApplied(_phase); + final own = ownClientSnapshotState(snap); + if (own == null) return; + _inputMuted = own.inputMuted; + _outputMuted = own.outputMuted; + if (!_voiceStateInitialized || _currentVoiceChannelId == null) { + _currentVoiceChannelId = own.channelId; + _pendingVoiceChannelId = null; + _inChannel = true; + _canJoinVoiceChannel = true; + _voiceStateInitialized = true; + } else if (_pendingVoiceChannelId == null) { + _currentVoiceChannelId = own.channelId; + } + _notifyChatFeedChanged(); + + if (!own.talkPowerOk && !_hardMuteByTalkPower) { + final talkPowerEpoch = _connectionEpoch; + _hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true); + _hardMute = _hardMuteOwners.effective; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + _connectionEpoch != talkPowerEpoch || + !_serverReachable || + !_hardMuteByTalkPower) { + return; + } + unawaited(rust.setHardMute(muted: true)); + unawaited(rust.setInputMuted(muted: true)); + }); + } else if (own.talkPowerOk && _hardMuteByTalkPower) { + final talkPowerEpoch = _connectionEpoch; + _hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false); + _hardMute = _hardMuteOwners.effective; + if (!_hardMuteOwners.effective) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || + _connectionEpoch != talkPowerEpoch || + !_serverReachable || + _hardMuteOwners.effective) { + return; + } + unawaited(rust.setHardMute(muted: false)); + unawaited(rust.setInputMuted(muted: false)); + }); + } + } + } + + OwnClientSnapshotState? get _ownClientState { + final snap = _snapshot; + return snap == null ? null : ownClientSnapshotState(snap); + } + + String _diagnosticsText() { + final rustText = rust.exportDiagnostics(); + final uiText = _uiDiagnostics.isEmpty + ? 'UI diagnostics: none' + : ['UI diagnostics:', ..._uiDiagnostics].join('\n'); + return '$uiText\n\nRust diagnostics:\n$rustText'; + } + + Future _onShowDiagnostics() async { + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => + LiveDiagnosticsDialog(diagnosticsTextBuilder: _diagnosticsText), + ); + } + + Future _onConfigurePtt(BuildContext context) async { + final l10n = AppL10n.of(context); + if (!mounted) return; + if (_pttBackendId == 'gnome-wayland-portal') { + try { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + SnackBar(content: Text(l10n.pttConfigurePortalRedirect)), + ); + await rust.setPttBinding( + inputClass: rust.BridgePttInputClass.keyboard, + platformKey: 'portal', + ); + } catch (e) { + if (!mounted) return; + _showUiErrorSnackBar(area: 'ptt portal binding', error: e); + } + return; + } + + final binding = await showDialog( + context: context, + builder: (ctx) => const PttBindingCaptureDialog(), + ); + if (binding == null) return; + try { + await rust.setPttBinding( + inputClass: binding.inputClass, + platformKey: binding.platformKey, + ); + if (mounted) { + setState(() { + _pttBoundKeyLabel = binding.platformKey; + }); + } + } catch (e) { + if (!mounted) return; + _showUiErrorSnackBar(area: 'ptt binding', error: e); + } + } + + Future _onShowAbout(BuildContext context) async { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.aboutAction), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.appTitle, style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + l10n.aboutVersion(kAppVersion), + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text(l10n.aboutAuthor, style: theme.textTheme.bodySmall), + const SizedBox(height: 16), + Text(l10n.aboutNonAffiliation, style: theme.textTheme.bodyMedium), + const SizedBox(height: 12), + Text(l10n.aboutLicenseHeading, style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Text(l10n.aboutLicenseBody, style: theme.textTheme.bodySmall), + const SizedBox(height: 12), + Text( + l10n.aboutThirdPartyHeading, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text(l10n.aboutThirdPartyBody, style: theme.textTheme.bodySmall), + ], + ), + ), + actions: [ + TextButton( + onPressed: () async { + Navigator.of(ctx).pop(); + await Future.delayed(Duration.zero); + if (!mounted) return; + await _onShowDiagnostics(); + }, + child: Text(l10n.diagnosticsAction), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(l10n.closeAction), + ), + ], + ), + ); + } + + Future _onAddCurrentBookmark() async { + final initial = _hostCtl.text.trim(); + final name = await showDialog( + context: context, + builder: (ctx) => BookmarkNameDialog(initialName: initial), + ); + if (name == null || name.trim().isEmpty) return; + if (!mounted) return; + try { + await wireStorage(); + await rust.addBookmark( + b: rust.BridgeBookmark( + id: 0, + displayName: name.trim(), + host: _hostCtl.text.trim(), + nickname: _nickCtl.text.trim(), + password: _passwordCtl.text, + ), + ); + await _reloadBookmarks(); + } catch (e) { + if (!mounted) return; + _showUiError('add bookmark', e); + } + } + + Future _onDeleteBookmark(rust.BridgeBookmark b) async { + try { + await wireStorage(); + await rust.deleteBookmark(id: b.id); + await _reloadBookmarks(); + } catch (e) { + if (!mounted) return; + _showUiError('delete bookmark', e); + } + } + + Future _onUseBookmark(rust.BridgeBookmark b) async { + _hostCtl.text = b.host; + _nickCtl.text = b.nickname; + _passwordCtl.text = b.password; + 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 wireStorage(); + 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; + _showUiError('teamspeak link bookmark', e); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + + final headerActions = [ + IconButton( + tooltip: l10n.aboutAction, + icon: const Icon(Icons.info_outline), + onPressed: () => _onShowAbout(context), + ), + ChanoraThemeModeMenu( + themeMode: widget.themeMode, + onThemeModeChanged: widget.onThemeModeChanged, + ), + IconButton( + tooltip: l10n.pokeSettingsAction, + icon: const Icon(Icons.notifications_outlined), + onPressed: () => unawaited(_onOpenPokeSettings()), + ), + if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[ + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: Badge( + isLabelVisible: _chatUnread > 0, + label: Text(_chatUnread.toString()), + child: IconButton( + tooltip: l10n.chatAction, + icon: const Icon(Icons.chat_bubble_outline), + onPressed: _onOpenChat, + ), + ), + ), + ], + ]; + + const headerTitle = SizedBox.shrink(); + + final appBarTitle = _serverReachable + ? Text( + _snapshot?.serverName ?? l10n.appTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ) + : headerTitle; + final compactIdleChrome = + !_serverReachable && + MediaQuery.sizeOf(context).width < ChanoraBreakpoints.medium; + final compactIdleHeader = Row( + children: [ + Expanded( + child: Text( + l10n.appTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ...headerActions, + ], + ); + + final connTokens = _phase.tokens(theme.colorScheme); + final statusText = connectionStatusText( + phase: _phase, + l10n: l10n, + serverName: _snapshot?.serverName, + lostReason: _lostReason, + reconnectAttempt: _reconnectAttempt, + reconnectDelay: _reconnectDelay, + ); + final awaitingServerSnapshot = _phase.shouldShowSnapshotLoading( + hasSnapshot: _snapshot != null, + ); + + final bodyContent = LayoutBuilder( + builder: (ctx, bodyConstraints) { + final layoutClass = layoutClassFromWidth(bodyConstraints.maxWidth); + _handleInlineChatViewport(layoutClass); + final isWideSnapshot = + bodyConstraints.maxWidth >= ChanoraBreakpoints.medium && + _serverReachable && + _snapshot != null; + final banner = Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + l10n.homeNotProductionReadyBanner, + style: TextStyle(color: theme.colorScheme.onTertiaryContainer), + ), + ); + return ViewportInfo( + layoutClass: layoutClass, + width: bodyConstraints.maxWidth, + height: bodyConstraints.maxHeight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)], + if (!_serverReachable) + Row( + children: [ + Icon(connTokens.icon, size: 18, color: connTokens.color), + const SizedBox(width: 6), + Expanded( + child: Text( + statusText, + softWrap: true, + style: theme.textTheme.titleMedium, + ), + ), + ], + ), + if (_lostReason != null || _reconnectAttempt != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: + connTokens.background ?? + theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onErrorContainer, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _reconnectAttempt != null + ? l10n.statusReconnecting( + _reconnectAttempt!, + _reconnectDelay ?? 0, + ) + : l10n.statusConnectionLost(_lostReason ?? ''), + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 12), + if (_phase == ConnectionPhase.idle || + _phase == ConnectionPhase.disconnected) ...[ + Expanded( + child: AnimatedPadding( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(ctx).bottom, + ), + child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConnectForm( + hostCtl: _hostCtl, + nickCtl: _nickCtl, + passwordCtl: _passwordCtl, + onConnect: () => _onConnect(), + onAddBookmark: _onAddCurrentBookmark, + ), + const SizedBox(height: 16), + BookmarkList( + bookmarks: _bookmarks, + onConnect: _onUseBookmark, + onDelete: _onDeleteBookmark, + ), + ], + ), + ), + ), + ), + ] else if (_phase == ConnectionPhase.connecting || + awaitingServerSnapshot) ...[ + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + statusText, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + ], + ), + ), + ), + ), + ] else if (_serverReachable && _snapshot != null) ...[ + Expanded( + child: LayoutBuilder( + builder: (ctx, constraints) { + final voiceBar = VoiceBar( + inChannel: _inChannel, + transmitMode: _transmitMode, + hardMute: _hardMute, + outputMuted: _outputMuted, + talkPowerBlocked: _hardMuteByTalkPower, + releaseTailMs: _releaseTailMs, + channelName: snapshotChannelName( + _snapshot, + _currentVoiceChannelId, + ), + audioStats: _audioStats, + inputLevel: _inputLevel, + pttLevel: _pttLevel, + pttBackendId: _pttBackendId, + pttBoundInputClass: _pttBoundInputClass, + pttBoundKeyLabel: _pttBoundKeyLabel, + onConfigure: _onOpenVoiceSettings, + onPttHeldChanged: _onOnscreenPttHeldChanged, + ); + final permissionBanner = + PermissionStateBanner.fromCallbacks( + recordAudioState: _activeRecordAudioState, + ensureRecordAudio: _ensureActiveRecordAudio, + openAppSettings: _openActivePermissionSettings, + ); + final snapshotView = SnapshotView( + snapshot: _snapshot!, + audioStats: _audioStats, + currentVoiceChannelId: _currentVoiceChannelId, + pendingVoiceChannelId: _pendingVoiceChannelId, + localInputMuted: _inputMuted || _hardMute, + localOutputMuted: _outputMuted, + hasJoinPending: _pendingVoiceChannelId != null, + canJoinVoiceChannel: _canJoinVoiceChannel, + unreadChannelIds: _unreadChannelIds, + onJoinChannel: (ch) => _onJoinChannel(ch), + onJoinChannelWithPassword: (ch) => + _onJoinChannel(ch, askForPassword: true), + enableClientLongPressMenu: isTouchOnlyPttHost, + onOpenClientInfo: (client) => + unawaited(_onOpenClientInfo(client)), + onOpenClientChat: (client) => unawaited( + _onOpenChat( + target: rust.BridgeMessageTarget.client(client.id), + clientName: client.name, + ), + ), + onOpenClientPoke: (client) => unawaited( + _onOpenChat( + target: rust.BridgeMessageTarget.poke(client.id), + clientName: client.name, + ), + ), + onOpenChannelChat: (channel) => unawaited( + _onOpenChat( + target: const rust.BridgeMessageTarget.channel(), + ), + ), + onTs3ServerLink: _onTs3ServerLink, + ); + final ownClientState = _ownClientState; + final layoutInfo = ViewportInfo.of(ctx); + if (layoutInfo.isWide) { + final inlineChatTarget = _inlineChatTarget; + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: ChanoraBreakpoints.voicePanelWidth, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + banner, + const SizedBox(height: 12), + permissionBanner, + voiceBar, + ], + ), + ), + const SizedBox(width: ChanoraBreakpoints.panelGap), + Expanded(child: snapshotView), + if (layoutInfo.isExpanded && + inlineChatTarget != null) ...[ + const SizedBox( + width: ChanoraBreakpoints.panelGap, + ), + ChatPanel( + messages: _chatMessages, + snapshot: _snapshot!, + target: inlineChatTarget, + clientName: _inlineChatClientName, + restoredDraft: _currentDraftFor( + inlineChatTarget, + ), + onDraftChanged: (text) { + final draftKey = _draftKeyForTarget( + inlineChatTarget, + ); + _chatDrafts[draftKey] = text; + }, + onTs3ServerLink: _onTs3ServerLink, + onClose: _closeInlineChat, + ), + ], + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: snapshotView), + const SizedBox(height: 8), + permissionBanner, + VoiceStatusChip( + transmitMode: _transmitMode, + releaseTailMs: _releaseTailMs, + pttBoundKeyLabel: _pttBoundKeyLabel, + audioStats: _audioStats, + isTouchOnly: isTouchOnlyPttHost, + inputMuted: _hardMute, + outputMuted: _outputMuted, + hardMuteByTalkPower: _hardMuteByTalkPower, + talkPower: ownClientState?.talkPower, + neededTalkPower: ownClientState?.neededTalkPower, + talkPowerGranted: ownClientState?.talkPowerGranted, + onTap: () => _onOpenVoiceDetailsSheet(), + onToggleInputMute: _onToggleHardMute, + onToggleOutputMute: _toggleOutputMute, + ), + if (_inChannel && + _transmitMode == rust.BridgeTransmitMode.ptt) ...[ + const SizedBox(height: 8), + VoicePttButton( + active: _audioStats?.pttActive ?? false, + onHeldChanged: _onOnscreenPttHeldChanged, + ), + ], + ], + ); + }, + ), + ), + ], + ], + ), + ); + }, + ); + + if (isMacOS) { + return Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: macOSTrafficLightPad), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded(child: headerTitle), + ...headerActions, + ], + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: bodyContent, + ), + ), + ], + ), + ), + ); + } + + return ChanoraMobileScaffold( + compactIdleChrome: compactIdleChrome, + canDisconnect: _phase.canDisconnect, + title: appBarTitle, + actions: headerActions, + onDisconnect: _onConfirmDisconnect, + compactHeader: compactIdleHeader, + disconnectTooltip: l10n.disconnectAction, + body: bodyContent, + ); + } +}