// Chanora Flutter application — External Beta build. // // Builds on Internal Beta v0.3.0-beta.1: // * server password field // * bookmark list with add / connect / delete actions // * channel tap-to-join with optional channel password // * self input + output mute toggles + master output gain slider // * diagnostics dialog + reconnect banner + identity persistence // (all carried over from v0.3.0-beta.1) 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 '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 '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; // 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; const Color _appSurfaceColor = Color(0xFFFFFBFE); /// Top padding for macOS to clear traffic-light buttons. 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; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); unawaited(() async { await wireStorage(); await wireCache(); }()); unawaited(wireConnectivity()); wireAudioLifecycle(); await configureBundledVadModels(); runApp(const ChanoraApp()); unawaited(_finishDeferredStartup()); } Future _finishDeferredStartup() async { try { _kAppVersion = await resolveAppVersion(); } catch (_) {} } class ChanoraApp extends StatefulWidget { const ChanoraApp({super.key}); @override State createState() => _ChanoraAppState(); } class _ChanoraAppState extends State { final UiPreferencesService _uiPreferences = const UiPreferencesService(); ThemeMode _themeMode = ThemeMode.system; @override void initState() { super.initState(); unawaited(_loadThemeMode()); } Future _loadThemeMode() async { try { final settings = await _uiPreferences.loadSettings(); if (!mounted) return; setState(() { _themeMode = settings.themeMode.toFlutterThemeMode(); }); } catch (_) {} } @override Widget build(BuildContext context) { return AnnotatedRegion( value: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, systemNavigationBarColor: _appSurfaceColor, systemNavigationBarDividerColor: _appSurfaceColor, systemNavigationBarIconBrightness: Brightness.dark, ), 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, colorSchemeSeed: const Color(0xFF3F51B5), scaffoldBackgroundColor: _appSurfaceColor, ), darkTheme: ThemeData( useMaterial3: true, colorSchemeSeed: const Color(0xFF3F51B5), brightness: Brightness.dark, ), themeMode: _themeMode, localizationsDelegates: AppL10n.localizationsDelegates, supportedLocales: AppL10n.supportedLocales, home: Stack( children: [ _BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode), if (_showAudioDebugOverlay) const AudioDebugStatsPanel(), ], ), ), ); } Future _setThemeMode(ThemeMode themeMode) async { setState(() { _themeMode = themeMode; }); try { await _uiPreferences.saveThemeMode(themeMode.toUiThemeMode()); } catch (_) {} } } extension on UiThemeMode { ThemeMode toFlutterThemeMode() { return switch (this) { UiThemeMode.system => ThemeMode.system, UiThemeMode.light => ThemeMode.light, UiThemeMode.dark => ThemeMode.dark, }; } } extension on ThemeMode { UiThemeMode toUiThemeMode() { return switch (this) { ThemeMode.system => UiThemeMode.system, ThemeMode.light => UiThemeMode.light, ThemeMode.dark => UiThemeMode.dark, }; } } bool isPokeSenderActiveChat({ required bool chatOpen, required rust.BridgeMessageTarget? inlineChatTarget, required BigInt senderId, }) { if (!chatOpen) return false; return switch (inlineChatTarget) { rust.BridgeMessageTarget_Poke(:final field0) || rust.BridgeMessageTarget_Client(:final field0) => field0 == senderId, _ => false, }; } class ChanoraThemeModeMenu extends StatelessWidget { const ChanoraThemeModeMenu({ super.key, required this.themeMode, required this.onThemeModeChanged, }); final ThemeMode themeMode; final Future Function(ThemeMode mode) onThemeModeChanged; @override Widget build(BuildContext context) { return PopupMenuButton( tooltip: 'Theme', icon: const Icon(Icons.palette_outlined), initialValue: themeMode, onSelected: (mode) { unawaited(onThemeModeChanged(mode)); }, itemBuilder: (context) => const [ PopupMenuItem(value: ThemeMode.system, child: Text('System')), PopupMenuItem(value: ThemeMode.light, child: Text('Light')), PopupMenuItem(value: ThemeMode.dark, child: Text('Dark')), ], ); } } class ChanoraMobileScaffold extends StatelessWidget { const ChanoraMobileScaffold({ super.key, required this.compactIdleChrome, required this.canDisconnect, required this.title, required this.actions, required this.onDisconnect, required this.compactHeader, required this.body, this.disconnectTooltip = 'Disconnect', }); final bool compactIdleChrome; final bool canDisconnect; final Widget? title; final List actions; final VoidCallback onDisconnect; final Widget compactHeader; final Widget body; final String disconnectTooltip; @override Widget build(BuildContext context) { final paddedBody = SafeArea( top: compactIdleChrome, child: Padding( padding: const EdgeInsets.all(16), child: compactIdleChrome ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ compactHeader, const SizedBox(height: 8), Expanded(child: body), ], ) : body, ), ); return Scaffold( appBar: compactIdleChrome ? null : AppBar( leading: canDisconnect ? IconButton( tooltip: disconnectTooltip, icon: const Icon(Icons.arrow_back), onPressed: onDisconnect, ) : null, leadingWidth: canDisconnect ? 44 : null, titleSpacing: canDisconnect ? 4 : null, title: title, actions: actions, ), body: paddedBody, ); } } 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(_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(); 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), ), ], ); } }