// 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 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'l10n/generated/app_localizations.dart'; import 'services/app_bootstrap.dart'; import 'services/audio_lifecycle_service.dart'; import 'services/ui_preferences_service.dart'; import 'screens/home_screen.dart'; import 'src/rust/api.dart' as rust; import 'src/rust/frb_generated.dart'; import 'widgets/audio_debug_stats_panel.dart'; bool get isMacOS => !kIsWeb && Platform.isMacOS; // Debug-only overlay. `kDebugMode` is a compile-time const in // release builds (= false), so the overlay subtree is tree-shaken // out of release/profile binaries entirely — release users never // see internal audio stats and we don't pay the render cost. bool get _showAudioDebugOverlay => kDebugMode && isMacOS; const Color _appSurfaceColor = Color(0xFFFFFBFE); /// Top padding for macOS to clear traffic-light buttons. const double macOSTrafficLightPad = 56.0; /// Public version string shown in the About dialog. String kAppVersion = appSemverBaseline; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); unawaited(() async { await wireStorage(); await wireCache(); }()); unawaited(wireConnectivity()); wireAudioLifecycle(); await configureBundledVadModels(); runApp(const ChanoraApp()); unawaited(_finishDeferredStartup()); } Future _finishDeferredStartup() async { try { kAppVersion = await resolveAppVersion(); } catch (_) {} } class ChanoraApp extends StatefulWidget { const ChanoraApp({super.key}); @override State createState() => _ChanoraAppState(); } class _ChanoraAppState extends State { final UiPreferencesService _uiPreferences = const UiPreferencesService(); ThemeMode _themeMode = ThemeMode.system; @override void initState() { super.initState(); unawaited(_loadThemeMode()); } Future _loadThemeMode() async { try { final settings = await _uiPreferences.loadSettings(); if (!mounted) return; setState(() { _themeMode = settings.themeMode.toFlutterThemeMode(); }); } catch (_) {} } @override Widget build(BuildContext context) { return AnnotatedRegion( value: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.dark, systemNavigationBarColor: _appSurfaceColor, systemNavigationBarDividerColor: _appSurfaceColor, systemNavigationBarIconBrightness: Brightness.dark, ), child: MaterialApp( onGenerateTitle: (ctx) => AppL10n.of(ctx).appTitle, debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, colorSchemeSeed: const Color(0xFF3F51B5), scaffoldBackgroundColor: _appSurfaceColor, ), darkTheme: ThemeData( useMaterial3: true, colorSchemeSeed: const Color(0xFF3F51B5), brightness: Brightness.dark, ), themeMode: _themeMode, localizationsDelegates: AppL10n.localizationsDelegates, supportedLocales: AppL10n.supportedLocales, home: Stack( children: [ BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode), if (_showAudioDebugOverlay) const AudioDebugStatsPanel(), ], ), ), ); } Future _setThemeMode(ThemeMode themeMode) async { setState(() { _themeMode = themeMode; }); try { await _uiPreferences.saveThemeMode(themeMode.toUiThemeMode()); } catch (_) {} } } extension on UiThemeMode { ThemeMode toFlutterThemeMode() { return switch (this) { UiThemeMode.system => ThemeMode.system, UiThemeMode.light => ThemeMode.light, UiThemeMode.dark => ThemeMode.dark, }; } } extension on ThemeMode { UiThemeMode toUiThemeMode() { return switch (this) { ThemeMode.system => UiThemeMode.system, ThemeMode.light => UiThemeMode.light, ThemeMode.dark => UiThemeMode.dark, }; } } bool isPokeSenderActiveChat({ required bool chatOpen, required rust.BridgeMessageTarget? inlineChatTarget, required BigInt senderId, }) { if (!chatOpen) return false; return switch (inlineChatTarget) { rust.BridgeMessageTarget_Poke(:final field0) || rust.BridgeMessageTarget_Client(:final field0) => field0 == senderId, _ => false, }; } class ChanoraThemeModeMenu extends StatelessWidget { const ChanoraThemeModeMenu({ super.key, required this.themeMode, required this.onThemeModeChanged, }); final ThemeMode themeMode; final Future Function(ThemeMode mode) onThemeModeChanged; @override Widget build(BuildContext context) { return PopupMenuButton( tooltip: 'Theme', icon: const Icon(Icons.palette_outlined), initialValue: themeMode, onSelected: (mode) { unawaited(onThemeModeChanged(mode)); }, itemBuilder: (context) => const [ PopupMenuItem(value: ThemeMode.system, child: Text('System')), PopupMenuItem(value: ThemeMode.light, child: Text('Light')), PopupMenuItem(value: ThemeMode.dark, child: Text('Dark')), ], ); } } class ChanoraMobileScaffold extends StatelessWidget { const ChanoraMobileScaffold({ super.key, required this.compactIdleChrome, required this.canDisconnect, required this.title, required this.actions, required this.onDisconnect, required this.compactHeader, required this.body, this.disconnectTooltip = 'Disconnect', }); final bool compactIdleChrome; final bool canDisconnect; final Widget? title; final List actions; final VoidCallback onDisconnect; final Widget compactHeader; final Widget body; final String disconnectTooltip; @override Widget build(BuildContext context) { final paddedBody = SafeArea( top: compactIdleChrome, child: Padding( padding: const EdgeInsets.all(16), child: compactIdleChrome ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ compactHeader, const SizedBox(height: 8), Expanded(child: body), ], ) : body, ), ); return Scaffold( appBar: compactIdleChrome ? null : AppBar( leading: canDisconnect ? IconButton( tooltip: disconnectTooltip, icon: const Icon(Icons.arrow_back), onPressed: onDisconnect, ) : null, leadingWidth: canDisconnect ? 44 : null, titleSpacing: canDisconnect ? 4 : null, title: title, actions: actions, ), body: paddedBody, ); } }