// Chanora Flutter application entry point — Alpha build. // // Wires the Alpha UI: server-address + nickname form, connect button, // channel tree, disconnect. All names from server-side state are // preserved verbatim per ADR-008 / DEC-015. 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), ), // DEC-015 (register v0.9.5): English + Chinese Simplified at MVP. localizationsDelegates: AppL10n.localizationsDelegates, supportedLocales: AppL10n.supportedLocales, home: const _AlphaHome(), ); } } /// Three-state UI: idle / connecting / connected. Errors collapse /// back to idle with the message captured. class _AlphaHome extends StatefulWidget { const _AlphaHome(); @override State<_AlphaHome> createState() => _AlphaHomeState(); } enum _Phase { idle, connecting, connected } class _AlphaHomeState extends State<_AlphaHome> { final _hostCtl = TextEditingController(text: 'cn.teamspeak.app'); final _nickCtl = TextEditingController(text: 'ChanoraAlpha'); _Phase _phase = _Phase.idle; rust.BridgeSnapshot? _snapshot; String? _error; @override void dispose() { _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 _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 { try { await rust.disconnect(); } catch (_) { // Best-effort. Even if disconnect throws we drop back to idle. } if (!mounted) return; setState(() { _phase = _Phase.idle; _snapshot = 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: [ // 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), ), ), const SizedBox(height: 12), // Status 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) ...[ 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 _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)); // Index clients by channel id for the tree view. 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), ), ), ], ], ); } }