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, ); } }