// 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 'package:connectivity_plus/connectivity_plus.dart'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'l10n/generated/app_localizations.dart'; import 'src/rust/api.dart' as rust; import 'src/rust/lib.dart' as rust_err; import 'src/rust/frb_generated.dart'; import 'widgets/voice_bar.dart'; import 'widgets/voice_compact.dart'; import 'widgets/voice_settings.dart'; /// True when the host is a touch-only mobile platform without a /// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart /// and widgets/voice_settings.dart so the AppBar + narrow-mode /// layout in main.dart can branch consistently. bool get _isTouchOnlyPttHost { if (kIsWeb) return false; return Platform.isIOS || Platform.isAndroid; } /// 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 `_kSemverBaseline` whenever the semver portion of /// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The /// build-counter suffix changes automatically on every pubspec /// `+` bump because Flutter writes it into Info.plist. const String _kSemverBaseline = 'v1.0.0-rc.8'; String _kAppVersion = _kSemverBaseline; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); await _resolveAppVersion(); unawaited(_wireStorage()); unawaited(_wireConnectivity()); _wireIosAudioLifecycle(); runApp(const ChanoraApp()); } /// Wire the iOS AVAudioSession lifecycle MethodChannel. /// /// Swift side (AppDelegate) posts route-change and interruption /// events through `FlutterMethodChannel` named /// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to /// the FRB bridge functions on the Rust side. void _wireIosAudioLifecycle() { const channel = MethodChannel('chanora/ios_audio_lifecycle'); channel.setMethodCallHandler((call) async { try { switch (call.method) { case 'handleRouteChange': rust.handleRouteChange(); break; case 'handleInterruptionBegan': rust.handleInterruptionBegan(); break; case 'handleInterruptionEnded': // `shouldResume` is passed from Swift as a bool argument. final shouldResume = call.arguments as bool? ?? false; rust.handleInterruptionEnded(shouldResume: shouldResume); break; default: // Unknown method — ignore gracefully rather than crashing. break; } } catch (_) { // Errors from the Rust side are already logged there; // don't propagate exceptions to the iOS framework. } }); } /// Populate `_kAppVersion` by suffixing the platform-canonical /// build number to `_kSemverBaseline`. Format: /// `v1.0.0-rc.8+` (e.g. `v1.0.0-rc.8+64`). The build /// number is iOS `CFBundleVersion` / Android `versionCode`, /// kept in sync with pubspec.yaml's `version: +`. Future _resolveAppVersion() async { try { final info = await PackageInfo.fromPlatform(); // info.buildNumber = "64" (CFBundleVersion on iOS; survives // the iOS version-string sanitiser that mangles // CFBundleShortVersionString). final build = info.buildNumber.isEmpty ? '' : '+${info.buildNumber}'; _kAppVersion = '$_kSemverBaseline$build'; } catch (_) { // Keep the hardcoded baseline. The build counter is lost // but the semver stays correct. } } Future _wireStorage() async { try { final dir = await getApplicationSupportDirectory(); await rust.initStorage(dir: dir.path); } catch (_) { // Best-effort; missing storage just means no identity persistence // and no bookmark list this session. } } rust.BridgeNetworkState _mapConnectivity(List results) { if (results.isEmpty) return rust.BridgeNetworkState.unknown; final allNone = results.every((r) => r == ConnectivityResult.none); if (allNone) return rust.BridgeNetworkState.offline; return rust.BridgeNetworkState.online; } Future _wireConnectivity() async { final connectivity = Connectivity(); try { final initial = await connectivity.checkConnectivity(); rust.setNetworkState(state: _mapConnectivity(initial)); } catch (_) {} connectivity.onConnectivityChanged.listen((results) { rust.setNetworkState(state: _mapConnectivity(results)); }); } class ChanoraApp extends StatelessWidget { const ChanoraApp({super.key}); @override Widget build(BuildContext context) { return 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), ), localizationsDelegates: AppL10n.localizationsDelegates, supportedLocales: AppL10n.supportedLocales, home: const _BetaHome(), ); } } enum _Phase { idle, connecting, connected } class _BetaHome extends StatefulWidget { const _BetaHome(); @override State<_BetaHome> createState() => _BetaHomeState(); } class _BetaHomeState extends State<_BetaHome> { final _hostCtl = TextEditingController(text: 'cn.teamspeak.app'); final _nickCtl = TextEditingController(text: 'ChanoraBeta'); final _passwordCtl = TextEditingController(); _Phase _phase = _Phase.idle; rust.BridgeSnapshot? _snapshot; String? _error; // ignore: unused_field bool _audioStarted = false; rust.BridgeAudioStats? _audioStats; Timer? _statsTimer; StreamSubscription? _eventsSub; // 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; int _releaseTailMs = 200; BigInt? _currentVoiceChannelId; String? _lostReason; int? _reconnectAttempt; int? _reconnectDelay; bool _inputMuted = false; bool _outputMuted = false; // ignore: unused_field double _outputGain = 1.0; // Desktop PTT capability badge state (gen2 v0.9.3 / SDD-091). // Populated by `BridgeEvent.PttCapability`. Defaults match the // 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'; // iOS interruption state from BridgeEvent::InterruptionState // (SDD-101). Can be used by UI surfaces (banner/snackbar). // ignore: unused_field bool _iosAudioInterrupted = false; // ignore: unused_field bool _iosInterruptionShouldResume = false; // Last platform-neutral key label the user saved in the // `_PttBindingCaptureDialog` (e.g. "Space", "F10", // "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 = ''; List _bookmarks = const []; @override void initState() { super.initState(); _eventsSub = rust.eventsStream().listen(_onEvent); unawaited(_reloadBookmarks()); unawaited(_hydratePttBinding()); } /// 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 (inputClass, keyLabel) = await rust.getPttBinding(); if (!mounted) return; if (keyLabel.isNotEmpty || inputClass.isNotEmpty) { setState(() { _pttBoundKeyLabel = keyLabel; _pttBoundInputClass = inputClass; }); } } catch (_) { // No persisted binding or storage not yet wired; not an error. } } Future _reloadBookmarks() async { try { 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(): setState(() { _phase = _Phase.connected; _lostReason = null; _reconnectAttempt = null; _reconnectDelay = null; }); case rust.BridgeEvent_Lost(:final reason): setState(() { _lostReason = reason; _reconnectAttempt = null; _reconnectDelay = null; }); case rust.BridgeEvent_Reconnecting(:final attempt, :final delaySecs): setState(() { _reconnectAttempt = attempt; _reconnectDelay = delaySecs; }); case rust.BridgeEvent_Disconnected(): setState(() { _phase = _Phase.idle; _lostReason = null; _reconnectAttempt = null; _reconnectDelay = null; }); case rust.BridgeEvent_AudioStarted(): setState(() => _audioStarted = true); case rust.BridgeEvent_AudioStopped(): setState(() => _audioStarted = false); case rust.BridgeEvent_SnapshotChanged(): unawaited(_onRefresh()); case rust.BridgeEvent_PttCapability( :final level, :final backendId, :final boundInputClass, ): setState(() { _pttLevel = level; _pttBackendId = backendId; _pttBoundInputClass = boundInputClass; }); case rust.BridgeEvent_VoiceState( :final inChannel, :final transmitMode, :final mute, :final releaseTailMs, ): setState(() { _inChannel = inChannel; _transmitMode = transmitMode; _hardMute = mute; _releaseTailMs = releaseTailMs; _audioStarted = inChannel; }); if (inChannel) { _ensureStatsTimer(); } else { _statsTimer?.cancel(); _statsTimer = null; } case rust.BridgeEvent_InterruptionState( :final began, :final shouldResume, ): setState(() { _iosAudioInterrupted = began; _iosInterruptionShouldResume = shouldResume; }); // Surface iOS audio interruption to the user (SDD-101). // Use unawaited to stay inside the sync _onEvent stream // without blocking it. if (!mounted) return; unawaited(() async { if (!mounted) return; final messenger = ScaffoldMessenger.of(context); if (began) { messenger.showSnackBar( const SnackBar( content: Text('Audio interrupted by system (phone call)'), duration: Duration(seconds: 3), backgroundColor: Colors.orange, ), ); } else if (shouldResume) { messenger.showSnackBar( const SnackBar( content: Text('Audio resuming'), duration: Duration(seconds: 2), backgroundColor: Colors.green, ), ); } }()); } } void _ensureStatsTimer() { if (_statsTimer != null) return; // Poll at 80 ms (~12 Hz) — this is the loop that drives the // Voice Bar's "PTT=on/off" indicator and the level meter, so // it needs to be fast enough that users don't perceive a lag // between physically pressing the bound key and seeing the // UI change. 80 ms is well below the ~150 ms perceptual // delay threshold and adds only a dozen tiny FFI calls per // second to the load. A future push-based BridgeEvent for // transmit-active transitions would let us drop this poll // entirely. _statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async { try { final s = await rust.audioStats(); if (!mounted) return; setState(() => _audioStats = s); } catch (_) {} }); } @override void dispose() { _eventsSub?.cancel(); _statsTimer?.cancel(); _hostCtl.dispose(); _nickCtl.dispose(); _passwordCtl.dispose(); super.dispose(); } Future _onConnect({ String? host, String? nickname, String? password, }) async { setState(() { _phase = _Phase.connecting; _error = null; _snapshot = null; }); try { final snap = await rust.connect( host: (host ?? _hostCtl.text).trim(), nickname: (nickname ?? _nickCtl.text).trim(), password: password ?? _passwordCtl.text, ); if (!mounted) return; setState(() { _phase = _Phase.connected; _snapshot = snap; }); } catch (e) { if (!mounted) return; setState(() { _phase = _Phase.idle; _error = e.toString(); }); } } // ignore: unused_element Future _setPtt(bool active) async { try { await rust.setPtt(active: active); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } // ignore: unused_element Future _toggleInputMute() async { final next = !_inputMuted; try { await rust.setInputMuted(muted: next); if (!mounted) return; setState(() => _inputMuted = next); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } Future _toggleOutputMute() async { final next = !_outputMuted; try { await rust.setOutputMuted(muted: next); if (!mounted) return; setState(() => _outputMuted = next); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } // ignore: unused_element Future _setOutputGain(double value) async { setState(() => _outputGain = value); try { await rust.setOutputGain(gain: value); } catch (_) { // Audio may not be started yet — that's fine; the next start_audio // picks up the current slider position next time we plumb it. } } Future _onJoinChannel(rust.BridgeChannel ch) async { if (ch.id == _currentVoiceChannelId) return; final l10n = AppL10n.of(context); final messenger = ScaffoldMessenger.of(context); String? password; // Heuristic: a channel name annotated with a lock prompts. if (ch.name.contains('🔒') || ch.name.toLowerCase().contains('password')) { password = await _askChannelPassword(l10n); if (password == null) return; // cancelled } try { await rust.voiceJoin(channelId: ch.id, password: password ?? ''); if (!mounted) return; setState(() => _currentVoiceChannelId = ch.id); } catch (e) { if (!mounted) return; // Surface as a SnackBar so the user sees it even while // connected (the persistent _error string lives in the // pre-connect area and is hidden post-connect). The message // is selected by TS3 error code per the canonical // catalogue at https://github.com/ReSpeak/tsdeclarations. final message = _channelJoinErrorMessage(l10n, e); messenger.showSnackBar( SnackBar( content: Text(message), behavior: SnackBarBehavior.floating, duration: const Duration(seconds: 4), ), ); } } /// Map a `voiceJoin` error to a localised user-facing message. /// Recognises the typed `BridgeError.serverRejected` variant /// (with a TS3 error code) and falls back to a generic message /// for everything else. String _channelJoinErrorMessage(AppL10n l10n, Object e) { if (e is rust_err.BridgeError) { return e.when( invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg), dnsFailed: (host, reason) => l10n.channelJoinFailedGeneric('$host: $reason'), connection: (msg) => l10n.channelJoinFailedGeneric(msg), notConnected: () => l10n.channelJoinFailedGeneric('not connected'), alreadyConnected: () => l10n.channelJoinFailedGeneric('already connected'), serverRejected: (code, message) { // Canonical TS3 error codes per ReSpeak/tsdeclarations // Errors.csv. switch (code) { case 0x0001: // Our sentinel for "snapshot poll didn't confirm". return l10n.channelJoinFailedTimeout; case 0x0a08: // permissions_client_insufficient return l10n.channelJoinFailedPermission; case 0x0302: // channel_already_in return l10n.channelJoinAlreadyIn; case 0x030d: // channel_invalid_password return l10n.channelJoinFailedPassword; case 0x0309: // channel_maxclients_reached return l10n.channelJoinFailedFull; case 0x030a: // channel_maxfamily_reached return l10n.channelJoinFailedFamilyFull; case 0x030e: // channel_is_private_channel return l10n.channelJoinFailedPrivate; default: return l10n.channelJoinFailedGeneric(message); } }, unmapped: (msg) => l10n.channelJoinFailedGeneric(msg), ); } return l10n.channelJoinFailedGeneric(e.toString()); } // ignore: unused_element Future _onLeaveVoice() async { try { await rust.voiceLeave(); if (!mounted) return; setState(() => _currentVoiceChannelId = null); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } Future _onToggleHardMute() async { final next = !_hardMute; 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); if (!mounted) return; setState(() => _inputMuted = next); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } /// 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; setState(() => _error = e.toString()); } } } /// 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 + (desktop-only) capability badge. /// Zero navigation depth \u2014 no nested dialog. Future _onOpenVoiceDetailsSheet() async { await showVoiceDetailsSheet( context, audioStats: _audioStats, transmitMode: _transmitMode, releaseTailMs: _releaseTailMs, pttBoundKeyLabel: _pttBoundKeyLabel, pttLevel: _pttLevel, pttBackendId: _pttBackendId, pttBoundInputClass: _pttBoundInputClass, isTouchOnly: _isTouchOnlyPttHost, onModeChanged: (mode) async { try { await rust.setTransmitMode(mode: mode); if (!mounted) return; setState(() => _transmitMode = mode); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } }, onReleaseTailChanged: (ms) async { try { await rust.setReleaseTailMs(ms: ms); if (!mounted) return; setState(() => _releaseTailMs = ms); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } }, ); } Future _onOpenVoiceSettings() async { final result = await showDialog( context: context, builder: (ctx) => VoiceSettingsDialog( initialMode: _transmitMode, initialReleaseTailMs: _releaseTailMs, ), ); if (result == null) return; try { await rust.setTransmitMode(mode: result.mode); await rust.setReleaseTailMs(ms: result.releaseTailMs); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } if (result.bindKeyRequested && mounted) { await _onConfigurePtt(context); } } 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 _onRefresh() async { try { final snap = await rust.snapshot(); if (!mounted) return; setState(() => _snapshot = snap); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } Future _onDisconnect() async { _statsTimer?.cancel(); _statsTimer = null; try { await rust.disconnect(); } catch (_) {} if (!mounted) return; setState(() { _phase = _Phase.idle; _snapshot = null; _audioStarted = false; _audioStats = null; _error = null; _inputMuted = false; _outputMuted = false; _inChannel = false; _currentVoiceChannelId = null; }); } String _currentVoiceChannelName() { final id = _currentVoiceChannelId; final snap = _snapshot; if (id == null || snap == null) return ''; for (final ch in snap.channels) { if (ch.id == id) return ch.name; } return ''; } Future _onShowDiagnostics(BuildContext context) async { final l10n = AppL10n.of(context); final text = rust.exportDiagnostics(); if (!mounted) return; await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l10n.diagnosticsAction), content: SingleChildScrollView( child: SelectableText( text, style: const TextStyle(fontFamily: 'monospace', fontSize: 11), ), ), actions: [ TextButton( onPressed: () async { await Clipboard.setData(ClipboardData(text: text)); if (!ctx.mounted) return; Navigator.of(ctx).pop(); }, child: Text(l10n.copyAction), ), TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(l10n.closeAction), ), ], ), ); } 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; final messenger = ScaffoldMessenger.of(this.context); messenger.showSnackBar( SnackBar(content: Text(l10n.statusError(e.toString()))), ); } 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<_CapturedBinding>( 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. if (!mounted) return; // Use the State's context (guaranteed valid because we // re-checked `mounted` immediately above). final messenger = ScaffoldMessenger.of(this.context); messenger.showSnackBar( SnackBar(content: Text(l10n.statusError(e.toString()))), ); } } 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: 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: () => 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 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; setState(() => _error = e.toString()); } } Future _onDeleteBookmark(rust.BridgeBookmark b) async { try { await rust.deleteBookmark(id: b.id); await _reloadBookmarks(); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } 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); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); String statusText() { switch (_phase) { case _Phase.idle: return _error != null ? l10n.statusError(_error!) : l10n.statusIdle; case _Phase.connecting: return l10n.statusConnecting; case _Phase.connected: return l10n.statusConnected(_snapshot?.serverName ?? ''); } } return Scaffold( appBar: AppBar( title: _AppBarTitle( phase: _phase, inChannel: _inChannel, channelName: _currentVoiceChannelName(), ), actions: [ // Narrow-mode AppBar gains mic-mute + headset-mute icons // when the user is in a voice channel, so the on-screen // body can dedicate ~85% of its height to the channel tree // + the bottom-anchored PTT button. Wide mode keeps these // controls inside the VoiceBar widget (left column) so the // signed-off rc.8 wide-layout doesn't shift. // // The gear icon (Icons.tune) that used to live here was // **removed** in the rc.8 follow-up: it duplicated the // "Adjust mode & release tail" button now inside the modal // sheet, and the user reported the duplication. The modal // is reached by tapping the status chip above the PTT // button — there is now exactly one entry point. if (_phase == _Phase.connected && _inChannel && MediaQuery.of(context).size.width < 840.0) ...[ IconButton( tooltip: l10n.voiceHardMuteLabel, icon: Icon(_hardMute ? Icons.mic_off : Icons.mic), isSelected: _hardMute, selectedIcon: const Icon(Icons.mic_off), onPressed: _onToggleHardMute, ), IconButton( tooltip: l10n.voiceOutputMuteLabel, icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset), isSelected: _outputMuted, selectedIcon: const Icon(Icons.headset_off), onPressed: _toggleOutputMute, ), ], IconButton( tooltip: l10n.aboutAction, icon: const Icon(Icons.info_outline), onPressed: () => _onShowAbout(context), ), IconButton( tooltip: l10n.diagnosticsAction, icon: const Icon(Icons.bug_report_outlined), onPressed: () => _onShowDiagnostics(context), ), if (_phase == _Phase.connected) ...[ // The previous Refresh action (Icons.refresh + _onRefresh) // was removed in v1.0.0-rc.8 — the snapshot stream the // bridge pushes via BridgeEvent::SnapshotChanged keeps the // tree current automatically, and a manual refresh was a // no-op from the user's perspective. IconButton( tooltip: l10n.disconnectAction, icon: const Icon(Icons.logout), onPressed: _onDisconnect, ), ], ], ), body: Padding( padding: const EdgeInsets.all(16), child: LayoutBuilder( builder: (ctx, bodyConstraints) { // Shared breakpoint with the inner snapshot LayoutBuilder // below — when the UI is wide enough to split into two // columns AND we are showing the snapshot, the // not-production-ready banner is moved into the left // column above the Voice Bar so it doesn't span the // wider channel-tree area. In every other state (narrow, // or idle / connecting at any width) the banner stays // pinned to the top of the body. const wideBreakpoint = 840.0; final isWideSnapshot = bodyConstraints.maxWidth >= wideBreakpoint && _phase == _Phase.connected && _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 Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)], Text(statusText(), style: theme.textTheme.titleMedium), if (_lostReason != null || _reconnectAttempt != null) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: 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 == _Phase.idle) ...[ Expanded( child: SingleChildScrollView( // Dismiss keyboard when the user drags away from // a focused field — friendlier mobile UX than // forcing them to tap outside. 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 == _Phase.connecting) ...[ const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator(), ), ), ] else if (_phase == _Phase.connected && _snapshot != null) ...[ Expanded( child: LayoutBuilder( builder: (ctx, constraints) { final voiceBar = VoiceBar( inChannel: _inChannel, transmitMode: _transmitMode, hardMute: _hardMute, outputMuted: _outputMuted, releaseTailMs: _releaseTailMs, channelName: _currentVoiceChannelName(), audioStats: _audioStats, pttLevel: _pttLevel, pttBackendId: _pttBackendId, pttBoundInputClass: _pttBoundInputClass, pttBoundKeyLabel: _pttBoundKeyLabel, onToggleMute: _onToggleHardMute, onToggleOutputMute: _toggleOutputMute, onConfigure: _onOpenVoiceSettings, onPttHeldChanged: _onOnscreenPttHeldChanged, ); final snapshotView = _SnapshotView( snapshot: _snapshot!, currentVoiceChannelId: _currentVoiceChannelId, onJoinChannel: _onJoinChannel, onLeaveVoice: _onLeaveVoice, ); // Responsive: at <840 dp use a stacked layout // (Voice Bar on top, channel tree below). At // ≥840 dp use a side-by-side layout with the // Voice Bar pinned to 320 dp on the left and // the channel tree expanding on the right. // 840 dp matches Material's tablet / desktop // breakpoint. const wideBreakpoint = 840.0; const voiceBarWidthWide = 320.0; if (constraints.maxWidth >= wideBreakpoint) { return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: voiceBarWidthWide, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ banner, const SizedBox(height: 12), voiceBar, ], ), ), const SizedBox(width: 12), Expanded(child: snapshotView), ], ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // In narrow / one-column layout the layout // is: // // [ channel tree (Expanded) ] // [ status chip (2-line live readout) ] // [ PTT button (mobile-only, // PTT-mode-only) ] // // The chip is a tap target that opens // `showVoiceDetailsSheet` with the mic // level meter + TX/RX counts + capability // badge. Mode + bind key + release-tail // live in `VoiceSettingsDialog` // (gear icon in AppBar). // // Wide mode (Row branch above) is // unchanged from rc.8. Expanded(child: snapshotView), const SizedBox(height: 8), VoiceStatusChip( transmitMode: _transmitMode, releaseTailMs: _releaseTailMs, pttBoundKeyLabel: _pttBoundKeyLabel, audioStats: _audioStats, isTouchOnly: _isTouchOnlyPttHost, onTap: () => _onOpenVoiceDetailsSheet(), ), // PTT button only when PTT mode is active // AND the user is in a voice channel. In // Continuous mode there is nothing to // hold; the chip alone surfaces the // "Mic on / off" state. if (_inChannel && _transmitMode == rust.BridgeTransmitMode.ptt) ...[ const SizedBox(height: 8), VoicePttButton( active: _audioStats?.pttActive ?? false, onHeldChanged: _onOnscreenPttHeldChanged, ), ], ], ); }, ), ), ], ], ); }, ), ), ); } } /// AppBar title that shows just the app name when idle / connecting, /// and 'app name · #channel' (channel as an outlined chip-style /// pill) when the user is in a voice channel. The chip is read- /// only — tapping does nothing because the source of truth for /// "current channel" is the channel tree below. Long names ellipsize. class _AppBarTitle extends StatelessWidget { const _AppBarTitle({ required this.phase, required this.inChannel, required this.channelName, }); final _Phase phase; final bool inChannel; final String channelName; @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); if (phase != _Phase.connected || !inChannel || channelName.isEmpty) { return Text(l10n.appTitle); } // When in a voice channel on a narrow phone width, the AppBar // is already crowded with mic / headset / settings / about / // diagnostics / disconnect icons (5-6 action buttons). Keeping // the "Chanora" app-name label in the title here causes the // Row to overflow at typical iPhone widths and Flutter renders // its yellow-and-black "OVERFLOWED BY X PIXELS" debug strip // next to the title — the user reported seeing "RFLOWED BY" // there. Drop the app-name label on narrow widths and let the // channel pill be the only title content; the user knows // they're in Chanora because they just opened it. On wide // widths (>= 840 dp, tablet/desktop) restore the app name // because there's plenty of room. final isNarrow = MediaQuery.of(context).size.width < 840.0; return Row( mainAxisSize: MainAxisSize.min, children: [ if (!isNarrow) ...[Text(l10n.appTitle), const SizedBox(width: 12)], Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.tag, size: 14, color: theme.colorScheme.onPrimaryContainer, ), const SizedBox(width: 4), Flexible( child: Text( channelName, maxLines: 1, overflow: TextOverflow.ellipsis, softWrap: false, style: TextStyle( color: theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 14, ), ), ), ], ), ), ), ], ); } } /// 'Save bookmark' name-entry dialog. Owns its own /// TextEditingController via a StatefulWidget lifecycle so the /// dispose() runs cleanly at unmount time (after the route pop /// animation has fully detached the dialog subtree), not /// synchronously from the caller's `await showDialog` resumption /// point. /// /// Inline dispose-after-showDialog pattern previously caused /// 'framework.dart line 6268 _dependents.isEmpty' on iOS because /// the controller was torn down mid-pop while EditableText still /// had live InheritedWidget dependencies on the dialog route. class _BookmarkNameDialog extends StatefulWidget { const _BookmarkNameDialog({required this.initialName}); final String initialName; @override State<_BookmarkNameDialog> createState() => _BookmarkNameDialogState(); } class _BookmarkNameDialogState extends State<_BookmarkNameDialog> { late final TextEditingController _ctl = TextEditingController( text: widget.initialName, ); @override void dispose() { _ctl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); return AlertDialog( title: Text(l10n.bookmarkAddTitle), content: TextField( controller: _ctl, autofocus: true, decoration: InputDecoration(labelText: l10n.fieldDisplayName), onSubmitted: (_) => Navigator.of(context).pop(_ctl.text), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l10n.closeAction), ), FilledButton( onPressed: () => Navigator.of(context).pop(_ctl.text), child: Text(l10n.bookmarkAddAction), ), ], ); } } /// Channel-password dialog. Same StatefulWidget pattern as /// [_BookmarkNameDialog] to keep TextEditingController disposal /// inside the dialog's own lifecycle and avoid the /// framework.dart:6268 _dependents.isEmpty assertion. class _ChannelPasswordDialog extends StatefulWidget { const _ChannelPasswordDialog(); @override State<_ChannelPasswordDialog> createState() => _ChannelPasswordDialogState(); } class _ChannelPasswordDialogState extends State<_ChannelPasswordDialog> { final TextEditingController _ctl = TextEditingController(); @override void dispose() { _ctl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); return AlertDialog( title: Text(l10n.channelPasswordTitle), content: TextField( controller: _ctl, obscureText: true, autofocus: true, decoration: InputDecoration(labelText: l10n.fieldPassword), onSubmitted: (_) => Navigator.of(context).pop(_ctl.text), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l10n.closeAction), ), FilledButton( onPressed: () => Navigator.of(context).pop(_ctl.text), child: Text(l10n.connectAction), ), ], ); } } class _ConnectForm extends StatefulWidget { const _ConnectForm({ required this.hostCtl, required this.nickCtl, required this.passwordCtl, required this.onConnect, required this.onAddBookmark, }); final TextEditingController hostCtl; final TextEditingController nickCtl; final TextEditingController passwordCtl; final VoidCallback onConnect; final VoidCallback onAddBookmark; @override State<_ConnectForm> createState() => _ConnectFormState(); } class _ConnectFormState extends State<_ConnectForm> { // FocusNodes + Listener(onPointerDown) wrappers fix a Flutter-on- // iOS gesture-arena issue where enclosing scrollables (in our // case, the SingleChildScrollView wrapping the connect form, and // the ListView wrapping the channel tree) absorb the first tap // as a possible scroll-intent. The second tap then succeeds // because the scrollable's tap-arena participant has already // decided not to handle a drag. // // Two-layer fix: // * focusNode + autofocus-on-tap inside TextField — works when // the arena resolves the tap as a TextField gesture // * Listener(onPointerDown) wrapping the TextField with // HitTestBehavior.translucent — fires synchronously on // pointer-down BEFORE arena resolution, so even if the // scrollable were to win the arena we have already grabbed // focus. Translucent means the pointer ALSO propagates down // to the TextField so its normal touch handling still runs. // // This is the documented workaround in // https://github.com/flutter/flutter/issues/22680 and many // related arena-race threads. final FocusNode _hostFocus = FocusNode(); final FocusNode _nickFocus = FocusNode(); final FocusNode _passwordFocus = FocusNode(); // _kickFocus (unfocus + Future.microtask refocus on every onTap) // was removed after user-reported 500\u20131000 ms keyboard appearance // latency on iPhone 16 Pro / iOS 26. // // The microtask deferral was the source of the lag: it forces // EditableText's attach-to-TextInput path to wait one frame past // the user's pointer-up, and iOS 26's keyboard slide-up animation // then dovetails into that extra frame in a way that adds another // 200\u2013800 ms before the keyboard actually appears. // // The tap-outside-to-unfocus GestureDetector wrapping the connect // form Column (see build() below) already guarantees the FocusNode // is in the unfocused state when the user taps a field, because // any prior keyboard dismissal (tap outside / tap a sibling field) // goes through FocusScope.unfocus(). Therefore TextField's native // tap path can attach TextInput on the first frame with no help // from us, and the keyboard appears instantly. // // If iOS regresses again such that focus state desyncs from // keyboard visibility, the workaround to re-introduce here is the // canonical pattern from flutter/flutter#181474: // // void _kickFocus(FocusNode node) { // if (node.hasFocus) node.unfocus(); // Future.microtask(() { // if (!mounted) return; // node.requestFocus(); // }); // } // // and wire onTap: () => _kickFocus(_xxxFocus) on each TextField. /// Dismisses the soft keyboard when the user taps outside any /// TextField in this form. Wired into each TextField via the /// built-in `onTapOutside` parameter (Flutter 3.10+), which uses /// the framework's TapRegion machinery to detect taps outside the /// field's region without us having to install an outer /// GestureDetector (which previously caused a /// '_dependents.isEmpty' assertion when modal-sheet pop sequences /// raced the InheritedWidget dependency cleanup). /// /// Uses FocusManager.instance \u2014 a global singleton with no /// BuildContext dependency \u2014 instead of FocusScope.of(context), /// to avoid subscribing this widget's Element to any /// InheritedWidget. void _onTapOutside(PointerDownEvent _) { FocusManager.instance.primaryFocus?.unfocus(); } @override void dispose() { _hostFocus.dispose(); _nickFocus.dispose(); _passwordFocus.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); // Tap-outside-to-unfocus. // // Iteration history: // * 020be77/23bd1c7: Listener pre-arena requestFocus + // SystemChannels.textInput.show. Caused first-tap-not-focusing // AND post-attach keystroke lag (Listener wins arena, // EditableText loses). // * 79f8360: _kickFocus(unfocus + Future.microtask refocus) on // every TextField.onTap. Caused 500-1000 ms keyboard slide-up // lag (microtask deferral collides with iOS keyboard // CAAnimation). // * 6955547: GestureDetector(HitTestBehavior.opaque, onTap: // FocusScope.of(context).unfocus()) wrapping the Column. // Caused 'package:flutter/src/widgets/framework.dart line // 6268 _dependents.isEmpty' assertion: the // FocusScope.of(context) call subscribes the GestureDetector's // Element to the _FocusScopeMarker InheritedWidget on every // build, and the modal-sheet pop sequence deactivates that // InheritedElement before the dependents fully clear, // tripping the debug assert. // // Current approach (no outer GestureDetector at all): // * Use TextField.onTapOutside (added Flutter 3.10+). Wired // per field. Uses FocusManager.instance (global, no // BuildContext dependency, no InheritedWidget race). // * iOS framework default for touch+mobile is to do NOTHING // on tap-outside (see flutter/widgets/editable_text.dart // _EditableTextTapOutsideAction:6748-6755) because the iOS // UX convention is 'swipe-down on the keyboard' or 'tap // Done' \u2014 not tap-outside. We override that explicitly // because most users expect tap-outside-to-dismiss in a // server-connect form context. // * No outer GestureDetector means zero gesture-arena // interference with the TextField's own tap recognizer \u2014 // keyboard appears synchronously on first tap, no lag. return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: widget.hostCtl, focusNode: _hostFocus, onTapOutside: _onTapOutside, // Server addresses are URL-shaped: hostname or // hostname:port, all-lowercase ASCII, never user- // friendly prose. Configure the on-screen keyboard // accordingly: // * keyboardType: .url \u2014 surfaces ".", "/", // ":" on the primary keyboard plane so the user // doesn't have to switch to the symbols pane to // type kr.teamspeak.app:9987. // * textInputAction: .next \u2014 return key advances // to the nickname field. // * autocorrect / enableSuggestions: false \u2014 iOS // should not autocorrect 'kr.teamspeak.app' to // 'kr.teamspeak.lap' or suggest 'KR' in caps. // * textCapitalization: .none \u2014 don't capitalise the // first letter the way iOS does for sentences. // * inputFormatters: deny whitespace and uppercase\u2014 // belt-and-braces in case the user pastes from a // formatted source (e.g. tab-indented copy). keyboardType: TextInputType.url, textCapitalization: TextCapitalization.none, textInputAction: TextInputAction.next, autocorrect: false, enableSuggestions: false, inputFormatters: [ FilteringTextInputFormatter.deny(RegExp(r'\s')), TextInputFormatter.withFunction( (oldValue, newValue) => newValue.copyWith( text: newValue.text.toLowerCase(), selection: newValue.selection, ), ), ], decoration: InputDecoration( labelText: l10n.fieldServerHost, hintText: 'host[:port]', prefixIcon: const Icon(Icons.dns_outlined), border: const OutlineInputBorder(), ), ), const SizedBox(height: 8), TextField( controller: widget.nickCtl, focusNode: _nickFocus, onTapOutside: _onTapOutside, textInputAction: TextInputAction.next, autocorrect: false, enableSuggestions: false, decoration: InputDecoration( labelText: l10n.fieldNickname, border: const OutlineInputBorder(), ), ), const SizedBox(height: 8), TextField( controller: widget.passwordCtl, focusNode: _passwordFocus, onTapOutside: _onTapOutside, obscureText: true, textInputAction: TextInputAction.done, autocorrect: false, enableSuggestions: false, decoration: InputDecoration( labelText: l10n.fieldServerPassword, helperText: l10n.fieldServerPasswordHelp, border: const OutlineInputBorder(), ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: FilledButton.icon( icon: const Icon(Icons.login), label: Text(l10n.connectAction), onPressed: widget.onConnect, ), ), const SizedBox(width: 8), OutlinedButton.icon( icon: const Icon(Icons.bookmark_add_outlined), label: Text(l10n.bookmarkAddAction), onPressed: widget.onAddBookmark, ), ], ), ], ); } } class _BookmarkList extends StatelessWidget { const _BookmarkList({ required this.bookmarks, required this.onConnect, required this.onDelete, }); final List bookmarks; final ValueChanged onConnect; final ValueChanged onDelete; @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); if (bookmarks.isEmpty) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall), ); } return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall), const SizedBox(height: 4), for (final b in bookmarks) Card( margin: const EdgeInsets.symmetric(vertical: 4), child: ListTile( title: Text(b.displayName), subtitle: Text('${b.host} — ${b.nickname}'), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.login), tooltip: l10n.connectAction, onPressed: () => onConnect(b), ), IconButton( icon: const Icon(Icons.delete_outline), tooltip: l10n.bookmarkDeleteAction, onPressed: () => onDelete(b), ), ], ), ), ), ], ); } } /// PTT capability badge (gen2 v0.9.3 / SDD-091). /// /// Renders the active PTT level + backend in the Voice Bar so the /// user understands which input path is in effect. When the /// resolved capability is `L0Focused` an info icon appears that /// opens a per-platform explanation sheet describing why Global /// PTT is not active and what the user can do to engage it. /// /// Driven by the `BridgeEvent::PttCapability` stream published by /// the `PttController` (SDD-088). The `_BetaHomeState` listener /// updates the props on each transition. class PttCapabilityBadge extends StatelessWidget { /// Construct a badge. const PttCapabilityBadge({ super.key, required this.level, required this.backendId, required this.boundInputClass, }); /// Resolved capability level as the bridge emits it /// (`L0Focused` / `L1WindowsHook` / `L2WindowsRawInput` / /// `L1MacOSEventTap` / `L1LinuxGnomeWaylandPortal`). final String level; /// Stable backend identifier (`focused`, `windows-raw-input`, …). final String backendId; /// Privacy-safe input class (`keyboard`, `mouse-side-button`, /// or empty when no binding is set). final String boundInputClass; bool get _isFocused => level == 'L0Focused'; String _explainBodyForPlatform(AppL10n l10n) { // Use `defaultTargetPlatform` rather than `Theme.of(context).platform` // because the latter is influenced by debug platform overrides // that callers may toggle in dev mode. We want the badge's // explanation to match the actual host OS. switch (defaultTargetPlatform) { case TargetPlatform.windows: return l10n.pttCapabilityExplainGoGlobalWindows; case TargetPlatform.macOS: return l10n.pttCapabilityExplainGoGlobalMacos; case TargetPlatform.linux: return l10n.pttCapabilityExplainGoGlobalLinux; case TargetPlatform.iOS: return l10n.pttCapabilityExplainGoGlobalIos; default: return l10n.pttCapabilityExplainGoGlobalGeneric; } } void _openExplanationSheet(BuildContext context) { final l10n = AppL10n.of(context); showModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) { final theme = Theme.of(sheetContext); return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 4, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.pttCapabilityExplainTitle, style: theme.textTheme.titleMedium, ), const SizedBox(height: 12), Text( l10n.pttCapabilityExplainFocusedHeading, style: theme.textTheme.titleSmall, ), const SizedBox(height: 4), Text( l10n.pttCapabilityExplainFocusedBody, style: theme.textTheme.bodyMedium, ), const SizedBox(height: 16), Text( _explainBodyForPlatform(l10n), style: theme.textTheme.bodyMedium, ), const SizedBox(height: 16), Align( alignment: AlignmentDirectional.centerEnd, child: TextButton( onPressed: () => Navigator.of(sheetContext).pop(), child: Text(l10n.closeAction), ), ), ], ), ), ); }, ); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); final badgeLabel = l10n.pttCapabilityBadge(level, backendId); final tooltipMessage = boundInputClass.isEmpty ? badgeLabel : '$badgeLabel\n($boundInputClass)'; return Padding( padding: const EdgeInsets.only(bottom: 6), child: Tooltip( message: tooltipMessage, child: Row( children: [ Icon( _isFocused ? Icons.crop_free : Icons.public, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 4), Expanded( child: Text( badgeLabel, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), // Info icon only for L0Focused — the explanation sheet // tells the user why their PTT may not work outside the // app window and how to grant the permission. There is // intentionally NO 'Configure' button here: the single // configuration entry point is the Voice Bar's // settings gear (onConfigure on `VoiceBar`). Having two // identical bind-key entry points just confuses users. if (_isFocused) IconButton( icon: const Icon(Icons.info_outline, size: 16), tooltip: l10n.pttCapabilityExplainTitle, visualDensity: VisualDensity.compact, onPressed: () => _openExplanationSheet(context), ), ], ), ), ); } } class _SnapshotView extends StatelessWidget { const _SnapshotView({ required this.snapshot, required this.currentVoiceChannelId, required this.onJoinChannel, required this.onLeaveVoice, }); final rust.BridgeSnapshot snapshot; final BigInt? currentVoiceChannelId; final ValueChanged onJoinChannel; final VoidCallback onLeaveVoice; @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); // Channels arrive pre-sorted from the bridge: the Rust // adapter (chanora_protocol::adapter::sort_channels_tree) // emits them in root-first depth-first order following the // TeamSpeak linked-list `order` predecessor pointers. We // therefore trust the server order verbatim — re-sorting by // `order` numerically here would re-introduce the bug fixed // in that adapter (TS3 `order` is NOT a numeric rank). final channels = snapshot.channels; // Compute each channel's depth in the parent hierarchy so we // can render it indented. Root channels (parent == 0) are // depth 0; their children depth 1; and so on. Depth caps at // 6 to keep the indent visually bounded on deeply-nested // servers (the cap plateaus silently — no glyph; the channel // is still tappable and its real depth lives in the data). final depthById = {}; final zero = BigInt.zero; for (final ch in channels) { if (ch.parent == zero) { depthById[ch.id] = 0; } else { final parentDepth = depthById[ch.parent] ?? 0; depthById[ch.id] = (parentDepth + 1).clamp(0, 6); } } const indentPerLevel = 18.0; final byChannel = >{}; for (final c in snapshot.clients) { byChannel.putIfAbsent(c.channel, () => []).add(c); } return ListView( children: [ Text( l10n.countChannelsAndClients( snapshot.channels.length, snapshot.clients.length, ), style: theme.textTheme.bodyMedium, ), if (snapshot.welcomeMessage.isNotEmpty) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), ), child: Text( snapshot.welcomeMessage, style: theme.textTheme.bodySmall, ), ), ], const Divider(height: 24), Text(l10n.channelsHeading, style: theme.textTheme.titleMedium), const SizedBox(height: 4), for (final ch in channels) ...[ Padding( padding: EdgeInsets.only( left: (depthById[ch.id] ?? 0) * indentPerLevel, ), child: ListTile( dense: true, leading: Icon( ch.id == currentVoiceChannelId ? Icons.volume_up : Icons.tag, ), title: Text(ch.name), subtitle: Text('id=${ch.id} parent=${ch.parent}'), trailing: IconButton( icon: Icon( ch.id == currentVoiceChannelId ? Icons.logout : Icons.login, ), tooltip: ch.id == currentVoiceChannelId ? l10n.leaveChannelAction : l10n.joinChannelAction, onPressed: ch.id == currentVoiceChannelId ? onLeaveVoice : () => onJoinChannel(ch), ), selected: ch.id == currentVoiceChannelId, onTap: ch.id == currentVoiceChannelId ? null : () => onJoinChannel(ch), ), ), for (final cl in byChannel[ch.id] ?? const []) Padding( padding: EdgeInsets.only( left: ((depthById[ch.id] ?? 0) * indentPerLevel) + 64, ), child: ListTile( dense: true, visualDensity: VisualDensity.compact, leading: const Icon(Icons.person, size: 18), title: Text(cl.name), ), ), ], ], ); } } /// Result of a successful PTT binding capture. Carries only the /// coarse input class and an opaque platform-key label — /// per DEC-027 the actual key code never crosses out of the /// capture dialog. class _CapturedBinding { const _CapturedBinding({required this.inputClass, required this.platformKey}); final rust.BridgePttInputClass inputClass; final String platformKey; } /// Focus-scoped dialog that captures the next key press or mouse /// side-button click and returns it as a `_CapturedBinding`. The /// captured value is the platform-neutral `LogicalKeyboardKey` /// debug label or `"mouse-side-button:{button}"`; the platform /// backend interprets the string and never logs it. class _PttBindingCaptureDialog extends StatefulWidget { const _PttBindingCaptureDialog(); @override State<_PttBindingCaptureDialog> createState() => _PttBindingCaptureDialogState(); } class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> { final FocusNode _focusNode = FocusNode(); String? _captured; rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); } @override void dispose() { _focusNode.dispose(); super.dispose(); } KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; final label = _displayLabelForKey(event.logicalKey); if (label == null) return KeyEventResult.ignored; setState(() { _captured = label; _capturedClass = rust.BridgePttInputClass.keyboard; }); return KeyEventResult.handled; } /// Translate a [LogicalKeyboardKey] into the platform-neutral /// label string the bridge expects (matching the entries in /// `crates/chanora_audio/src/ptt_backends/windows_keymap.rs`). /// /// `LogicalKeyboardKey.keyLabel` returns `" "` for Space, empty /// for pure modifiers (shift / ctrl / alt / meta), and localised /// strings for some special keys; we normalise to the canonical /// English label so the badge displays something readable AND so /// the Windows backend's keymap can resolve to a `VK_*`. Pure /// modifier keys are intentionally rejected — chording into the /// real binding (e.g. Ctrl+Shift+M) is supported by ignoring the /// individual modifier-down events. String? _displayLabelForKey(LogicalKeyboardKey k) { // Whitespace / common control keys whose keyLabel is unhelpful. if (k == LogicalKeyboardKey.space) return 'Space'; if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) { return 'Enter'; } if (k == LogicalKeyboardKey.tab) return 'Tab'; if (k == LogicalKeyboardKey.escape) return 'Escape'; if (k == LogicalKeyboardKey.backspace) return 'Backspace'; if (k == LogicalKeyboardKey.delete) return 'Delete'; if (k == LogicalKeyboardKey.insert) return 'Insert'; if (k == LogicalKeyboardKey.home) return 'Home'; if (k == LogicalKeyboardKey.end) return 'End'; if (k == LogicalKeyboardKey.pageUp) return 'Page Up'; if (k == LogicalKeyboardKey.pageDown) return 'Page Down'; if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up'; if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down'; if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left'; if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right'; // Pure modifier keys are not bindable on their own (user can // still chord by pressing a non-modifier while holding them). if (k == LogicalKeyboardKey.shift || k == LogicalKeyboardKey.shiftLeft || k == LogicalKeyboardKey.shiftRight || k == LogicalKeyboardKey.control || k == LogicalKeyboardKey.controlLeft || k == LogicalKeyboardKey.controlRight || k == LogicalKeyboardKey.alt || k == LogicalKeyboardKey.altLeft || k == LogicalKeyboardKey.altRight || k == LogicalKeyboardKey.meta || k == LogicalKeyboardKey.metaLeft || k == LogicalKeyboardKey.metaRight || k == LogicalKeyboardKey.capsLock || k == LogicalKeyboardKey.numLock || k == LogicalKeyboardKey.scrollLock) { return null; } // Fall back to keyLabel for letters, digits, function keys, // numpad digits, and punctuation. Trim whitespace as a final // belt-and-braces guard. final fallback = k.keyLabel.trim(); if (fallback.isEmpty) return null; return fallback; } void _captureMouseSideButton(int button) { setState(() { _captured = 'mouse-side-button:$button'; _capturedClass = rust.BridgePttInputClass.mouseSideButton; }); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); return AlertDialog( title: Text(l10n.pttConfigureTitle), content: SizedBox( width: 360, child: Focus( focusNode: _focusNode, onKeyEvent: _onKeyEvent, autofocus: true, child: Listener( // HitTestBehavior.opaque so the Listener fires for // mouse side-button presses anywhere inside the // dialog's bounds, not just on top of a child widget. // Default (deferToChild) only delivers events when a // child's hit-test claims them; the surrounding padding // and Container backgrounds don't, so the user had to // hover over the captured-result Container before the // side-button click would register. Opaque means the // entire dialog content area receives PointerDown // events. behavior: HitTestBehavior.opaque, // Capture mouse side buttons (4 and 5) without // capturing primary / secondary clicks which the // user uses to interact with the dialog itself. The // raw button bitmask values are stable per Flutter's // `PointerEvent.buttons` documentation (back = 0x08, // forward = 0x10). onPointerDown: (e) { const int back = 0x08; const int forward = 0x10; if (e.buttons == back || e.buttons == forward) { _captureMouseSideButton(e.buttons); } }, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.pttConfigurePrompt, style: theme.textTheme.bodyMedium, ), const SizedBox(height: 12), Container( padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), ), child: Text( _captured == null ? l10n.pttConfigureWaiting : '${l10n.pttConfigureCaptured}: $_captured', style: theme.textTheme.bodyMedium?.copyWith( fontFamily: 'monospace', ), ), ), const SizedBox(height: 12), Text( l10n.pttConfigurePrivacyNote, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(l10n.closeAction), ), FilledButton( onPressed: _captured == null ? null : () => Navigator.of(context).pop( _CapturedBinding( inputClass: _capturedClass, platformKey: _captured!, ), ), child: Text(l10n.pttConfigureSaveAction), ), ], ); } }