// Chanora Flutter application — Beta build (v0.2.0-beta.1). // // Adds voice in/out via push-to-talk on top of the Alpha UI: // 1. Connect form + channel/client tree (Alpha) // 2. "Start audio" button after connect → opens the audio engine // 3. Push-to-talk button: hold to transmit, release to stop // 4. Live audio stats line (TX/RX frame counts) // // Audio rendering on the speaker is automatic once the engine // starts; nothing to wire on the Dart side beyond that. import 'dart:async'; import 'package:flutter/material.dart'; import 'l10n/generated/app_localizations.dart'; import 'src/rust/api.dart' as rust; import 'src/rust/frb_generated.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); runApp(const ChanoraApp()); } 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'); _Phase _phase = _Phase.idle; rust.BridgeSnapshot? _snapshot; String? _error; bool _audioStarted = false; rust.BridgeAudioStats? _audioStats; Timer? _statsTimer; @override void dispose() { _statsTimer?.cancel(); _hostCtl.dispose(); _nickCtl.dispose(); super.dispose(); } Future _onConnect() async { setState(() { _phase = _Phase.connecting; _error = null; _snapshot = null; }); try { final snap = await rust.connect( host: _hostCtl.text.trim(), nickname: _nickCtl.text.trim(), ); 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 _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; }); } @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: [ 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), const SizedBox(height: 12), if (_phase == _Phase.idle) ...[ _ConnectForm( hostCtl: _hostCtl, nickCtl: _nickCtl, onConnect: _onConnect, ), ] else if (_phase == _Phase.connecting) ...[ const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator(), ), ), ] else if (_phase == _Phase.connected && _snapshot != null) ...[ // Audio row: start button or stats + PTT. if (!_audioStarted) ...[ FilledButton.icon( icon: const Icon(Icons.mic_none), label: Text(l10n.startAudioAction), onPressed: _onStartAudio, ), const SizedBox(height: 12), ] else ...[ _AudioControls( stats: _audioStats, onPttDown: () => _setPtt(true), onPttUp: () => _setPtt(false), ), const SizedBox(height: 12), ], Expanded(child: _SnapshotView(snapshot: _snapshot!)), ], ], ), ), ); } } class _ConnectForm extends StatelessWidget { const _ConnectForm({ required this.hostCtl, required this.nickCtl, required this.onConnect, }); final TextEditingController hostCtl; final TextEditingController nickCtl; final VoidCallback onConnect; @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: 16), FilledButton.icon( icon: const Icon(Icons.login), label: Text(l10n.connectAction), onPressed: onConnect, ), ], ); } } class _AudioControls extends StatefulWidget { const _AudioControls({ required this.stats, required this.onPttDown, required this.onPttUp, }); final rust.BridgeAudioStats? stats; final VoidCallback onPttDown; final VoidCallback onPttUp; @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: 6), Text(statsText, style: theme.textTheme.bodySmall), ], ); } } class _SnapshotView extends StatelessWidget { const _SnapshotView({required this.snapshot}); final rust.BridgeSnapshot snapshot; @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), ), // Server-provided content; preserved verbatim per ADR-008. 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}'), ), 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), ), ), ], ], ); } }