// 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 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path_provider/path_provider.dart'; import 'l10n/generated/app_localizations.dart'; import 'src/rust/api.dart' as rust; import 'src/rust/frb_generated.dart'; /// Public version string shown in the About dialog. Aligned with /// `pubspec.yaml` and the git tag for the MVP release candidate. const String _kAppVersion = 'v1.0.0-rc.1'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); unawaited(_wireStorage()); unawaited(_wireConnectivity()); runApp(const ChanoraApp()); } 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, 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; bool _audioStarted = false; rust.BridgeAudioStats? _audioStats; Timer? _statsTimer; StreamSubscription? _eventsSub; String? _lostReason; int? _reconnectAttempt; int? _reconnectDelay; bool _inputMuted = false; bool _outputMuted = false; 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'; // 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()); } 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; }); } } @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(); }); } } Future _onStartAudio() async { try { await rust.startAudio(); if (!mounted) return; setState(() => _audioStarted = true); _statsTimer?.cancel(); _statsTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { try { final s = await rust.audioStats(); if (!mounted) return; setState(() => _audioStats = s); } catch (_) {} }); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } Future _setPtt(bool active) async { try { await rust.setPtt(active: active); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } 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()); } } 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 { final l10n = AppL10n.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.moveToChannel( channelId: ch.id, password: password ?? '', ); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); } } Future _askChannelPassword(AppL10n l10n) async { final ctl = TextEditingController(); final result = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l10n.channelPasswordTitle), content: TextField( controller: ctl, obscureText: true, autofocus: true, decoration: InputDecoration(labelText: l10n.fieldPassword), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(l10n.closeAction), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(ctl.text), child: Text(l10n.connectAction), ), ], ), ); ctl.dispose(); return result; } 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; }); } 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 { final l10n = AppL10n.of(context); final nameCtl = TextEditingController(text: _hostCtl.text.trim()); final name = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l10n.bookmarkAddTitle), content: TextField( controller: nameCtl, autofocus: true, decoration: InputDecoration(labelText: l10n.fieldDisplayName), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: Text(l10n.closeAction), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(nameCtl.text), child: Text(l10n.bookmarkAddAction), ), ], ), ); nameCtl.dispose(); if (name == null || name.trim().isEmpty) 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: Text(l10n.appTitle), actions: [ 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) ...[ IconButton( tooltip: l10n.refreshAction, icon: const Icon(Icons.refresh), onPressed: _onRefresh, ), IconButton( tooltip: l10n.disconnectAction, icon: const Icon(Icons.logout), onPressed: _onDisconnect, ), ], ], ), body: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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), ), ), 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( 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) ...[ if (!_audioStarted) ...[ FilledButton.icon( icon: const Icon(Icons.mic_none), label: Text(l10n.startAudioAction), onPressed: _onStartAudio, ), const SizedBox(height: 12), ] else ...[ _AudioControls( stats: _audioStats, inputMuted: _inputMuted, outputMuted: _outputMuted, outputGain: _outputGain, pttLevel: _pttLevel, pttBackendId: _pttBackendId, pttBoundInputClass: _pttBoundInputClass, pttBoundKeyLabel: _pttBoundKeyLabel, onPttDown: () => _setPtt(true), onPttUp: () => _setPtt(false), onToggleInputMute: _toggleInputMute, onToggleOutputMute: _toggleOutputMute, onGainChanged: _setOutputGain, onConfigurePtt: () => _onConfigurePtt(context), ), const SizedBox(height: 12), ], Expanded( child: _SnapshotView( snapshot: _snapshot!, onJoinChannel: _onJoinChannel, ), ), ], ], ), ), ); } } class _ConnectForm extends StatelessWidget { 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 Widget build(BuildContext context) { final l10n = AppL10n.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: hostCtl, decoration: InputDecoration( labelText: l10n.fieldServerHost, border: const OutlineInputBorder(), ), ), const SizedBox(height: 8), TextField( controller: nickCtl, decoration: InputDecoration( labelText: l10n.fieldNickname, border: const OutlineInputBorder(), ), ), const SizedBox(height: 8), TextField( controller: passwordCtl, obscureText: true, 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: onConnect, ), ), const SizedBox(width: 8), OutlinedButton.icon( icon: const Icon(Icons.bookmark_add_outlined), label: Text(l10n.bookmarkAddAction), onPressed: 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), ), ], ), ), ), ], ); } } class _AudioControls extends StatefulWidget { const _AudioControls({ required this.stats, required this.inputMuted, required this.outputMuted, required this.outputGain, required this.pttLevel, required this.pttBackendId, required this.pttBoundInputClass, required this.pttBoundKeyLabel, required this.onPttDown, required this.onPttUp, required this.onToggleInputMute, required this.onToggleOutputMute, required this.onGainChanged, required this.onConfigurePtt, }); final rust.BridgeAudioStats? stats; final bool inputMuted; final bool outputMuted; final double outputGain; final String pttLevel; final String pttBackendId; final String pttBoundInputClass; final String pttBoundKeyLabel; final VoidCallback onPttDown; final VoidCallback onPttUp; final VoidCallback onToggleInputMute; final VoidCallback onToggleOutputMute; final ValueChanged onGainChanged; final VoidCallback onConfigurePtt; @override State<_AudioControls> createState() => _AudioControlsState(); } class _AudioControlsState extends State<_AudioControls> { bool _pressed = false; @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); final stats = widget.stats; final statsText = stats == null ? '—' : l10n.audioStatsLine( stats.framesSent, stats.framesReceived, stats.pttActive ? 'on' : 'off', ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Capability badge (gen2 v0.9.3 / SDD-091). Renders the // active PTT level + backend so the user understands when // Global PTT has fallen back to Focused PTT, and surfaces // a per-platform explanation sheet when the resolved // capability is `L0Focused`. PttCapabilityBadge( level: widget.pttLevel, backendId: widget.pttBackendId, boundInputClass: widget.pttBoundInputClass, boundKeyLabel: widget.pttBoundKeyLabel, onConfigure: widget.onConfigurePtt, ), // Accessibility (SysRS-262 + SysRS-282 + SysRS-263): // wrap the custom Listener-based PTT control in a // `Semantics` node so screen readers announce its role // ("button") and its current state ("Transmitting" / // "Hold to talk"). The icon + text inside already // communicate the state without relying on colour // alone. Semantics( button: true, enabled: true, toggled: _pressed, label: _pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk, hint: l10n.pttHoldToTalkSemanticsHint, excludeSemantics: true, child: Listener( onPointerDown: (_) { setState(() => _pressed = true); widget.onPttDown(); }, onPointerUp: (_) { setState(() => _pressed = false); widget.onPttUp(); }, onPointerCancel: (_) { setState(() => _pressed = false); widget.onPttUp(); }, child: Container( padding: const EdgeInsets.symmetric(vertical: 16), decoration: BoxDecoration( color: _pressed ? theme.colorScheme.primary : theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( _pressed ? Icons.mic : Icons.mic_off, color: _pressed ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, ), const SizedBox(width: 8), Text( _pressed ? l10n.pttTransmitting : l10n.pttHoldToTalk, style: TextStyle( color: _pressed ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, ), ), ], ), ), ), ), const SizedBox(height: 8), Row( children: [ Expanded( child: FilterChip( avatar: Icon( widget.inputMuted ? Icons.mic_off : Icons.mic, size: 18, ), label: Text( widget.inputMuted ? l10n.inputUnmuteAction : l10n.inputMuteAction, ), selected: widget.inputMuted, onSelected: (_) => widget.onToggleInputMute(), ), ), const SizedBox(width: 8), Expanded( child: FilterChip( avatar: Icon( widget.outputMuted ? Icons.volume_off : Icons.volume_up, size: 18, ), label: Text( widget.outputMuted ? l10n.outputUnmuteAction : l10n.outputMuteAction, ), selected: widget.outputMuted, onSelected: (_) => widget.onToggleOutputMute(), ), ), ], ), const SizedBox(height: 4), Row( children: [ const Icon(Icons.volume_down, size: 18), Expanded( child: Slider( value: widget.outputGain.clamp(0.0, 2.0), min: 0.0, max: 2.0, divisions: 40, label: '${(widget.outputGain * 100).round()}%', onChanged: widget.onGainChanged, ), ), const Icon(Icons.volume_up, size: 18), ], ), Text(statsText, style: theme.textTheme.bodySmall), ], ); } } /// 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, required this.boundKeyLabel, required this.onConfigure, }); /// 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; /// Platform-neutral key label captured by the binding dialog /// (e.g. `"Space"`, `"F10"`, `"mouse-side-button:8"`). Empty /// when the user has not saved a binding in the current /// process. Display-only; the bridge holds the authoritative /// binding. Per DEC-027 this string is the same one already /// crossed into the Rust side — no new privacy surface. final String boundKeyLabel; /// Open the configure-binding dialog. Wired by the caller. final VoidCallback onConfigure; 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; 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: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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, ), ), ), if (_isFocused) IconButton( icon: const Icon(Icons.info_outline, size: 16), tooltip: l10n.pttCapabilityExplainTitle, visualDensity: VisualDensity.compact, onPressed: () => _openExplanationSheet(context), ), TextButton.icon( icon: const Icon(Icons.tune, size: 14), label: Text(l10n.pttConfigureAction), onPressed: onConfigure, ), ], ), ), // Second line: show which key the user just bound, so // they can remember what to press. Only rendered when a // binding has been saved in the current process (the // bridge holds the authoritative binding across restarts; // this label is display-only). if (boundKeyLabel.isNotEmpty) Padding( padding: const EdgeInsets.only(left: 18, top: 2), child: Text( l10n.pttCapabilityBoundKey(boundKeyLabel), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontFamily: 'monospace', ), ), ), ], ), ); } } class _SnapshotView extends StatelessWidget { const _SnapshotView({ required this.snapshot, required this.onJoinChannel, }); final rust.BridgeSnapshot snapshot; final ValueChanged onJoinChannel; @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); final channels = [...snapshot.channels] ..sort((a, b) => a.order.compareTo(b.order)); 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) ...[ ListTile( dense: true, leading: const Icon(Icons.tag), title: Text(ch.name), subtitle: Text('id=${ch.id} parent=${ch.parent}'), trailing: IconButton( icon: const Icon(Icons.login), tooltip: l10n.joinChannelAction, onPressed: () => onJoinChannel(ch), ), onTap: () => onJoinChannel(ch), ), for (final cl in byChannel[ch.id] ?? const []) Padding( padding: const EdgeInsets.only(left: 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; // Skip modifier-only presses so the user can chord into the // real binding. final keyLabel = event.logicalKey.keyLabel; if (keyLabel.isEmpty) return KeyEventResult.ignored; setState(() { _captured = keyLabel; _capturedClass = rust.BridgePttInputClass.keyboard; }); return KeyEventResult.handled; } void _captureMouseSideButton(int button) { setState(() { _captured = 'mouse-side-button:$button'; _capturedClass = rust.BridgePttInputClass.mouseSideButton; }); } @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); return AlertDialog( title: Text(l10n.pttConfigureTitle), content: SizedBox( width: 360, child: Focus( focusNode: _focusNode, onKeyEvent: _onKeyEvent, autofocus: true, child: Listener( // 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), ), ], ); } }