the-mews/app/lib/presentation/app.dart

242 lines
8.2 KiB
Dart
Raw Normal View History

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<FetchCommand, FetchResult>? fetch,
Strings? strings,
KeyStore? keys,
Future<PickedPem?> 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<FetchCommand, FetchResult> 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<void> applyLanguage() => strings.use(activeLocale);
Future<void> 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<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,
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,
),
);
}
}