2026-10-11 13:20:00 +03:00
|
|
|
import 'dart:async' show unawaited;
|
|
|
|
|
|
2026-10-11 14:54:43 +03:00
|
|
|
import 'dart:ui' show PlatformDispatcher;
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
import 'package:actors/actors.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
2026-10-11 14:54:43 +03:00
|
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
|
|
|
|
|
|
|
|
|
import '../application/actors/fetch_actor.dart';
|
2026-10-11 13:12:48 +03:00
|
|
|
import '../application/controllers/bookmarks_controller.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import '../application/controllers/browser_controller.dart';
|
|
|
|
|
import '../application/controllers/reader_settings_controller.dart';
|
2026-10-11 13:12:48 +03:00
|
|
|
import '../application/controllers/subscriptions_controller.dart';
|
2026-10-11 14:24:06 +03:00
|
|
|
import '../application/fonts.dart';
|
2026-10-11 13:05:07 +03:00
|
|
|
import '../application/internal_actions.dart';
|
2026-10-11 11:48:24 +03:00
|
|
|
import '../application/internal_pages.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import '../database/database.dart';
|
2026-10-11 14:54:43 +03:00
|
|
|
import '../l10n/locales.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import '../l10n/strings.dart';
|
2026-10-11 11:48:24 +03:00
|
|
|
import '../mews/document.dart';
|
|
|
|
|
import 'reader/palettes.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
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 {
|
2026-10-11 11:48:24 +03:00
|
|
|
AppDependencies({
|
|
|
|
|
MewsDatabase? database,
|
|
|
|
|
Actor<FetchCommand, FetchResult>? fetch,
|
|
|
|
|
Strings? strings,
|
|
|
|
|
}) : db = database ?? MewsDatabase(),
|
|
|
|
|
fetch = fetch ?? Actor.create(FetchHandler.new),
|
|
|
|
|
strings = strings ?? Strings() {
|
2026-10-11 09:33:25 +03:00
|
|
|
settings = ReaderSettingsController(db);
|
2026-10-11 13:12:48 +03:00
|
|
|
bookmarks = BookmarksController(db);
|
2026-10-11 13:20:00 +03:00
|
|
|
subscriptions = SubscriptionsController(db: db, fetch: this.fetch);
|
|
|
|
|
internalPages = InternalPages(
|
|
|
|
|
db: db,
|
|
|
|
|
settings: settings,
|
|
|
|
|
subscriptions: subscriptions,
|
|
|
|
|
strings: this.strings);
|
2026-10-11 13:25:57 +03:00
|
|
|
internalActions = InternalActions(
|
|
|
|
|
db: db,
|
2026-10-11 14:54:43 +03:00
|
|
|
applyLanguage: applyLanguage,
|
2026-10-11 13:25:57 +03:00
|
|
|
settings: settings,
|
|
|
|
|
bookmarks: bookmarks,
|
|
|
|
|
subscriptions: subscriptions,
|
|
|
|
|
);
|
2026-10-11 13:05:07 +03:00
|
|
|
browser = BrowserController(
|
|
|
|
|
db: db,
|
|
|
|
|
fetch: this.fetch,
|
|
|
|
|
internalPages: internalPages,
|
|
|
|
|
internalActions: internalActions,
|
|
|
|
|
);
|
2026-10-11 13:20:00 +03:00
|
|
|
// 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;
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 11:48:24 +03:00
|
|
|
/// Localized strings, also used to title the internal pages.
|
|
|
|
|
final Strings strings;
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
final MewsDatabase db;
|
|
|
|
|
final Actor<FetchCommand, FetchResult> fetch;
|
|
|
|
|
late final BrowserController browser;
|
|
|
|
|
late final ReaderSettingsController settings;
|
2026-10-11 13:12:48 +03:00
|
|
|
late final BookmarksController bookmarks;
|
|
|
|
|
late final SubscriptionsController subscriptions;
|
2026-10-11 11:48:24 +03:00
|
|
|
late final InternalPages internalPages;
|
2026-10-11 13:05:07 +03:00
|
|
|
late final InternalActions internalActions;
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 14:54:43 +03:00
|
|
|
/// 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<void> applyLanguage() => strings.use(activeLocale);
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Future<void> start() async {
|
|
|
|
|
await settings.load();
|
2026-10-11 14:54:43 +03:00
|
|
|
await applyLanguage();
|
2026-10-11 13:12:48 +03:00
|
|
|
await bookmarks.load();
|
|
|
|
|
await subscriptions.load();
|
2026-10-11 09:33:25 +03:00
|
|
|
await browser.restoreLastSession();
|
2026-10-11 13:20:00 +03:00
|
|
|
// Subscriptions are checked in the background: the reader never waits on
|
|
|
|
|
// the network to see their last page.
|
|
|
|
|
unawaited(subscriptions.refreshAll());
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<AppScope>()!;
|
|
|
|
|
|
|
|
|
|
@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,
|
2026-10-11 10:28:54 +03:00
|
|
|
child: StringsScope(
|
|
|
|
|
strings: strings,
|
|
|
|
|
child: const _ThemedApp(),
|
|
|
|
|
),
|
2026-10-11 09:33:25 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 12:51:18 +03:00
|
|
|
/// The chrome (address bar, dialogs, text selection) follows the same palette
|
2026-10-11 11:48:24 +03:00
|
|
|
/// the reader selected for pages, so the whole app shares one color scheme.
|
2026-10-11 09:33:25 +03:00
|
|
|
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,
|
|
|
|
|
};
|
2026-10-11 11:48:24 +03:00
|
|
|
final settings = deps.settings;
|
2026-10-11 14:03:44 +03:00
|
|
|
final font = settings.font.value;
|
2026-10-11 14:54:43 +03:00
|
|
|
// Read so the whole app rebuilds when the reader changes language.
|
|
|
|
|
settings.language.value;
|
2026-10-11 11:48:24 +03:00
|
|
|
final page = deps.browser.currentPage.value;
|
|
|
|
|
final variant = settings.variant.value == 'site'
|
|
|
|
|
? (page?.variant ?? MewsVariant.plain)
|
|
|
|
|
: pageVariantFromName(settings.variant.value);
|
|
|
|
|
|
2026-10-11 14:54:43 +03:00
|
|
|
// 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;
|
2026-10-11 09:33:25 +03:00
|
|
|
return MaterialApp(
|
|
|
|
|
title: 'Mews',
|
2026-10-11 14:54:43 +03:00
|
|
|
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,
|
|
|
|
|
],
|
2026-10-11 09:33:25 +03:00
|
|
|
themeMode: mode,
|
2026-10-11 14:03:44 +03:00
|
|
|
theme: _themeFor(
|
|
|
|
|
paletteFor(pageVariant: variant, readerVariant: 'site', dark: false),
|
|
|
|
|
font),
|
|
|
|
|
darkTheme: _themeFor(
|
|
|
|
|
paletteFor(pageVariant: variant, readerVariant: 'site', dark: true),
|
|
|
|
|
font),
|
2026-10-11 09:33:25 +03:00
|
|
|
home: const ReaderScreen(),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-11 11:48:24 +03:00
|
|
|
|
2026-10-11 14:03:44 +03:00
|
|
|
ThemeData _themeFor(MewsPalette palette, String fontFamily) {
|
2026-10-11 11:48:24 +03:00
|
|
|
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,
|
2026-10-11 14:03:44 +03:00
|
|
|
// 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,
|
2026-10-11 14:24:06 +03:00
|
|
|
fontFamilyFallback: fallbackFor(fontFamily),
|
2026-10-11 11:48:24 +03:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|