import 'dart:async' show unawaited; import 'dart:ui' show PlatformDispatcher; import 'package:actors/actors.dart'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../application/actors/fetch_actor.dart'; import '../application/controllers/bookmarks_controller.dart'; import '../application/controllers/browser_controller.dart'; import '../application/controllers/reader_settings_controller.dart'; import '../application/controllers/subscriptions_controller.dart'; import '../application/fonts.dart'; import '../application/internal_actions.dart' show InternalActions, PickedPem; import '../application/internal_pages.dart'; import '../application/key_store.dart'; import '../database/database.dart'; import '../l10n/locales.dart'; import '../l10n/strings.dart'; import '../mews/document.dart'; import 'reader/palettes.dart'; import 'reader/reader_screen.dart'; /// Wires the app's dependencies together and provides them to the tree. /// Everything is local-first: the database is created up front and the fetch /// actor is the only path to the network. class AppDependencies { AppDependencies({ MewsDatabase? database, Actor? fetch, Strings? strings, KeyStore? keys, Future Function(String title)? pickPemFile, }) : db = database ?? MewsDatabase(), fetch = fetch ?? Actor.create(FetchHandler.new), strings = strings ?? Strings() { settings = ReaderSettingsController(db); bookmarks = BookmarksController(db); subscriptions = SubscriptionsController(db: db, fetch: this.fetch); this.keys = keys ?? KeyStore(() async { final support = await getApplicationSupportDirectory(); return Directory(p.join(support.path, 'keys')); }); internalPages = InternalPages( db: db, settings: settings, subscriptions: subscriptions, strings: this.strings); internalActions = InternalActions( db: db, applyLanguage: applyLanguage, settings: settings, bookmarks: bookmarks, subscriptions: subscriptions, keys: this.keys, strings: this.strings, pickPemFile: pickPemFile, ); browser = BrowserController( db: db, fetch: this.fetch, keys: this.keys, internalPages: internalPages, internalActions: internalActions, ); // A check running in the background redraws whichever of our pages is // open, so a new post shows up without the reader navigating. subscriptions.onChanged = browser.refreshInternalPage; } /// Localized strings, also used to title the internal pages. final Strings strings; final MewsDatabase db; final Actor fetch; late final KeyStore keys; late final BrowserController browser; late final ReaderSettingsController settings; late final BookmarksController bookmarks; late final SubscriptionsController subscriptions; late final InternalPages internalPages; late final InternalActions internalActions; /// The language to read the interface in: the reader's choice, or the best /// match for what the device asks for. String get activeLocale => settings.language.value == systemLanguage ? resolveLocale( PlatformDispatcher.instance.locales.map((l) => l.toLanguageTag())) : settings.language.value; /// Loads the chosen language's messages. Called at startup and whenever the /// reader changes the setting. Future applyLanguage() => strings.use(activeLocale); Future start() async { await settings.load(); await applyLanguage(); await bookmarks.load(); await subscriptions.load(); await browser.restoreLastSession(); // Subscriptions are checked in the background: the reader never waits on // the network to see their last page. unawaited(subscriptions.refreshAll()); } } class AppScope extends InheritedWidget { const AppScope({ required this.deps, required this.strings, required super.child, super.key, }); final AppDependencies deps; final Strings strings; static AppScope of(BuildContext context) => context.getInheritedWidgetOfExactType()!; @override bool updateShouldNotify(AppScope oldWidget) => false; } class MewsApp extends StatelessWidget { const MewsApp({required this.deps, required this.strings, super.key}); final AppDependencies deps; final Strings strings; @override Widget build(BuildContext context) { return AppScope( deps: deps, strings: strings, child: StringsScope( strings: strings, child: const _ThemedApp(), ), ); } } /// The chrome (address bar, dialogs, text selection) follows the same palette /// the reader selected for pages, so the whole app shares one color scheme. class _ThemedApp extends StatelessWidget { const _ThemedApp(); @override Widget build(BuildContext context) { final deps = AppScope.of(context).deps; return SignalBuilder(builder: (context) { final mode = switch (deps.settings.themeMode.value) { 'light' => ThemeMode.light, 'dark' => ThemeMode.dark, _ => ThemeMode.system, }; final settings = deps.settings; final font = settings.font.value; // Read so the whole app rebuilds when the reader changes language. settings.language.value; final page = deps.browser.currentPage.value; // Flutter's own widgets and the text direction follow the same // language, so a right-to-left interface mirrors without us asking. final locale = deps.activeLocale; return MaterialApp( title: 'Mews', locale: Locale(locale.split('-').first, locale.contains('-') ? locale.split('-').last : null), supportedLocales: [ for (final l in appLocales) Locale(l.tag.split('-').first, l.tag.contains('-') ? l.tag.split('-').last : null), ], localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], themeMode: mode, theme: _themeFor(_chromePalette(settings, page, dark: false), font), darkTheme: _themeFor(_chromePalette(settings, page, dark: true), font), home: const ReaderScreen(), ); }); } /// The interface wears the current page's palette, so the chrome around a /// page matches it. MewsPalette _chromePalette(ReaderSettingsController settings, MewsPage? page, {required bool dark}) { return paletteFor( pageVariant: page?.variant ?? MewsVariant.plain, readerVariant: settings.variant.value, dark: dark, url: page?.url, ); } ThemeData _themeFor(MewsPalette palette, String fontFamily) { final dark = palette.background.computeLuminance() < 0.5; final scheme = ColorScheme( brightness: dark ? Brightness.dark : Brightness.light, primary: palette.link, onPrimary: palette.background, primaryContainer: palette.link, onPrimaryContainer: palette.background, secondary: palette.border, onSecondary: palette.foreground, surface: palette.background, onSurface: palette.foreground, onSurfaceVariant: palette.foreground, surfaceContainerHighest: palette.preBackground, outline: palette.border, error: const Color(0xffba1a1a), onError: Colors.white, ); return ThemeData( useMaterial3: true, colorScheme: scheme, // The chrome reads in the typeface the reader chose for pages, so the // address bar and the page below it are not two different faces. fontFamily: fontFamily, fontFamilyFallback: fallbackFor(fontFamily), scaffoldBackgroundColor: palette.background, // Selection highlights and the caret follow the palette too. textSelectionTheme: TextSelectionThemeData( cursorColor: palette.link, selectionColor: palette.link.withValues(alpha: 0.3), selectionHandleColor: palette.link, ), ); } }