feat: theme chrome by palette, selectable text, wap fallback, and library pages
This commit is contained in:
parent
c104c2026b
commit
56f3d959fa
12 changed files with 534 additions and 51 deletions
|
|
@ -5,26 +5,38 @@ import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
|||
import '../application/actors/fetch_actor.dart';
|
||||
import '../application/controllers/browser_controller.dart';
|
||||
import '../application/controllers/reader_settings_controller.dart';
|
||||
import '../application/internal_pages.dart';
|
||||
import '../database/database.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})
|
||||
: db = database ?? MewsDatabase(),
|
||||
fetch = fetch ?? Actor.create(FetchHandler.new) {
|
||||
browser = BrowserController(db: db, fetch: this.fetch);
|
||||
AppDependencies({
|
||||
MewsDatabase? database,
|
||||
Actor<FetchCommand, FetchResult>? fetch,
|
||||
Strings? strings,
|
||||
}) : db = database ?? MewsDatabase(),
|
||||
fetch = fetch ?? Actor.create(FetchHandler.new),
|
||||
strings = strings ?? Strings() {
|
||||
settings = ReaderSettingsController(db);
|
||||
library = LibraryController(db);
|
||||
internalPages = InternalPages(db: db, library: library, strings: this.strings);
|
||||
browser = BrowserController(db: db, fetch: this.fetch, internalPages: internalPages);
|
||||
}
|
||||
|
||||
/// Localized strings, also used to title the internal pages.
|
||||
final Strings strings;
|
||||
|
||||
final MewsDatabase db;
|
||||
final Actor<FetchCommand, FetchResult> fetch;
|
||||
late final BrowserController browser;
|
||||
late final ReaderSettingsController settings;
|
||||
late final LibraryController library;
|
||||
late final InternalPages internalPages;
|
||||
|
||||
Future<void> start() async {
|
||||
await settings.load();
|
||||
|
|
@ -70,6 +82,8 @@ class MewsApp extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// The chrome (address bar, FOB, sheet, dialogs) follows the same palette
|
||||
/// the reader selected for pages, so the whole app shares one color scheme.
|
||||
class _ThemedApp extends StatelessWidget {
|
||||
const _ThemedApp();
|
||||
|
||||
|
|
@ -82,21 +96,55 @@ class _ThemedApp extends StatelessWidget {
|
|||
'dark' => ThemeMode.dark,
|
||||
_ => ThemeMode.system,
|
||||
};
|
||||
final settings = deps.settings;
|
||||
final page = deps.browser.currentPage.value;
|
||||
final variant = settings.variant.value == 'site'
|
||||
? (page?.variant ?? MewsVariant.plain)
|
||||
: pageVariantFromName(settings.variant.value);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Mews',
|
||||
themeMode: mode,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff1a4f9c)),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xff1a4f9c), brightness: Brightness.dark),
|
||||
),
|
||||
theme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: false)),
|
||||
darkTheme: _themeFor(paletteFor(pageVariant: variant, readerVariant: 'site', dark: true)),
|
||||
home: const ReaderScreen(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ThemeData _themeFor(MewsPalette palette) {
|
||||
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,
|
||||
scaffoldBackgroundColor: palette.background,
|
||||
// The FOB follows the palette, not the M3 container defaults.
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: palette.link,
|
||||
foregroundColor: 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
textDirection: widget.page.direction == TextDirection.rtl
|
||||
? ui.TextDirection.rtl
|
||||
: ui.TextDirection.ltr,
|
||||
child: ColoredBox(
|
||||
// Page text is the reader's to copy; forms manage their own selection.
|
||||
child: SelectionArea(
|
||||
child: ColoredBox(
|
||||
color: palette.background,
|
||||
child: ListView(
|
||||
controller: widget.scrollController,
|
||||
|
|
@ -94,6 +96,7 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ const _monoDark = MewsPalette(
|
|||
/// Resolves the palette: the reader's variant choice overrides the page's own
|
||||
/// variant unless the choice is "site" (spec 5.3, 8.2).
|
||||
MewsPalette paletteFor({required MewsVariant pageVariant, required String readerVariant, required bool dark}) {
|
||||
final variant = readerVariant == 'site' ? pageVariant.name : pageVariantFromName(readerVariant);
|
||||
final variant = readerVariant == 'site' ? pageVariant : pageVariantFromName(readerVariant);
|
||||
return switch ((variant, dark)) {
|
||||
(MewsVariant.warm, false) => _warmLight,
|
||||
(MewsVariant.warm, true) => _warmDark,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||
|
||||
import '../../application/controllers/reader_settings_controller.dart';
|
||||
import '../../application/internal_pages.dart';
|
||||
import '../app.dart';
|
||||
import 'reader_fonts.dart';
|
||||
|
||||
|
|
@ -44,6 +45,24 @@ class SettingsSheet extends StatelessWidget {
|
|||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
// The library pages are internal Mews pages; opening one joins
|
||||
// the walk history like any other page.
|
||||
ListTile(
|
||||
leading: const Icon(Icons.history),
|
||||
title: Text(strings.t('settings-history')),
|
||||
onTap: () => _openPage(context, InternalPages.historyUrl),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.star_outline),
|
||||
title: Text(strings.t('settings-bookmarks')),
|
||||
onTap: () => _openPage(context, InternalPages.bookmarksUrl),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_outlined),
|
||||
title: Text(strings.t('settings-subscriptions')),
|
||||
onTap: () => _openPage(context, InternalPages.subscriptionsUrl),
|
||||
),
|
||||
const Divider(),
|
||||
_heading(context, strings.t('settings-font-heading')),
|
||||
RadioGroup<String>(
|
||||
groupValue: settings.font.value,
|
||||
|
|
@ -125,7 +144,7 @@ class SettingsSheet extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (url != null) ...[
|
||||
if (url != null && !InternalPages.isInternal(url)) ...[
|
||||
ListTile(
|
||||
leading: Icon(library.isBookmarked(url) ? Icons.star : Icons.star_border),
|
||||
title: Text(
|
||||
|
|
@ -156,6 +175,11 @@ class SettingsSheet extends StatelessWidget {
|
|||
});
|
||||
}
|
||||
|
||||
void _openPage(BuildContext context, String url) {
|
||||
Navigator.of(context).pop();
|
||||
AppScope.of(context).deps.browser.open(url);
|
||||
}
|
||||
|
||||
Widget _heading(BuildContext context, String text) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
|
|||
|
||||
bool get _settledAnimating => _settling != null && _settle.isAnimating;
|
||||
|
||||
/// How close to a screen edge a drag must begin to navigate; horizontal
|
||||
/// drags elsewhere belong to text selection.
|
||||
static const _edgeZone = 40.0;
|
||||
|
||||
void _onDragStart(DragStartDetails details) {
|
||||
if (_settledAnimating) return;
|
||||
setState(() => _dragging = true);
|
||||
|
|
@ -161,24 +165,37 @@ class _SwipeNavigatorState extends State<SwipeNavigator>
|
|||
)
|
||||
: null;
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onHorizontalDragStart: _onDragStart,
|
||||
onHorizontalDragUpdate: _onDragUpdate,
|
||||
onHorizontalDragEnd: _onDragEnd,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (peek != null) Positioned.fill(child: peek),
|
||||
Transform.translate(
|
||||
offset: Offset(dx, 0),
|
||||
child: widget.child,
|
||||
),
|
||||
],
|
||||
),
|
||||
return Stack(
|
||||
children: [
|
||||
if (peek != null) Positioned.fill(child: peek),
|
||||
Transform.translate(
|
||||
offset: Offset(dx, 0),
|
||||
child: widget.child,
|
||||
),
|
||||
// Only horizontal drags that begin at a screen edge navigate;
|
||||
// the strips are translucent, so taps, vertical scrolling and
|
||||
// text selection still pass through everywhere.
|
||||
_edgeStrip(fromLeft: true),
|
||||
_edgeStrip(fromLeft: false),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _edgeStrip({required bool fromLeft}) => Positioned(
|
||||
left: fromLeft ? 0 : null,
|
||||
right: fromLeft ? null : 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: _edgeZone,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: _onDragStart,
|
||||
onHorizontalDragUpdate: _onDragUpdate,
|
||||
onHorizontalDragEnd: _onDragEnd,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The neighboring page's fallback panel while it is still being parsed.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue