// 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/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; 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()); } } @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 _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, onPttDown: () => _setPtt(true), onPttUp: () => _setPtt(false), onToggleInputMute: _toggleInputMute, onToggleOutputMute: _toggleOutputMute, onGainChanged: _setOutputGain, ), 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.onPttDown, required this.onPttUp, required this.onToggleInputMute, required this.onToggleOutputMute, required this.onGainChanged, }); final rust.BridgeAudioStats? stats; final bool inputMuted; final bool outputMuted; final double outputGain; final VoidCallback onPttDown; final VoidCallback onPttUp; final VoidCallback onToggleInputMute; final VoidCallback onToggleOutputMute; final ValueChanged onGainChanged; @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: [ 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), ], ); } } 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), ), ), ], ], ); } }